diff --git a/src/GuestAccess.js b/src/GuestAccess.js new file mode 100644 index 0000000000..ef48d23ded --- /dev/null +++ b/src/GuestAccess.js @@ -0,0 +1,51 @@ +/* +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. +*/ +const IS_GUEST_KEY = "matrix-is-guest"; + +class GuestAccess { + + constructor(localStorage) { + this.localStorage = localStorage; + try { + this._isGuest = localStorage.getItem(IS_GUEST_KEY) === "true"; + } + catch (e) {} // don't care + } + + setPeekedRoom(roomId) { + // we purposefully do not persist this to local storage as peeking is + // entirely transient. + this._peekedRoomId = roomId; + } + + getPeekedRoom() { + return this._peekedRoomId; + } + + isGuest() { + return this._isGuest; + } + + markAsGuest(isGuest) { + try { + this.localStorage.setItem(IS_GUEST_KEY, JSON.stringify(isGuest)); + } catch (e) {} // ignore. If they don't do LS, they'll just get a new account. + this._isGuest = isGuest; + this._peekedRoomId = null; + } +} + +module.exports = GuestAccess; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 4a83ed09d9..dbb3dbf83e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -18,6 +18,7 @@ limitations under the License. // A thing that holds your Matrix Client var Matrix = require("matrix-js-sdk"); +var GuestAccess = require("./GuestAccess"); var matrixClient = null; @@ -33,7 +34,7 @@ function deviceId() { return id; } -function createClient(hs_url, is_url, user_id, access_token) { +function createClient(hs_url, is_url, user_id, access_token, guestAccess) { var opts = { baseUrl: hs_url, idBaseUrl: is_url, @@ -47,6 +48,15 @@ function createClient(hs_url, is_url, user_id, access_token) { } matrixClient = Matrix.createClient(opts); + if (guestAccess) { + console.log("Guest: %s", guestAccess.isGuest()); + matrixClient.setGuest(guestAccess.isGuest()); + var peekedRoomId = guestAccess.getPeekedRoom(); + if (peekedRoomId) { + console.log("Peeking in room %s", peekedRoomId); + matrixClient.peekInRoom(peekedRoomId); + } + } } if (localStorage) { @@ -54,12 +64,18 @@ if (localStorage) { var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; var access_token = localStorage.getItem("mx_access_token"); var user_id = localStorage.getItem("mx_user_id"); + var guestAccess = new GuestAccess(localStorage); if (access_token && user_id && hs_url) { - createClient(hs_url, is_url, user_id, access_token); + createClient(hs_url, is_url, user_id, access_token, guestAccess); } } class MatrixClient { + + constructor(guestAccess) { + this.guestAccess = guestAccess; + } + get() { return matrixClient; } @@ -97,7 +113,7 @@ class MatrixClient { } } - replaceUsingAccessToken(hs_url, is_url, user_id, access_token) { + replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) { if (localStorage) { try { localStorage.clear(); @@ -105,7 +121,8 @@ class MatrixClient { console.warn("Error using local storage"); } } - createClient(hs_url, is_url, user_id, access_token); + this.guestAccess.markAsGuest(Boolean(isGuest)); + createClient(hs_url, is_url, user_id, access_token, this.guestAccess); if (localStorage) { try { localStorage.setItem("mx_hs_url", hs_url); @@ -122,6 +139,6 @@ class MatrixClient { } if (!global.mxMatrixClient) { - global.mxMatrixClient = new MatrixClient(); + global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage)); } module.exports = global.mxMatrixClient; diff --git a/src/PasswordReset.js b/src/PasswordReset.js new file mode 100644 index 0000000000..1029b07b70 --- /dev/null +++ b/src/PasswordReset.js @@ -0,0 +1,104 @@ +/* +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 Matrix = require("matrix-js-sdk"); + +/** + * Allows a user to reset their password on a homeserver. + * + * This involves getting an email token from the identity server to "prove" that + * the client owns the given email address, which is then passed to the password + * API on the homeserver in question with the new password. + */ +class PasswordReset { + + /** + * Configure the endpoints for password resetting. + * @param {string} homeserverUrl The URL to the HS which has the account to reset. + * @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping. + */ + constructor(homeserverUrl, identityUrl) { + this.client = Matrix.createClient({ + baseUrl: homeserverUrl, + idBaseUrl: identityUrl + }); + this.clientSecret = generateClientSecret(); + this.identityServerDomain = identityUrl.split("://")[1]; + } + + /** + * Attempt to reset the user's password. This will trigger a side-effect of + * sending an email to the provided email address. + * @param {string} emailAddress The email address + * @param {string} newPassword The new password for the account. + * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). + */ + resetPassword(emailAddress, newPassword) { + this.password = newPassword; + return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { + this.sessionId = res.sid; + return res; + }, function(err) { + if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + + /** + * Checks if the email link has been clicked by attempting to change the password + * for the mxid linked to the email. + * @return {Promise} Resolves if the password was reset. Rejects with an object + * with a "message" property which contains a human-readable message detailing why + * the reset failed, e.g. "There is no mapped matrix user ID for the given email address". + */ + checkEmailLinkClicked() { + return this.client.setPassword({ + type: "m.login.email.identity", + threepid_creds: { + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: this.identityServerDomain + } + }, this.password).catch(function(err) { + if (err.httpStatus === 401) { + err.message = "Failed to verify email address: make sure you clicked the link in the email"; + } + else if (err.httpStatus === 404) { + err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver."; + } + else if (err.httpStatus) { + err.message += ` (Status ${err.httpStatus})`; + } + throw err; + }); + } +} + +// from Angular SDK +function generateClientSecret() { + var ret = ""; + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (var i = 0; i < 32; i++) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return ret; +} + +module.exports = PasswordReset; diff --git a/src/Presence.js b/src/Presence.js index 5c9d6945a3..4152d7a487 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -73,6 +73,11 @@ class Presence { } var old_state = this.state; this.state = newState; + + if (MatrixClientPeg.get().isGuest()) { + return; // don't try to set presence when a guest; it won't work. + } + var self = this; MatrixClientPeg.get().setPresence(this.state).done(function() { console.log("Presence: %s", newState); diff --git a/src/Signup.js b/src/Signup.js index 74c4ad5f19..42468959fe 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -69,6 +69,10 @@ class Register extends Signup { this.params.idSid = idSid; } + setGuestAccessToken(token) { + this.guestAccessToken = token; + } + getStep() { return this._step; } @@ -126,7 +130,8 @@ class Register extends Signup { } return MatrixClientPeg.get().register( - this.username, this.password, this.params.sessionId, authDict, bindEmail + this.username, this.password, this.params.sessionId, authDict, bindEmail, + this.guestAccessToken ).then(function(result) { self.credentials = result; self.setStep("COMPLETE"); diff --git a/src/UserActivity.js b/src/UserActivity.js index 3048ad4454..8b136c0bcc 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -31,6 +31,11 @@ class UserActivity { start() { document.onmousemove = this._onUserActivity.bind(this); document.onkeypress = this._onUserActivity.bind(this); + // can't use document.scroll here because that's only the document + // itself being scrolled. Need to use addEventListener's useCapture. + // also this needs to be the wheel event, not scroll, as scroll is + // fired when the view scrolls down for a new message. + window.addEventListener('wheel', this._onUserActivity.bind(this), true); this.lastActivityAtTs = new Date().getTime(); this.lastDispatchAtTs = 0; } @@ -41,10 +46,11 @@ class UserActivity { stop() { document.onmousemove = undefined; document.onkeypress = undefined; + window.removeEventListener('wheel', this._onUserActivity.bind(this), true); } _onUserActivity(event) { - if (event.screenX) { + if (event.screenX && event.type == "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 1b1e8810a9..45aca1f0dc 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -15,7 +15,7 @@ limitations under the License. */ 'use strict'; - +var q = require("q"); var MatrixClientPeg = require("./MatrixClientPeg"); var Notifier = require("./Notifier"); @@ -35,6 +35,11 @@ module.exports = { }, loadThreePids: function() { + if (MatrixClientPeg.get().isGuest()) { + return q({ + threepids: [] + }); // guests can't poke 3pid endpoint + } return MatrixClientPeg.get().getThreePids(); }, diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d973a17f7f..066b1e2d05 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -36,7 +36,7 @@ module.exports = React.createClass({ var old = oldChildren[c.key]; var oldNode = ReactDom.findDOMNode(self.nodes[old.key]); - if (oldNode.style.left != c.props.style.left) { + if (oldNode && 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 @@ -73,6 +73,7 @@ module.exports = React.createClass({ collectNode: function(k, node) { if ( + node && this.nodes[k] === undefined && node.props.startStyle && Object.keys(node.props.startStyle).length diff --git a/src/component-index.js b/src/component-index.js index 7975b9a360..329e46b0fe 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -23,14 +23,15 @@ limitations under the License. module.exports.components = {}; module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); +module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); +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.ScrollPanel'] = require('./components/structures/ScrollPanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); -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['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'); @@ -52,10 +53,10 @@ module.exports.components['views.login.LoginHeader'] = require('./components/vie 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.MessageEvent'] = require('./components/views/messages/MessageEvent'); 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'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index ef77787035..e5af2a86b5 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -43,6 +43,7 @@ module.exports = React.createClass({ ConferenceHandler: React.PropTypes.any, onNewScreen: React.PropTypes.func, registrationUrl: React.PropTypes.string, + enableGuest: React.PropTypes.bool, startingQueryParams: React.PropTypes.object }, @@ -84,8 +85,21 @@ module.exports = React.createClass({ }, componentDidMount: function() { + this._autoRegisterAsGuest = false; + if (this.props.enableGuest) { + if (!this.props.config || !this.props.config.default_hs_url) { + console.error("Cannot enable guest access: No supplied config prop for HS/IS URLs"); + } + else { + this._autoRegisterAsGuest = true; + } + } + this.dispatcherRef = dis.register(this.onAction); if (this.state.logged_in) { + // Don't auto-register as a guest. This applies if you refresh the page on a + // logged in client THEN hit the Sign Out button. + this._autoRegisterAsGuest = false; this.startMatrixClient(); } this.focusComposer = false; @@ -94,8 +108,11 @@ module.exports = React.createClass({ this.scrollStateMap = {}; document.addEventListener("keydown", this.onKeyDown); window.addEventListener("focus", this.onFocus); + if (this.state.logged_in) { this.notifyNewScreen(''); + } else if (this._autoRegisterAsGuest) { + this._registerAsGuest(); } else { this.notifyNewScreen('login'); } @@ -127,6 +144,34 @@ module.exports = React.createClass({ } }, + _registerAsGuest: function() { + var self = this; + var config = this.props.config; + console.log("Doing guest login on %s", config.default_hs_url); + MatrixClientPeg.replaceUsingUrls( + config.default_hs_url, config.default_is_url + ); + MatrixClientPeg.get().registerGuest().done(function(creds) { + console.log("Registered as guest: %s", creds.user_id); + self._setAutoRegisterAsGuest(false); + self.onLoggedIn({ + userId: creds.user_id, + accessToken: creds.access_token, + homeserverUrl: config.default_hs_url, + identityServerUrl: config.default_is_url, + guest: true + }); + }, function(err) { + console.error(err.data); + self._setAutoRegisterAsGuest(false); + }); + }, + + _setAutoRegisterAsGuest: function(shouldAutoRegister) { + this._autoRegisterAsGuest = shouldAutoRegister; + this.forceUpdate(); + }, + onAction: function(payload) { var roomIndexDelta = 1; @@ -181,6 +226,21 @@ module.exports = React.createClass({ screen: 'post_registration' }); break; + case 'start_upgrade_registration': + this.replaceState({ + screen: "register", + upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), + guestAccessToken: MatrixClientPeg.get().getAccessToken() + }); + this.notifyNewScreen('register'); + break; + case 'start_password_recovery': + if (this.state.logged_in) return; + this.replaceState({ + screen: 'forgot_password' + }); + this.notifyNewScreen('forgot_password'); + break; case 'token_login': if (this.state.logged_in) return; @@ -392,10 +452,11 @@ module.exports = React.createClass({ }, onLoggedIn: function(credentials) { - console.log("onLoggedIn => %s", credentials.userId); + credentials.guest = Boolean(credentials.guest); + console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest); MatrixClientPeg.replaceUsingAccessToken( credentials.homeserverUrl, credentials.identityServerUrl, - credentials.userId, credentials.accessToken + credentials.userId, credentials.accessToken, credentials.guest ); this.setState({ screen: undefined, @@ -515,6 +576,11 @@ module.exports = React.createClass({ action: 'token_login', params: params }); + } else if (screen == 'forgot_password') { + dis.dispatch({ + action: 'start_password_recovery', + params: params + }); } else if (screen == 'new') { dis.dispatch({ action: 'view_create_room', @@ -624,6 +690,10 @@ module.exports = React.createClass({ this.showScreen("login"); }, + onForgotPasswordClick: function() { + this.showScreen("forgot_password"); + }, + onRegistered: function(credentials) { this.onLoggedIn(credentials); // do post-registration stuff @@ -662,6 +732,7 @@ module.exports = React.createClass({ var CreateRoom = sdk.getComponent('structures.CreateRoom'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); + var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); // needs to be before normal PageTypes as you are logged in technically if (this.state.screen == 'post_registration') { @@ -725,12 +796,20 @@ module.exports = React.createClass({ ); } - } else if (this.state.logged_in) { + } else if (this.state.logged_in || (!this.state.logged_in && this._autoRegisterAsGuest)) { var Spinner = sdk.getComponent('elements.Spinner'); + var logoutLink; + if (this.state.logged_in) { + logoutLink = ( + + Logout + + ); + } return (
- Logout + {logoutLink}
); } else if (this.state.screen == 'register') { @@ -740,19 +819,30 @@ module.exports = React.createClass({ sessionId={this.state.register_session_id} idSid={this.state.register_id_sid} email={this.props.startingQueryParams.email} + username={this.state.upgradeUsername} + disableUsernameChanges={Boolean(this.state.upgradeUsername)} + guestAccessToken={this.state.guestAccessToken} hsUrl={this.props.config.default_hs_url} isUrl={this.props.config.default_is_url} registrationUrl={this.props.registrationUrl} onLoggedIn={this.onRegistered} onLoginClick={this.onLoginClick} /> ); + } else if (this.state.screen == 'forgot_password') { + return ( + + ); } else { return ( + identityServerUrl={this.props.config.default_is_url} + onForgotPasswordClick={this.onForgotPasswordClick} /> ); } } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 9249a26351..f3083bc717 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -41,6 +41,7 @@ var Tinter = require("../../Tinter"); var PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; +var SEND_READ_RECEIPT_DELAY = 2000; var DEBUG_SCROLL = false; @@ -75,6 +76,8 @@ module.exports = React.createClass({ syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, + readMarkerEventId: room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId), + readMarkerGhostEventId: undefined, } }, @@ -99,9 +102,33 @@ module.exports = React.createClass({ this.forceUpdate(); } }); + // if this is an unknown room then we're in one of three states: + // - This is a room we can peek into (search engine) (we can /peek) + // - This is a room we can publicly join or were invited to. (we can /join) + // - This is a room we cannot join at all. (no action can help us) + // We can't try to /join because this may implicitly accept invites (!) + // We can /peek though. If it fails then we present the join UI. If it + // succeeds then great, show the preview (but we still may be able to /join!). + if (!this.state.room) { + console.log("Attempting to peek into room %s", this.props.roomId); + MatrixClientPeg.get().peekInRoom(this.props.roomId).done(function() { + // we don't need to do anything - JS SDK will emit Room events + // which will update the UI. + }, function(err) { + console.error("Failed to peek into room: %s", err); + }); + } + + }, componentWillUnmount: function() { + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted, but facebook have deprecated that.) + this.unmounted = true; + if (this.refs.messagePanel) { // disconnect the D&D event listeners from the message panel. This // is really just for hygiene - the messagePanel is going to be @@ -201,7 +228,7 @@ module.exports = React.createClass({ },*/ onRoomTimeline: function(ev, room, toStartOfTimeline) { - if (!this.isMounted()) return; + if (this.unmounted) return; // ignore anything that comes in whilst paginating: we get one // event for each new matrix event so this would cause a huge @@ -265,7 +292,33 @@ module.exports = React.createClass({ onRoomReceipt: function(receiptEvent, room) { if (room.roomId == this.props.roomId) { - this.forceUpdate(); + var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); + var readMarkerGhostEventId = this.state.readMarkerGhostEventId; + if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) { + readMarkerGhostEventId = this.state.readMarkerEventId; + } + + + // if the event after the one referenced in the read receipt if sent by us, do nothing since + // this is a temporary period before the synthesized receipt for our own message arrives + var readMarkerGhostEventIndex; + for (var i = 0; i < room.timeline.length; ++i) { + if (room.timeline[i].getId() == readMarkerGhostEventId) { + readMarkerGhostEventIndex = i; + break; + } + } + if (readMarkerGhostEventIndex + 1 < room.timeline.length) { + var nextEvent = room.timeline[readMarkerGhostEventIndex + 1]; + if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) { + readMarkerGhostEventId = undefined; + } + } + + this.setState({ + readMarkerEventId: readMarkerEventId, + readMarkerGhostEventId: readMarkerGhostEventId, + }); } }, @@ -383,11 +436,14 @@ module.exports = React.createClass({ _paginateCompleted: function() { debuglog("paginate complete"); - this.setState({ - room: MatrixClientPeg.get().getRoom(this.props.roomId) - }); + // we might have switched rooms since the paginate started - just bin + // the results if so. + if (this.unmounted) return; - this.setState({paginating: false}); + this.setState({ + room: MatrixClientPeg.get().getRoom(this.props.roomId), + paginating: false, + }); }, onSearchResultsFillRequest: function(backwards) { @@ -452,6 +508,12 @@ module.exports = React.createClass({ joining: false, joinError: error }); + var msg = error.message ? error.message : JSON.stringify(error); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to join room", + description: msg + }); }); this.setState({ joining: true @@ -565,7 +627,7 @@ module.exports = React.createClass({ return searchPromise.then(function(results) { debuglog("search complete"); - if (!self.state.searching || self.searchId != localSearchId) { + if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { console.error("Discarding stale search results"); return; } @@ -583,7 +645,8 @@ module.exports = React.createClass({ // For overlapping highlights, // favour longer (more specific) terms first - highlights = highlights.sort(function(a, b) { b.length - a.length }); + highlights = highlights.sort(function(a, b) { + return b.length - a.length }); self.setState({ searchHighlights: highlights, @@ -678,10 +741,10 @@ module.exports = React.createClass({ var EventTile = sdk.getComponent('rooms.EventTile'); - var prevEvent = null; // the last event we showed - var readReceiptEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap); + var readMarkerIndex; + var ghostIndex; for (var i = startIdx; i < this.state.room.timeline.length; i++) { var mxEv = this.state.room.timeline[i]; @@ -695,6 +758,25 @@ module.exports = React.createClass({ } } + // now we've decided whether or not to show this message, + // add the read up to marker if appropriate + // doing this here means we implicitly do not show the marker + // if it's at the bottom + // NB. it would be better to decide where the read marker was going + // when the state changed rather than here in the render method, but + // this is where we decide what messages we show so it's the only + // place we know whether we're at the bottom or not. + var self = this; + var mxEvSender = mxEv.sender ? mxEv.sender.userId : null; + if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) { + var hr; + hr = (
); + readMarkerIndex = ret.length; + ret.push(
  • {hr}
  • ); + } + // is this a continuation of the previous message? var continuation = false; if (prevEvent !== null) { @@ -731,17 +813,33 @@ module.exports = React.createClass({ ); - if (eventId == readReceiptEventId) { - ret.push(
    ); + // A read up to marker has died and returned as a ghost! + // Lives in the dom as the ghost of the previous one while it fades away + if (eventId == this.state.readMarkerGhostEventId) { + ghostIndex = ret.length; } prevEvent = mxEv; } + // splice the read marker ghost in now that we know whether the read receipt + // is the last element or not, because we only decide as we're going along. + if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) { + var hr; + hr = (
    ); + ret.splice(ghostIndex, 0, ( +
  • {hr}
  • + )); + } + return ret; }, - uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels, new_color_scheme) { + uploadNewState: function(newVals) { var old_name = this.state.room.name; var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', ''); @@ -767,54 +865,63 @@ module.exports = React.createClass({ var deferreds = []; - if (old_name != new_name && new_name != undefined) { + if (old_name != newVals.name && newVals.name != undefined) { deferreds.push( - MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name) + MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name) ); } - if (old_topic != new_topic && new_topic != undefined) { + if (old_topic != newVals.topic && newVals.topic != undefined) { deferreds.push( - MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic) + MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, newVals.topic) ); } - if (old_join_rule != new_join_rule && new_join_rule != undefined) { + if (old_join_rule != newVals.join_rule && newVals.join_rule != undefined) { deferreds.push( MatrixClientPeg.get().sendStateEvent( this.state.room.roomId, "m.room.join_rules", { - join_rule: new_join_rule, + join_rule: newVals.join_rule, }, "" ) ); } - if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) { + if (old_history_visibility != newVals.history_visibility && + newVals.history_visibility != undefined) { deferreds.push( MatrixClientPeg.get().sendStateEvent( this.state.room.roomId, "m.room.history_visibility", { - history_visibility: new_history_visibility, + history_visibility: newVals.history_visibility, }, "" ) ); } - if (new_power_levels) { + if (newVals.power_levels) { deferreds.push( MatrixClientPeg.get().sendStateEvent( - this.state.room.roomId, "m.room.power_levels", new_power_levels, "" + this.state.room.roomId, "m.room.power_levels", newVals.power_levels, "" ) ); } - if (new_color_scheme) { + if (newVals.color_scheme) { deferreds.push( MatrixClientPeg.get().setRoomAccountData( - this.state.room.roomId, "org.matrix.room.color_scheme", new_color_scheme + this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme ) ); } + deferreds.push( + MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { + allowRead: newVals.guest_read, + allowJoin: newVals.guest_join + }) + ); + + if (deferreds.length) { var self = this; q.all(deferreds).fail(function(err) { @@ -899,21 +1006,16 @@ module.exports = React.createClass({ uploadingRoomSettings: true, }); - var new_name = this.refs.header.getRoomName(); - var new_topic = this.refs.header.getTopic(); - var new_join_rule = this.refs.room_settings.getJoinRules(); - var new_history_visibility = this.refs.room_settings.getHistoryVisibility(); - var new_power_levels = this.refs.room_settings.getPowerLevels(); - var new_color_scheme = this.refs.room_settings.getColorScheme(); - - this.uploadNewState( - new_name, - new_topic, - new_join_rule, - new_history_visibility, - new_power_levels, - new_color_scheme - ); + this.uploadNewState({ + name: this.refs.header.getRoomName(), + topic: this.refs.room_settings.getTopic(), + join_rule: this.refs.room_settings.getJoinRules(), + history_visibility: this.refs.room_settings.getHistoryVisibility(), + power_levels: this.refs.room_settings.getPowerLevels(), + guest_join: this.refs.room_settings.canGuestsJoin(), + guest_read: this.refs.room_settings.canGuestsRead(), + color_scheme: this.refs.room_settings.getColorScheme(), + }); }, onCancelClick: function() { @@ -1074,10 +1176,23 @@ module.exports = React.createClass({ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; if (this.refs.callView) { - // XXX: don't understand why we have to call findDOMNode here in react 0.14 - it should already be a DOM node. - var video = ReactDOM.findDOMNode(this.refs.callView.refs.video.refs.remote); + var video = this.refs.callView.getVideoView().getRemoteVideoElement(); + + // header + footer + status + give us at least 100px of scrollback at all times. + auxPanelMaxHeight = window.innerHeight - + (83 + 72 + + sdk.getComponent('rooms.MessageComposer').MAX_HEIGHT + + 100); + + // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway + // but it's better than the video going missing entirely + if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; video.style.maxHeight = auxPanelMaxHeight + "px"; + + // the above might have made the video panel resize itself, so now + // we need to tell the gemini panel to adapt. + this.onChildResize(); } // we need to do this for general auxPanels too @@ -1117,6 +1232,15 @@ module.exports = React.createClass({ }); }, + onChildResize: function() { + // When the video or the message composer resizes, the scroll panel + // also changes size. Work around GeminiScrollBar fail by telling it + // about it. This also ensures that the scroll offset is updated. + if (this.refs.messagePanel) { + this.refs.messagePanel.forceUpdate(); + } + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1307,7 +1431,7 @@ module.exports = React.createClass({ if (canSpeak) { messageComposer = } @@ -1410,7 +1534,8 @@ module.exports = React.createClass({ } /> { fileDropTarget }
    - + { conferenceCallNotification } { aux }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 042458717d..8d26b2e365 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -112,6 +112,14 @@ module.exports = React.createClass({ this.checkFillState(); }, + componentWillUnmount: function() { + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted(), but facebook have deprecated that.) + this.unmounted = true; + }, + onScroll: function(ev) { var sn = this._getScrollNode(); debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); @@ -158,6 +166,10 @@ module.exports = React.createClass({ // check the scroll state and send out backfill requests if necessary. checkFillState: function() { + if (this.unmounted) { + return; + } + var sn = this._getScrollNode(); // if there is less than a screenful of messages above or below the @@ -346,6 +358,12 @@ module.exports = React.createClass({ * message panel. */ _getScrollNode: function() { + if (this.unmounted) { + // this shouldn't happen, but when it does, turn the NPE into + // something more meaningful. + throw new Error("ScrollPanel._getScrollNode called when unmounted"); + } + var panel = ReactDOM.findDOMNode(this.refs.geminiPanel); // If the gemini scrollbar is doing its thing, this will be a div within diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index c1550f9b6b..ddf4229170 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -135,6 +135,12 @@ module.exports = React.createClass({ }); }, + onUpgradeClicked: function() { + dis.dispatch({ + action: "start_upgrade_registration" + }); + }, + onLogoutPromptCancel: function() { this.logoutModal.closeDialog(); }, @@ -164,6 +170,28 @@ module.exports = React.createClass({ this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null ); + var accountJsx; + + if (MatrixClientPeg.get().isGuest()) { + accountJsx = ( +
    + Upgrade (It's free!) +
    + ); + } + else { + accountJsx = ( + + ); + } + return (
    @@ -213,14 +241,7 @@ module.exports = React.createClass({

    Account

    - + {accountJsx}
    diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js new file mode 100644 index 0000000000..dcf6a7c28e --- /dev/null +++ b/src/components/structures/login/ForgotPassword.js @@ -0,0 +1,199 @@ +/* +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. +*/ + +'use strict'; + +var React = require('react'); +var sdk = require('../../../index'); +var Modal = require("../../../Modal"); +var MatrixClientPeg = require('../../../MatrixClientPeg'); + +var PasswordReset = require("../../../PasswordReset"); + +module.exports = React.createClass({ + displayName: 'ForgotPassword', + + propTypes: { + homeserverUrl: React.PropTypes.string, + identityServerUrl: React.PropTypes.string, + onComplete: React.PropTypes.func.isRequired + }, + + getInitialState: function() { + return { + enteredHomeserverUrl: this.props.homeserverUrl, + enteredIdentityServerUrl: this.props.identityServerUrl, + progress: null + }; + }, + + submitPasswordReset: function(hsUrl, identityUrl, email, password) { + this.setState({ + progress: "sending_email" + }); + this.reset = new PasswordReset(hsUrl, identityUrl); + this.reset.resetPassword(email, password).done(() => { + this.setState({ + progress: "sent_email" + }); + }, (err) => { + this.showErrorDialog("Failed to send email: " + err.message); + this.setState({ + progress: null + }); + }) + }, + + onVerify: function(ev) { + ev.preventDefault(); + if (!this.reset) { + console.error("onVerify called before submitPasswordReset!"); + return; + } + this.reset.checkEmailLinkClicked().done((res) => { + this.setState({ progress: "complete" }); + }, (err) => { + this.showErrorDialog(err.message); + }) + }, + + onSubmitForm: function(ev) { + ev.preventDefault(); + + if (!this.state.email) { + this.showErrorDialog("The email address linked to your account must be entered."); + } + else if (!this.state.password || !this.state.password2) { + this.showErrorDialog("A new password must be entered."); + } + else if (this.state.password !== this.state.password2) { + this.showErrorDialog("New passwords must match each other."); + } + else { + this.submitPasswordReset( + this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, + this.state.email, this.state.password + ); + } + }, + + onInputChanged: function(stateKey, ev) { + this.setState({ + [stateKey]: ev.target.value + }); + }, + + onHsUrlChanged: function(newHsUrl) { + this.setState({ + enteredHomeserverUrl: newHsUrl + }); + }, + + onIsUrlChanged: function(newIsUrl) { + this.setState({ + enteredIdentityServerUrl: newIsUrl + }); + }, + + showErrorDialog: function(body, title) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: title, + description: body + }); + }, + + render: function() { + var LoginHeader = sdk.getComponent("login.LoginHeader"); + var LoginFooter = sdk.getComponent("login.LoginFooter"); + var ServerConfig = sdk.getComponent("login.ServerConfig"); + var Spinner = sdk.getComponent("elements.Spinner"); + + var resetPasswordJsx; + + if (this.state.progress === "sending_email") { + resetPasswordJsx = + } + else if (this.state.progress === "sent_email") { + resetPasswordJsx = ( +
    + An email has been sent to {this.state.email}. Once you've followed + the link it contains, click below. +
    + +
    + ); + } + else if (this.state.progress === "complete") { + resetPasswordJsx = ( +
    +

    Your password has been reset.

    +

    You have been logged out of all devices and will no longer receive push notifications. + To re-enable notifications, re-log in on each device.

    + +
    + ); + } + else { + resetPasswordJsx = ( +
    + To reset your password, enter the email address linked to your account: +
    +
    +
    + +
    + +
    + +
    + +
    + + +
    +
    + ); + } + + + return ( +
    +
    + + {resetPasswordJsx} +
    +
    + ); + } +}); diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index b7d2d762a4..b853b8fd95 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -33,7 +33,9 @@ module.exports = React.createClass({displayName: 'Login', homeserverUrl: React.PropTypes.string, identityServerUrl: React.PropTypes.string, // login shouldn't know or care how registration is done. - onRegisterClick: React.PropTypes.func.isRequired + onRegisterClick: React.PropTypes.func.isRequired, + // login shouldn't care how password recovery is done. + onForgotPasswordClick: React.PropTypes.func }, getDefaultProps: function() { @@ -138,7 +140,9 @@ module.exports = React.createClass({displayName: 'Login', switch (step) { case 'm.login.password': return ( - + ); case 'm.login.cas': return ( diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 7f6e408fef..f89d65d740 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -19,7 +19,6 @@ limitations under the License. var React = require('react'); var sdk = require('../../../index'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); var dis = require('../../../dispatcher'); var Signup = require("../../../Signup"); var ServerConfig = require("../../views/login/ServerConfig"); @@ -40,6 +39,9 @@ module.exports = React.createClass({ hsUrl: React.PropTypes.string, isUrl: React.PropTypes.string, email: React.PropTypes.string, + username: React.PropTypes.string, + guestAccessToken: React.PropTypes.string, + disableUsernameChanges: React.PropTypes.bool, // registration shouldn't know or care how login is done. onLoginClick: React.PropTypes.func.isRequired }, @@ -63,6 +65,7 @@ module.exports = React.createClass({ this.registerLogic.setSessionId(this.props.sessionId); this.registerLogic.setRegistrationUrl(this.props.registrationUrl); this.registerLogic.setIdSid(this.props.idSid); + this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); this.registerLogic.recheckState(); }, @@ -186,7 +189,9 @@ module.exports = React.createClass({ registerStep = ( diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 3367ac3257..a8751da1a7 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -22,7 +22,8 @@ var ReactDOM = require('react-dom'); */ module.exports = React.createClass({displayName: 'PasswordLogin', propTypes: { - onSubmit: React.PropTypes.func.isRequired // fn(username, password) + onSubmit: React.PropTypes.func.isRequired, // fn(username, password) + onForgotPasswordClick: React.PropTypes.func // fn() }, getInitialState: function() { @@ -46,6 +47,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin', }, render: function() { + var forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = ( + + Forgot your password? + + ); + } + return (
    @@ -57,6 +68,7 @@ module.exports = React.createClass({displayName: 'PasswordLogin', value={this.state.password} onChange={this.onPasswordChanged} placeholder="Password" />
    + {forgotPasswordJsx}
    diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index bc461dd1bb..534464a4ae 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -30,6 +30,7 @@ module.exports = React.createClass({ defaultUsername: React.PropTypes.string, showEmail: React.PropTypes.bool, minPasswordLength: React.PropTypes.number, + disableUsernameChanges: React.PropTypes.bool, onError: React.PropTypes.func, onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise }, @@ -109,7 +110,8 @@ module.exports = React.createClass({ {emailSection}
    + placeholder="User name" defaultValue={this.state.username} + disabled={this.props.disableUsernameChanges} />
    diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 083cee46a2..a3ad033acc 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -65,8 +65,17 @@ function mdownToHtml(mdown) { module.exports = React.createClass({ displayName: 'MessageComposer', + statics: { + // the height we limit the composer to + MAX_HEIGHT: 100, + }, + propTypes: { - tabComplete: React.PropTypes.any + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, }, componentWillMount: function() { @@ -237,13 +246,15 @@ module.exports = React.createClass({ // scrollHeight is at least equal to clientHeight, so we have to // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value this.refs.textarea.style.height = "0px"; - var newHeight = this.refs.textarea.scrollHeight < 100 ? this.refs.textarea.scrollHeight : 100; + var newHeight = Math.min(this.refs.textarea.scrollHeight, + this.constructor.MAX_HEIGHT); this.refs.textarea.style.height = Math.ceil(newHeight) + "px"; - if (this.props.roomView) { - // kick gemini-scrollbar to re-layout - this.props.roomView.forceUpdate(); - } this.oldScrollHeight = this.refs.textarea.scrollHeight; + + if (this.props.onResize) { + // kick gemini-scrollbar to re-layout + this.props.onResize(); + } }, onKeyUp: function(ev) { diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 8fed0bbbab..f3b3b356df 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -77,6 +77,14 @@ module.exports = React.createClass({ }; }, + canGuestsJoin: function() { + return this.refs.guests_join.checked; + }, + + canGuestsRead: function() { + return this.refs.guests_read.checked; + }, + getTopic: function() { return this.refs.topic.value; }, @@ -162,6 +170,10 @@ module.exports = React.createClass({ if (history_visibility) history_visibility = history_visibility.getContent().history_visibility; var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + var guest_access = this.props.room.currentState.getStateEvents('m.room.guest_access', ''); + if (guest_access) { + guest_access = guest_access.getContent().guest_access; + } var events_levels = power_levels.events || {}; @@ -361,8 +373,15 @@ module.exports = React.createClass({


    -
    - +
    +
    +
    { room_colors_section } diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js index bc82556163..ed44313b9e 100644 --- a/src/components/views/voip/CallView.js +++ b/src/components/views/voip/CallView.js @@ -33,6 +33,12 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); module.exports = React.createClass({ displayName: 'CallView', + propTypes: { + // a callback which is called when the video within the callview + // due to a change in video metadata + onResize: React.PropTypes.func, + }, + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); if (this.props.room) { @@ -97,7 +103,7 @@ module.exports = React.createClass({ render: function(){ var VideoView = sdk.getComponent('voip.VideoView'); return ( - + ); } }); diff --git a/src/components/views/voip/VideoFeed.js b/src/components/views/voip/VideoFeed.js index e4833dba9f..c4a65d1145 100644 --- a/src/components/views/voip/VideoFeed.js +++ b/src/components/views/voip/VideoFeed.js @@ -21,9 +21,29 @@ var React = require('react'); module.exports = React.createClass({ displayName: 'VideoFeed', + propTypes: { + // a callback which is called when the video element is resized + // due to a change in video metadata + onResize: React.PropTypes.func, + }, + + componentDidMount() { + this.refs.vid.addEventListener('resize', this.onResize); + }, + + componentWillUnmount() { + this.refs.vid.removeEventListener('resize', this.onResize); + }, + + onResize: function(e) { + if(this.props.onResize) { + this.props.onResize(e); + } + }, + render: function() { return ( -