diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf53..00000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..880331a09e5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# Copyright 2017 Aviral Dasgupta +# +# 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. + +root = true + +[*] +charset=utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.eslintrc.js b/.eslintrc.js index 34d3af270c9..6cd0e1015ec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { plugins: [ "react", "flowtype", + "babel" ], env: { es6: true, @@ -23,6 +24,11 @@ module.exports = { } }, rules: { + // eslint's built in no-invalid-this rule breaks with class properties + "no-invalid-this": "off", + // so we replace it with a version that is class property aware + "babel/no-invalid-this": "error", + /** react **/ // This just uses the react plugin to help eslint known when // variables have been used in JSX diff --git a/jenkins.sh b/jenkins.sh index c1fba19e945..6a77911c272 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -3,7 +3,7 @@ set -e export KARMAFLAGS="--no-colors" -export NVM_DIR="/home/jenkins/.nvm" +export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" nvm use 4 diff --git a/package.json b/package.json index 95bfafbb74a..a07e2236aa8 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.7.5", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.4.0", @@ -90,6 +90,7 @@ "babel-preset-react": "^6.11.1", "eslint": "^3.13.1", "eslint-config-google": "^0.7.1", + "eslint-plugin-babel": "^4.0.1", "eslint-plugin-flowtype": "^2.30.0", "eslint-plugin-react": "^6.9.0", "expect": "^1.16.0", diff --git a/src/CallHandler.js b/src/CallHandler.js index 268a599d8ef..42cc681d085 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -105,6 +105,15 @@ function _setCallListeners(call) { call.hangup(); _setCallState(undefined, call.roomId, "ended"); }); + call.on('send_event_error', function(err) { + if (err.name === "UnknownDeviceError") { + dis.dispatch({ + action: 'unknown_device_error', + err: err, + room: MatrixClientPeg.get().getRoom(call.roomId), + }); + } + }); call.on("hangup", function() { _setCallState(undefined, call.roomId, "ended"); }); @@ -301,9 +310,10 @@ function _onAction(payload) { placeCall(call); }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Conference call failed: " + err); Modal.createDialog(ErrorDialog, { title: "Failed to set up conference call", - description: "Conference call failed: " + err, + description: "Conference call failed.", }); }); } diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 17c8155c1bc..4ab982c98f3 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -276,7 +276,7 @@ class ContentMessages { sendContentToRoom(file, roomId, matrixClient) { const content = { - body: file.name, + body: file.name || 'Attachment', info: { size: file.size, } @@ -316,7 +316,7 @@ class ContentMessages { } const upload = { - fileName: file.name, + fileName: file.name || 'Attachment', roomId: roomId, total: 0, loaded: 0, diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index c7b13bc0713..f1420d0a221 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -28,6 +28,7 @@ emojione.imagePathSVG = 'emojione/svg/'; emojione.imageType = 'svg'; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); +const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js * because we want to include emoji shortnames in title text @@ -57,6 +58,22 @@ export function unicodeToImage(str) { return str; } +/** + * Given one or more unicode characters (represented by unicode + * character number), return an image node with the corresponding + * emoji. + * + * @param alt {string} String to use for the image alt text + * @param unicode {integer} One or more integers representing unicode characters + * @returns A img node with the corresponding emoji + */ +export function charactersToImageNode(alt, ...unicode) { + const fileName = unicode.map((u) => { + return u.toString(16); + }).join('-'); + return {alt}; +} + export function stripParagraphs(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; @@ -87,11 +104,12 @@ var sanitizeHtmlParams = { // deliberately no h1/h2 to stop people shouting. 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', - 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre' + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', ], allowedAttributes: { // custom ones first: - font: ['color'], // custom to matrix + font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix // We don't currently allow img itself by default, but this // would make sense if we did @@ -136,6 +154,38 @@ var sanitizeHtmlParams = { attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName: tagName, attribs : attribs }; }, + '*': function(tagName, attribs) { + // Delete any style previously assigned, style is an allowedTag for font and span + // because attributes are stripped after transforming + delete attribs.style; + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper = { + 'data-mx-color': 'color', + 'data-mx-bg-color': 'background-color', + // $customAttributeKey: $cssAttributeKey + }; + + let style = ""; + Object.keys(customCSSMapper).forEach((customAttributeKey) => { + const cssAttributeKey = customCSSMapper[customAttributeKey]; + const customAttributeValue = attribs[customAttributeKey]; + if (customAttributeValue && + typeof customAttributeValue === 'string' && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";"; + delete attribs[customAttributeKey]; + } + }); + + if (style) { + attribs.style = style; + } + + return { tagName: tagName, attribs: attribs }; + }, }, }; @@ -290,7 +340,7 @@ export function bodyToHtml(content, highlights, opts) { } EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body.trim(); + let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; let match = EMOJI_REGEX.exec(contentBodyTrimmed); let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; diff --git a/src/Invite.js b/src/Invite.js index d1f03fe211e..0e8aca2cb55 100644 --- a/src/Invite.js +++ b/src/Invite.js @@ -19,8 +19,7 @@ import MultiInviter from './utils/MultiInviter'; const emailRegex = /^\S+@\S+\.\S+$/; -// We allow localhost for mxids to avoid confusion -const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/ +const mxidRegex = /^@\S+:\S+$/ export function getAddressType(inputText) { const isEmailAddress = emailRegex.test(inputText); diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 493bbf12aa5..fc8087e12d4 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +24,9 @@ import UserActivity from './UserActivity'; import Presence from './Presence'; import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; +import RtsClient from './RtsClient'; +import Modal from './Modal'; +import sdk from './index'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -108,16 +112,17 @@ export function loadSession(opts) { return q(); } - if (_restoreFromLocalStorage()) { - return q(); - } + return _restoreFromLocalStorage().then((success) => { + if (success) { + return; + } - if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); - } + if (enableGuest) { + return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + } - // fall back to login screen - return q(); + // fall back to login screen + }); } function _loginWithToken(queryParams, defaultDeviceDisplayName) { @@ -150,7 +155,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { console.log("Doing guest login on %s", hsUrl); - // TODO: we should probably de-duplicate this and Signup.Login.loginAsGuest. + // TODO: we should probably de-duplicate this and Login.loginAsGuest. // Not really sure where the right home for it is. // create a temporary MatrixClient to do the login @@ -177,10 +182,11 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }); } -// returns true if a session is found in localstorage +// returns a promise which resolves to true if a session is found in +// localstorage function _restoreFromLocalStorage() { if (!localStorage) { - return false; + return q(false); } const hs_url = localStorage.getItem("mx_hs_url"); const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; @@ -207,28 +213,60 @@ function _restoreFromLocalStorage() { identityServerUrl: is_url, guest: is_guest, }); - return true; + return q(true); } catch (e) { - console.log("Unable to restore session", e); - - var msg = e.message; - if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { - msg = "You need to log back in to generate end-to-end encryption keys " - + "for this device and submit the public key to your homeserver. " - + "This is a once off; sorry for the inconvenience."; - } - - // don't leak things into the new session - _clearLocalStorage(); - - throw new Error("Unable to restore previous session: " + msg); + return _handleRestoreFailure(e); } } else { console.log("No previous session found."); - return false; + return q(false); } } +function _handleRestoreFailure(e) { + console.log("Unable to restore session", e); + + let msg = e.message; + if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { + msg = "You need to log back in to generate end-to-end encryption keys " + + "for this device and submit the public key to your homeserver. " + + "This is a once off; sorry for the inconvenience."; + + _clearLocalStorage(); + + return q.reject(new Error( + "Unable to restore previous session: " + msg, + )); + } + + const def = q.defer(); + const SessionRestoreErrorDialog = + sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); + + Modal.createDialog(SessionRestoreErrorDialog, { + error: msg, + onFinished: (success) => { + def.resolve(success); + }, + }); + + return def.promise.then((success) => { + if (success) { + // user clicked continue. + _clearLocalStorage(); + return false; + } + + // try, try again + return _restoreFromLocalStorage(); + }); +} + +let rtsClient = null; +export function initRtsClient(url) { + rtsClient = new RtsClient(url); +} + /** * Transitions to a logged-in state using the given credentials * @param {MatrixClientCreds} credentials The credentials to use @@ -239,6 +277,9 @@ export function setLoggedIn(credentials) { credentials.userId, credentials.guest, credentials.homeserverUrl); + // Resolves by default + let teamPromise = Promise.resolve(null); + // persist the session if (localStorage) { try { @@ -261,13 +302,30 @@ export function setLoggedIn(credentials) { } catch (e) { console.warn("Error using local storage: can't persist session!", e); } + + if (rtsClient && !credentials.guest) { + teamPromise = rtsClient.login(credentials.userId).then((body) => { + if (body.team_token) { + localStorage.setItem("mx_team_token", body.team_token); + } + return body.team_token; + }); + } } else { console.warn("No local storage available: can't persist session!"); } + // stop any running clients before we create a new one with these new credentials + stopMatrixClient(); + MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({action: 'on_logged_in'}); + teamPromise.then((teamToken) => { + dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); + }, (err) => { + console.warn("Failed to get team token on login", err); + dis.dispatch({action: 'on_logged_in', teamToken: null}); + }); startMatrixClient(); } @@ -361,6 +419,7 @@ export function stopMatrixClient() { if (cli) { cli.stopClient(); cli.removeAllListeners(); + cli.store.deleteAllData(); MatrixClientPeg.unset(); } } diff --git a/src/Login.js b/src/Login.js new file mode 100644 index 00000000000..107a8825e94 --- /dev/null +++ b/src/Login.js @@ -0,0 +1,205 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations 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. +*/ + +import Matrix from "matrix-js-sdk"; + +import q from 'q'; +import url from 'url'; + +export default class Login { + constructor(hsUrl, isUrl, fallbackHsUrl, opts) { + this._hsUrl = hsUrl; + this._isUrl = isUrl; + this._fallbackHsUrl = fallbackHsUrl; + this._currentFlowIndex = 0; + this._flows = []; + this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + } + + getHomeserverUrl() { + return this._hsUrl; + } + + getIdentityServerUrl() { + return this._isUrl; + } + + setHomeserverUrl(hsUrl) { + this._hsUrl = hsUrl; + } + + setIdentityServerUrl(isUrl) { + this._isUrl = isUrl; + } + + /** + * Get a temporary MatrixClient, which can be used for login or register + * requests. + */ + _createTemporaryClient() { + return Matrix.createClient({ + baseUrl: this._hsUrl, + idBaseUrl: this._isUrl, + }); + } + + getFlows() { + var self = this; + var client = this._createTemporaryClient(); + return client.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; + } + + loginAsGuest() { + var client = this._createTemporaryClient(); + return client.registerGuest({ + body: { + initial_device_display_name: this._defaultDeviceDisplayName, + }, + }).then((creds) => { + return { + userId: creds.user_id, + deviceId: creds.device_id, + accessToken: creds.access_token, + homeserverUrl: this._hsUrl, + identityServerUrl: this._isUrl, + guest: true + }; + }, (error) => { + if (error.httpStatus === 403) { + error.friendlyText = "Guest access is disabled on this Home Server."; + } else { + error.friendlyText = "Failed to register as guest: " + error.data; + } + throw error; + }); + } + + loginViaPassword(username, phoneCountry, phoneNumber, pass) { + const self = this; + + const isEmail = username.indexOf("@") > 0; + + let identifier; + let legacyParams; // parameters added to support old HSes + if (phoneCountry && phoneNumber) { + identifier = { + type: 'm.id.phone', + country: phoneCountry, + number: phoneNumber, + }; + // No legacy support for phone number login + } else if (isEmail) { + identifier = { + type: 'm.id.thirdparty', + medium: 'email', + address: username, + }; + legacyParams = { + medium: 'email', + address: username, + }; + } else { + identifier = { + type: 'm.id.user', + user: username, + }; + legacyParams = { + user: username, + }; + } + + const loginParams = { + password: pass, + identifier: identifier, + initial_device_display_name: this._defaultDeviceDisplayName, + }; + Object.assign(loginParams, legacyParams); + + const client = this._createTemporaryClient(); + return client.login('m.login.password', loginParams).then(function(data) { + return q({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_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.' + ); + if (self._fallbackHsUrl) { + var fbClient = Matrix.createClient({ + baseUrl: self._fallbackHsUrl, + idBaseUrl: this._isUrl, + }); + + return fbClient.login('m.login.password', loginParams).then(function(data) { + return q({ + homeserverUrl: self._fallbackHsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token + }); + }, function(fallback_error) { + // throw the original error + throw error; + }); + } + } + else { + error.friendlyText = ( + 'There was a problem logging in. (HTTP ' + error.httpStatus + ")" + ); + } + throw error; + }); + } + + redirectToCas() { + var client = this._createTemporaryClient(); + var parsedUrl = url.parse(window.location.href, true); + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); + parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); + var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + window.location.href = casUrl; + } +} diff --git a/src/Markdown.js b/src/Markdown.js index d6dc979a5a8..4a46ce4f249 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -92,7 +92,16 @@ export default class Markdown { } toHTML() { - const renderer = new commonmark.HtmlRenderer({safe: false}); + const renderer = new commonmark.HtmlRenderer({ + safe: false, + + // Set soft breaks to hard HTML breaks: commonmark + // puts softbreaks in for multiple lines in a blockquote, + // so if these are just newline characters then the + // block quote ends up all on one line + // (https://github.com/vector-im/riot-web/issues/3154) + softbreak: '
', + }); const real_paragraph = renderer.paragraph; renderer.paragraph = function(node, entering) { diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 9c0daf47263..baa32930735 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -16,6 +16,7 @@ limitations under the License. 'use strict'; +import q from "q"; import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; @@ -71,7 +72,16 @@ class MatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; - this.get().startClient(opts); + + let promise = this.matrixClient.store.startup(); + // log any errors when starting up the database (if one exists) + promise.catch((err) => { console.error(err); }); + + // regardless of errors, start the client. If we did error out, we'll + // just end up doing a full initial /sync. + promise.finally(() => { + this.get().startClient(opts); + }); } getCredentials(): MatrixClientCreds { @@ -111,6 +121,17 @@ class MatrixClientPeg { if (localStorage) { opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); } + if (window.indexedDB && localStorage) { + // FIXME: bodge to remove old database. Remove this after a few weeks. + window.indexedDB.deleteDatabase("matrix-js-sdk:default"); + + opts.store = new Matrix.IndexedDBStore( + new Matrix.IndexedDBStoreBackend(window.indexedDB, "riot-web-sync"), + new Matrix.SyncAccumulator(), { + localStorage: localStorage, + } + ); + } this.matrixClient = Matrix.createClient(opts); diff --git a/src/Modal.js b/src/Modal.js index b6cc46ed452..7be37da92ee 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -43,7 +43,13 @@ const AsyncWrapper = React.createClass({ componentWillMount: function() { this._unmounted = false; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); this.props.loader((e) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('AsyncWrapper load completed with '+e.displayName); if (this._unmounted) { return; } diff --git a/src/PageTypes.js b/src/PageTypes.js index b2e2ecf4bc4..d87b363a6f0 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.js @@ -16,6 +16,7 @@ limitations under the License. /** The types of page which can be shown by the LoggedInView */ export default { + HomePage: "home_page", RoomView: "room_view", UserSettings: "user_settings", CreateRoom: "create_room", diff --git a/src/Resend.js b/src/Resend.js index ad0f58eb9b1..bbd980ea7f3 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -18,23 +18,42 @@ var MatrixClientPeg = require('./MatrixClientPeg'); var dis = require('./dispatcher'); var sdk = require('./index'); var Modal = require('./Modal'); +import { EventStatus } from 'matrix-js-sdk'; module.exports = { + resendUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.resend(event); + }); + }, + cancelUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.removeFromQueue(event); + }); + }, resend: function(event) { + const room = MatrixClientPeg.get().getRoom(event.getRoomId()); MatrixClientPeg.get().resendEvent( - event, MatrixClientPeg.get().getRoom(event.getRoomId()) + event, room ).done(function(res) { dis.dispatch({ action: 'message_sent', event: event }); }, function(err) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Resend got send failure: ' + err.name + '('+err+')'); if (err.name === "UnknownDeviceError") { - var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); - Modal.createDialog(UnknownDeviceDialog, { - devices: err.devices, - room: MatrixClientPeg.get().getRoom(event.getRoomId()), - }, "mx_Dialog_unknownDevice"); + dis.dispatch({ + action: 'unknown_device_error', + err: err, + room: room, + }); } dis.dispatch({ @@ -43,7 +62,6 @@ module.exports = { }); }); }, - removeFromQueue: function(event) { MatrixClientPeg.get().cancelPendingEvent(event); dis.dispatch({ diff --git a/src/RtsClient.js b/src/RtsClient.js index ae62fb8b22f..8c3ce54b375 100644 --- a/src/RtsClient.js +++ b/src/RtsClient.js @@ -50,18 +50,18 @@ export default class RtsClient { * Track a referral with the Riot Team Server. This should be called once a referred * user has been successfully registered. * @param {string} referrer the user ID of one who referred the user to Riot. - * @param {string} userId the user ID of the user being referred. - * @param {string} userEmail the email address linked to `userId`. + * @param {string} sid the sign-up identity server session ID . + * @param {string} clientSecret the sign-up client secret. * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon * success. */ - trackReferral(referrer, userId, userEmail) { + trackReferral(referrer, sid, clientSecret) { return request(this._url + '/register', { body: { referrer: referrer, - user_id: userId, - user_email: userEmail, + session_id: sid, + client_secret: clientSecret, }, method: 'POST', } @@ -77,4 +77,21 @@ export default class RtsClient { } ); } + + /** + * Signal to the RTS that a login has occurred and that a user requires their team's + * token. + * @param {string} userId the user ID of the user who is a member of a team. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + login(userId) { + return request(this._url + '/login', + { + qs: { + user_id: userId, + }, + } + ); + } } diff --git a/src/Signup.js b/src/Signup.js deleted file mode 100644 index d3643bd7498..00000000000 --- a/src/Signup.js +++ /dev/null @@ -1,461 +0,0 @@ -"use strict"; - -import Matrix from "matrix-js-sdk"; - -var MatrixClientPeg = require("./MatrixClientPeg"); -var SignupStages = require("./SignupStages"); -var dis = require("./dispatcher"); -var q = require("q"); -var url = require("url"); - -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, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - } - - getHomeserverUrl() { - return this._hsUrl; - } - - getIdentityServerUrl() { - return this._isUrl; - } - - setHomeserverUrl(hsUrl) { - this._hsUrl = hsUrl; - } - - setIdentityServerUrl(isUrl) { - this._isUrl = isUrl; - } - - /** - * Get a temporary MatrixClient, which can be used for login or register - * requests. - */ - _createTemporaryClient() { - return Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, - }); - } -} - -/** - * Registration logic class - * This exists for the lifetime of a user's attempt to register an account, - * so if their registration attempt fails for whatever reason and they - * try again, call register() on the same instance again. - * - * TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It - * would be nice to make use of that rather than rolling our own version of it. - */ -class Register extends Signup { - constructor(hsUrl, isUrl, opts) { - super(hsUrl, isUrl, opts); - 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; - } - - setGuestAccessToken(token) { - this.guestAccessToken = token; - } - - 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" - }); - } - - /** - * Starts the registration process from the first stage - */ - register(formVals) { - var {username, password, email} = formVals; - this.email = email; - this.username = username; - this.password = password; - const client = this._createTemporaryClient(); - this.activeStage = null; - - // If there hasn't been a client secret set by this point, - // generate one for this session. It will only be used if - // we do email verification, but far simpler to just make - // sure we have one. - // We re-use this same secret over multiple calls to register - // so that the identity server can honour the sendAttempt - // parameter and not re-send email unless we actually want - // another mail to be sent. - if (!this.params.clientSecret) { - this.params.clientSecret = client.generateClientSecret(); - } - return this._tryRegister(client); - } - - _tryRegister(client, authDict, poll_for_success) { - var self = this; - - var bindEmail; - - if (this.username && this.password) { - // only need to bind_email when sending u/p - sending it at other - // times clobbers the u/p resulting in M_MISSING_PARAM (password) - bindEmail = true; - } - - // TODO need to figure out how to send the device display name to /register. - return client.register( - this.username, this.password, this.params.sessionId, authDict, bindEmail, - this.guestAccessToken - ).then(function(result) { - self.credentials = result; - self.setStep("COMPLETE"); - return result; // contains the credentials - }, function(error) { - if (error.httpStatus === 401) { - if (error.data && error.data.flows) { - // Remember the session ID from the server: - // Either this is our first 401 in which case we need to store the - // session ID for future calls, or it isn't in which case this - // is just a no-op since it ought to be the same (or if it isn't, - // we should use the latest one from the server in any case). - self.params.sessionId = error.data.session; - 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); - if (!self.activeStage || flowStage != self.activeStage.type) { - return self._startStage(client, flowStage).catch(function(err) { - self.setStep('START'); - throw err; - }); - } - } - } - if (poll_for_success) { - return q.delay(2000).then(function() { - return self._tryRegister(client, authDict, poll_for_success); - }); - } else { - throw new Error("Authorisation failed!"); - } - } else { - if (error.errcode === 'M_USER_IN_USE') { - throw new Error("Username in use"); - } else if (error.errcode == 'M_INVALID_USERNAME') { - throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); - } else if (error.httpStatus >= 400 && error.httpStatus < 500) { - let msg = null; - if (error.message) { - msg = error.message; - } else if (error.errcode) { - msg = error.errcode; - } - if (msg) { - throw new Error(`Registration failed! (${error.httpStatus}) - ${msg}`); - } else { - throw new Error(`Registration failed! (${error.httpStatus}) - That's all we know.`); - } - } 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(client, 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(client, 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(client, request.auth, request.poll_for_success); - } - 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() { - // 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) { - const client = this._createTemporaryClient(); - this.registrationPromise = this._startStage(client, 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, fallbackHsUrl, opts) { - super(hsUrl, isUrl, opts); - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - } - - getFlows() { - var self = this; - var client = this._createTemporaryClient(); - return client.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; - } - - loginAsGuest() { - var client = this._createTemporaryClient(); - return client.registerGuest({ - body: { - initial_device_display_name: this._defaultDeviceDisplayName, - }, - }).then((creds) => { - return { - userId: creds.user_id, - deviceId: creds.device_id, - accessToken: creds.access_token, - homeserverUrl: this._hsUrl, - identityServerUrl: this._isUrl, - guest: true - }; - }, (error) => { - if (error.httpStatus === 403) { - error.friendlyText = "Guest access is disabled on this Home Server."; - } else { - error.friendlyText = "Failed to register as guest: " + error.data; - } - throw error; - }); - } - - loginViaPassword(username, pass) { - var self = this; - var isEmail = username.indexOf("@") > 0; - var loginParams = { - password: pass, - initial_device_display_name: this._defaultDeviceDisplayName, - }; - if (isEmail) { - loginParams.medium = 'email'; - loginParams.address = username; - } else { - loginParams.user = username; - } - - var client = this._createTemporaryClient(); - return client.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._hsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_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.' - ); - if (self._fallbackHsUrl) { - var fbClient = Matrix.createClient({ - baseUrl: self._fallbackHsUrl, - idBaseUrl: this._isUrl, - }); - - return fbClient.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._fallbackHsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token - }); - }, function(fallback_error) { - // throw the original error - throw error; - }); - } - } - else { - error.friendlyText = ( - 'There was a problem logging in. (HTTP ' + error.httpStatus + ")" - ); - } - throw error; - }); - } - - redirectToCas() { - var client = this._createTemporaryClient(); - var parsedUrl = url.parse(window.location.href, true); - parsedUrl.query["homeserver"] = client.getHomeserverUrl(); - parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); - var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); - window.location.href = casUrl; - } -} - -module.exports.Register = Register; -module.exports.Login = Login; diff --git a/src/SignupStages.js b/src/SignupStages.js deleted file mode 100644 index 6bdc331566c..00000000000 --- a/src/SignupStages.js +++ /dev/null @@ -1,171 +0,0 @@ -"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.authDict = { - auth: { - type: 'm.login.recaptcha', - // we'll add in the response param if we get one from the local user. - }, - poll_for_success: true, - }; - } - - // called when the recaptcha has been completed. - onReceiveData(data) { - if (!data || !data.response) { - return; - } - this.authDict.auth.response = data.response; - } - - complete() { - // we return the authDict with no response, telling Signup to keep polling - // the server in case the captcha is filled in on another window (e.g. by - // following a nextlink from an email signup). If the user completes the - // captcha locally, then we return at the next poll. - return q(this.authDict); - } -} -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(); - - var clientSecret = this.clientSecret || this.signupInstance.params.clientSecret; - var sid = this.sid || this.signupInstance.params.idSid; - - return q({ - auth: { - type: 'm.login.email.identity', - threepid_creds: { - sid: sid, - client_secret: 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(); - } - - this.clientSecret = this.signupInstance.params.clientSecret; - if (!this.clientSecret) { - return q.reject(new Error("No client secret specified by Signup class!")); - } - - var nextLink = this.signupInstance.params.registrationUrl + - '?client_secret=' + - encodeURIComponent(this.clientSecret) + - "&hs_url=" + - encodeURIComponent(this.signupInstance.getHomeserverUrl()) + - "&is_url=" + - encodeURIComponent(this.signupInstance.getIdentityServerUrl()) + - "&session_id=" + - encodeURIComponent(this.signupInstance.getServerData().session); - - var self = this; - return this.client.requestRegisterEmailToken( - this.signupInstance.email, - this.clientSecret, - 1, // TODO: Multiple send attempts? - nextLink - ).then(function(response) { - self.sid = response.sid; - return self._completeVerify(); - }).then(function(request) { - request.poll_for_success = true; - return request; - }, function(error) { - console.error(error); - var e = { - isFatal: true - }; - if (error.errcode == 'M_THREEPID_IN_USE') { - e.message = "This email address is already registered"; - } 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 -}; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 3f772e9cfb8..3e1659f3922 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -116,7 +116,6 @@ function textForRoomNameEvent(ev) { function textForMessageEvent(ev) { var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - var message = senderDisplayName + ': ' + ev.getContent().body; if (ev.getContent().msgtype === "m.emote") { message = "* " + senderDisplayName + " " + message; diff --git a/src/UnknownDeviceErrorHandler.js b/src/UnknownDeviceErrorHandler.js new file mode 100644 index 00000000000..d842cc3a6e5 --- /dev/null +++ b/src/UnknownDeviceErrorHandler.js @@ -0,0 +1,47 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import dis from './dispatcher'; +import sdk from './index'; +import Modal from './Modal'; + +const onAction = function(payload) { + if (payload.action === 'unknown_device_error') { + var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); + Modal.createDialog(UnknownDeviceDialog, { + devices: payload.err.devices, + room: payload.room, + onFinished: (r) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('UnknownDeviceDialog closed with '+r); + }, + }, "mx_Dialog_unknownDevice"); + } +} + +let ref = null; + +export function startListening () { + ref = dis.register(onAction); +} + +export function stopListening () { + if (ref) { + dis.unregister(ref); + ref = null; + } +} diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index d7d3e7bc7a2..66a872958c8 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -26,7 +26,7 @@ var Notifier = require("./Notifier"); module.exports = { LABS_FEATURES: [ { - name: 'Rich Text Editor', + name: 'New Composer & Autocomplete', id: 'rich_text_editor', default: false, }, diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index ecd7c495f93..4502b0ccd99 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -48,10 +48,11 @@ module.exports = { return whoIsTyping; }, - whoIsTypingString: function(room, limit) { - const whoIsTyping = this.usersTypingApartFromMe(room); - const othersCount = limit === undefined ? - 0 : Math.max(whoIsTyping.length - limit, 0); + whoIsTypingString: function(whoIsTyping, limit) { + let othersCount = 0; + if (whoIsTyping.length > limit) { + othersCount = whoIsTyping.length - limit + 1; + } if (whoIsTyping.length == 0) { return ''; } else if (whoIsTyping.length == 1) { @@ -62,7 +63,7 @@ module.exports = { }); if (othersCount) { const other = ' other' + (othersCount > 1 ? 's' : ''); - return names.slice(0, limit).join(', ') + ' and ' + + return names.slice(0, limit - 1).join(', ') + ' and ' + othersCount + other + ' are typing'; } else { const lastPerson = names.pop(); diff --git a/src/component-index.js b/src/component-index.js index 5b28be0627c..59d3ad53e48 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -31,6 +31,8 @@ import structures$CreateRoom from './components/structures/CreateRoom'; structures$CreateRoom && (module.exports.components['structures.CreateRoom'] = structures$CreateRoom); import structures$FilePanel from './components/structures/FilePanel'; structures$FilePanel && (module.exports.components['structures.FilePanel'] = structures$FilePanel); +import structures$InteractiveAuth from './components/structures/InteractiveAuth'; +structures$InteractiveAuth && (module.exports.components['structures.InteractiveAuth'] = structures$InteractiveAuth); import structures$LoggedInView from './components/structures/LoggedInView'; structures$LoggedInView && (module.exports.components['structures.LoggedInView'] = structures$LoggedInView); import structures$MatrixChat from './components/structures/MatrixChat'; @@ -73,8 +75,12 @@ import views$create_room$RoomAlias from './components/views/create_room/RoomAlia views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias); import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog'; views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog); +import views$dialogs$ChatCreateOrReuseDialog from './components/views/dialogs/ChatCreateOrReuseDialog'; +views$dialogs$ChatCreateOrReuseDialog && (module.exports.components['views.dialogs.ChatCreateOrReuseDialog'] = views$dialogs$ChatCreateOrReuseDialog); import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); +import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog'; +views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; views$dialogs$DeactivateAccountDialog && (module.exports.components['views.dialogs.DeactivateAccountDialog'] = views$dialogs$DeactivateAccountDialog); import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog'; @@ -85,6 +91,8 @@ import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedT views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog); import views$dialogs$QuestionDialog from './components/views/dialogs/QuestionDialog'; views$dialogs$QuestionDialog && (module.exports.components['views.dialogs.QuestionDialog'] = views$dialogs$QuestionDialog); +import views$dialogs$SessionRestoreErrorDialog from './components/views/dialogs/SessionRestoreErrorDialog'; +views$dialogs$SessionRestoreErrorDialog && (module.exports.components['views.dialogs.SessionRestoreErrorDialog'] = views$dialogs$SessionRestoreErrorDialog); import views$dialogs$SetDisplayNameDialog from './components/views/dialogs/SetDisplayNameDialog'; views$dialogs$SetDisplayNameDialog && (module.exports.components['views.dialogs.SetDisplayNameDialog'] = views$dialogs$SetDisplayNameDialog); import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog'; @@ -101,6 +109,8 @@ import views$elements$DeviceVerifyButtons from './components/views/elements/Devi views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons); import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox'; views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox); +import views$elements$Dropdown from './components/views/elements/Dropdown'; +views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown); import views$elements$EditableText from './components/views/elements/EditableText'; views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText); import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer'; @@ -123,6 +133,8 @@ import views$login$CaptchaForm from './components/views/login/CaptchaForm'; views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm); import views$login$CasLogin from './components/views/login/CasLogin'; views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin); +import views$login$CountryDropdown from './components/views/login/CountryDropdown'; +views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown); import views$login$CustomServerDialog from './components/views/login/CustomServerDialog'; views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog); import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents'; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js new file mode 100644 index 00000000000..71fee883bea --- /dev/null +++ b/src/components/structures/InteractiveAuth.js @@ -0,0 +1,219 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import Matrix from 'matrix-js-sdk'; +const InteractiveAuth = Matrix.InteractiveAuth; + +import React from 'react'; + +import sdk from '../../index'; + +import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents'; + +export default React.createClass({ + displayName: 'InteractiveAuth', + + propTypes: { + // matrix client to use for UI auth requests + matrixClient: React.PropTypes.object.isRequired, + + // response from initial request. If not supplied, will do a request on + // mount. + authData: React.PropTypes.shape({ + flows: React.PropTypes.array, + params: React.PropTypes.object, + session: React.PropTypes.string, + }), + + // callback + makeRequest: React.PropTypes.func.isRequired, + + // callback called when the auth process has finished, + // successfully or unsuccessfully. + // @param {bool} status True if the operation requiring + // auth was completed sucessfully, false if canceled. + // @param {object} result The result of the authenticated call + // if successful, otherwise the error object + // @param {object} extra Additional information about the UI Auth + // process: + // * emailSid {string} If email auth was performed, the sid of + // the auth session. + // * clientSecret {string} The client secret used in auth + // sessions with the ID server. + onAuthFinished: React.PropTypes.func.isRequired, + + // Inputs provided by the user to the auth process + // and used by various stages. As passed to js-sdk + // interactive-auth + inputs: React.PropTypes.object, + + // As js-sdk interactive-auth + makeRegistrationUrl: React.PropTypes.func, + sessionId: React.PropTypes.string, + clientSecret: React.PropTypes.string, + emailSid: React.PropTypes.string, + + // If true, poll to see if the auth flow has been completed + // out-of-band + poll: React.PropTypes.bool, + }, + + getInitialState: function() { + return { + authStage: null, + busy: false, + errorText: null, + stageErrorText: null, + submitButtonEnabled: false, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this._authLogic = new InteractiveAuth({ + authData: this.props.authData, + doRequest: this._requestCallback, + inputs: this.props.inputs, + stateUpdated: this._authStateUpdated, + matrixClient: this.props.matrixClient, + sessionId: this.props.sessionId, + clientSecret: this.props.clientSecret, + emailSid: this.props.emailSid, + }); + + this._authLogic.attemptAuth().then((result) => { + const extra = { + emailSid: this._authLogic.getEmailSid(), + clientSecret: this._authLogic.getClientSecret(), + }; + this.props.onAuthFinished(true, result, extra); + }).catch((error) => { + this.props.onAuthFinished(false, error); + console.error("Error during user-interactive auth:", error); + if (this._unmounted) { + return; + } + + const msg = error.message || error.toString(); + this.setState({ + errorText: msg + }); + }).done(); + + this._intervalId = null; + if (this.props.poll) { + this._intervalId = setInterval(() => { + this._authLogic.poll(); + }, 2000); + } + }, + + componentWillUnmount: function() { + this._unmounted = true; + + if (this._intervalId !== null) { + clearInterval(this._intervalId); + } + }, + + _authStateUpdated: function(stageType, stageState) { + const oldStage = this.state.authStage; + this.setState({ + authStage: stageType, + stageState: stageState, + errorText: stageState.error, + }, () => { + if (oldStage != stageType) this._setFocus(); + }); + }, + + _requestCallback: function(auth) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + return this.props.makeRequest(auth).finally(() => { + if (this._unmounted) { + return; + } + this.setState({ + busy: false, + }); + }); + }, + + _setFocus: function() { + if (this.refs.stageComponent && this.refs.stageComponent.focus) { + this.refs.stageComponent.focus(); + } + }, + + _submitAuthDict: function(authData) { + this._authLogic.submitAuthDict(authData); + }, + + _renderCurrentStage: function() { + const stage = this.state.authStage; + if (!stage) return null; + + const StageComponent = getEntryComponentForLoginType(stage); + return ( + + ); + }, + + _onAuthStageFailed: function(e) { + this.props.onAuthFinished(false, e); + }, + _setEmailSid: function(sid) { + this._authLogic.setEmailSid(sid); + }, + + render: function() { + let error = null; + if (this.state.errorText) { + error = ( +
+ {this.state.errorText} +
+ ); + } + + return ( +
+
+ {this._renderCurrentStage()} + {error} +
+
+ ); + }, +}); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 44beb787c82..c2243820cde 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -42,16 +42,23 @@ export default React.createClass({ onRoomCreated: React.PropTypes.func, onUserSettingsClose: React.PropTypes.func, + teamToken: React.PropTypes.string, + // and lots and lots of other stuff. }, childContextTypes: { matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), + authCache: React.PropTypes.object, }, getChildContext: function() { return { matrixClient: this._matrixClient, + authCache: { + auth: {}, + lastUpdate: 0, + }, }; }, @@ -137,6 +144,7 @@ export default React.createClass({ var UserSettings = sdk.getComponent('structures.UserSettings'); var CreateRoom = sdk.getComponent('structures.CreateRoom'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); + var HomePage = sdk.getComponent('structures.HomePage'); var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar'); var NewVersionBar = sdk.getComponent('globals.NewVersionBar'); @@ -172,6 +180,7 @@ export default React.createClass({ collapsedRhs={this.props.collapse_rhs} enableLabs={this.props.config.enableLabs} referralBaseUrl={this.props.config.referralBaseUrl} + teamToken={this.props.teamToken} />; if (!this.props.collapse_rhs) right_panel = ; break; @@ -191,6 +200,16 @@ export default React.createClass({ />; if (!this.props.collapse_rhs) right_panel = ; break; + + case PageTypes.HomePage: + page_element = + if (!this.props.collapse_rhs) right_panel = + break; + case PageTypes.UserView: page_element = null; // deliberately null for now right_panel = ; @@ -219,7 +238,12 @@ export default React.createClass({
{topBar}
- +
{page_element}
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 6a84fb940f8..2fa5e926082 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -41,6 +42,7 @@ var Lifecycle = require('../../Lifecycle'); var PageTypes = require('../../PageTypes'); var createRoom = require("../../createRoom"); +import * as UDEHandler from '../../UnknownDeviceErrorHandler'; module.exports = React.createClass({ displayName: 'MatrixChat', @@ -64,6 +66,9 @@ module.exports = React.createClass({ // displayname, if any, to set on the device when logging // in/registering. defaultDeviceDisplayName: React.PropTypes.string, + + // A function that makes a registration URL + makeRegistrationUrl: React.PropTypes.func.isRequired, }, childContextTypes: { @@ -190,10 +195,56 @@ module.exports = React.createClass({ if (this.props.config.sync_timeline_limit) { MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; } + + // To enable things like riot.im/geektime in a nicer way than rewriting the URL + // and appending a team token query parameter, use the first path segment to + // indicate a team, with "public" team tokens stored in the config teamTokenMap. + let routedTeamToken = null; + if (this.props.config.teamTokenMap) { + const teamName = window.location.pathname.split('/')[1]; + if (teamName && this.props.config.teamTokenMap.hasOwnProperty(teamName)) { + routedTeamToken = this.props.config.teamTokenMap[teamName]; + } + } + + // Persist the team token across refreshes using sessionStorage. A new window or + // tab will not persist sessionStorage, but refreshes will. + if (this.props.startingFragmentQueryParams.team_token) { + window.sessionStorage.setItem( + 'mx_team_token', + this.props.startingFragmentQueryParams.team_token, + ); + } + + // Use the locally-stored team token first, then as a fall-back, check to see if + // a referral link was used, which will contain a query parameter `team_token`. + this._teamToken = routedTeamToken || + window.localStorage.getItem('mx_team_token') || + window.sessionStorage.getItem('mx_team_token'); + + // Some users have ended up with "undefined" as their local storage team token, + // treat that as undefined. + if (this._teamToken === "undefined") { + this._teamToken = undefined; + } + + if (this._teamToken) { + console.info(`Team token set to ${this._teamToken}`); + } + + // Set a default HS with query param `hs_url` + const paramHs = this.props.startingFragmentQueryParams.hs_url; + if (paramHs) { + console.log('Setting register_hs_url ', paramHs); + this.setState({ + register_hs_url: paramHs, + }); + } }, componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); + UDEHandler.startListening(); this.focusComposer = false; window.addEventListener("focus", this.onFocus); @@ -210,6 +261,12 @@ module.exports = React.createClass({ window.addEventListener('resize', this.handleResize); this.handleResize(); + if (this.props.config.teamServerConfig && + this.props.config.teamServerConfig.teamServerURL + ) { + Lifecycle.initRtsClient(this.props.config.teamServerConfig.teamServerURL); + } + // the extra q() ensures that synchronous exceptions hit the same codepath as // asynchronous ones. q().then(() => { @@ -234,6 +291,7 @@ module.exports = React.createClass({ componentWillUnmount: function() { Lifecycle.stopMatrixClient(); dis.unregister(this.dispatcherRef); + UDEHandler.stopListening(); window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); }, @@ -270,23 +328,19 @@ module.exports = React.createClass({ Lifecycle.logout(); break; case 'start_registration': - var newState = payload.params || {}; - newState.screen = 'register'; - if ( - payload.params && - payload.params.client_secret && - payload.params.session_id && - payload.params.hs_url && - payload.params.is_url && - payload.params.sid - ) { - newState.register_client_secret = payload.params.client_secret; - newState.register_session_id = payload.params.session_id; - newState.register_hs_url = payload.params.hs_url; - newState.register_is_url = payload.params.is_url; - newState.register_id_sid = payload.params.sid; - } - this.setStateForNewScreen(newState); + const params = payload.params || {}; + this.setStateForNewScreen({ + screen: 'register', + // these params may be undefined, but if they are, + // unset them from our state: we don't want to + // resume a previous registration session if the + // user just clicked 'register' + register_client_secret: params.client_secret, + register_session_id: params.session_id, + register_hs_url: params.hs_url, + register_is_url: params.is_url, + register_id_sid: params.sid, + }); this.notifyNewScreen('register'); break; case 'start_login': @@ -302,13 +356,22 @@ module.exports = React.createClass({ }); break; case 'start_upgrade_registration': - // stash our guest creds so we can backout if needed + // also stash our credentials, then if we restore the session, + // we can just do it the same way whether we started upgrade + // registration or explicitly logged out this.guestCreds = MatrixClientPeg.getCredentials(); this.setStateForNewScreen({ screen: "register", upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), guestAccessToken: MatrixClientPeg.get().getAccessToken(), }); + + // stop the client: if we are syncing whilst the registration + // is completed in another browser, we'll be 401ed for using + // a guest access token for a non-guest account. + // It will be restarted in onReturnToGuestClick + Lifecycle.stopMatrixClient(); + this.notifyNewScreen('register'); break; case 'start_password_recovery': @@ -339,9 +402,10 @@ module.exports = React.createClass({ dis.dispatch({action: 'view_next_room'}); }, function(err) { modal.close(); + console.error("Failed to leave room " + payload.room_id + " " + err); Modal.createDialog(ErrorDialog, { title: "Failed to leave room", - description: err.toString() + description: "Server may be unavailable, overloaded, or you hit a bug." }); }); } @@ -421,6 +485,14 @@ module.exports = React.createClass({ this._setPage(PageTypes.RoomDirectory); this.notifyNewScreen('directory'); break; + case 'view_home_page': + if (!this._teamToken) { + dis.dispatch({action: 'view_room_directory'}); + return; + } + this._setPage(PageTypes.HomePage); + this.notifyNewScreen('home'); + break; case 'view_create_chat': this._createChat(); break; @@ -460,7 +532,7 @@ module.exports = React.createClass({ this._onSetTheme(payload.value); break; case 'on_logged_in': - this._onLoggedIn(); + this._onLoggedIn(payload.teamToken); break; case 'on_logged_out': this._onLoggedOut(); @@ -636,13 +708,20 @@ module.exports = React.createClass({ /** * Called when a new logged in session has started */ - _onLoggedIn: function(credentials) { + _onLoggedIn: function(teamToken) { this.guestCreds = null; this.notifyNewScreen(''); this.setState({ screen: undefined, logged_in: true, }); + + if (teamToken) { + this._teamToken = teamToken; + this._setPage(PageTypes.HomePage); + } else if (this._is_registered) { + this._setPage(PageTypes.UserSettings); + } }, /** @@ -659,6 +738,7 @@ module.exports = React.createClass({ currentRoomId: null, page_type: PageTypes.RoomDirectory, }); + this._teamToken = null; }, /** @@ -690,7 +770,11 @@ module.exports = React.createClass({ )[0].roomId; self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView}); } else { - self.setState({ready: true, page_type: PageTypes.RoomDirectory}); + if (self._teamToken) { + self.setState({ready: true, page_type: PageTypes.HomePage}); + } else { + self.setState({ready: true, page_type: PageTypes.RoomDirectory}); + } } } else { self.setState({ready: true, page_type: PageTypes.RoomView}); @@ -710,7 +794,11 @@ module.exports = React.createClass({ } else { // There is no information on presentedId // so point user to fallback like /directory - self.notifyNewScreen('directory'); + if (self._teamToken) { + self.notifyNewScreen('home'); + } else { + self.notifyNewScreen('directory'); + } } dis.dispatch({action: 'focus_composer'}); @@ -774,6 +862,10 @@ module.exports = React.createClass({ dis.dispatch({ action: 'view_user_settings', }); + } else if (screen == 'home') { + dis.dispatch({ + action: 'view_home_page', + }); } else if (screen == 'directory') { dis.dispatch({ action: 'view_room_directory', @@ -852,14 +944,6 @@ module.exports = React.createClass({ onUserClick: function(event, userId) { event.preventDefault(); - // var MemberInfo = sdk.getComponent('rooms.MemberInfo'); - // var member = new Matrix.RoomMember(null, userId); - // ContextualMenu.createMenu(MemberInfo, { - // member: member, - // right: window.innerWidth - event.pageX, - // top: event.pageY - // }); - var member = new Matrix.RoomMember(null, userId); if (!member) { return; } dis.dispatch({ @@ -925,18 +1009,11 @@ module.exports = React.createClass({ } }, - onRegistered: function(credentials) { + onRegistered: function(credentials, teamToken) { + // teamToken may not be truthy + this._teamToken = teamToken; + this._is_registered = true; Lifecycle.setLoggedIn(credentials); - // do post-registration stuff - // This now goes straight to user settings - // We use _setPage since if we wait for - // showScreen to do the dispatch loop, - // the showScreen dispatch will race with the - // sdk sync finishing and we'll probably see - // the page type still unset when the MatrixClient - // is started and show the Room Directory instead. - //this.showScreen("view_user_settings"); - this._setPage(PageTypes.UserSettings); }, onFinishPostRegistration: function() { @@ -1002,6 +1079,13 @@ module.exports = React.createClass({ this.setState({currentRoomId: room_id}); }, + _makeRegistrationUrl: function(params) { + if (this.props.startingFragmentQueryParams.referrer) { + params.referrer = this.props.startingFragmentQueryParams.referrer; + } + return this.props.makeRegistrationUrl(params); + }, + render: function() { var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); var LoggedInView = sdk.getComponent('structures.LoggedInView'); @@ -1033,6 +1117,7 @@ module.exports = React.createClass({ onRoomIdResolved={this.onRoomIdResolved} onRoomCreated={this.onRoomCreated} onUserSettingsClose={this.onUserSettingsClose} + teamToken={this._teamToken} {...this.props} {...this.state} /> @@ -1064,7 +1149,7 @@ module.exports = React.createClass({ teamServerConfig={this.props.config.teamServerConfig} customHsUrl={this.getCurrentHsUrl()} customIsUrl={this.getCurrentIsUrl()} - registrationUrl={this.props.registrationUrl} + makeRegistrationUrl={this._makeRegistrationUrl} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} onLoggedIn={this.onRegistered} onLoginClick={this.onLoginClick} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index dcebe38fa48..ff507b6f908 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -295,7 +295,10 @@ module.exports = React.createClass({ var last = (i == lastShownEventIndex); // Wrap consecutive member events in a ListSummary, ignore if redacted - if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) { + if (isMembershipChange(mxEv) && + EventTile.haveTileForEvent(mxEv) && + !mxEv.isRedacted() + ) { let ts1 = mxEv.getTs(); // Ensure that the key of the MemberEventListSummary does not change with new // member events. This will prevent it from being re-created unnecessarily, and @@ -349,7 +352,9 @@ module.exports = React.createClass({ + data-scroll-token={eventId} + onToggle={this._onWidgetLoad} // Update scroll state + > {eventTiles} ); @@ -362,10 +367,6 @@ module.exports = React.createClass({ // replacing all of the DOM elements every time we paginate. ret.push(...this._getTilesForEvent(prevEvent, mxEv, last)); prevEvent = mxEv; - } else if (!mxEv.status) { - // if we aren't showing the event, put in a dummy scroll token anyway, so - // that we can scroll to the right place. - ret.push(
  • ); } var isVisibleReadMarker = false; @@ -410,7 +411,9 @@ module.exports = React.createClass({ // is this a continuation of the previous message? var continuation = false; - if (prevEvent !== null && prevEvent.sender && mxEv.sender + + if (prevEvent !== null + && !prevEvent.isRedacted() && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId && mxEv.getType() == prevEvent.getType()) { continuation = true; @@ -463,6 +466,7 @@ module.exports = React.createClass({ ref={this._collectEventNode.bind(this, eventId)} data-scroll-token={scrollToken}> 24h apart if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) { return true; } // Compare weekdays - return prevEvent.getDate().getDay() !== nextEventDate.getDay(); + return prevEventDate.getDay() !== nextEventDate.getDay(); }, // get a list of read receipts that should be shown next to this event diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 3fd0a3b751e..626c376d9fb 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -82,17 +82,14 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - whoIsTypingLimit: 2, + whoIsTypingLimit: 3, }; }, getInitialState: function() { return { syncState: MatrixClientPeg.get().getSyncState(), - whoisTypingString: WhoIsTyping.whoIsTypingString( - this.props.room, - this.props.whoIsTypingLimit - ), + usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), }; }, @@ -106,7 +103,7 @@ module.exports = React.createClass({ this.props.onResize(); } - const size = this._getSize(this.state, this.props); + const size = this._getSize(this.props, this.state); if (size > 0) { this.props.onVisible(); } else { @@ -141,22 +138,21 @@ module.exports = React.createClass({ onRoomMemberTyping: function(ev, member) { this.setState({ - whoisTypingString: WhoIsTyping.whoIsTypingString( - this.props.room, - this.props.whoIsTypingLimit - ), + usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), }); }, // We don't need the actual height - just whether it is likely to have // changed - so we use '0' to indicate normal size, and other values to // indicate other sizes. - _getSize: function(state, props) { + _getSize: function(props, state) { if (state.syncState === "ERROR" || - state.whoisTypingString || + (state.usersTyping.length > 0) || props.numUnreadMessages || !props.atEndOfLiveTimeline || - props.hasActiveCall) { + props.hasActiveCall || + props.tabComplete.isTabCompleting() + ) { return STATUS_BAR_EXPANDED; } else if (props.tabCompleteEntries) { return STATUS_BAR_HIDDEN; @@ -169,7 +165,8 @@ module.exports = React.createClass({ // determine if we need to call onResize _checkForResize: function(prevProps, prevState) { // figure out the old height and the new height of the status bar. - return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state); + return this._getSize(prevProps, prevState) + !== this._getSize(this.props, this.state); }, // return suitable content for the image on the left of the status bar. @@ -199,8 +196,9 @@ module.exports = React.createClass({ } if (this.props.hasActiveCall) { + var TintableSvg = sdk.getComponent("elements.TintableSvg"); return ( - + ); } @@ -220,13 +218,15 @@ module.exports = React.createClass({ }, _renderTypingIndicatorAvatars: function(limit) { - let users = WhoIsTyping.usersTypingApartFromMe(this.props.room); + let users = this.state.usersTyping; - let othersCount = Math.max(users.length - limit, 0); - users = users.slice(0, limit); + let othersCount = 0; + if (users.length > limit) { + othersCount = users.length - limit + 1; + users = users.slice(0, limit - 1); + } - let avatars = users.map((u, index) => { - let showInitial = othersCount === 0 && index === users.length - 1; + const avatars = users.map((u) => { return ( ); }); @@ -324,7 +323,10 @@ module.exports = React.createClass({ ); } - var typingString = this.state.whoisTypingString; + const typingString = WhoIsTyping.whoIsTypingString( + this.state.usersTyping, + this.props.whoIsTypingLimit + ); if (typingString) { return (
    @@ -347,7 +349,7 @@ module.exports = React.createClass({ render: function() { var content = this._getContent(); - var indicator = this._getIndicator(this.state.whoisTypingString !== null); + var indicator = this._getIndicator(this.state.usersTyping.length > 0); return (
    diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index a7d52019c47..52161012aae 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -155,8 +156,10 @@ module.exports = React.createClass({ this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room", this.onRoom); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().on("accountData", this.onAccountData); this.tabComplete = new TabComplete({ @@ -342,8 +345,10 @@ module.exports = React.createClass({ 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("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().removeListener("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().removeListener("accountData", this.onAccountData); } @@ -479,13 +484,48 @@ module.exports = React.createClass({ } }, + onRoomName: function(room) { + if (this.state.room && room.roomId == this.state.room.roomId) { + this.forceUpdate(); + } + }, + // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). _onRoomLoaded: function(room) { + this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); }, + _warnAboutEncryption: function (room) { + if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { + return; + } + let userHasUsedEncryption = false; + if (localStorage) { + userHasUsedEncryption = localStorage.getItem('mx_user_has_used_encryption'); + } + if (!userHasUsedEncryption) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Warning!", + hasCancelButton: false, + description: ( +
    +

    End-to-end encryption is in beta and may not be reliable.

    +

    You should not yet trust it to secure data.

    +

    Devices will not yet be able to decrypt history from before they joined the room.

    +

    Encrypted messages will not be visible on clients that do not yet implement encryption.

    +
    + ), + }); + } + if (localStorage) { + localStorage.setItem('mx_user_has_used_encryption', true); + } + }, + _calculatePeekRules: function(room) { var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { @@ -603,6 +643,12 @@ module.exports = React.createClass({ this._updateRoomMembers(); }, + onRoomMemberMembership: function(ev, member, oldMembership) { + if (member.userId == MatrixClientPeg.get().credentials.userId) { + this.forceUpdate(); + } + }, + // rate limited because a power level change will emit an event for every // member in the room. _updateRoomMembers: new rate_limited_func(function() { @@ -699,17 +745,11 @@ module.exports = React.createClass({ }, onResendAllClick: function() { - var eventsToResend = this._getUnsentMessages(this.state.room); - eventsToResend.forEach(function(event) { - Resend.resend(event); - }); + Resend.resendUnsentEvents(this.state.room); }, onCancelAllClick: function() { - var eventsToResend = this._getUnsentMessages(this.state.room); - eventsToResend.forEach(function(event) { - Resend.removeFromQueue(event); - }); + Resend.cancelUnsentEvents(this.state.room); }, onJoinButtonClicked: function(ev) { @@ -890,9 +930,10 @@ module.exports = React.createClass({ file, this.state.room.roomId, MatrixClientPeg.get() ).done(undefined, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to upload file " + file + " " + error); Modal.createDialog(ErrorDialog, { title: "Failed to upload file", - description: error.toString() + description: "Server may be unavailable, overloaded, or the file too big", }); }); }, @@ -976,9 +1017,10 @@ module.exports = React.createClass({ }); }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Search failed: " + error); Modal.createDialog(ErrorDialog, { title: "Search failed", - description: error.toString() + description: "Server may be unavailable, overloaded, or search timed out :(" }); }).finally(function() { self.setState({ @@ -1447,6 +1489,7 @@ module.exports = React.createClass({ />
    ; } @@ -1564,6 +1608,7 @@ module.exports = React.createClass({ } aux = ( - {call.isLocalVideoMuted()
    ; } voiceMuteButton =
    - {call.isMicrophoneMuted()
    ; diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index e91e558cb2d..8266a11bc8c 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -90,8 +90,8 @@ module.exports = React.createClass({displayName: 'UploadBar',
    - - +
    diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index ff19e7c239e..febdccd9c30 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -39,6 +39,10 @@ const REACT_SDK_VERSION = // 'id' gives the key name in the im.vector.web.settings account data event // 'label' is how we describe it in the UI. const SETTINGS_LABELS = [ + { + id: 'autoplayGifsAndVideos', + label: 'Autoplay GIFs and videos', + }, /* { id: 'alwaysShowTimestamps', @@ -109,6 +113,10 @@ module.exports = React.createClass({ // true if RightPanel is collapsed collapsedRhs: React.PropTypes.bool, + + // Team token for the referral link. If falsy, the referral section will + // not appear + teamToken: React.PropTypes.string, }, getDefaultProps: function() { @@ -198,9 +206,10 @@ module.exports = React.createClass({ }); }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to load user settings: " + error); Modal.createDialog(ErrorDialog, { title: "Can't load user settings", - description: error.toString() + description: "Server may be unavailable or overloaded", }); }); }, @@ -238,10 +247,11 @@ module.exports = React.createClass({ self._refreshFromServer(); }, function(err) { var errMsg = (typeof err === "string") ? err : (err.error || ""); + console.error("Failed to set avatar: " + err); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Error", - description: "Failed to set avatar. " + errMsg + description: "Failed to set avatar." }); }); }, @@ -278,6 +288,7 @@ module.exports = React.createClass({ errMsg += ` (HTTP status ${err.httpStatus})`; } var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to change password: " + errMsg); Modal.createDialog(ErrorDialog, { title: "Error", description: errMsg @@ -329,9 +340,10 @@ module.exports = React.createClass({ }); }, (err) => { this.setState({email_add_pending: false}); + console.error("Unable to add email address " + email_address + " " + err); Modal.createDialog(ErrorDialog, { - title: "Unable to add email address", - description: err.message + title: "Error", + description: "Unable to add email address" }); }); ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); @@ -353,9 +365,10 @@ module.exports = React.createClass({ return this._refreshFromServer(); }).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Unable to remove contact information: " + err); Modal.createDialog(ErrorDialog, { - title: "Unable to remove contact information", - description: err.toString(), + title: "Error", + description: "Unable to remove contact information", }); }).done(); } @@ -393,9 +406,10 @@ module.exports = React.createClass({ }); } else { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Unable to verify email address: " + err); Modal.createDialog(ErrorDialog, { - title: "Unable to verify email address", - description: err.toString(), + title: "Error", + description: "Unable to verify email address", }); } }); @@ -414,6 +428,14 @@ module.exports = React.createClass({ Modal.createDialog(BugReportDialog, {}); }, + _onClearCacheClicked: function() { + MatrixClientPeg.get().store.deleteAllData().done(() => { + // forceReload=false since we don't really need new HTML/JS files + // we just need to restart the JS runtime. + window.location.reload(false); + }); + }, + _onInviteStateChange: function(event, member, oldMembership) { if (member.userId === this._me && oldMembership === "invite") { this.forceUpdate(); @@ -462,7 +484,7 @@ module.exports = React.createClass({ }, _renderReferral: function() { - const teamToken = window.localStorage.getItem('mx_team_token'); + const teamToken = this.props.teamToken; if (!teamToken) { return null; } @@ -552,21 +574,20 @@ module.exports = React.createClass({ const deviceId = client.deviceId; const identityKey = client.getDeviceEd25519Key() || ""; - let exportButton = null, - importButton = null; + let importExportButtons = null; if (client.isCryptoEnabled) { - exportButton = ( - - Export E2E room keys - - ); - importButton = ( - - Import E2E room keys - + importExportButtons = ( +
    + + Export E2E room keys + + + Import E2E room keys + +
    ); } return ( @@ -577,8 +598,7 @@ module.exports = React.createClass({
  • {deviceId}
  • {identityKey}
  • - {exportButton} - {importButton} + { importExportButtons }
    { CRYPTO_SETTINGS_LABELS.map( this._renderLocalSetting ) } @@ -688,6 +708,18 @@ module.exports = React.createClass({
    ; }, + _renderClearCache: function() { + return
    +

    Clear Cache

    +
    + + Clear Cache and Reload + +
    +
    ; + }, + _renderBulkOptions: function() { let invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => { return r.hasMembershipState(this._me, "invite"); @@ -906,11 +938,13 @@ module.exports = React.createClass({
    matrix-react-sdk version: {REACT_SDK_VERSION}
    - vector-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
    + riot-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
    olm version: {olmVersionString}
    + {this._renderClearCache()} + {this._renderDeactivateAccount()} diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index fe9b5447514..0a1549f75bb 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,13 +20,13 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); var sdk = require('../../../index'); -var Signup = require("../../../Signup"); +var Login = require("../../../Login"); var PasswordLogin = require("../../views/login/PasswordLogin"); var CasLogin = require("../../views/login/CasLogin"); var ServerConfig = require("../../views/login/ServerConfig"); /** - * A wire component which glues together login UI components and Signup logic + * A wire component which glues together login UI components and Login logic */ module.exports = React.createClass({ displayName: 'Login', @@ -64,8 +65,10 @@ module.exports = React.createClass({ enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, - // used for preserving username when changing homeserver + // used for preserving form values when changing homeserver username: "", + phoneCountry: null, + phoneNumber: "", }; }, @@ -73,20 +76,21 @@ module.exports = React.createClass({ this._initLoginLogic(); }, - onPasswordLogin: function(username, password) { - var self = this; - self.setState({ + onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { + this.setState({ busy: true, errorText: null, loginIncorrect: false, }); - this._loginLogic.loginViaPassword(username, password).then(function(data) { - self.props.onLoggedIn(data); - }, function(error) { - self._setStateFromError(error, true); - }).finally(function() { - self.setState({ + this._loginLogic.loginViaPassword( + username, phoneCountry, phoneNumber, password, + ).then((data) => { + this.props.onLoggedIn(data); + }, (error) => { + this._setStateFromError(error, true); + }).finally(() => { + this.setState({ busy: false }); }).done(); @@ -119,6 +123,14 @@ module.exports = React.createClass({ this.setState({ username: username }); }, + onPhoneCountryChanged: function(phoneCountry) { + this.setState({ phoneCountry: phoneCountry }); + }, + + onPhoneNumberChanged: function(phoneNumber) { + this.setState({ phoneNumber: phoneNumber }); + }, + onHsUrlChanged: function(newHsUrl) { var self = this; this.setState({ @@ -146,7 +158,7 @@ module.exports = React.createClass({ var fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null; - var loginLogic = new Signup.Login(hsUrl, isUrl, fallbackHsUrl, { + var loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); this._loginLogic = loginLogic; @@ -225,7 +237,11 @@ module.exports = React.createClass({ diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 0fc0cac5273..f4805ef0440 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,25 +15,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import Matrix from 'matrix-js-sdk'; -var React = require('react'); +import q from 'q'; +import React from 'react'; -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"); -var RtsClient = require("../../../RtsClient"); +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import ServerConfig from '../../views/login/ServerConfig'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import RegistrationForm from '../../views/login/RegistrationForm'; +import CaptchaForm from '../../views/login/CaptchaForm'; +import RtsClient from '../../../RtsClient'; -var MIN_PASSWORD_LENGTH = 6; +const MIN_PASSWORD_LENGTH = 6; -/** - * TODO: It would be nice to make use of the InteractiveAuthEntryComponents - * here, rather than inventing our own. - */ module.exports = React.createClass({ displayName: 'Registration', @@ -40,7 +37,7 @@ module.exports = React.createClass({ onLoggedIn: React.PropTypes.func.isRequired, clientSecret: React.PropTypes.string, sessionId: React.PropTypes.string, - registrationUrl: React.PropTypes.string, + makeRegistrationUrl: React.PropTypes.func.isRequired, idSid: React.PropTypes.string, customHsUrl: React.PropTypes.string, customIsUrl: React.PropTypes.string, @@ -81,24 +78,20 @@ module.exports = React.createClass({ formVals: { email: this.props.email, }, + // true if we're waiting for the user to complete + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: Boolean(this.props.sessionId), + hsUrl: this.props.customHsUrl, + isUrl: this.props.customIsUrl, }; }, componentWillMount: function() { this._unmounted = false; - this.dispatcherRef = dis.register(this.onAction); - // attach this to the instance rather than this.state since it isn't UI - this.registerLogic = new Signup.Register( - this.props.customHsUrl, this.props.customIsUrl, { - defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, - } - ); - this.registerLogic.setClientSecret(this.props.clientSecret); - this.registerLogic.setSessionId(this.props.sessionId); - this.registerLogic.setRegistrationUrl(this.props.registrationUrl); - this.registerLogic.setIdSid(this.props.idSid); - this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); - this.registerLogic.recheckState(); + + this._replaceClient(); if ( this.props.teamServerConfig && @@ -130,152 +123,127 @@ module.exports = React.createClass({ } }, - componentWillUnmount: function() { - dis.unregister(this.dispatcherRef); - this._unmounted = true; - }, - - componentDidMount: function() { - // may have already done an HTTP hit (e.g. redirect from an email) so - // check for any pending response - var promise = this.registerLogic.getPromise(); - if (promise) { - this.onProcessingRegistration(promise); - } - }, - onHsUrlChanged: function(newHsUrl) { - this.registerLogic.setHomeserverUrl(newHsUrl); + this.setState({ + hsUrl: newHsUrl, + }); + this._replaceClient(); }, onIsUrlChanged: function(newIsUrl) { - this.registerLogic.setIdentityServerUrl(newIsUrl); + this.setState({ + isUrl: newIsUrl, + }); + this._replaceClient(); }, - onAction: function(payload) { - if (payload.action !== "registration_step_update") { - return; - } - // If the registration state has changed, this means the - // user now needs to do something. It would be better - // to expose the explicitly in the register logic. - this.setState({ - busy: false + _replaceClient: function() { + this._matrixClient = Matrix.createClient({ + baseUrl: this.state.hsUrl, + idBaseUrl: this.state.isUrl, }); }, onFormSubmit: function(formVals) { - var self = this; this.setState({ errorText: "", busy: true, formVals: formVals, + doingUIAuth: true, }); - - if (formVals.username !== this.props.username) { - // don't try to upgrade if we changed our username - this.registerLogic.setGuestAccessToken(null); - } - - this.onProcessingRegistration(this.registerLogic.register(formVals)); }, - // Promise is resolved when the registration process is FULLY COMPLETE - onProcessingRegistration: function(promise) { - var self = this; - promise.done(function(response) { - self.setState({ - busy: false - }); - if (!response || !response.access_token) { - console.warn( - "FIXME: Register fulfilled without a final response, " + - "did you break the promise chain?" - ); - // no matter, we'll grab it direct - response = self.registerLogic.getCredentials(); - } - if (!response || !response.user_id || !response.access_token) { - console.error("Final response is missing keys."); - self.setState({ - errorText: "Registration failed on server" - }); - return; - } - self.props.onLoggedIn({ - userId: response.user_id, - deviceId: response.device_id, - homeserverUrl: self.registerLogic.getHomeserverUrl(), - identityServerUrl: self.registerLogic.getIdentityServerUrl(), - accessToken: response.access_token + _onUIAuthFinished: function(success, response, extra) { + if (!success) { + this.setState({ + busy: false, + doingUIAuth: false, + errorText: response.message || response.toString(), }); + return; + } + + this.setState({ + // we're still busy until we get unmounted: don't show the registration form again + busy: true, + doingUIAuth: false, + }); - if ( - self._rtsClient && - self.props.referrer && - self.state.teamSelected - ) { - // Track referral, get team_token in order to retrieve team config - self._rtsClient.trackReferral( - self.props.referrer, - response.user_id, - self.state.formVals.email - ).then((data) => { - const teamToken = data.team_token; - // Store for use /w welcome pages - window.localStorage.setItem('mx_team_token', teamToken); - - self._rtsClient.getTeam(teamToken).then((team) => { - console.log( - `User successfully registered with team ${team.name}` - ); - if (!team.rooms) { - return; + // Done regardless of `teamSelected`. People registering with non-team emails + // will just nop. The point of this being we might not have the email address + // that the user registered with at this stage (depending on whether this + // is the client they initiated registration). + let trackPromise = q(null); + if (this._rtsClient && extra.emailSid) { + // Track referral if this.props.referrer set, get team_token in order to + // retrieve team config and see welcome page etc. + trackPromise = this._rtsClient.trackReferral( + this.props.referrer || '', // Default to empty string = not referred + extra.emailSid, + extra.clientSecret, + ).then((data) => { + const teamToken = data.team_token; + // Store for use /w welcome pages + window.localStorage.setItem('mx_team_token', teamToken); + this.props.onTeamMemberRegistered(teamToken); + + this._rtsClient.getTeam(teamToken).then((team) => { + console.log( + `User successfully registered with team ${team.name}` + ); + if (!team.rooms) { + return; + } + // Auto-join rooms + team.rooms.forEach((room) => { + if (room.auto_join && room.room_id) { + console.log(`Auto-joining ${room.room_id}`); + MatrixClientPeg.get().joinRoom(room.room_id); } - // Auto-join rooms - team.rooms.forEach((room) => { - if (room.auto_join && room.room_id) { - console.log(`Auto-joining ${room.room_id}`); - MatrixClientPeg.get().joinRoom(room.room_id); - } - }); - }, (err) => { - console.error('Error getting team config', err); }); }, (err) => { - console.error('Error tracking referral', err); + console.error('Error getting team config', err); }); - } - 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); - }); - } + return teamToken; + }, (err) => { + console.error('Error tracking referral', err); + }); + } - }, function(err) { - if (err.message) { - self.setState({ - errorText: err.message - }); + trackPromise.then((teamToken) => { + console.info('Team token promise',teamToken); + this.props.onLoggedIn({ + userId: response.user_id, + deviceId: response.device_id, + homeserverUrl: this._matrixClient.getHomeserverUrl(), + identityServerUrl: this._matrixClient.getIdentityServerUrl(), + accessToken: response.access_token + }, teamToken); + }).then(() => { + return this._setupPushers(); + }); + }, + + _setupPushers: function() { + if (!this.props.brand) { + return q(); + } + return MatrixClientPeg.get().getPushers().then((resp)=>{ + const pushers = resp.pushers; + for (let i = 0; i < pushers.length; ++i) { + if (pushers[i].kind == 'email') { + const emailPusher = pushers[i]; + emailPusher.data = { brand: this.props.brand }; + MatrixClientPeg.get().setPusher(emailPusher).done(() => { + console.log("Set email branding to " + this.props.brand); + }, (error) => { + console.error("Couldn't set email branding: " + error); + }); + } } - self.setState({ - busy: false - }); - console.log(err); + }, (error) => { + console.error("Couldn't get pushers: " + error); }); }, @@ -294,6 +262,9 @@ module.exports = React.createClass({ case "RegistrationForm.ERR_EMAIL_INVALID": errMsg = "This doesn't look like a valid email address"; break; + case "RegistrationForm.ERR_PHONE_NUMBER_INVALID": + errMsg = "This doesn't look like a valid phone number"; + break; case "RegistrationForm.ERR_USERNAME_INVALID": errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores."; break; @@ -310,121 +281,136 @@ module.exports = React.createClass({ }); }, - onCaptchaResponse: function(response) { - this.registerLogic.tellStage("m.login.recaptcha", { - response: response - }); - }, - onTeamSelected: function(teamSelected) { if (!this._unmounted) { this.setState({ teamSelected }); } }, - _getRegisterContentJsx: function() { - const Spinner = sdk.getComponent("elements.Spinner"); + _makeRegisterRequest: function(auth) { + let guestAccessToken = this.props.guestAccessToken; - var currStep = this.registerLogic.getStep(); - var registerStep; - switch (currStep) { - case "Register.COMPLETE": - break; // NOP - case "Register.START": - case "Register.STEP_m.login.dummy": - // NB. Our 'username' prop is specifically for upgrading - // a guest account - if (this.state.teamServerBusy) { - registerStep = ; - break; - } - registerStep = ( + if ( + this.state.formVals.username !== this.props.username || + this.state.hsUrl != this.props.defaultHsUrl + ) { + // don't try to upgrade if we changed our username + // or are registering on a different HS + guestAccessToken = null; + } + + // Only send the bind params if we're sending username / pw params + // (Since we need to send no params at all to use the ones saved in the + // session). + const bindThreepids = this.state.formVals.password ? { + email: true, + msisdn: true, + } : {}; + + return this._matrixClient.register( + this.state.formVals.username, + this.state.formVals.password, + undefined, // session id: included in the auth dict already + auth, + bindThreepids, + guestAccessToken, + ); + }, + + _getUIAuthInputs: function() { + return { + emailAddress: this.state.formVals.email, + phoneCountry: this.state.formVals.phoneCountry, + phoneNumber: this.state.formVals.phoneNumber, + } + }, + + render: function() { + const LoginHeader = sdk.getComponent('login.LoginHeader'); + const LoginFooter = sdk.getComponent('login.LoginFooter'); + const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); + const Spinner = sdk.getComponent("elements.Spinner"); + const ServerConfig = sdk.getComponent('views.login.ServerConfig'); + + let registerBody; + if (this.state.doingUIAuth) { + registerBody = ( + + ); + } else if (this.state.busy || this.state.teamServerBusy) { + registerBody = ; + } else { + let guestUsername = this.props.username; + if (this.state.hsUrl != this.props.defaultHsUrl) { + guestUsername = null; + } + let errorSection; + if (this.state.errorText) { + errorSection =
    {this.state.errorText}
    ; + } + registerBody = ( +
    - ); - break; - case "Register.STEP_m.login.email.identity": - registerStep = ( -
    - Please check your email to continue registration. -
    - ); - break; - case "Register.STEP_m.login.recaptcha": - var publicKey; - var serverParams = this.registerLogic.getServerData().params; - if (serverParams && serverParams["m.login.recaptcha"]) { - publicKey = serverParams["m.login.recaptcha"].public_key; - } - - registerStep = ( - - ); - break; - default: - console.error("Unknown register state: %s", currStep); - break; - } - var busySpinner; - if (this.state.busy) { - busySpinner = ( - +
    ); } - var returnToAppJsx; + let returnToAppJsx; if (this.props.onCancelClick) { - returnToAppJsx = + returnToAppJsx = ( Return to app - ; - } - - return ( -
    -

    Create an account

    - {registerStep} -
    {this.state.errorText}
    - {busySpinner} - -
    -
    - - I already have an account - { returnToAppJsx } -
    - ); - }, - - render: function() { - var LoginHeader = sdk.getComponent('login.LoginHeader'); - var LoginFooter = sdk.getComponent('login.LoginFooter'); + ); + } return (
    - - {this._getRegisterContentJsx()} + +

    Create an account

    + {registerBody} + + I already have an account + + {returnToAppJsx}
    diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 2b3980c5364..0b2ca5225d9 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -17,6 +17,8 @@ limitations under the License. import React from 'react'; import * as KeyCode from '../../../KeyCode'; +import AccessibleButton from '../elements/AccessibleButton'; +import sdk from '../../../index'; /** * Basic container for modal dialogs. @@ -59,9 +61,20 @@ export default React.createClass({ } }, + _onCancelClick: function(e) { + this.props.onFinished(); + }, + render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + return (
    + + +
    { this.props.title }
    diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js new file mode 100644 index 00000000000..1a6ddf0456b --- /dev/null +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -0,0 +1,115 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import DMRoomMap from '../../../utils/DMRoomMap'; +import AccessibleButton from '../elements/AccessibleButton'; +import Unread from '../../../Unread'; +import classNames from 'classnames'; +import createRoom from '../../../createRoom'; + +export default class ChatCreateOrReuseDialog extends React.Component { + + constructor(props) { + super(props); + this.onNewDMClick = this.onNewDMClick.bind(this); + this.onRoomTileClick = this.onRoomTileClick.bind(this); + } + + onNewDMClick() { + createRoom({dmUserId: this.props.userId}); + this.props.onFinished(true); + } + + onRoomTileClick(roomId) { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + this.props.onFinished(true); + } + + render() { + const client = MatrixClientPeg.get(); + + const dmRoomMap = new DMRoomMap(client); + const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.userId); + + const RoomTile = sdk.getComponent("rooms.RoomTile"); + + const tiles = []; + for (const roomId of dmRooms) { + const room = client.getRoom(roomId); + if (room) { + const me = room.getMember(client.credentials.userId); + const highlight = ( + room.getUnreadNotificationCount('highlight') > 0 || + me.membership == "invite" + ); + tiles.push( + + ); + } + } + + const labelClasses = classNames({ + mx_MemberInfo_createRoom_label: true, + mx_RoomTile_name: true, + }); + const startNewChat = +
    + +
    +
    Start new chat
    +
    ; + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return ( + { + this.props.onFinished(false) + }} + title='Create a new chat or reuse an existing one' + > +
    + You already have existing direct chats with this user: +
    + {tiles} + {startNewChat} +
    +
    +
    + ); + } +} + +ChatCreateOrReuseDialog.propTyps = { + userId: React.PropTypes.string.isRequired, + onFinished: React.PropTypes.func.isRequired, +}; diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index ca3b07aa007..f958b8887cb 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -26,6 +26,7 @@ import dis from '../../../dispatcher'; import Modal from '../../../Modal'; import AccessibleButton from '../elements/AccessibleButton'; import q from 'q'; +import Fuse from 'fuse.js'; const TRUNCATE_QUERY_LIST = 40; @@ -85,6 +86,19 @@ module.exports = React.createClass({ // Set the cursor at the end of the text input this.refs.textinput.value = this.props.value; } + // Create a Fuse instance for fuzzy searching this._userList + this._fuse = new Fuse( + // Use an empty list at first that will later be populated + // (see this._updateUserList) + [], + { + shouldSort: true, + location: 0, // The index of the query in the test string + distance: 5, // The distance away from location the query can be + // 0.0 = exact match, 1.0 = match anything + threshold: 0.3, + } + ); this._updateUserList(); }, @@ -97,18 +111,27 @@ module.exports = React.createClass({ if (inviteList === null) return; } + const addrTexts = inviteList.map(addr => addr.address); if (inviteList.length > 0) { - if (this._isDmChat(inviteList)) { + if (this._isDmChat(addrTexts)) { + const userId = inviteList[0].address; // Direct Message chat - var room = this._getDirectMessageRoom(inviteList[0]); - if (room) { - // A Direct Message room already exists for this user and you - // so go straight to that room - dis.dispatch({ - action: 'view_room', - room_id: room.roomId, + const rooms = this._getDirectMessageRooms(userId); + if (rooms.length > 0) { + // A Direct Message room already exists for this user, so select a + // room from a list that is similar to the one in MemberInfo panel + const ChatCreateOrReuseDialog = sdk.getComponent( + "views.dialogs.ChatCreateOrReuseDialog" + ); + Modal.createDialog(ChatCreateOrReuseDialog, { + userId: userId, + onFinished: (success) => { + if (success) { + this.props.onFinished(true, inviteList[0]); + } + // else show this ChatInviteDialog again + } }); - this.props.onFinished(true, inviteList[0]); } else { this._startChat(inviteList); } @@ -167,45 +190,60 @@ module.exports = React.createClass({ const query = ev.target.value; let queryList = []; - // Only do search if there is something to search - if (query.length > 0 && query != '@') { - // filter the known users list - queryList = this._userList.filter((user) => { - return this._matches(query, user); - }).map((user) => { - // Return objects, structure of which is defined - // by InviteAddressType - return { - addressType: 'mx', - address: user.userId, - displayName: user.displayName, - avatarMxc: user.avatarUrl, - isKnown: true, - } - }); + if (query.length < 2) { + return; + } + + if (this.queryChangedDebouncer) { + clearTimeout(this.queryChangedDebouncer); + } + this.queryChangedDebouncer = setTimeout(() => { + // Only do search if there is something to search + if (query.length > 0 && query != '@') { + // Weighted keys prefer to match userIds when first char is @ + this._fuse.options.keys = [{ + name: 'displayName', + weight: query[0] === '@' ? 0.1 : 0.9, + },{ + name: 'userId', + weight: query[0] === '@' ? 0.9 : 0.1, + }]; + queryList = this._fuse.search(query).map((user) => { + // Return objects, structure of which is defined + // by InviteAddressType + return { + addressType: 'mx', + address: user.userId, + displayName: user.displayName, + avatarMxc: user.avatarUrl, + isKnown: true, + } + }); - // If the query isn't a user we know about, but is a - // valid address, add an entry for that - if (queryList.length == 0) { - const addrType = getAddressType(query); - if (addrType !== null) { - queryList[0] = { - addressType: addrType, - address: query, - isKnown: false, - }; - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - if (addrType == 'email') { - this._lookupThreepid(addrType, query).done(); + // If the query isn't a user we know about, but is a + // valid address, add an entry for that + if (queryList.length == 0) { + const addrType = getAddressType(query); + if (addrType !== null) { + queryList[0] = { + addressType: addrType, + address: query, + isKnown: false, + }; + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (addrType == 'email') { + this._lookupThreepid(addrType, query).done(); + } } } } - } - - this.setState({ - queryList: queryList, - error: false, - }); + this.setState({ + queryList: queryList, + error: false, + }, () => { + this.addressSelector.moveSelectionTop(); + }); + }, 200); }, onDismissed: function(index) { @@ -238,22 +276,20 @@ module.exports = React.createClass({ if (this._cancelThreepidLookup) this._cancelThreepidLookup(); }, - _getDirectMessageRoom: function(addr) { + _getDirectMessageRooms: function(addr) { const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); - var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); - if (dmRooms.length > 0) { - // Cycle through all the DM rooms and find the first non forgotten or parted room - for (let i = 0; i < dmRooms.length; i++) { - let room = MatrixClientPeg.get().getRoom(dmRooms[i]); - if (room) { - const me = room.getMember(MatrixClientPeg.get().credentials.userId); - if (me.membership == 'join') { - return room; - } + const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); + const rooms = []; + dmRooms.forEach(dmRoom => { + let room = MatrixClientPeg.get().getRoom(dmRoom); + if (room) { + const me = room.getMember(MatrixClientPeg.get().credentials.userId); + if (me.membership == 'join') { + rooms.push(room); } } - } - return null; + }); + return rooms; }, _startChat: function(addrs) { @@ -282,8 +318,8 @@ module.exports = React.createClass({ console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Failure to invite", - description: err.toString() + title: "Error", + description: "Failed to invite", }); return null; }) @@ -295,8 +331,8 @@ module.exports = React.createClass({ console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Failure to invite user", - description: err.toString() + title: "Error", + description: "Failed to invite user", }); return null; }) @@ -316,8 +352,8 @@ module.exports = React.createClass({ console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Failure to invite", - description: err.toString() + title: "Error", + description: "Failed to invite", }); return null; }) @@ -331,48 +367,14 @@ module.exports = React.createClass({ _updateUserList: new rate_limited_func(function() { // Get all the users this._userList = MatrixClientPeg.get().getUsers(); - }, 500), - - // This is the search algorithm for matching users - _matches: function(query, user) { - var name = user.displayName.toLowerCase(); - var uid = user.userId.toLowerCase(); - query = query.toLowerCase(); - - // don't match any that are already on the invite list - if (this._isOnInviteList(uid)) { - return false; - } - - // ignore current user - if (uid === MatrixClientPeg.get().credentials.userId) { - return false; - } - - // direct prefix matches - if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { - return true; - } - - // strip @ on uid and try matching again - if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { - return true; - } - - // Try to find the query following a "word boundary", except that - // this does avoids using \b because it only considers letters from - // the roman alphabet to be word characters. - // Instead, we look for the query following either: - // * The start of the string - // * Whitespace, or - // * A fixed number of punctuation characters - const expr = new RegExp("(?:^|[\\s\\(\)'\",\.-_@\?;:{}\\[\\]\\#~`\\*\\&\\$])" + escapeRegExp(query)); - if (expr.test(name)) { - return true; - } + // Remove current user + const meIx = this._userList.findIndex((u) => { + return u.userId === MatrixClientPeg.get().credentials.userId; + }); + this._userList.splice(meIx, 1); - return false; - }, + this._fuse.set(this._userList); + }, 500), _isOnInviteList: function(uid) { for (let i = 0; i < this.state.inviteList.length; i++) { @@ -386,8 +388,11 @@ module.exports = React.createClass({ return false; }, - _isDmChat: function(addrs) { - if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) { + _isDmChat: function(addrTexts) { + if (addrTexts.length === 1 && + getAddressType(addrTexts[0]) === "mx" && + !this.props.roomId + ) { return true; } else { return false; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js new file mode 100644 index 00000000000..6cfaac65d42 --- /dev/null +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -0,0 +1,120 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import classnames from 'classnames'; + +/* + * A dialog for confirming an operation on another user. + * Takes a user ID and a verb, displays the target user prominently + * such that it should be easy to confirm that the operation is being + * performed on the right person, and displays the operation prominently + * to make it obvious what is going to happen. + * Also tweaks the style for 'dangerous' actions (albeit only with colour) + */ +export default React.createClass({ + displayName: 'ConfirmUserActionDialog', + propTypes: { + member: React.PropTypes.object.isRequired, // matrix-js-sdk member object + action: React.PropTypes.string.isRequired, // eg. 'Ban' + + // Whether to display a text field for a reason + // If true, the second argument to onFinished will + // be the string entered. + askReason: React.PropTypes.bool, + danger: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired, + }, + + defaultProps: { + danger: false, + askReason: false, + }, + + componentWillMount: function() { + this._reasonField = null; + }, + + onOk: function() { + let reason; + if (this._reasonField) { + reason = this._reasonField.value; + } + this.props.onFinished(true, reason); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + _collectReasonField: function(e) { + this._reasonField = e; + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); + + const title = this.props.action + " this person?"; + const confirmButtonClass = classnames({ + 'mx_Dialog_primary': true, + 'danger': this.props.danger, + }); + + let reasonBox; + if (this.props.askReason) { + reasonBox = ( +
    +
    + +
    +
    + ); + } + + return ( + +
    +
    + +
    +
    {this.props.member.name}
    +
    {this.props.member.userId}
    +
    + {reasonBox} +
    + + + +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index a4abbb17d94..145b4b64534 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,18 +16,20 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -const InteractiveAuth = Matrix.InteractiveAuth; import React from 'react'; import sdk from '../../../index'; -import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents'; +import AccessibleButton from '../elements/AccessibleButton'; export default React.createClass({ displayName: 'InteractiveAuthDialog', propTypes: { + // matrix client to use for UI auth requests + matrixClient: React.PropTypes.object.isRequired, + // response from initial request. If not supplied, will do a request on // mount. authData: React.PropTypes.shape({ @@ -41,169 +44,70 @@ export default React.createClass({ onFinished: React.PropTypes.func.isRequired, title: React.PropTypes.string, - submitButtonLabel: React.PropTypes.string, }, getDefaultProps: function() { return { title: "Authentication", - submitButtonLabel: "Submit", }; }, getInitialState: function() { return { - authStage: null, - busy: false, - errorText: null, - stageErrorText: null, - submitButtonEnabled: false, - }; + authError: null, + } }, - componentWillMount: function() { - this._unmounted = false; - this._authLogic = new InteractiveAuth({ - authData: this.props.authData, - doRequest: this._requestCallback, - startAuthStage: this._startAuthStage, - }); - - this._authLogic.attemptAuth().then((result) => { + _onAuthFinished: function(success, result) { + if (success) { this.props.onFinished(true, result); - }).catch((error) => { - console.error("Error during user-interactive auth:", error); - if (this._unmounted) { - return; - } - - const msg = error.message || error.toString(); - this.setState({ - errorText: msg - }); - }).done(); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, - - _startAuthStage: function(stageType, error) { - this.setState({ - authStage: stageType, - errorText: error ? error.error : null, - }, this._setFocus); - }, - - _requestCallback: function(auth) { - this.setState({ - busy: true, - errorText: null, - stageErrorText: null, - }); - return this.props.makeRequest(auth).finally(() => { - if (this._unmounted) { - return; - } + } else { this.setState({ - busy: false, + authError: result, }); - }); - }, - - _onEnterPressed: function(e) { - if (this.state.submitButtonEnabled && !this.state.busy) { - this._onSubmit(); } }, - _onSubmit: function() { - if (this.refs.stageComponent && this.refs.stageComponent.onSubmitClick) { - this.refs.stageComponent.onSubmitClick(); - } - }, - - _setFocus: function() { - if (this.refs.stageComponent && this.refs.stageComponent.focus) { - this.refs.stageComponent.focus(); - } - }, - - _onCancel: function() { + _onDismissClick: function() { this.props.onFinished(false); }, - _setSubmitButtonEnabled: function(enabled) { - this.setState({ - submitButtonEnabled: enabled, - }); - }, - - _submitAuthDict: function(authData) { - this._authLogic.submitAuthDict(authData); - }, - - _renderCurrentStage: function() { - const stage = this.state.authStage; - var StageComponent = getEntryComponentForLoginType(stage); - return ( - - ); - }, - render: function() { - const Loader = sdk.getComponent("elements.Spinner"); + const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - let error = null; - if (this.state.errorText) { - error = ( -
    - {this.state.errorText} + let content; + if (this.state.authError) { + content = ( +
    +
    {this.state.authError.message || this.state.authError.toString()}
    +
    + + Dismiss + +
    + ); + } else { + content = ( +
    +
    ); } - const submitLabel = this.state.busy ? : this.props.submitButtonLabel; - const submitEnabled = this.state.submitButtonEnabled && !this.state.busy; - - const submitButton = ( - - ); - - const cancelButton = ( - - ); - return ( -
    -

    This operation requires additional authentication.

    - {this._renderCurrentStage()} - {error} -
    -
    - {submitButton} - {cancelButton} -
    + {content}
    ); }, diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 3f7f237c30e..0260fc29e2c 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -36,6 +36,7 @@ export default React.createClass({ description: "", button: "OK", focus: true, + hasCancelButton: true, }; }, @@ -49,6 +50,11 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const cancelButton = this.props.hasCancelButton ? ( + + ) : null; return ( {this.props.button} - - + {cancelButton}
    ); diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js new file mode 100644 index 00000000000..358bbf1fec6 --- /dev/null +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -0,0 +1,74 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import Modal from '../../../Modal'; + + +export default React.createClass({ + displayName: 'SessionRestoreErrorDialog', + + propTypes: { + error: React.PropTypes.string.isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + _sendBugReport: function() { + const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); + Modal.createDialog(BugReportDialog, {}); + }, + + _continueClicked: function() { + this.props.onFinished(true); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + let bugreport; + + if (SdkConfig.get().bug_report_endpoint_url) { + bugreport = ( +

    Otherwise, + click here to send a bug report. +

    + ); + } + + return ( + +
    +

    We encountered an error trying to restore your previous session. If + you continue, you will need to log in again, and encrypted chat + history will be unreadable.

    + +

    If you have previously used a more recent version of Riot, your session + may be incompatible with this version. Close this window and return + to the more recent version.

    + + {bugreport} +
    +
    + +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 409852a2ccd..da9c8e8f653 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -16,8 +16,10 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import dis from '../../../dispatcher'; import MatrixClientPeg from '../../../MatrixClientPeg'; import GeminiScrollbar from 'react-gemini-scrollbar'; +import Resend from '../../../Resend'; function DeviceListEntry(props) { const {userId, device} = props; @@ -85,7 +87,7 @@ UnknownDeviceList.propTypes = { export default React.createClass({ - displayName: 'UnknownEventDialog', + displayName: 'UnknownDeviceDialog', propTypes: { room: React.PropTypes.object.isRequired, @@ -103,6 +105,10 @@ export default React.createClass({ MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); }); }); + + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Opening UnknownDeviceDialog'); }, render: function() { @@ -121,14 +127,10 @@ export default React.createClass({ } else { warning = (
    -

    - This means there is no guarantee that the devices - belong to the users they claim to. -

    We recommend you go through the verification process - for each device before continuing, but you can resend - the message without verifying if you prefer. + for each device to confirm they belong to their legitimate owner, + but you can resend the message without verifying if you prefer.

    ); @@ -137,13 +139,17 @@ export default React.createClass({ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log("UnknownDeviceDialog closed by escape"); + this.props.onFinished(); + }} title='Room contains unknown devices' >

    - This room contains unknown devices which have not been - verified. + This room contains devices that you haven't seen before.

    { warning } Unknown devices: @@ -152,7 +158,19 @@ export default React.createClass({
    +
    diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index ffea8e1ba7b..2c23c0d2083 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -27,8 +27,8 @@ import React from 'react'; export default function AccessibleButton(props) { const {element, onClick, children, ...restProps} = props; restProps.onClick = onClick; - restProps.onKeyDown = function(e) { - if (e.keyCode == 13 || e.keyCode == 32) return onClick(); + restProps.onKeyUp = function(e) { + if (e.keyCode == 13 || e.keyCode == 32) return onClick(e); }; restProps.tabIndex = restProps.tabIndex || "0"; restProps.role = "button"; diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 9f37fa90ff0..6bad15f7d05 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -61,6 +61,15 @@ export default React.createClass({ } }, + moveSelectionTop: function() { + if (this.state.selected > 0) { + this.setState({ + selected: 0, + hover: false, + }); + } + }, + moveSelectionUp: function() { if (this.state.selected > 0) { this.setState({ @@ -124,7 +133,14 @@ export default React.createClass({ // Saving the addressListElement so we can use it to work out, in the componentDidUpdate // method, how far to scroll when using the arrow keys addressList.push( -
    { this.addressListElement = ref; }} > +
    { this.addressListElement = ref; }} + >
    ); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 18492d8ae6b..9961a1a428d 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -64,19 +64,14 @@ export default React.createClass({ const address = this.props.address; const name = address.displayName || address.address; - let imgUrl; - if (address.avatarMxc) { - imgUrl = MatrixClientPeg.get().mxcUrlToHttp( - address.avatarMxc, 25, 25, 'crop' - ); - } + let imgUrls = []; - if (address.addressType === "mx") { - if (!imgUrl) imgUrl = 'img/icon-mx-user.svg'; + if (address.addressType === "mx" && address.avatarMxc) { + imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( + address.avatarMxc, 25, 25, 'crop' + )); } else if (address.addressType === 'email') { - if (!imgUrl) imgUrl = 'img/icon-email-user.svg'; - } else { - if (!imgUrl) imgUrl = "img/avatar-error.svg"; + imgUrls.push('img/icon-email-user.svg'); } // Removing networks for now as they're not really supported @@ -168,7 +163,7 @@ export default React.createClass({ return (
    - +
    { info } { dismiss } diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js new file mode 100644 index 00000000000..3b34d3cac19 --- /dev/null +++ b/src/components/views/elements/Dropdown.js @@ -0,0 +1,324 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import React from 'react'; +import classnames from 'classnames'; +import AccessibleButton from './AccessibleButton'; + +class MenuOption extends React.Component { + constructor(props) { + super(props); + this._onMouseEnter = this._onMouseEnter.bind(this); + this._onClick = this._onClick.bind(this); + } + + _onMouseEnter() { + this.props.onMouseEnter(this.props.dropdownKey); + } + + _onClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.onClick(this.props.dropdownKey); + } + + render() { + const optClasses = classnames({ + mx_Dropdown_option: true, + mx_Dropdown_option_highlight: this.props.highlighted, + }); + + return
    + {this.props.children} +
    + } +}; + +MenuOption.propTypes = { + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.node), + React.PropTypes.node + ]), + highlighted: React.PropTypes.bool, + dropdownKey: React.PropTypes.string, + onClick: React.PropTypes.func.isRequired, + onMouseEnter: React.PropTypes.func.isRequired, +}; + +/* + * Reusable dropdown select control, akin to react-select, + * but somewhat simpler as react-select is 79KB of minified + * javascript. + * + * TODO: Port NetworkDropdown to use this. + */ +export default class Dropdown extends React.Component { + constructor(props) { + super(props); + + this.dropdownRootElement = null; + this.ignoreEvent = null; + + this._onInputClick = this._onInputClick.bind(this); + this._onRootClick = this._onRootClick.bind(this); + this._onDocumentClick = this._onDocumentClick.bind(this); + this._onMenuOptionClick = this._onMenuOptionClick.bind(this); + this._onInputKeyPress = this._onInputKeyPress.bind(this); + this._onInputKeyUp = this._onInputKeyUp.bind(this); + this._onInputChange = this._onInputChange.bind(this); + this._collectRoot = this._collectRoot.bind(this); + this._collectInputTextBox = this._collectInputTextBox.bind(this); + this._setHighlightedOption = this._setHighlightedOption.bind(this); + + this.inputTextBox = null; + + this._reindexChildren(this.props.children); + + const firstChild = React.Children.toArray(props.children)[0]; + + this.state = { + // True if the menu is dropped-down + expanded: false, + // The key of the highlighted option + // (the option that would become selected if you pressed enter) + highlightedOption: firstChild ? firstChild.key : null, + // the current search query + searchQuery: '', + }; + } + + componentWillMount() { + // Listen for all clicks on the document so we can close the + // menu when the user clicks somewhere else + document.addEventListener('click', this._onDocumentClick, false); + } + + componentWillUnmount() { + document.removeEventListener('click', this._onDocumentClick, false); + } + + componentWillReceiveProps(nextProps) { + this._reindexChildren(nextProps.children); + const firstChild = React.Children.toArray(nextProps.children)[0]; + this.setState({ + highlightedOption: firstChild ? firstChild.key : null, + }); + } + + _reindexChildren(children) { + this.childrenByKey = {}; + React.Children.forEach(children, (child) => { + this.childrenByKey[child.key] = child; + }); + } + + _onDocumentClick(ev) { + // Close the dropdown if the user clicks anywhere that isn't + // within our root element + if (ev !== this.ignoreEvent) { + this.setState({ + expanded: false, + }); + } + } + + _onRootClick(ev) { + // This captures any clicks that happen within our elements, + // such that we can then ignore them when they're seen by the + // click listener on the document handler, ie. not close the + // dropdown immediately after opening it. + // NB. We can't just stopPropagation() because then the event + // doesn't reach the React onClick(). + this.ignoreEvent = ev; + } + + _onInputClick(ev) { + this.setState({ + expanded: !this.state.expanded, + }); + ev.preventDefault(); + } + + _onMenuOptionClick(dropdownKey) { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(dropdownKey); + } + + _onInputKeyPress(e) { + // This needs to be on the keypress event because otherwise + // it can't cancel the form submission + if (e.key == 'Enter') { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(this.state.highlightedOption); + e.preventDefault(); + } + } + + _onInputKeyUp(e) { + // These keys don't generate keypress events and so needs to + // be on keyup + if (e.key == 'Escape') { + this.setState({ + expanded: false, + }); + } else if (e.key == 'ArrowDown') { + this.setState({ + highlightedOption: this._nextOption(this.state.highlightedOption), + }); + } else if (e.key == 'ArrowUp') { + this.setState({ + highlightedOption: this._prevOption(this.state.highlightedOption), + }); + } + } + + _onInputChange(e) { + this.setState({ + searchQuery: e.target.value, + }); + if (this.props.onSearchChange) { + this.props.onSearchChange(e.target.value); + } + } + + _collectRoot(e) { + if (this.dropdownRootElement) { + this.dropdownRootElement.removeEventListener( + 'click', this._onRootClick, false, + ); + } + if (e) { + e.addEventListener('click', this._onRootClick, false); + } + this.dropdownRootElement = e; + } + + _collectInputTextBox(e) { + this.inputTextBox = e; + if (e) e.focus(); + } + + _setHighlightedOption(optionKey) { + this.setState({ + highlightedOption: optionKey, + }); + } + + _nextOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index + 1) % keys.length]; + } + + _prevOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index - 1) % keys.length]; + } + + _getMenuOptions() { + const options = React.Children.map(this.props.children, (child) => { + return ( + + {child} + + ); + }); + + if (!this.state.searchQuery) { + options.push( +
    + Type to search... +
    + ); + } + return options; + } + + render() { + let currentValue; + + const menuStyle = {}; + if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; + + let menu; + if (this.state.expanded) { + currentValue = ; + menu =
    + {this._getMenuOptions()} +
    ; + } else { + const selectedChild = this.props.getShortOption ? + this.props.getShortOption(this.props.value) : + this.childrenByKey[this.props.value]; + currentValue =
    + {selectedChild} +
    + } + + const dropdownClasses = { + mx_Dropdown: true, + }; + if (this.props.className) { + dropdownClasses[this.props.className] = true; + } + + // Note the menu sits inside the AccessibleButton div so it's anchored + // to the input, but overflows below it. The root contains both. + return
    + + {currentValue} + + {menu} + +
    ; + } +} + +Dropdown.propTypes = { + // The width that the dropdown should be. If specified, + // the dropped-down part of the menu will be set to this + // width. + menuWidth: React.PropTypes.number, + // Called when the selected option changes + onOptionChange: React.PropTypes.func.isRequired, + // Called when the value of the search field changes + onSearchChange: React.PropTypes.func, + // Function that, given the key of an option, returns + // a node representing that option to be displayed in the + // box itself as the currently-selected option (ie. as + // opposed to in the actual dropped-down part). If + // unspecified, the appropriate child element is used as + // in the dropped-down menu. + getShortOption: React.PropTypes.func, + value: React.PropTypes.string, +} diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 2c745676986..3ce8c90447c 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -33,7 +33,10 @@ module.exports = React.createClass({ className: React.PropTypes.string, labelClassName: React.PropTypes.string, placeholderClassName: React.PropTypes.string, + // Overrides blurToSubmit if true blurToCancel: React.PropTypes.bool, + // Will cause onValueChanged(value, true) to fire on blur + blurToSubmit: React.PropTypes.bool, editable: React.PropTypes.bool, }, @@ -51,6 +54,7 @@ module.exports = React.createClass({ editable: true, className: "mx_EditableText", placeholderClassName: "mx_EditableText_placeholder", + blurToSubmit: false, }; }, @@ -119,6 +123,7 @@ module.exports = React.createClass({ this.value = this.props.initialValue; this.showPlaceholder(!this.value); this.onValueChanged(false); + this.refs.editable_div.blur(); }, onValueChanged: function(shouldSubmit) { @@ -182,13 +187,15 @@ module.exports = React.createClass({ } }, - onFinish: function(ev) { + onFinish: function(ev, shouldSubmit) { var self = this; - var submit = (ev.key === "Enter"); + var submit = (ev.key === "Enter") || shouldSubmit; this.setState({ phase: this.Phases.Display, }, function() { - self.onValueChanged(submit); + if (this.value !== this.props.initialValue) { + self.onValueChanged(submit); + } }); }, @@ -199,7 +206,7 @@ module.exports = React.createClass({ if (this.props.blurToCancel) {this.cancelEdit();} else - {this.onFinish(ev);} + {this.onFinish(ev, this.props.blurToSubmit);} this.showPlaceholder(!this.value); }, diff --git a/src/components/views/elements/EditableTextContainer.js b/src/components/views/elements/EditableTextContainer.js index b17f1b417dc..d6f9c555c66 100644 --- a/src/components/views/elements/EditableTextContainer.js +++ b/src/components/views/elements/EditableTextContainer.js @@ -116,6 +116,7 @@ export default class EditableTextContainer extends React.Component { ); } @@ -137,11 +138,15 @@ EditableTextContainer.propTypes = { /* callback to update the value. Called with a single argument: the new * value. */ onSubmit: React.PropTypes.func, + + /* should the input submit when focus is lost? */ + blurToSubmit: React.PropTypes.bool, }; EditableTextContainer.defaultProps = { initialValue: "", placeholder: "", + blurToSubmit: false, onSubmit: function(v) {return q(); }, }; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 61fa0e076f4..d7f876c16e7 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -30,6 +30,8 @@ module.exports = React.createClass({ avatarsMaxLength: React.PropTypes.number, // The minimum number of events needed to trigger summarisation threshold: React.PropTypes.number, + // Called when the MELS expansion is toggled + onToggle: React.PropTypes.func, }, getInitialState: function() { @@ -63,6 +65,7 @@ module.exports = React.createClass({ this.setState({ expanded: !this.state.expanded, }); + this.props.onToggle(); }, /** @@ -108,7 +111,7 @@ module.exports = React.createClass({ } return ( - + {summaries.join(", ")} ); @@ -264,7 +267,7 @@ module.exports = React.createClass({ ); }); return ( - + {avatars} ); @@ -381,11 +384,11 @@ module.exports = React.createClass({ // Initialise a user's events if (!userEvents[userId]) { userEvents[userId] = []; - avatarMembers.push(e.target); + if (e.target) avatarMembers.push(e.target); } userEvents[userId].push({ mxEvent: e, - displayName: e.target.name || userId, + displayName: (e.target ? e.target.name : null) || userId, index: index, }); }); @@ -397,31 +400,28 @@ module.exports = React.createClass({ (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2] ); - const avatars = this._renderAvatars(avatarMembers); - const summary = this._renderSummary(aggregate.names, orderedTransitionSequences); + let summaryContainer = null; + if (!expanded) { + summaryContainer = ( +
    +
    + {this._renderAvatars(avatarMembers)} + {this._renderSummary(aggregate.names, orderedTransitionSequences)} +
    +
    + ); + } const toggleButton = ( - +
    {expanded ? 'collapse' : 'expand'} - - ); - - const summaryContainer = ( -
    -
    - - {avatars} - - - {summary} -   - {toggleButton} -
    ); return (
    + {toggleButton} {summaryContainer} + {expanded ?
     
    : null} {expandedEvents}
    ); diff --git a/src/components/views/login/CountryDropdown.js b/src/components/views/login/CountryDropdown.js new file mode 100644 index 00000000000..fc1e89661b9 --- /dev/null +++ b/src/components/views/login/CountryDropdown.js @@ -0,0 +1,123 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +import React from 'react'; + +import sdk from '../../../index'; + +import { COUNTRIES } from '../../../phonenumber'; +import { charactersToImageNode } from '../../../HtmlUtils'; + +const COUNTRIES_BY_ISO2 = new Object(null); +for (const c of COUNTRIES) { + COUNTRIES_BY_ISO2[c.iso2] = c; +} + +function countryMatchesSearchQuery(query, country) { + if (country.name.toUpperCase().indexOf(query.toUpperCase()) == 0) return true; + if (country.iso2 == query.toUpperCase()) return true; + if (country.prefix == query) return true; + return false; +} + +const MAX_DISPLAYED_ROWS = 2; + +export default class CountryDropdown extends React.Component { + constructor(props) { + super(props); + this._onSearchChange = this._onSearchChange.bind(this); + + this.state = { + searchQuery: '', + } + + if (!props.value) { + // If no value is given, we start with the first + // country selected, but our parent component + // doesn't know this, therefore we do this. + this.props.onOptionChange(COUNTRIES[0].iso2); + } + } + + _onSearchChange(search) { + this.setState({ + searchQuery: search, + }); + } + + _flagImgForIso2(iso2) { + // Unicode Regional Indicator Symbol letter 'A' + const RIS_A = 0x1F1E6; + const ASCII_A = 65; + return charactersToImageNode(iso2, + RIS_A + (iso2.charCodeAt(0) - ASCII_A), + RIS_A + (iso2.charCodeAt(1) - ASCII_A), + ); + } + + render() { + const Dropdown = sdk.getComponent('elements.Dropdown'); + + let displayedCountries; + if (this.state.searchQuery) { + displayedCountries = COUNTRIES.filter( + countryMatchesSearchQuery.bind(this, this.state.searchQuery), + ); + if ( + this.state.searchQuery.length == 2 && + COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()] + ) { + // exact ISO2 country name match: make the first result the matches ISO2 + const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]; + displayedCountries = displayedCountries.filter((c) => { + return c.iso2 != matched.iso2; + }); + displayedCountries.unshift(matched); + } + } else { + displayedCountries = COUNTRIES; + } + + if (displayedCountries.length > MAX_DISPLAYED_ROWS) { + displayedCountries = displayedCountries.slice(0, MAX_DISPLAYED_ROWS); + } + + const options = displayedCountries.map((country) => { + return
    + {this._flagImgForIso2(country.iso2)} + {country.name} +
    ; + }); + + // default value here too, otherwise we need to handle null / undefined + // values between mounting and the initial value propgating + const value = this.props.value || COUNTRIES[0].iso2; + + return + {options} + + } +} + +CountryDropdown.propTypes = { + className: React.PropTypes.string, + onOptionChange: React.PropTypes.func.isRequired, + value: React.PropTypes.string, +}; diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index ec184ca09f9..2d8abf92169 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,27 +16,47 @@ limitations under the License. */ import React from 'react'; +import url from 'url'; +import classnames from 'classnames'; import sdk from '../../../index'; -import MatrixClientPeg from '../../../MatrixClientPeg'; /* This file contains a collection of components which are used by the - * InteractiveAuthDialog to prompt the user to enter the information needed + * InteractiveAuth to prompt the user to enter the information needed * for an auth stage. (The intention is that they could also be used for other * components, such as the registration flow). * * Call getEntryComponentForLoginType() to get a component suitable for a * particular login type. Each component requires the same properties: * + * matrixClient: A matrix client. May be a different one to the one + * currently being used generally (eg. to register with + * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server + * clientSecret: The client secret in use for ID server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict - * setSubmitButtonEnabled: a function which will enable/disable the 'submit' button + * busy: a boolean indicating whether the auth logic is doing something + * the user needs to wait for. + * inputs: Object of inputs provided by the user, as in js-sdk + * interactive-auth + * stageState: Stage-specific object used for communicating state information + * to the UI from the state-specific auth logic. + * Defined keys for stages are: + * m.login.email.identity: + * * emailSid: string representing the sid of the active + * verification session from the ID server, or + * null if no session is active. + * fail: a function which should be called with an error object if an + * error occurred during the auth stage. This will cause the auth + * session to be failed and the process to go back to the start. + * setEmailSid: m.login.email.identity only: a function to be called with the + * email sid after a token is requested. + * makeRegistrationUrl A function that makes a registration URL * * Each component may also provide the following functions (beyond the standard React ones): - * onSubmitClick: handle a 'submit' button click * focus: set the input focus appropriately in the form. */ @@ -47,13 +68,18 @@ export const PasswordAuthEntry = React.createClass({ }, propTypes: { + matrixClient: React.PropTypes.object.isRequired, submitAuthDict: React.PropTypes.func.isRequired, - setSubmitButtonEnabled: React.PropTypes.func.isRequired, errorText: React.PropTypes.string, + // is the auth logic currently waiting for something to + // happen? + busy: React.PropTypes.bool, }, - componentWillMount: function() { - this.props.setSubmitButtonEnabled(false); + getInitialState: function() { + return { + passwordValid: false, + }; }, focus: function() { @@ -62,17 +88,22 @@ export const PasswordAuthEntry = React.createClass({ } }, - onSubmitClick: function() { + _onSubmit: function(e) { + e.preventDefault(); + if (this.props.busy) return; + this.props.submitAuthDict({ type: PasswordAuthEntry.LOGIN_TYPE, - user: MatrixClientPeg.get().credentials.userId, + user: this.props.matrixClient.credentials.userId, password: this.refs.passwordField.value, }); }, _onPasswordFieldChange: function(ev) { // enable the submit button iff the password is non-empty - this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); + this.setState({ + passwordValid: Boolean(this.refs.passwordField.value), + }); }, render: function() { @@ -82,16 +113,34 @@ export const PasswordAuthEntry = React.createClass({ passwordBoxClass = 'error'; } + let submitButtonOrSpinner; + if (this.props.busy) { + const Loader = sdk.getComponent("elements.Spinner"); + submitButtonOrSpinner = ; + } else { + submitButtonOrSpinner = ( + + ); + } + return (

    To continue, please enter your password.

    Password:

    - +
    + +
    + {submitButtonOrSpinner} +
    +
    {this.props.errorText}
    @@ -110,14 +159,9 @@ export const RecaptchaAuthEntry = React.createClass({ propTypes: { submitAuthDict: React.PropTypes.func.isRequired, stageParams: React.PropTypes.object.isRequired, - setSubmitButtonEnabled: React.PropTypes.func.isRequired, errorText: React.PropTypes.string, }, - componentWillMount: function() { - this.props.setSubmitButtonEnabled(false); - }, - _onCaptchaResponse: function(response) { this.props.submitAuthDict({ type: RecaptchaAuthEntry.LOGIN_TYPE, @@ -141,14 +185,217 @@ export const RecaptchaAuthEntry = React.createClass({ }, }); +export const EmailIdentityAuthEntry = React.createClass({ + displayName: 'EmailIdentityAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.email.identity", + }, + + propTypes: { + matrixClient: React.PropTypes.object.isRequired, + submitAuthDict: React.PropTypes.func.isRequired, + authSessionId: React.PropTypes.string.isRequired, + clientSecret: React.PropTypes.string.isRequired, + inputs: React.PropTypes.object.isRequired, + stageState: React.PropTypes.object.isRequired, + fail: React.PropTypes.func.isRequired, + setEmailSid: React.PropTypes.func.isRequired, + makeRegistrationUrl: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + requestingToken: false, + }; + }, + + componentWillMount: function() { + if (this.props.stageState.emailSid === null) { + this.setState({requestingToken: true}); + this._requestEmailToken().catch((e) => { + this.props.fail(e); + }).finally(() => { + this.setState({requestingToken: false}); + }).done(); + } + }, + + /* + * Requests a verification token by email. + */ + _requestEmailToken: function() { + const nextLink = this.props.makeRegistrationUrl({ + client_secret: this.props.clientSecret, + hs_url: this.props.matrixClient.getHomeserverUrl(), + is_url: this.props.matrixClient.getIdentityServerUrl(), + session_id: this.props.authSessionId, + }); + + return this.props.matrixClient.requestRegisterEmailToken( + this.props.inputs.emailAddress, + this.props.clientSecret, + 1, // TODO: Multiple send attempts? + nextLink, + ).then((result) => { + this.props.setEmailSid(result.sid); + }); + }, + + render: function() { + if (this.state.requestingToken) { + const Loader = sdk.getComponent("elements.Spinner"); + return ; + } else { + return ( +
    +

    An email has been sent to {this.props.inputs.emailAddress}

    +

    Please check your email to continue registration.

    +
    + ); + } + }, +}); + +export const MsisdnAuthEntry = React.createClass({ + displayName: 'MsisdnAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.msisdn", + }, + + propTypes: { + inputs: React.PropTypes.shape({ + phoneCountry: React.PropTypes.string, + phoneNumber: React.PropTypes.string, + }), + fail: React.PropTypes.func, + clientSecret: React.PropTypes.func, + submitAuthDict: React.PropTypes.func.isRequired, + matrixClient: React.PropTypes.object, + submitAuthDict: React.PropTypes.func, + }, + + getInitialState: function() { + return { + token: '', + requestingToken: false, + }; + }, + + componentWillMount: function() { + this._sid = null; + this._msisdn = null; + this._tokenBox = null; + + this.setState({requestingToken: true}); + this._requestMsisdnToken().catch((e) => { + this.props.fail(e); + }).finally(() => { + this.setState({requestingToken: false}); + }).done(); + }, + + /* + * Requests a verification token by SMS. + */ + _requestMsisdnToken: function() { + return this.props.matrixClient.requestRegisterMsisdnToken( + this.props.inputs.phoneCountry, + this.props.inputs.phoneNumber, + this.props.clientSecret, + 1, // TODO: Multiple send attempts? + ).then((result) => { + this._sid = result.sid; + this._msisdn = result.msisdn; + }); + }, + + _onTokenChange: function(e) { + this.setState({ + token: e.target.value, + }); + }, + + _onFormSubmit: function(e) { + e.preventDefault(); + if (this.state.token == '') return; + + this.setState({ + errorText: null, + }); + + this.props.matrixClient.submitMsisdnToken( + this._sid, this.props.clientSecret, this.state.token + ).then((result) => { + if (result.success) { + const idServerParsedUrl = url.parse( + this.props.matrixClient.getIdentityServerUrl(), + ) + this.props.submitAuthDict({ + type: MsisdnAuthEntry.LOGIN_TYPE, + threepid_creds: { + sid: this._sid, + client_secret: this.props.clientSecret, + id_server: idServerParsedUrl.host, + }, + }); + } else { + this.setState({ + errorText: "Token incorrect", + }); + } + }).catch((e) => { + this.props.fail(e); + console.log("Failed to submit msisdn token"); + }).done(); + }, + + render: function() { + if (this.state.requestingToken) { + const Loader = sdk.getComponent("elements.Spinner"); + return ; + } else { + const enableSubmit = Boolean(this.state.token); + const submitClasses = classnames({ + mx_InteractiveAuthEntryComponents_msisdnSubmit: true, + mx_UserSettings_button: true, // XXX button classes + }); + return ( +
    +

    A text message has been sent to +{this._msisdn}

    +

    Please enter the code it contains:

    +
    +
    + +
    + +
    +
    + {this.state.errorText} +
    +
    +
    + ); + } + }, +}); + export const FallbackAuthEntry = React.createClass({ displayName: 'FallbackAuthEntry', propTypes: { + matrixClient: React.PropTypes.object.isRequired, authSessionId: React.PropTypes.string.isRequired, loginType: React.PropTypes.string.isRequired, submitAuthDict: React.PropTypes.func.isRequired, - setSubmitButtonEnabled: React.PropTypes.func.isRequired, errorText: React.PropTypes.string, }, @@ -156,7 +403,6 @@ export const FallbackAuthEntry = React.createClass({ // we have to make the user click a button, as browsers will block // the popup if we open it immediately. this._popupWindow = null; - this.props.setSubmitButtonEnabled(true); window.addEventListener("message", this._onReceiveMessage); }, @@ -167,19 +413,18 @@ export const FallbackAuthEntry = React.createClass({ } }, - onSubmitClick: function() { - var url = MatrixClientPeg.get().getFallbackAuthUrl( + _onShowFallbackClick: function() { + var url = this.props.matrixClient.getFallbackAuthUrl( this.props.loginType, this.props.authSessionId ); this._popupWindow = window.open(url); - this.props.setSubmitButtonEnabled(false); }, _onReceiveMessage: function(event) { if ( event.data === "authDone" && - event.origin === MatrixClientPeg.get().getHomeserverUrl() + event.origin === this.props.matrixClient.getHomeserverUrl() ) { this.props.submitAuthDict({}); } @@ -188,7 +433,7 @@ export const FallbackAuthEntry = React.createClass({ render: function() { return (
    - Click "Submit" to authenticate + Start authentication
    {this.props.errorText}
    @@ -200,6 +445,8 @@ export const FallbackAuthEntry = React.createClass({ const AuthEntryComponents = [ PasswordAuthEntry, RecaptchaAuthEntry, + EmailIdentityAuthEntry, + MsisdnAuthEntry, ]; export function getEntryComponentForLoginType(loginType) { diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 6f6081858bd..61cb3da6525 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +18,7 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; +import sdk from '../../../index'; import {field_input_incorrect} from '../../../UiEffects'; @@ -28,8 +30,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin', onSubmit: React.PropTypes.func.isRequired, // fn(username, password) onForgotPasswordClick: React.PropTypes.func, // fn() initialUsername: React.PropTypes.string, + initialPhoneCountry: React.PropTypes.string, + initialPhoneNumber: React.PropTypes.string, initialPassword: React.PropTypes.string, onUsernameChanged: React.PropTypes.func, + onPhoneCountryChanged: React.PropTypes.func, + onPhoneNumberChanged: React.PropTypes.func, onPasswordChanged: React.PropTypes.func, loginIncorrect: React.PropTypes.bool, }, @@ -38,7 +44,11 @@ module.exports = React.createClass({displayName: 'PasswordLogin', return { onUsernameChanged: function() {}, onPasswordChanged: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, initialUsername: "", + initialPhoneCountry: "", + initialPhoneNumber: "", initialPassword: "", loginIncorrect: false, }; @@ -48,6 +58,8 @@ module.exports = React.createClass({displayName: 'PasswordLogin', return { username: this.props.initialUsername, password: this.props.initialPassword, + phoneCountry: this.props.initialPhoneCountry, + phoneNumber: this.props.initialPhoneNumber, }; }, @@ -63,7 +75,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin', onSubmitForm: function(ev) { ev.preventDefault(); - this.props.onSubmit(this.state.username, this.state.password); + this.props.onSubmit( + this.state.username, + this.state.phoneCountry, + this.state.phoneNumber, + this.state.password, + ); }, onUsernameChanged: function(ev) { @@ -71,6 +88,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin', this.props.onUsernameChanged(ev.target.value); }, + onPhoneCountryChanged: function(country) { + this.setState({phoneCountry: country}); + this.props.onPhoneCountryChanged(country); + }, + + onPhoneNumberChanged: function(ev) { + this.setState({phoneNumber: ev.target.value}); + this.props.onPhoneNumberChanged(ev.target.value); + }, + onPasswordChanged: function(ev) { this.setState({password: ev.target.value}); this.props.onPasswordChanged(ev.target.value); @@ -92,13 +119,28 @@ module.exports = React.createClass({displayName: 'PasswordLogin', error: this.props.loginIncorrect, }); + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); return (
    - + or +
    + + +

    {this._passwordField = e;}} type="password" name="password" diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 1cb82538120..4868c9de63e 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,18 +15,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import React from 'react'; +import { field_input_incorrect } from '../../../UiEffects'; +import sdk from '../../../index'; +import Email from '../../../email'; +import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; +import Modal from '../../../Modal'; -var React = require('react'); -var UiEffects = require('../../../UiEffects'); -var sdk = require('../../../index'); -var Email = require('../../../email'); -var Modal = require("../../../Modal"); - -var FIELD_EMAIL = 'field_email'; -var FIELD_USERNAME = 'field_username'; -var FIELD_PASSWORD = 'field_password'; -var FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +const FIELD_EMAIL = 'field_email'; +const FIELD_PHONE_COUNTRY = 'field_phone_country'; +const FIELD_PHONE_NUMBER = 'field_phone_number'; +const FIELD_USERNAME = 'field_username'; +const FIELD_PASSWORD = 'field_password'; +const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; /** * A pure UI component which displays a registration form. @@ -36,6 +38,8 @@ module.exports = React.createClass({ propTypes: { // Values pre-filled in the input boxes when the component loads defaultEmail: React.PropTypes.string, + defaultPhoneCountry: React.PropTypes.string, + defaultPhoneNumber: React.PropTypes.string, defaultUsername: React.PropTypes.string, defaultPassword: React.PropTypes.string, teamsConfig: React.PropTypes.shape({ @@ -54,15 +58,13 @@ module.exports = React.createClass({ // a different username will cause a fresh account to be generated. guestUsername: React.PropTypes.string, - showEmail: React.PropTypes.bool, minPasswordLength: React.PropTypes.number, onError: React.PropTypes.func, - onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise + onRegisterClick: React.PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise }, getDefaultProps: function() { return { - showEmail: false, minPasswordLength: 6, onError: function(e) { console.error(e); @@ -74,6 +76,8 @@ module.exports = React.createClass({ return { fieldValid: {}, selectedTeam: null, + // The ISO2 country code selected in the phone number entry + phoneCountry: this.props.defaultPhoneCountry, }; }, @@ -88,6 +92,7 @@ module.exports = React.createClass({ this.validateField(FIELD_PASSWORD_CONFIRM); this.validateField(FIELD_PASSWORD); this.validateField(FIELD_USERNAME); + this.validateField(FIELD_PHONE_NUMBER); this.validateField(FIELD_EMAIL); var self = this; @@ -121,6 +126,8 @@ module.exports = React.createClass({ username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), email: email, + phoneCountry: this.state.phoneCountry, + phoneNumber: this.refs.phoneNumber.value.trim(), }); if (promise) { @@ -146,7 +153,7 @@ module.exports = React.createClass({ }, _isUniEmail: function(email) { - return email.endsWith('.ac.uk') || email.endsWith('.edu'); + return email.endsWith('.ac.uk') || email.endsWith('.edu') || email.endsWith('matrix.org'); }, validateField: function(field_id) { @@ -174,8 +181,13 @@ module.exports = React.createClass({ showSupportEmail: false, }); } - const valid = email === '' || Email.looksValid(email); - this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID"); + const emailValid = email === '' || Email.looksValid(email); + this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); + break; + case FIELD_PHONE_NUMBER: + const phoneNumber = this.refs.phoneNumber.value; + const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber); + this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID"); break; case FIELD_USERNAME: // XXX: SPEC-1 @@ -227,7 +239,7 @@ module.exports = React.createClass({ fieldValid[field_id] = val; this.setState({fieldValid: fieldValid}); if (!val) { - UiEffects.field_input_incorrect(this.fieldElementById(field_id)); + field_input_incorrect(this.fieldElementById(field_id)); this.props.onError(error_code); } }, @@ -236,6 +248,8 @@ module.exports = React.createClass({ switch (field_id) { case FIELD_EMAIL: return this.refs.email; + case FIELD_PHONE_NUMBER: + return this.refs.phoneNumber; case FIELD_USERNAME: return this.refs.username; case FIELD_PASSWORD: @@ -245,8 +259,8 @@ module.exports = React.createClass({ } }, - _classForField: function(field_id, baseClass) { - let cls = baseClass || ''; + _classForField: function(field_id, ...baseClasses) { + let cls = baseClasses.join(' '); if (this.state.fieldValid[field_id] === false) { if (cls) cls += ' '; cls += 'error'; @@ -254,46 +268,71 @@ module.exports = React.createClass({ return cls; }, + _onPhoneCountryChange(newVal) { + this.setState({ + phoneCountry: newVal, + }); + }, + render: function() { var self = this; - var emailSection, belowEmailSection, registerButton; - if (this.props.showEmail) { - emailSection = ( + + const emailSection = ( +
    - ); - if (this.props.teamsConfig) { - if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { - belowEmailSection = ( -

    - Sorry, but your university is not registered with us just yet.  - Email us on  - - {this.props.teamsConfig.supportEmail} -   - to get your university signed up. Or continue to register with Riot to enjoy our open source platform. -

    - ); - } else if (this.state.selectedTeam) { - belowEmailSection = ( -

    - You are registering with {this.state.selectedTeam.name} -

    - ); - } +
    + ); + let belowEmailSection; + if (this.props.teamsConfig) { + if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { + belowEmailSection = ( +

    + Sorry, but your university is not registered with us just yet.  + Email us on  + + {this.props.teamsConfig.supportEmail} +   + to get your university signed up. Or continue to register with Riot to enjoy our open source platform. +

    + ); + } else if (this.state.selectedTeam) { + belowEmailSection = ( +

    + You are registering with {this.state.selectedTeam.name} +

    + ); } } - if (this.props.onRegisterClick) { - registerButton = ( - - ); - } - var placeholderUserName = "User name"; + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + const phoneSection = ( +
    + + +
    + ); + + const registerButton = ( + + ); + + let placeholderUserName = "User name"; if (this.props.guestUsername) { placeholderUserName += " (default: " + this.props.guestUsername + ")"; } @@ -303,6 +342,7 @@ module.exports = React.createClass({ {emailSection} {belowEmailSection} + {phoneSection} { return decryptFile(content.file).then(function(blob) { decryptedBlob = blob; @@ -168,7 +174,7 @@ module.exports = React.createClass({ // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box //console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth); - var thumbHeight = null; + let thumbHeight = null; if (content.info) { thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); } @@ -190,7 +196,6 @@ module.exports = React.createClass({ } if (content.file !== undefined && this.state.decryptedUrl === null) { - // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. @@ -210,7 +215,12 @@ module.exports = React.createClass({ } const contentUrl = this._getContentUrl(); - const thumbUrl = this._getThumbUrl(); + let thumbUrl; + if (this._isGif() && UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) { + thumbUrl = contentUrl; + } else { + thumbUrl = this._getThumbUrl(); + } if (thumbUrl) { return ( diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index e2d4af9e691..d843115cafa 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -23,6 +23,7 @@ import Model from '../../../Modal'; import sdk from '../../../index'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; import q from 'q'; +import UserSettingsStore from '../../../UserSettingsStore'; module.exports = React.createClass({ displayName: 'MVideoBody', @@ -152,11 +153,11 @@ module.exports = React.createClass({ const contentUrl = this._getContentUrl(); const thumbUrl = this._getThumbUrl(); - - var height = null; - var width = null; - var poster = null; - var preload = "metadata"; + const autoplay = UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false); + let height = null; + let width = null; + let poster = null; + let preload = "metadata"; if (content.info) { const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); if (scale) { @@ -169,11 +170,10 @@ module.exports = React.createClass({ preload = "none"; } } - return ( diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index fd26ae58da7..a625e63062f 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -222,7 +222,8 @@ module.exports = React.createClass({ title: "Add an Integration", description:
    - You are about to taken to a third-party site so you can authenticate your account for use with {integrationsUrl}.
    + You are about to be taken to a third-party site so you can + authenticate your account for use with {integrationsUrl}.
    Do you wish to continue?
    , button: "Continue", diff --git a/src/components/views/messages/UnknownBody.js b/src/components/views/messages/UnknownBody.js index 00784b18b09..a0fe8fdf744 100644 --- a/src/components/views/messages/UnknownBody.js +++ b/src/components/views/messages/UnknownBody.js @@ -22,10 +22,10 @@ module.exports = React.createClass({ displayName: 'UnknownBody', render: function() { - var content = this.props.mxEvent.getContent(); + const text = this.props.mxEvent.getContent().body; return ( - {content.body} + {text} ); }, diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index c6a766509a5..48f0f282c10 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -25,18 +25,10 @@ var TextForEvent = require('../../../TextForEvent'); import WithMatrixClient from '../../../wrappers/WithMatrixClient'; var ContextualMenu = require('../../structures/ContextualMenu'); -var dispatcher = require("../../../dispatcher"); +import dis from '../../../dispatcher'; var ObjectUtils = require('../../../ObjectUtils'); -var bounce = false; -try { - if (global.localStorage) { - bounce = global.localStorage.getItem('avatar_bounce') == 'true'; - } -} catch (e) { -} - var eventTileTypes = { 'm.room.message': 'messages.MessageEvent', 'm.room.member' : 'messages.TextualEvent', @@ -73,6 +65,12 @@ module.exports = WithMatrixClient(React.createClass({ /* the MatrixEvent to show */ mxEvent: React.PropTypes.object.isRequired, + /* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted() + * might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent + * references the same this.props.mxEvent. + */ + isRedacted: React.PropTypes.bool, + /* true if this is a continuation of the previous event (which has the * effect of not showing another avatar/displayname */ @@ -318,8 +316,12 @@ module.exports = WithMatrixClient(React.createClass({ this.props.readReceiptMap[userId] = readReceiptInfo; } } + // TODO: we keep the extra read avatars in the dom to make animation simpler + // we could optimise this to reduce the dom size. + if (!hidden) { + left -= 15; + } - //console.log("i = " + i + ", MAX_READ_AVATARS = " + MAX_READ_AVATARS + ", allReadAvatars = " + this.state.allReadAvatars + " visibility = " + style.visibility); // add to the start so the most recent is on the end (ie. ends up rightmost) avatars.unshift( = dayAfterEventTime} /> ); - - // TODO: we keep the extra read avatars in the dom to make animation simpler - // we could optimise this to reduce the dom size. - if (!hidden) { - left -= 15; - } } var remText; if (!this.state.allReadAvatars) { @@ -345,9 +341,8 @@ module.exports = WithMatrixClient(React.createClass({ if (remainder > 0) { remText = { remainder }+ + style={{ right: -(left - 15) }}>{ remainder }+ ; - left -= 15; } } @@ -359,7 +354,7 @@ module.exports = WithMatrixClient(React.createClass({ onSenderProfileClick: function(event) { var mxEvent = this.props.mxEvent; - dispatcher.dispatch({ + dis.dispatch({ action: 'insert_displayname', displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''), }); @@ -375,6 +370,17 @@ module.exports = WithMatrixClient(React.createClass({ }); }, + onPermalinkClicked: function(e) { + // This allows the permalink to be opened in a new tab/window or copied as + // matrix.to, but also for it to enable routing within Riot when clicked. + e.preventDefault(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + room_id: this.props.mxEvent.getRoomId(), + }); + }, + render: function() { var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); var SenderProfile = sdk.getComponent('messages.SenderProfile'); @@ -399,6 +405,7 @@ module.exports = WithMatrixClient(React.createClass({ var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId()); var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); + const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted; var classes = classNames({ mx_EventTile: true, @@ -415,8 +422,12 @@ module.exports = WithMatrixClient(React.createClass({ mx_EventTile_verified: this.state.verified == true, mx_EventTile_unverified: this.state.verified == false, mx_EventTile_bad: this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted', + mx_EventTile_redacted: isRedacted, }); - var permalink = "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId(); + + const permalink = "https://matrix.to/#/" + + this.props.mxEvent.getRoomId() + "/" + + this.props.mxEvent.getId(); var readAvatars = this.getReadAvatars(); @@ -424,7 +435,10 @@ module.exports = WithMatrixClient(React.createClass({ let avatarSize; let needsSenderProfile; - if (this.props.tileShape === "notif") { + if (isRedacted) { + avatarSize = 0; + needsSenderProfile = false; + } else if (this.props.tileShape === "notif") { avatarSize = 24; needsSenderProfile = true; } else if (isInfoMessage) { @@ -489,6 +503,8 @@ module.exports = WithMatrixClient(React.createClass({ else if (e2eEnabled) { e2e = ; } + const timestamp = this.props.mxEvent.isRedacted() ? + null : ; if (this.props.tileShape === "notif") { var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); @@ -496,15 +512,15 @@ module.exports = WithMatrixClient(React.createClass({ return (
    @@ -530,10 +546,14 @@ module.exports = WithMatrixClient(React.createClass({ tileShape={this.props.tileShape} onWidgetLoad={this.props.onWidgetLoad} />
    - +
    { sender } - + { timestamp }
    @@ -548,8 +568,8 @@ module.exports = WithMatrixClient(React.createClass({ { avatar } { sender }
    - - + + { timestamp } { e2e } { + if (!proceed) return; + + this.setState({ updating: this.state.updating + 1 }); + this.props.matrixClient.kick( + this.props.member.roomId, this.props.member.userId, + reason || undefined + ).then(function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Kick success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Kick error: " + err); + Modal.createDialog(ErrorDialog, { + title: "Error", + description: "Failed to kick user", + }); + } + ).finally(()=>{ + this.setState({ updating: this.state.updating - 1 }); }); } - ).finally(()=>{ - this.setState({ updating: this.state.updating - 1 }); }); - this.props.onFinished(); }, - onBan: function() { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - var roomId = this.props.member.roomId; - var target = this.props.member.userId; - this.setState({ updating: this.state.updating + 1 }); - this.props.matrixClient.ban(roomId, target).then( - function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Ban success"); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Ban error", - description: err.message + onBanOrUnban: function() { + const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + Modal.createDialog(ConfirmUserActionDialog, { + member: this.props.member, + action: this.props.member.membership == 'ban' ? 'Unban' : 'Ban', + askReason: this.props.member.membership != 'ban', + danger: this.props.member.membership != 'ban', + onFinished: (proceed, reason) => { + if (!proceed) return; + + this.setState({ updating: this.state.updating + 1 }); + let promise; + if (this.props.member.membership == 'ban') { + promise = this.props.matrixClient.unban( + this.props.member.roomId, this.props.member.userId, + ); + } else { + promise = this.props.matrixClient.ban( + this.props.member.roomId, this.props.member.userId, + reason || undefined + ); + } + promise.then( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Ban success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Ban error: " + err); + Modal.createDialog(ErrorDialog, { + title: "Error", + description: "Failed to ban user", + }); + } + ).finally(()=>{ + this.setState({ updating: this.state.updating - 1 }); }); - } - ).finally(()=>{ - this.setState({ updating: this.state.updating - 1 }); + }, }); - this.props.onFinished(); }, onMuteToggle: function() { @@ -272,14 +298,12 @@ module.exports = WithMatrixClient(React.createClass({ var target = this.props.member.userId; var room = this.props.matrixClient.getRoom(roomId); if (!room) { - this.props.onFinished(); return; } var powerLevelEvent = room.currentState.getStateEvents( "m.room.power_levels", "" ); if (!powerLevelEvent) { - this.props.onFinished(); return; } var isMuted = this.state.muted; @@ -305,16 +329,16 @@ module.exports = WithMatrixClient(React.createClass({ // get out of sync if we force setState here! console.log("Mute toggle success"); }, function(err) { + console.error("Mute error: " + err); Modal.createDialog(ErrorDialog, { - title: "Mute error", - description: err.message + title: "Error", + description: "Failed to mute user", }); } ).finally(()=>{ this.setState({ updating: this.state.updating - 1 }); }); } - this.props.onFinished(); }, onModToggle: function() { @@ -323,19 +347,16 @@ module.exports = WithMatrixClient(React.createClass({ var target = this.props.member.userId; var room = this.props.matrixClient.getRoom(roomId); if (!room) { - this.props.onFinished(); return; } var powerLevelEvent = room.currentState.getStateEvents( "m.room.power_levels", "" ); if (!powerLevelEvent) { - this.props.onFinished(); return; } var me = room.getMember(this.props.matrixClient.credentials.userId); if (!me) { - this.props.onFinished(); return; } var defaultLevel = powerLevelEvent.getContent().users_default; @@ -357,16 +378,16 @@ module.exports = WithMatrixClient(React.createClass({ description: "This action cannot be performed by a guest user. Please register to be able to do this." }); } else { + console.error("Toggle moderator error:" + err); Modal.createDialog(ErrorDialog, { - title: "Moderator toggle error", - description: err.message + title: "Error", + description: "Failed to toggle moderator status", }); } } ).finally(()=>{ this.setState({ updating: this.state.updating - 1 }); }); - this.props.onFinished(); }, _applyPowerChange: function(roomId, target, powerLevel, powerLevelEvent) { @@ -378,15 +399,15 @@ module.exports = WithMatrixClient(React.createClass({ console.log("Power change success"); }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to change power level " + err); Modal.createDialog(ErrorDialog, { - title: "Failure to change power level", - description: err.message + title: "Error", + description: "Failed to change power level", }); } ).finally(()=>{ this.setState({ updating: this.state.updating - 1 }); }).done(); - this.props.onFinished(); }, onPowerChange: function(powerLevel) { @@ -396,14 +417,12 @@ module.exports = WithMatrixClient(React.createClass({ var room = this.props.matrixClient.getRoom(roomId); var self = this; if (!room) { - this.props.onFinished(); return; } var powerLevelEvent = room.currentState.getStateEvents( "m.room.power_levels", "" ); if (!powerLevelEvent) { - this.props.onFinished(); return; } if (powerLevelEvent.getContent().users) { @@ -422,9 +441,6 @@ module.exports = WithMatrixClient(React.createClass({ if (confirmed) { self._applyPowerChange(roomId, target, powerLevel, powerLevelEvent); } - else { - self.props.onFinished(); - } }, }); } @@ -440,7 +456,6 @@ module.exports = WithMatrixClient(React.createClass({ onNewDMClick: function() { this.setState({ updating: this.state.updating + 1 }); createRoom({dmUserId: this.props.member.userId}).finally(() => { - this.props.onFinished(); this.setState({ updating: this.state.updating - 1 }); }).done(); }, @@ -450,30 +465,29 @@ module.exports = WithMatrixClient(React.createClass({ action: 'leave_room', room_id: this.props.member.roomId, }); - this.props.onFinished(); }, _calculateOpsPermissions: function(member) { - var defaultPerms = { + const defaultPerms = { can: {}, muted: false, modifyLevel: false }; - var room = this.props.matrixClient.getRoom(member.roomId); + const room = this.props.matrixClient.getRoom(member.roomId); if (!room) { return defaultPerms; } - var powerLevels = room.currentState.getStateEvents( + const powerLevels = room.currentState.getStateEvents( "m.room.power_levels", "" ); if (!powerLevels) { return defaultPerms; } - var me = room.getMember(this.props.matrixClient.credentials.userId); + const me = room.getMember(this.props.matrixClient.credentials.userId); if (!me) { return defaultPerms; } - var them = member; + const them = member; return { can: this._calculateCanPermissions( me, them, powerLevels.getContent() @@ -484,22 +498,22 @@ module.exports = WithMatrixClient(React.createClass({ }, _calculateCanPermissions: function(me, them, powerLevels) { - var can = { + const can = { kick: false, ban: false, mute: false, modifyLevel: false }; - var canAffectUser = them.powerLevel < me.powerLevel; + const canAffectUser = them.powerLevel < me.powerLevel; if (!canAffectUser) { //console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel); return can; } - var editPowerLevel = ( + const editPowerLevel = ( (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default ); - var levelToSend = ( + const levelToSend = ( (powerLevels.events ? powerLevels.events["m.room.message"] : null) || powerLevels.events_default ); @@ -544,6 +558,13 @@ module.exports = WithMatrixClient(React.createClass({ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); }, + onRoomTileClick(roomId) { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + }, + _renderDevices: function() { if (!this._enableDevices) { return null; @@ -560,7 +581,7 @@ module.exports = WithMatrixClient(React.createClass({ } else if (devices === null) { devComponents = "Unable to load device list"; } else if (devices.length === 0) { - devComponents = "No registered devices"; + devComponents = "No devices with registered encryption keys"; } else { devComponents = []; for (var i = 0; i < devices.length; i++) { @@ -604,6 +625,7 @@ module.exports = WithMatrixClient(React.createClass({ unread={Unread.doesRoomHaveUnreadMessages(room)} highlight={highlight} isInvite={me.membership == "invite"} + onClick={this.onRoomTileClick} /> ); } @@ -646,10 +668,14 @@ module.exports = WithMatrixClient(React.createClass({ ); } if (this.state.can.ban) { + let label = 'Ban'; + if (this.props.member.membership == 'ban') { + label = 'Unban'; + } banButton = ( - Ban + onClick={this.onBanOrUnban}> + {label} ); } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 113224666d5..8a3b1289084 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -91,8 +91,9 @@ export default class MessageComposer extends React.Component { this.refs.uploadInput.click(); } - onUploadFileSelected(ev) { - let files = ev.target.files; + onUploadFileSelected(files, isPasted) { + if (!isPasted) + files = files.target.files; let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let TintableSvg = sdk.getComponent("elements.TintableSvg"); @@ -100,7 +101,7 @@ export default class MessageComposer extends React.Component { let fileList = []; for (let i=0; i - {files[i].name} + {files[i].name || 'Attachment'} ); } @@ -171,7 +172,7 @@ export default class MessageComposer extends React.Component { } onUpArrow() { - return this.refs.autocomplete.onUpArrow(); + return this.refs.autocomplete.onUpArrow(); } onDownArrow() { @@ -223,8 +224,8 @@ export default class MessageComposer extends React.Component { ); let e2eImg, e2eTitle, e2eClass; - - if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) { + const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + if (roomIsEncrypted) { // FIXME: show a /!\ if there are untrusted devices in the room... e2eImg = 'img/e2e-verified.svg'; e2eTitle = 'Encrypted room'; @@ -286,15 +287,20 @@ export default class MessageComposer extends React.Component { key="controls_formatting" /> ); + const placeholderText = roomIsEncrypted ? + "Send an encrypted message…" : "Send a message (unencrypted)…"; + controls.push( this.messageComposerInput = c} key="controls_input" onResize={this.props.onResize} room={this.props.room} + placeholder={placeholderText} tryComplete={this._tryComplete} onUpArrow={this.onUpArrow} onDownArrow={this.onDownArrow} + onUploadFileSelected={this.onUploadFileSelected} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete onContentChanged={this.onInputContentChanged} onInputStateChanged={this.onInputStateChanged} />, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 9aab1745119..d702b7558dc 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -84,6 +84,7 @@ export default class MessageComposerInput extends React.Component { this.onAction = this.onAction.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); + this.handlePastedFiles = this.handlePastedFiles.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this); this.setEditorState = this.setEditorState.bind(this); this.onUpArrow = this.onUpArrow.bind(this); @@ -475,6 +476,10 @@ export default class MessageComposerInput extends React.Component { return false; } + handlePastedFiles(files) { + this.props.onUploadFileSelected(files, true); + } + handleReturn(ev) { if (ev.shiftKey) { this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); @@ -504,7 +509,7 @@ export default class MessageComposerInput extends React.Component { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Server error", - description: err.message + description: "Server unavailable, overloaded, or something else went wrong.", }); }); } @@ -721,13 +726,14 @@ export default class MessageComposerInput extends React.Component { title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`} src={`img/button-md-${!this.state.isRichtextEnabled}.png`} /> -