diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..880331a09e --- /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/CHANGELOG.md b/CHANGELOG.md index 488a9814e6..292e60607d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,249 @@ +Changes in [0.8.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7) (2017-04-12) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.4...v0.8.7) + + * No changes + +Changes in [0.8.7-rc.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.4) (2017-04-11) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.3...v0.8.7-rc.4) + + * Fix people section vanishing on 'clear cache' + [\#799](https://github.com/matrix-org/matrix-react-sdk/pull/799) + * Make the clear cache button work on desktop + [\#798](https://github.com/matrix-org/matrix-react-sdk/pull/798) + +Changes in [0.8.7-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.3) (2017-04-10) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.2...v0.8.7-rc.3) + + * Use matrix-js-sdk v0.7.6-rc.2 + + +Changes in [0.8.7-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.2) (2017-04-10) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.1...v0.8.7-rc.2) + + * fix the warning shown to users about needing to export e2e keys + [\#797](https://github.com/matrix-org/matrix-react-sdk/pull/797) + +Changes in [0.8.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.1) (2017-04-07) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6...v0.8.7-rc.1) + + * Add support for using indexeddb in a webworker + [\#792](https://github.com/matrix-org/matrix-react-sdk/pull/792) + * Fix infinite pagination/glitches with pagination + [\#795](https://github.com/matrix-org/matrix-react-sdk/pull/795) + * Fix issue where teamTokenMap was ignored for guests + [\#793](https://github.com/matrix-org/matrix-react-sdk/pull/793) + * Click emote sender -> insert display name into composer + [\#791](https://github.com/matrix-org/matrix-react-sdk/pull/791) + * Fix scroll token selection logic + [\#785](https://github.com/matrix-org/matrix-react-sdk/pull/785) + * Replace sdkReady with firstSyncPromise, add mx_last_room_id + [\#790](https://github.com/matrix-org/matrix-react-sdk/pull/790) + * Change "Unread messages." to "Jump to first unread message." + [\#789](https://github.com/matrix-org/matrix-react-sdk/pull/789) + * Update for new IndexedDBStore interface + [\#786](https://github.com/matrix-org/matrix-react-sdk/pull/786) + * Add
    to allowed attributes list + [\#787](https://github.com/matrix-org/matrix-react-sdk/pull/787) + * Fix the onFinished for timeline pos dialog + [\#784](https://github.com/matrix-org/matrix-react-sdk/pull/784) + * Only join a room when enter is hit if the join button is shown + [\#776](https://github.com/matrix-org/matrix-react-sdk/pull/776) + * Remove non-functional session load error + [\#783](https://github.com/matrix-org/matrix-react-sdk/pull/783) + * Use Login & Register via component interface + [\#782](https://github.com/matrix-org/matrix-react-sdk/pull/782) + * Attempt to fix the flakyness seen with tests + [\#781](https://github.com/matrix-org/matrix-react-sdk/pull/781) + * Remove React warning + [\#780](https://github.com/matrix-org/matrix-react-sdk/pull/780) + * Only clear the local notification count if needed + [\#779](https://github.com/matrix-org/matrix-react-sdk/pull/779) + * Don't re-notify about messages on browser refresh + [\#777](https://github.com/matrix-org/matrix-react-sdk/pull/777) + * Improve zeroing of RoomList notification badges + [\#775](https://github.com/matrix-org/matrix-react-sdk/pull/775) + * Fix VOIP bar hidden on first render of RoomStatusBar + [\#774](https://github.com/matrix-org/matrix-react-sdk/pull/774) + * Correct confirm prompt for disinvite + [\#772](https://github.com/matrix-org/matrix-react-sdk/pull/772) + * Add state loggingIn to MatrixChat to fix flashing login + [\#773](https://github.com/matrix-org/matrix-react-sdk/pull/773) + * Fix bug where you can't invite a valid address + [\#771](https://github.com/matrix-org/matrix-react-sdk/pull/771) + * Fix people section DropTarget and refactor Rooms + [\#761](https://github.com/matrix-org/matrix-react-sdk/pull/761) + * Read Receipt offset + [\#770](https://github.com/matrix-org/matrix-react-sdk/pull/770) + * Support adding phone numbers in UserSettings + [\#756](https://github.com/matrix-org/matrix-react-sdk/pull/756) + * Prevent crash on login of no guest session + [\#769](https://github.com/matrix-org/matrix-react-sdk/pull/769) + * Add canResetTimeline callback and thread it through to TimelinePanel + [\#768](https://github.com/matrix-org/matrix-react-sdk/pull/768) + * Show spinner whilst processing recaptcha response + [\#767](https://github.com/matrix-org/matrix-react-sdk/pull/767) + * Login / registration with phone number, mark 2 + [\#750](https://github.com/matrix-org/matrix-react-sdk/pull/750) + * Display threepids slightly prettier + [\#758](https://github.com/matrix-org/matrix-react-sdk/pull/758) + * Fix extraneous leading space in sent emotes + [\#764](https://github.com/matrix-org/matrix-react-sdk/pull/764) + * Add ConfirmRedactDialog component + [\#763](https://github.com/matrix-org/matrix-react-sdk/pull/763) + * Fix password UI auth test + [\#760](https://github.com/matrix-org/matrix-react-sdk/pull/760) + * Display timestamps and profiles for redacted events + [\#759](https://github.com/matrix-org/matrix-react-sdk/pull/759) + * Fix UDD for voip in e2e rooms + [\#757](https://github.com/matrix-org/matrix-react-sdk/pull/757) + * Add "Export E2E keys" option to logout dialog + [\#755](https://github.com/matrix-org/matrix-react-sdk/pull/755) + * Fix People section a bit + [\#754](https://github.com/matrix-org/matrix-react-sdk/pull/754) + * Do routing to /register _onLoadCompleted + [\#753](https://github.com/matrix-org/matrix-react-sdk/pull/753) + * Double UNPAGINATION_PADDING again + [\#747](https://github.com/matrix-org/matrix-react-sdk/pull/747) + * Add null check to start_login + [\#751](https://github.com/matrix-org/matrix-react-sdk/pull/751) + * Merge the two RoomTile context menus into one + [\#746](https://github.com/matrix-org/matrix-react-sdk/pull/746) + * Fix import for Lifecycle + [\#748](https://github.com/matrix-org/matrix-react-sdk/pull/748) + * Make UDD appear when UDE on uploading a file + [\#745](https://github.com/matrix-org/matrix-react-sdk/pull/745) + * Decide on which screen to show after login in one place + [\#743](https://github.com/matrix-org/matrix-react-sdk/pull/743) + * Add onClick to permalinks to route within Riot + [\#744](https://github.com/matrix-org/matrix-react-sdk/pull/744) + * Add support for pasting files into the text box + [\#605](https://github.com/matrix-org/matrix-react-sdk/pull/605) + * Show message redactions as black event tiles + [\#739](https://github.com/matrix-org/matrix-react-sdk/pull/739) + * Allow user to choose from existing DMs on new chat + [\#736](https://github.com/matrix-org/matrix-react-sdk/pull/736) + * Fix the team server registration + [\#741](https://github.com/matrix-org/matrix-react-sdk/pull/741) + * Clarify "No devices" message + [\#740](https://github.com/matrix-org/matrix-react-sdk/pull/740) + * Change timestamp permalinks to matrix.to + [\#735](https://github.com/matrix-org/matrix-react-sdk/pull/735) + * Fix resend bar and "send anyway" in UDD + [\#734](https://github.com/matrix-org/matrix-react-sdk/pull/734) + * Make COLOR_REGEX stricter + [\#737](https://github.com/matrix-org/matrix-react-sdk/pull/737) + * Port registration over to use InteractiveAuth + [\#729](https://github.com/matrix-org/matrix-react-sdk/pull/729) + * Test to see how fuse feels + [\#732](https://github.com/matrix-org/matrix-react-sdk/pull/732) + * Submit a new display name on blur of input field + [\#733](https://github.com/matrix-org/matrix-react-sdk/pull/733) + * Allow [bf]g colors for style attrib + [\#610](https://github.com/matrix-org/matrix-react-sdk/pull/610) + * MELS: either expanded or summary, not both + [\#683](https://github.com/matrix-org/matrix-react-sdk/pull/683) + * Autoplay videos and GIFs if enabled by the user. + [\#730](https://github.com/matrix-org/matrix-react-sdk/pull/730) + * Warn users about using e2e for the first time + [\#731](https://github.com/matrix-org/matrix-react-sdk/pull/731) + * Show UDDialog on UDE during VoIP calls + [\#721](https://github.com/matrix-org/matrix-react-sdk/pull/721) + * Notify MatrixChat of teamToken after login + [\#726](https://github.com/matrix-org/matrix-react-sdk/pull/726) + * Fix a couple of issues with RRs + [\#727](https://github.com/matrix-org/matrix-react-sdk/pull/727) + * Do not push a dummy element with a scroll token for invisible events + [\#718](https://github.com/matrix-org/matrix-react-sdk/pull/718) + * MELS: check scroll on load + use mels-1,-2,... key + [\#715](https://github.com/matrix-org/matrix-react-sdk/pull/715) + * Fix message composer placeholders + [\#723](https://github.com/matrix-org/matrix-react-sdk/pull/723) + * Clarify non-e2e vs. e2e /w composers placeholder + [\#720](https://github.com/matrix-org/matrix-react-sdk/pull/720) + * Fix status bar expanded on tab-complete + [\#722](https://github.com/matrix-org/matrix-react-sdk/pull/722) + * add .editorconfig + [\#713](https://github.com/matrix-org/matrix-react-sdk/pull/713) + * Change the name of the database + [\#719](https://github.com/matrix-org/matrix-react-sdk/pull/719) + * Allow setting the default HS from the query parameter + [\#716](https://github.com/matrix-org/matrix-react-sdk/pull/716) + * first cut of improving UX for deleting devices. + [\#717](https://github.com/matrix-org/matrix-react-sdk/pull/717) + * Fix block quotes all being on a single line + [\#711](https://github.com/matrix-org/matrix-react-sdk/pull/711) + * Support reasons for kick / ban + [\#710](https://github.com/matrix-org/matrix-react-sdk/pull/710) + * Show when you've been kicked or banned + [\#709](https://github.com/matrix-org/matrix-react-sdk/pull/709) + * Add a 'Clear Cache' button + [\#708](https://github.com/matrix-org/matrix-react-sdk/pull/708) + * Update the room view on room name change + [\#707](https://github.com/matrix-org/matrix-react-sdk/pull/707) + * Add a button to un-ban users in RoomSettings + [\#698](https://github.com/matrix-org/matrix-react-sdk/pull/698) + * Use IndexedDBStore from the JS-SDK + [\#687](https://github.com/matrix-org/matrix-react-sdk/pull/687) + * Make UserSettings use the right teamToken + [\#706](https://github.com/matrix-org/matrix-react-sdk/pull/706) + * If the home page is somehow accessed, goto directory + [\#705](https://github.com/matrix-org/matrix-react-sdk/pull/705) + * Display avatar initials in typing notifications + [\#699](https://github.com/matrix-org/matrix-react-sdk/pull/699) + * fix eslint's no-invalid-this rule for class properties + [\#703](https://github.com/matrix-org/matrix-react-sdk/pull/703) + * If a referrer hasn't been specified, use empty string + [\#701](https://github.com/matrix-org/matrix-react-sdk/pull/701) + * Don't force-logout the user if reading localstorage fails + [\#700](https://github.com/matrix-org/matrix-react-sdk/pull/700) + * Convert some missed buttons to AccessibleButton + [\#697](https://github.com/matrix-org/matrix-react-sdk/pull/697) + * Make ban either ban or unban + [\#696](https://github.com/matrix-org/matrix-react-sdk/pull/696) + * Add confirmation dialog to kick/ban buttons + [\#694](https://github.com/matrix-org/matrix-react-sdk/pull/694) + * Fix typo with Scalar popup + [\#695](https://github.com/matrix-org/matrix-react-sdk/pull/695) + * Treat the literal team token string "undefined" as undefined + [\#693](https://github.com/matrix-org/matrix-react-sdk/pull/693) + * Store retrieved sid in the signupInstance of EmailIdentityStage + [\#692](https://github.com/matrix-org/matrix-react-sdk/pull/692) + * Split out InterActiveAuthDialog + [\#691](https://github.com/matrix-org/matrix-react-sdk/pull/691) + * View /home on registered /w team + [\#689](https://github.com/matrix-org/matrix-react-sdk/pull/689) + * Instead of sending userId, userEmail, send sid, client_secret + [\#688](https://github.com/matrix-org/matrix-react-sdk/pull/688) + * Enable branded URLs again by parsing the path client-side + [\#686](https://github.com/matrix-org/matrix-react-sdk/pull/686) + * Use new method of getting team icon + [\#680](https://github.com/matrix-org/matrix-react-sdk/pull/680) + * Persist query parameter team token across refreshes + [\#685](https://github.com/matrix-org/matrix-react-sdk/pull/685) + * Thread teamToken through to LeftPanel for "Home" button + [\#684](https://github.com/matrix-org/matrix-react-sdk/pull/684) + * Fix typing notif and status bar + [\#682](https://github.com/matrix-org/matrix-react-sdk/pull/682) + * Consider emails ending in matrix.org as a uni email + [\#681](https://github.com/matrix-org/matrix-react-sdk/pull/681) + * Set referrer qp in nextLink + [\#679](https://github.com/matrix-org/matrix-react-sdk/pull/679) + * Do not set team_token if not returned by RTS on login + [\#678](https://github.com/matrix-org/matrix-react-sdk/pull/678) + * Get team_token from the RTS on login + [\#676](https://github.com/matrix-org/matrix-react-sdk/pull/676) + * Quick and dirty support for custom welcome pages + [\#550](https://github.com/matrix-org/matrix-react-sdk/pull/550) + * RTS Welcome Pages + [\#666](https://github.com/matrix-org/matrix-react-sdk/pull/666) + * Logging to try to track down riot-web#3148 + [\#677](https://github.com/matrix-org/matrix-react-sdk/pull/677) + Changes in [0.8.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6) (2017-02-04) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.3...v0.8.6) diff --git a/package.json b/package.json index 9b260e341a..30eed9a59d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.8.6", + "version": "0.8.7", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/src/AddThreepid.js b/src/AddThreepid.js index d6a1d58aa0..c89de4f5fa 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.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. @@ -51,11 +52,36 @@ class AddThreepid { }); } + /** + * Attempt to add a msisdn threepid. This will trigger a side-effect of + * sending a test message to the provided phone number. + * @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in + * @param {string} phoneNumber The national or international formatted phone number to add + * @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server + * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). + */ + addMsisdn(phoneCountry, phoneNumber, bind) { + this.bind = bind; + return MatrixClientPeg.get().requestAdd3pidMsisdnToken( + phoneCountry, phoneNumber, this.clientSecret, 1, + ).then((res) => { + this.sessionId = res.sid; + return res; + }, function(err) { + if (err.errcode == 'M_THREEPID_IN_USE') { + err.message = "This phone number is already in use"; + } else if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + /** * Checks if the email link has been clicked by attempting to add the threepid - * @return {Promise} Resolves if the password was reset. Rejects with an object + * @return {Promise} Resolves if the email address was added. Rejects with an object * with a "message" property which contains a human-readable message detailing why - * the reset failed, e.g. "There is no mapped matrix user ID for the given email address". + * the request failed. */ checkEmailLinkClicked() { var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; @@ -73,6 +99,29 @@ class AddThreepid { throw err; }); } + + /** + * Takes a phone number verification code as entered by the user and validates + * it with the ID server, then if successful, adds the phone number. + * @return {Promise} Resolves if the phone number was added. Rejects with an object + * with a "message" property which contains a human-readable message detailing why + * the request failed. + */ + haveMsisdnToken(token) { + return MatrixClientPeg.get().submitMsisdnToken( + this.sessionId, this.clientSecret, token, + ).then((result) => { + if (result.errcode) { + throw result; + } + const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; + return MatrixClientPeg.get().addThreePid({ + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: identityServerDomain + }, this.bind); + }); + } } module.exports = AddThreepid; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 8bdf7d0391..6eed22f436 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -82,4 +82,12 @@ export default class BasePlatform { screenCaptureErrorString() { return "Not implemented"; } + + /** + * Restarts the application, without neccessarily reloading + * any application code + */ + reload() { + throw new Error("reload not implemented!"); + } } diff --git a/src/CallHandler.js b/src/CallHandler.js index 268a599d8e..42cc681d08 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 17c8155c1b..4ab982c98f 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 8ae2c0a4a8..5fe3fb890e 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,15 +104,17 @@ 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', 'img', + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], 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 img: ['src'], + ol: ['start'], }, // Lots of these won't come up by default because we don't allow them selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], @@ -136,6 +155,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 }; + }, }, }; diff --git a/src/Invite.js b/src/Invite.js index d1f03fe211..0e8aca2cb5 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 295ffe44f4..f20716cae6 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -155,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 @@ -276,6 +276,14 @@ export function setLoggedIn(credentials) { console.log("setLoggedIn => %s (guest=%s) hs=%s", credentials.userId, credentials.guest, credentials.homeserverUrl); + // This is dispatched to indicate that the user is still in the process of logging in + // because `teamPromise` may take some time to resolve, breaking the assumption that + // `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms + // later than MatrixChat might assume. + dis.dispatch({action: 'on_logging_in'}); + + // Resolves by default + let teamPromise = Promise.resolve(null); // persist the session if (localStorage) { @@ -300,25 +308,29 @@ export function setLoggedIn(credentials) { console.warn("Error using local storage: can't persist session!", e); } - if (rtsClient) { - rtsClient.login(credentials.userId).then((body) => { + if (rtsClient && !credentials.guest) { + teamPromise = rtsClient.login(credentials.userId).then((body) => { if (body.team_token) { localStorage.setItem("mx_team_token", body.team_token); } - }, (err) =>{ - console.error( - "Failed to get team token on login, not persisting to localStorage", - err - ); + 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(); } diff --git a/src/Login.js b/src/Login.js new file mode 100644 index 0000000000..107a8825e9 --- /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 d6dc979a5a..4a46ce4f24 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 3759aa72c1..452b67c4ee 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -50,6 +50,18 @@ class MatrixClientPeg { this.opts = { initialSyncLimit: 20, }; + this.indexedDbWorkerScript = null; + } + + /** + * Sets the script href passed to the IndexedDB web worker + * If set, a separate web worker will be started to run the IndexedDB + * queries on. + * + * @param {string} script href to the script to be passed to the web worker + */ + setIndexedDbWorkerScript(script) { + this.indexedDbWorkerScript = script; } get(): MatrixClient { @@ -122,12 +134,15 @@ class MatrixClientPeg { opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); } if (window.indexedDB && localStorage) { - opts.store = new Matrix.IndexedDBStore( - new Matrix.IndexedDBStoreBackend(window.indexedDB), - new Matrix.SyncAccumulator(), { - localStorage: 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({ + indexedDB: window.indexedDB, + dbName: "riot-web-sync", + localStorage: localStorage, + workerScript: this.indexedDbWorkerScript, + }); } this.matrixClient = Matrix.createClient(opts); diff --git a/src/Notifier.js b/src/Notifier.js index 67642e734a..92770877b7 100644 --- a/src/Notifier.js +++ b/src/Notifier.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,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - var MatrixClientPeg = require("./MatrixClientPeg"); var PlatformPeg = require("./PlatformPeg"); var TextForEvent = require('./TextForEvent'); @@ -99,16 +98,16 @@ var Notifier = { MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; - this.isPrepared = false; + this.isSyncing = false; }, stop: function() { - if (MatrixClientPeg.get()) { + if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } - this.isPrepared = false; + this.isSyncing = false; }, supportsDesktopNotifications: function() { @@ -214,18 +213,18 @@ var Notifier = { }, onSyncStateChange: function(state) { - if (state === "PREPARED" || state === "SYNCING") { - this.isPrepared = true; + if (state === "SYNCING") { + this.isSyncing = true; } else if (state === "STOPPED" || state === "ERROR") { - this.isPrepared = false; + this.isSyncing = false; } }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) return; if (!room) return; - if (!this.isPrepared) return; // don't alert for any messages initially + if (!this.isSyncing) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; diff --git a/src/Resend.js b/src/Resend.js index e2f0c5a1ee..bbd980ea7f 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -18,11 +18,27 @@ 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', @@ -33,16 +49,11 @@ module.exports = { // 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()), - 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"); + dis.dispatch({ + action: 'unknown_device_error', + err: err, + room: room, + }); } dis.dispatch({ @@ -51,7 +62,6 @@ module.exports = { }); }); }, - removeFromQueue: function(event) { MatrixClientPeg.get().cancelPendingEvent(event); dis.dispatch({ diff --git a/src/Roles.js b/src/Roles.js new file mode 100644 index 0000000000..cef8670aad --- /dev/null +++ b/src/Roles.js @@ -0,0 +1,29 @@ +/* +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. +*/ +export const LEVEL_ROLE_MAP = { + undefined: 'Default', + 0: 'User', + 50: 'Moderator', + 100: 'Admin', +}; + +export function textualPowerLevel(level, userDefault) { + if (LEVEL_ROLE_MAP[level]) { + return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`); + } else { + return level; + } +} diff --git a/src/Rooms.js b/src/Rooms.js index fbcc843ad2..08fa7f797f 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -79,6 +79,20 @@ export function looksLikeDirectMessageRoom(room, me) { return false; } +export function guessAndSetDMRoom(room, isDirect) { + let newTarget; + if (isDirect) { + const guessedTarget = guessDMRoomTarget( + room, room.getMember(MatrixClientPeg.get().credentials.userId), + ); + newTarget = guessedTarget.userId; + } else { + newTarget = null; + } + + return setDMRoom(room.roomId, newTarget); +} + /** * Marks or unmarks the given room as being as a DM room. * @param {string} roomId The ID of the room to modify diff --git a/src/Signup.js b/src/Signup.js deleted file mode 100644 index 022a93524c..0000000000 --- a/src/Signup.js +++ /dev/null @@ -1,465 +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; - } - - setReferrer(referrer) { - this.params.referrer = referrer; - } - - 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 1441682c85..0000000000 --- a/src/SignupStages.js +++ /dev/null @@ -1,177 +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); - - // Add the user ID of the referring user, if set - if (this.signupInstance.params.referrer) { - nextLink += "&referrer=" + encodeURIComponent(this.signupInstance.params.referrer); - } - - 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; - self.signupInstance.setIdSid(self.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 3f772e9cfb..40d6a49998 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -17,6 +17,8 @@ limitations under the License. var MatrixClientPeg = require("./MatrixClientPeg"); var CallHandler = require("./CallHandler"); +import * as Roles from './Roles'; + function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" var senderName = ev.sender ? ev.sender.name : ev.getSender(); @@ -116,7 +118,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; @@ -183,6 +184,45 @@ function textForEncryptionEvent(event) { return senderName + " turned on end-to-end encryption (algorithm " + event.getContent().algorithm + ")"; } +// Currently will only display a change if a user's power level is changed +function textForPowerEvent(event) { + const senderName = event.sender ? event.sender.name : event.getSender(); + if (!event.getPrevContent() || !event.getPrevContent().users) { + return ''; + } + const userDefault = event.getContent().users_default || 0; + // Construct set of userIds + let users = []; + Object.keys(event.getContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + } + ); + Object.keys(event.getPrevContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + } + ); + let diff = []; + users.forEach((userId) => { + // Previous power level + const from = event.getPrevContent().users[userId]; + // Current power level + const to = event.getContent().users[userId]; + if (to !== from) { + diff.push( + userId + + ' from ' + Roles.textualPowerLevel(from, userDefault) + + ' to ' + Roles.textualPowerLevel(to, userDefault) + ); + } + }); + if (!diff.length) { + return ''; + } + return senderName + ' changed the power level of ' + diff.join(', '); +} + var handlers = { 'm.room.message': textForMessageEvent, 'm.room.name': textForRoomNameEvent, @@ -194,6 +234,7 @@ var handlers = { 'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.encryption': textForEncryptionEvent, + 'm.room.power_levels': textForPowerEvent, }; module.exports = { diff --git a/src/UnknownDeviceErrorHandler.js b/src/UnknownDeviceErrorHandler.js new file mode 100644 index 0000000000..2aa0573e22 --- /dev/null +++ b/src/UnknownDeviceErrorHandler.js @@ -0,0 +1,51 @@ +/* +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'; + +let isDialogOpen = false; + +const onAction = function(payload) { + if (payload.action === 'unknown_device_error' && !isDialogOpen) { + var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); + isDialogOpen = true; + Modal.createDialog(UnknownDeviceDialog, { + devices: payload.err.devices, + room: payload.room, + onFinished: (r) => { + isDialogOpen = false; + // 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/component-index.js b/src/component-index.js index c705150e12..d6873c6dfd 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -75,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$ConfirmRedactDialog from './components/views/dialogs/ConfirmRedactDialog'; +views$dialogs$ConfirmRedactDialog && (module.exports.components['views.dialogs.ConfirmRedactDialog'] = views$dialogs$ConfirmRedactDialog); 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'; @@ -107,6 +111,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'; @@ -129,6 +135,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'; @@ -221,6 +229,8 @@ import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnread views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar); import views$rooms$UserTile from './components/views/rooms/UserTile'; views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile); +import views$settings$AddPhoneNumber from './components/views/settings/AddPhoneNumber'; +views$settings$AddPhoneNumber && (module.exports.components['views.settings.AddPhoneNumber'] = views$settings$AddPhoneNumber); import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar'; views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar); import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName'; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 70b3c2e306..7c8a5b8065 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -27,6 +27,9 @@ 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({ @@ -38,11 +41,34 @@ export default React.createClass({ // callback makeRequest: React.PropTypes.func.isRequired, - // callback called when the auth process has finished + // 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 result The result of the authenticated call - onFinished: React.PropTypes.func.isRequired, + // @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() { @@ -60,12 +86,22 @@ export default React.createClass({ this._authLogic = new InteractiveAuth({ authData: this.props.authData, doRequest: this._requestCallback, - startAuthStage: this._startAuthStage, + 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) => { - this.props.onFinished(true, 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; @@ -76,26 +112,48 @@ export default React.createClass({ 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); + } }, - _startAuthStage: function(stageType, error) { + _authStateUpdated: function(stageType, stageState) { + const oldStage = this.state.authStage; this.setState({ authStage: stageType, - errorText: error ? error.error : null, - }, this._setFocus); + stageState: stageState, + errorText: stageState.error, + }, () => { + if (oldStage != stageType) this._setFocus(); + }); }, - _requestCallback: function(auth) { + _requestCallback: function(auth, background) { + const makeRequestPromise = this.props.makeRequest(auth); + + // if it's a background request, just do it: we don't want + // it to affect the state of our UI. + if (background) return makeRequestPromise; + + // otherwise, manage the state of the spinner and error messages this.setState({ busy: true, errorText: null, stageErrorText: null, }); - return this.props.makeRequest(auth).finally(() => { + return makeRequestPromise.finally(() => { if (this._unmounted) { return; } @@ -117,19 +175,35 @@ export default React.createClass({ _renderCurrentStage: function() { const stage = this.state.authStage; - var StageComponent = getEntryComponentForLoginType(stage); + 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) { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index aa9470f126..ef9d8d112a 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -49,11 +49,16 @@ export default React.createClass({ childContextTypes: { matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), + authCache: React.PropTypes.object, }, getChildContext: function() { return { matrixClient: this._matrixClient, + authCache: { + auth: {}, + lastUpdate: 0, + }, }; }, @@ -76,6 +81,13 @@ export default React.createClass({ return this._scrollStateMap[roomId]; }, + canResetTimelineInRoom: function(roomId) { + if (!this.refs.roomView) { + return true; + } + return this.refs.roomView.canResetTimeline(); + }, + _onKeyDown: function(ev) { /* // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers @@ -94,6 +106,17 @@ export default React.createClass({ var handled = false; switch (ev.keyCode) { + case KeyCode.ESCAPE: + + // Implemented this way so possible handling for other pages is neater + switch (this.props.page_type) { + case PageTypes.UserSettings: + this.props.onUserSettingsClose(); + handled = true; + break; + } + + break; case KeyCode.UP: case KeyCode.DOWN: if (ev.altKey) { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 3265249105..b449ff3094 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. @@ -28,10 +29,6 @@ var UserActivity = require("../../UserActivity"); var Presence = require("../../Presence"); var dis = require("../../dispatcher"); -var Login = require("./login/Login"); -var Registration = require("./login/Registration"); -var PostRegistration = require("./login/PostRegistration"); - var Modal = require("../../Modal"); var Tinter = require("../../Tinter"); var sdk = require('../../index'); @@ -41,6 +38,7 @@ var Lifecycle = require('../../Lifecycle'); var PageTypes = require('../../PageTypes'); var createRoom = require("../../createRoom"); +import * as UDEHandler from '../../UnknownDeviceErrorHandler'; module.exports = React.createClass({ displayName: 'MatrixChat', @@ -61,9 +59,19 @@ module.exports = React.createClass({ // called when the session load completes onLoadCompleted: React.PropTypes.func, + // Represents the screen to display as a result of parsing the initial + // window.location + initialScreenAfterLogin: React.PropTypes.shape({ + screen: React.PropTypes.string.isRequired, + params: React.PropTypes.object, + }), + // 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: { @@ -84,6 +92,12 @@ module.exports = React.createClass({ var s = { loading: true, screen: undefined, + screenAfterLogin: this.props.initialScreenAfterLogin, + + // Stashed guest credentials if the user logs out + // whilst logged in as a guest user (so they can change + // their mind & log back in) + guestCreds: null, // What the LoggedInView would be showing if visible page_type: null, @@ -99,7 +113,8 @@ module.exports = React.createClass({ // If we're trying to just view a user ID (i.e. /user URL), this is it viewUserId: null, - logged_in: false, + loggedIn: false, + loggingIn: false, collapse_lhs: false, collapse_rhs: false, ready: false, @@ -179,13 +194,9 @@ module.exports = React.createClass({ componentWillMount: function() { SdkConfig.put(this.props.config); - // Stashed guest credentials if the user logs out - // whilst logged in as a guest user (so they can change - // their mind & log back in) - this.guestCreds = null; - - // if the automatic session load failed, the error - this.sessionLoadError = null; + // Used by _viewRoom before getting state from sync + this.firstSyncComplete = false; + this.firstSyncPromise = q.defer(); if (this.props.config.sync_timeline_limit) { MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; @@ -226,10 +237,20 @@ module.exports = React.createClass({ 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); @@ -265,7 +286,6 @@ module.exports = React.createClass({ }); }).catch((e) => { console.error("Unable to load session", e); - this.sessionLoadError = e.message; }).done(()=>{ // stuff this through the dispatcher so that it happens // after the on_logged_in action. @@ -276,6 +296,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); }, @@ -291,7 +312,7 @@ module.exports = React.createClass({ const newState = { screen: undefined, viewUserId: null, - logged_in: false, + loggedIn: false, ready: false, upgradeUsername: null, guestAccessToken: null, @@ -301,88 +322,123 @@ module.exports = React.createClass({ }, onAction: function(payload) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var roomIndexDelta = 1; var self = this; switch (payload.action) { case 'logout': - if (MatrixClientPeg.get().isGuest()) { - this.guestCreds = MatrixClientPeg.getCredentials(); - } 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': - if (this.state.logged_in) return; + if (MatrixClientPeg.get() && + MatrixClientPeg.get().isGuest() + ) { + this.setState({ + guestCreds: MatrixClientPeg.getCredentials(), + }); + } this.setStateForNewScreen({ screen: 'login', }); this.notifyNewScreen('login'); break; case 'start_post_registration': - this.setState({ // don't clobber logged_in status + this.setState({ // don't clobber loggedIn status screen: 'post_registration' }); break; case 'start_upgrade_registration': - // stash our guest creds so we can backout if needed - this.guestCreds = MatrixClientPeg.getCredentials(); + // 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.setStateForNewScreen({ + guestCreds: MatrixClientPeg.getCredentials(), 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': - if (this.state.logged_in) return; + if (this.state.loggedIn) return; this.setStateForNewScreen({ screen: 'forgot_password', }); this.notifyNewScreen('forgot_password'); break; case 'leave_room': - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - - var roomId = payload.room_id; Modal.createDialog(QuestionDialog, { title: "Leave room", description: "Are you sure you want to leave the room?", - onFinished: function(should_leave) { + onFinished: (should_leave) => { if (should_leave) { - var d = MatrixClientPeg.get().leave(roomId); + const d = MatrixClientPeg.get().leave(payload.room_id); // FIXME: controller shouldn't be loading a view :( - var Loader = sdk.getComponent("elements.Spinner"); - var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const Loader = sdk.getComponent("elements.Spinner"); + const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); - d.then(function() { + d.then(() => { modal.close(); - dis.dispatch({action: 'view_next_room'}); - }, function(err) { + if (this.currentRoomId === payload.room_id) { + dis.dispatch({action: 'view_next_room'}); + } + }, (err) => { modal.close(); + console.error("Failed to leave room " + payload.room_id + " " + err); Modal.createDialog(ErrorDialog, { title: "Failed to leave room", + description: "Server may be unavailable, overloaded, or you hit a bug." + }); + }); + } + } + }); + break; + case 'reject_invite': + Modal.createDialog(QuestionDialog, { + title: "Reject invitation", + description: "Are you sure you want to reject the invitation?", + onFinished: (confirm) => { + if (confirm) { + // FIXME: controller shouldn't be loading a view :( + const Loader = sdk.getComponent("elements.Spinner"); + const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + + MatrixClientPeg.get().leave(payload.room_id).done(() => { + modal.close(); + if (this.currentRoomId === payload.room_id) { + dis.dispatch({action: 'view_next_room'}); + } + }, (err) => { + modal.close(); + Modal.createDialog(ErrorDialog, { + title: "Failed to reject invitation", description: err.toString() }); }); @@ -509,8 +565,11 @@ module.exports = React.createClass({ case 'set_theme': this._onSetTheme(payload.value); break; + case 'on_logging_in': + this.setState({loggingIn: true}); + break; case 'on_logged_in': - this._onLoggedIn(); + this._onLoggedIn(payload.teamToken); break; case 'on_logged_out': this._onLoggedOut(); @@ -582,36 +641,38 @@ module.exports = React.createClass({ } } - if (this.sdkReady) { - // if the SDK is not ready yet, remember what room - // we're supposed to be on but don't notify about - // the new screen yet (we won't be showing it yet) - // The normal case where this happens is navigating - // to the room in the URL bar on page load. - var presentedId = room_info.room_alias || room_info.room_id; - var room = MatrixClientPeg.get().getRoom(room_info.room_id); + // Wait for the first sync to complete so that if a room does have an alias, + // it would have been retrieved. + let waitFor = q(null); + if (!this.firstSyncComplete) { + if (!this.firstSyncPromise) { + console.warn('Cannot view a room before first sync. room_id:', room_info.room_id); + return; + } + waitFor = this.firstSyncPromise.promise; + } + + waitFor.done(() => { + let presentedId = room_info.room_alias || room_info.room_id; + const room = MatrixClientPeg.get().getRoom(room_info.room_id); if (room) { - var theAlias = Rooms.getDisplayAliasForRoom(room); + const theAlias = Rooms.getDisplayAliasForRoom(room); if (theAlias) presentedId = theAlias; - // No need to do this given RoomView triggers it itself... - // var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); - // var color_scheme = {}; - // if (color_scheme_event) { - // color_scheme = color_scheme_event.getContent(); - // // XXX: we should validate the event - // } - // console.log("Tinter.tint from _viewRoom"); - // Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); + // Store this as the ID of the last room accessed. This is so that we can + // persist which room is being stored across refreshes and browser quits. + if (localStorage) { + localStorage.setItem('mx_last_room_id', room.roomId); + } } if (room_info.event_id) { - presentedId += "/"+room_info.event_id; + presentedId += "/" + room_info.event_id; } - this.notifyNewScreen('room/'+presentedId); + this.notifyNewScreen('room/' + presentedId); newState.ready = true; - } - this.setState(newState); + this.setState(newState); + }); }, _createChat: function() { @@ -637,6 +698,14 @@ module.exports = React.createClass({ _onLoadCompleted: function() { this.props.onLoadCompleted(); this.setState({loading: false}); + + // Show screens (like 'register') that need to be shown without _onLoggedIn + // being called. 'register' needs to be routed here when the email confirmation + // link is clicked on. + if (this.state.screenAfterLogin && + ['register'].indexOf(this.state.screenAfterLogin.screen) !== -1) { + this._showScreenAfterLogin(); + } }, /** @@ -686,13 +755,48 @@ module.exports = React.createClass({ /** * Called when a new logged in session has started */ - _onLoggedIn: function(credentials) { - this.guestCreds = null; - this.notifyNewScreen(''); + _onLoggedIn: function(teamToken) { this.setState({ - screen: undefined, - logged_in: true, + guestCreds: null, + loggedIn: true, + loggingIn: false, }); + + if (teamToken) { + // A team member has logged in, not a guest + this._teamToken = teamToken; + dis.dispatch({action: 'view_home_page'}); + } else if (this._is_registered) { + // The user has just logged in after registering + dis.dispatch({action: 'view_user_settings'}); + } else { + this._showScreenAfterLogin(); + } + }, + + _showScreenAfterLogin: function() { + // If screenAfterLogin is set, use that, then null it so that a second login will + // result in view_home_page, _user_settings or _room_directory + if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) { + this.showScreen( + this.state.screenAfterLogin.screen, + this.state.screenAfterLogin.params + ); + this.notifyNewScreen(this.state.screenAfterLogin.screen); + this.setState({screenAfterLogin: null}); + } else if (localStorage && localStorage.getItem('mx_last_room_id')) { + // Before defaulting to directory, show the last viewed room + dis.dispatch({ + action: 'view_room', + room_id: localStorage.getItem('mx_last_room_id'), + }); + } else if (this._teamToken) { + // Team token might be set if we're a guest. + // Guests do not call _onLoggedIn with a teamToken + dis.dispatch({action: 'view_home_page'}); + } else { + dis.dispatch({action: 'view_room_directory'}); + } }, /** @@ -701,7 +805,7 @@ module.exports = React.createClass({ _onLoggedOut: function() { this.notifyNewScreen('login'); this.setStateForNewScreen({ - logged_in: false, + loggedIn: false, ready: false, collapse_lhs: false, collapse_rhs: false, @@ -709,6 +813,7 @@ module.exports = React.createClass({ currentRoomId: null, page_type: PageTypes.RoomDirectory, }); + this._teamToken = null; }, /** @@ -716,9 +821,31 @@ module.exports = React.createClass({ * (useful for setting listeners) */ _onWillStartClient() { + var self = this; var cli = MatrixClientPeg.get(); - var self = this; + // Allow the JS SDK to reap timeline events. This reduces the amount of + // memory consumed as the JS SDK stores multiple distinct copies of room + // state (each of which can be 10s of MBs) for each DISJOINT timeline. This is + // particularly noticeable when there are lots of 'limited' /sync responses + // such as when laptops unsleep. + // https://github.com/vector-im/riot-web/issues/3307#issuecomment-282895568 + cli.setCanResetTimelineCallback(function(roomId) { + console.log("Request to reset timeline in room ", roomId, " viewing:", self.state.currentRoomId); + if (roomId !== self.state.currentRoomId) { + // It is safe to remove events from rooms we are not viewing. + return true; + } + // We are viewing the room which we want to reset. It is only safe to do + // this if we are not scrolled up in the view. To find out, delegate to + // the timeline panel. If the timeline panel doesn't exist, then we assume + // it is safe to reset the timeline. + if (!self.refs.loggedInView) { + return true; + } + return self.refs.loggedInView.canResetTimelineInRoom(roomId); + }); + cli.on('sync', function(state, prevState) { self.updateStatusIndicator(state, prevState); if (state === "SYNCING" && prevState === "SYNCING") { @@ -726,55 +853,12 @@ module.exports = React.createClass({ } console.log("MatrixClient sync state => %s", state); if (state !== "PREPARED") { return; } - self.sdkReady = true; - if (self.starting_room_alias_payload) { - dis.dispatch(self.starting_room_alias_payload); - delete self.starting_room_alias_payload; - } else if (!self.state.page_type) { - if (!self.state.currentRoomId) { - var firstRoom = null; - if (cli.getRooms() && cli.getRooms().length) { - firstRoom = RoomListSorter.mostRecentActivityFirst( - cli.getRooms() - )[0].roomId; - self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView}); - } else { - 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}); - } + self.firstSyncComplete = true; + self.firstSyncPromise.resolve(); - // we notifyNewScreen now because now the room will actually be displayed, - // and (mostly) now we can get the correct alias. - var presentedId = self.state.currentRoomId; - var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId); - if (room) { - var theAlias = Rooms.getDisplayAliasForRoom(room); - if (theAlias) presentedId = theAlias; - } - - if (presentedId != undefined) { - self.notifyNewScreen('room/'+presentedId); - } else { - // There is no information on presentedId - // so point user to fallback like /directory - if (self._teamToken) { - self.notifyNewScreen('home'); - } else { - self.notifyNewScreen('directory'); - } - } - - dis.dispatch({action: 'focus_composer'}); - } else { - self.setState({ready: true}); - } + dis.dispatch({action: 'focus_composer'}); + self.setState({ready: true}); }); cli.on('Call.incoming', function(call) { dis.dispatch({ @@ -874,12 +958,7 @@ module.exports = React.createClass({ // we can't view a room unless we're logged in // (a guest account is fine) - if (!this.state.logged_in) { - // we may still be loading (ie, trying to register a guest - // session); otherwise we're (probably) already showing a login - // screen. Either way, we'll show the room once the client starts. - this.starting_room_alias_payload = payload; - } else { + if (this.state.loggedIn) { dis.dispatch(payload); } } else if (screen.indexOf('user/') == 0) { @@ -973,29 +1052,17 @@ module.exports = React.createClass({ onReturnToGuestClick: function() { // reanimate our guest login - if (this.guestCreds) { - Lifecycle.setLoggedIn(this.guestCreds); - this.guestCreds = null; + if (this.state.guestCreds) { + Lifecycle.setLoggedIn(this.state.guestCreds); + this.setState({guestCreds: null}); } }, - onRegistered: function(credentials) { - 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); - }, - - onTeamMemberRegistered: function(teamToken) { + onRegistered: function(credentials, teamToken) { + // teamToken may not be truthy this._teamToken = teamToken; - this._setPage(PageTypes.HomePage); + this._is_registered = true; + Lifecycle.setLoggedIn(credentials); }, onFinishPostRegistration: function() { @@ -1061,15 +1128,20 @@ 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'); - - // console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen + - // "; logged_in="+this.state.logged_in+"; ready="+this.state.ready); - - if (this.state.loading) { - var Spinner = sdk.getComponent('elements.Spinner'); + // `loading` might be set to false before `loggedIn = true`, causing the default + // (``) to be visible for a few MS (say, whilst a request is in-flight to + // the RTS). So in the meantime, use `loggingIn`, which is true between + // actions `on_logging_in` and `on_logged_in`. + if (this.state.loading || this.state.loggingIn) { + const Spinner = sdk.getComponent('elements.Spinner'); return (
    @@ -1078,15 +1150,17 @@ module.exports = React.createClass({ } // needs to be before normal PageTypes as you are logged in technically else if (this.state.screen == 'post_registration') { + const PostRegistration = sdk.getComponent('structures.login.PostRegistration'); return ( ); - } else if (this.state.logged_in && this.state.ready) { + } else if (this.state.loggedIn && this.state.ready) { /* for now, we stuff the entirety of our props and state into the LoggedInView. * we should go through and figure out what we actually need to pass down, as well * as using something like redux to avoid having a billion bits of state kicking around. */ + const LoggedInView = sdk.getComponent('structures.LoggedInView'); return ( ); - } else if (this.state.logged_in) { + } else if (this.state.loggedIn) { // we think we are logged in, but are still waiting for the /sync to complete - var Spinner = sdk.getComponent('elements.Spinner'); + const Spinner = sdk.getComponent('elements.Spinner'); return (
    @@ -1109,6 +1183,7 @@ module.exports = React.createClass({
    ); } else if (this.state.screen == 'register') { + const Registration = sdk.getComponent('structures.login.Registration'); return ( ); } else if (this.state.screen == 'forgot_password') { + const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); return ( ); } else { - var r = ( + const Login = sdk.getComponent('structures.login.Login'); + return ( ); - - // we only want to show the session load error the first time the - // Login component is rendered. This is pretty hacky but I can't - // think of another way to achieve it. - this.sessionLoadError = null; - - return r; } } }); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index dcebe38fa4..0f8d35f525 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(
  1. ); } 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.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 ca50e1071a..0389b606aa 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -96,26 +96,12 @@ module.exports = React.createClass({ componentWillMount: function() { MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); + + this._checkSize(); }, - componentDidUpdate: function(prevProps, prevState) { - if(this.props.onResize && this._checkForResize(prevProps, prevState)) { - this.props.onResize(); - } - - const size = this._getSize(this.props, this.state); - if (size > 0) { - this.props.onVisible(); - } else { - if (this.hideDebouncer) { - clearTimeout(this.hideDebouncer); - } - this.hideDebouncer = setTimeout(() => { - // temporarily stop hiding the statusbar as per - // https://github.com/vector-im/riot-web/issues/1991#issuecomment-276953915 - // this.props.onHidden(); - }, HIDE_DEBOUNCE_MS); - } + componentDidUpdate: function() { + this._checkSize(); }, componentWillUnmount: function() { @@ -142,31 +128,33 @@ module.exports = React.createClass({ }); }, + // Check whether current size is greater than 0, if yes call props.onVisible + _checkSize: function () { + if (this.props.onVisible && this._getSize()) { + this.props.onVisible(); + } + }, + // 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(props, state) { - if (state.syncState === "ERROR" || - (state.usersTyping.length > 0) || - props.numUnreadMessages || - !props.atEndOfLiveTimeline || - props.hasActiveCall) { + _getSize: function() { + if (this.state.syncState === "ERROR" || + (this.state.usersTyping.length > 0) || + this.props.numUnreadMessages || + !this.props.atEndOfLiveTimeline || + this.props.hasActiveCall || + this.props.tabComplete.isTabCompleting() + ) { return STATUS_BAR_EXPANDED; - } else if (props.tabCompleteEntries) { + } else if (this.props.tabCompleteEntries) { return STATUS_BAR_HIDDEN; - } else if (props.unsentMessageError) { + } else if (this.props.unsentMessageError) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; }, - // 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 suitable content for the image on the left of the status bar. // // if wantPlaceholder is true, we include a "..." placeholder if @@ -194,8 +182,9 @@ module.exports = React.createClass({ } if (this.props.hasActiveCall) { + var TintableSvg = sdk.getComponent("elements.TintableSvg"); return ( - + ); } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5bf192dfc6..b22d867acf 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -490,13 +490,49 @@ module.exports = React.createClass({ } }, + canResetTimeline: function() { + if (!this.refs.messagePanel) { + return true; + } + return this.refs.messagePanel.canResetTimeline(); + }, + // 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") { @@ -716,17 +752,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) { @@ -892,8 +922,6 @@ module.exports = React.createClass({ }, uploadFile: function(file) { - var self = this; - if (MatrixClientPeg.get().isGuest()) { var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { @@ -905,11 +933,20 @@ module.exports = React.createClass({ ContentMessages.sendContentToRoom( file, this.state.room.roomId, MatrixClientPeg.get() - ).done(undefined, function(error) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + ).done(undefined, (error) => { + if (error.name === "UnknownDeviceError") { + dis.dispatch({ + action: 'unknown_device_error', + err: error, + room: this.state.room, + }); + return; + } + const 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", }); }); }, @@ -993,9 +1030,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({ @@ -1639,14 +1677,14 @@ module.exports = React.createClass({ videoMuteButton =
    - {call.isLocalVideoMuted()
    ; } voiceMuteButton =
    - {call.isMicrophoneMuted()
    ; diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 4a0faae9db..83bec03e9e 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -25,7 +25,7 @@ var DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. -const UNPAGINATION_PADDING = 3000; +const UNPAGINATION_PADDING = 6000; // The number of milliseconds to debounce calls to onUnfillRequest, to prevent // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; @@ -333,33 +333,27 @@ module.exports = React.createClass({ if (excessHeight <= 0) { return; } - var itemlist = this.refs.itemlist; - var tiles = itemlist.children; + const tiles = this.refs.itemlist.children; // The scroll token of the first/last tile to be unpaginated let markerScrollToken = null; - // Subtract clientHeights to simulate the events being unpaginated whilst counting - // the events to be unpaginated. - if (backwards) { - // Iterate forwards from start of tiles, subtracting event tile height - let i = 0; - while (i < tiles.length && excessHeight > tiles[i].clientHeight) { - excessHeight -= tiles[i].clientHeight; - if (tiles[i].dataset.scrollToken) { - markerScrollToken = tiles[i].dataset.scrollToken; - } - i++; + // Subtract heights of tiles to simulate the tiles being unpaginated until the + // excess height is less than the height of the next tile to subtract. This + // prevents excessHeight becoming negative, which could lead to future + // pagination. + // + // If backwards is true, we unpaginate (remove) tiles from the back (top). + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[backwards ? i : tiles.length - 1 - i]; + // Subtract height of tile as if it were unpaginated + excessHeight -= tile.clientHeight; + // The tile may not have a scroll token, so guard it + if (tile.dataset.scrollToken) { + markerScrollToken = tile.dataset.scrollToken; } - } else { - // Iterate backwards from end of tiles, subtracting event tile height - let i = tiles.length - 1; - while (i > 0 && excessHeight > tiles[i].clientHeight) { - excessHeight -= tiles[i].clientHeight; - if (tiles[i].dataset.scrollToken) { - markerScrollToken = tiles[i].dataset.scrollToken; - } - i--; + if (tile.clientHeight > excessHeight) { + break; } } @@ -589,24 +583,34 @@ module.exports = React.createClass({ var itemlist = this.refs.itemlist; var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); var messages = itemlist.children; + let newScrollState = null; for (var i = messages.length-1; i >= 0; --i) { var node = messages[i]; if (!node.dataset.scrollToken) continue; var boundingRect = node.getBoundingClientRect(); - if (boundingRect.bottom < wrapperRect.bottom) { - this.scrollState = { - stuckAtBottom: false, - trackedScrollToken: node.dataset.scrollToken, - pixelOffset: wrapperRect.bottom - boundingRect.bottom, - }; - debuglog("ScrollPanel: saved scroll state", this.scrollState); - return; + newScrollState = { + stuckAtBottom: false, + trackedScrollToken: node.dataset.scrollToken, + pixelOffset: wrapperRect.bottom - boundingRect.bottom, + }; + // If the bottom of the panel intersects the ClientRect of node, use this node + // as the scrollToken. + // If this is false for the entire for-loop, we default to the last node + // (which is why newScrollState is set on every iteration). + if (boundingRect.top < wrapperRect.bottom) { + // Use this node as the scrollToken + break; } } - - debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); + // This is only false if there were no nodes with `node.dataset.scrollToken` set. + if (newScrollState) { + this.scrollState = newScrollState; + debuglog("ScrollPanel: saved scroll state", this.scrollState); + } else { + debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); + } }, _restoreSavedScrollState: function() { diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index cb42f701a3..8cd820c284 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -251,10 +251,12 @@ var TimelinePanel = React.createClass({ }, onMessageListUnfillRequest: function(backwards, scrollToken) { + // If backwards, unpaginate from the back (i.e. the start of the timeline) let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); - // All tiles are inserted by MessagePanel to have a scrollToken === eventId + // All tiles are inserted by MessagePanel to have a scrollToken === eventId, and + // this particular event should be the first or last to be unpaginated. let eventId = scrollToken; let marker = this.state.events.findIndex( @@ -431,6 +433,10 @@ var TimelinePanel = React.createClass({ } }, + canResetTimeline: function() { + return this.refs.messagePanel && this.refs.messagePanel.isAtBottom(); + }, + onRoomRedaction: function(ev, room) { if (this.unmounted) return; @@ -469,14 +475,6 @@ var TimelinePanel = React.createClass({ // we still have a client. if (!MatrixClientPeg.get()) return; - // if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount - // to avoid having to wait from the remote echo from the homeserver. - if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount('total', 0); - this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); - // XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up - } - var currentReadUpToEventId = this._getCurrentReadReceipt(true); var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId); @@ -514,6 +512,19 @@ var TimelinePanel = React.createClass({ // it failed, so allow retries next time the user is active this.last_rr_sent_event_id = undefined; }); + + // do a quick-reset of our unreadNotificationCount to avoid having + // to wait from the remote echo from the homeserver. + // we only do this if we're right at the end, because we're just assuming + // that sending an RR for the latest message will set our notif counter + // to zero: it may not do this if we send an RR for somewhere before the end. + if (this.isAtEndOfLiveTimeline()) { + this.props.timelineSet.room.setUnreadNotificationCount('total', 0); + this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); + dis.dispatch({ + action: 'on_room_read', + }); + } } }, @@ -810,7 +821,7 @@ var TimelinePanel = React.createClass({ // go via the dispatcher so that the URL is updated dis.dispatch({ action: 'view_room', - room_id: this.props.timelineSet.roomId, + room_id: this.props.timelineSet.room.roomId, }); }; } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 8266a11bc8..01a879fd1b 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -25,12 +25,13 @@ module.exports = React.createClass({displayName: 'UploadBar', }, componentDidMount: function() { - dis.register(this.onAction); + this.dispatcherRef = dis.register(this.onAction); this.mounted = true; }, componentWillUnmount: function() { this.mounted = false; + dis.unregister(this.dispatcherRef); }, onAction: function(payload) { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index faa8a56894..892865fdf9 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.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. @@ -39,6 +40,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', @@ -135,6 +140,7 @@ module.exports = React.createClass({ componentWillMount: function() { this._unmounted = false; + this._addThreepid = null; if (PlatformPeg.get()) { q().then(() => { @@ -202,9 +208,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", }); }); }, @@ -242,10 +249,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." }); }); }, @@ -256,12 +264,18 @@ module.exports = React.createClass({ title: "Sign out?", description:
    - For security, logging out will delete any end-to-end encryption keys from this browser, - making previous encrypted chat history unreadable if you log back in. - In future this will be improved, - but for now be warned. + For security, logging out will delete any end-to-end encryption keys from this browser. + + If you want to be able to decrypt your conversation history from future Riot sessions, + please export your room keys for safe-keeping.
    , button: "Sign out", + extraButtons: [ + + ], onFinished: (confirmed) => { if (confirmed) { dis.dispatch({action: 'logout'}); @@ -282,6 +296,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 @@ -308,12 +323,16 @@ module.exports = React.createClass({ UserSettingsStore.setEnableNotifications(event.target.checked); }, - onAddThreepidClicked: function(value, shouldSubmit) { + _onAddEmailEditFinished: function(value, shouldSubmit) { if (!shouldSubmit) return; + this._addEmail(); + }, + + _addEmail: function() { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - var email_address = this.refs.add_threepid_input.value; + var email_address = this.refs.add_email_input.value; if (!Email.looksValid(email_address)) { Modal.createDialog(ErrorDialog, { title: "Invalid Email Address", @@ -321,10 +340,10 @@ module.exports = React.createClass({ }); return; } - this.add_threepid = new AddThreepid(); + this._addThreepid = new AddThreepid(); // we always bind emails when registering, so let's do the // same here. - this.add_threepid.addEmailAddress(email_address, true).done(() => { + this._addThreepid.addEmailAddress(email_address, true).done(() => { Modal.createDialog(QuestionDialog, { title: "Verification Pending", description: "Please check your email and click on the link it contains. Once this is done, click continue.", @@ -333,12 +352,13 @@ 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(); + ReactDOM.findDOMNode(this.refs.add_email_input).blur(); this.setState({email_add_pending: true}); }, @@ -357,9 +377,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(); } @@ -376,8 +397,8 @@ module.exports = React.createClass({ }, verifyEmailAddress: function() { - this.add_threepid.checkEmailLinkClicked().done(() => { - this.add_threepid = undefined; + this._addThreepid.checkEmailLinkClicked().done(() => { + this._addThreepid = null; this.setState({ phase: "UserSettings.LOADING", }); @@ -397,9 +418,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", }); } }); @@ -419,10 +441,11 @@ module.exports = React.createClass({ }, _onClearCacheClicked: function() { + if (!PlatformPeg.get()) return; + + MatrixClientPeg.get().stopClient(); 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); + PlatformPeg.get().reload(); }); }, @@ -745,6 +768,14 @@ module.exports = React.createClass({ return medium[0].toUpperCase() + medium.slice(1); }, + presentableTextForThreepid: function(threepid) { + if (threepid.medium == 'msisdn') { + return '+' + threepid.address; + } else { + return threepid.address; + } + }, + render: function() { var Loader = sdk.getComponent("elements.Spinner"); switch (this.state.phase) { @@ -777,7 +808,9 @@ module.exports = React.createClass({
  2. - +
    Remove @@ -785,30 +818,35 @@ module.exports = React.createClass({
    ); }); - var addThreepidSection; + let addEmailSection; if (this.state.email_add_pending) { - addThreepidSection = ; + addEmailSection = ; } else if (!MatrixClientPeg.get().isGuest()) { - addThreepidSection = ( -
    + addEmailSection = ( +
    + onValueChanged={ this._onAddEmailEditFinished } />
    - Add + Add
    ); } - threepidsSection.push(addThreepidSection); + const AddPhoneNumber = sdk.getComponent('views.settings.AddPhoneNumber'); + const addMsisdnSection = ( + + ); + threepidsSection.push(addEmailSection); + threepidsSection.push(addMsisdnSection); var accountJsx; diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 2c10052b98..d75c7b7584 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -93,11 +93,17 @@ module.exports = React.createClass({ description:
    Resetting password will currently reset any end-to-end encryption keys on all devices, - making encrypted chat history unreadable. - In future this may be improved, - but for now be warned. + making encrypted chat history unreadable, unless you first export your room keys + and re-import them afterwards. + In future this will be improved.
    , button: "Continue", + extraButtons: [ + + ], onFinished: (confirmed) => { if (confirmed) { this.submitPasswordReset( @@ -110,6 +116,18 @@ module.exports = React.createClass({ } }, + _onExportE2eKeysClicked: function() { + Modal.createDialogAsync( + (cb) => { + require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { + cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + } + ); + }, + onInputChanged: function(stateKey, ev) { this.setState({ [stateKey]: ev.target.value diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index fe9b544751..7e1a5f9d35 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', @@ -52,20 +53,20 @@ module.exports = React.createClass({ // login shouldn't care how password recovery is done. onForgotPasswordClick: React.PropTypes.func, onCancelClick: React.PropTypes.func, - - initialErrorText: React.PropTypes.string, }, getInitialState: function() { return { busy: false, - errorText: this.props.initialErrorText, + errorText: null, loginIncorrect: false, 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 +74,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 +121,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 +156,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 +235,11 @@ module.exports = React.createClass({ diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index db1147a5d2..4e0d61e716 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, @@ -58,7 +55,6 @@ module.exports = React.createClass({ teamServerURL: React.PropTypes.string.isRequired, }), teamSelected: React.PropTypes.object, - onTeamMemberRegistered: React.PropTypes.func.isRequired, defaultDeviceDisplayName: React.PropTypes.string, @@ -82,27 +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); - if (this.props.referrer) { - this.registerLogic.setReferrer(this.props.referrer); - } - this.registerLogic.recheckState(); + + this._replaceClient(); if ( this.props.teamServerConfig && @@ -134,154 +123,137 @@ 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(); + _onUIAuthFinished: function(success, response, extra) { + if (!success) { + let msg = response.message || response.toString(); + // can we give a better error message? + if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { + let msisdn_available = false; + for (const flow of response.available_flows) { + msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1; + } + if (!msisdn_available) { + msg = "This server does not support authentication with a phone number"; + } } - 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 + this.setState({ + busy: false, + doingUIAuth: false, + errorText: msg, }); + 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). - if (self._rtsClient) { - // Track referral if self.props.referrer set, get team_token in order to - // retrieve team config and see welcome page etc. - self._rtsClient.trackReferral( - self.props.referrer || '', // Default to empty string = not referred - self.registerLogic.params.idSid, - self.registerLogic.params.clientSecret - ).then((data) => { - const teamToken = data.team_token; - // Store for use /w welcome pages - window.localStorage.setItem('mx_team_token', teamToken); - self.props.onTeamMemberRegistered(teamToken); + this.setState({ + // we're still busy until we get unmounted: don't show the registration form again + busy: true, + doingUIAuth: false, + }); - 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._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); - }); - } - - }, function(err) { - if (err.message) { - self.setState({ - errorText: err.message - }); - } - self.setState({ - busy: false + return teamToken; + }, (err) => { + console.error('Error tracking referral', err); }); - console.log(err); + } + + 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); + }); + } + } + }, (error) => { + console.error("Couldn't get pushers: " + error); }); }, @@ -300,6 +272,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; @@ -316,116 +291,121 @@ 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 (
    @@ -435,7 +415,12 @@ module.exports = React.createClass({ this.state.teamSelected.domain + "/icon.png" : null} /> - {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 e83403ef7c..0b2ca5225d 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -18,6 +18,7 @@ import React from 'react'; import * as KeyCode from '../../../KeyCode'; import AccessibleButton from '../elements/AccessibleButton'; +import sdk from '../../../index'; /** * Basic container for modal dialogs. @@ -65,15 +66,14 @@ export default React.createClass({ }, render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + return (
    - Cancel +
    { this.props.title } diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js new file mode 100644 index 0000000000..1a6ddf0456 --- /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 ca3b07aa00..16f756a773 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -26,18 +26,10 @@ 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; -/* - * Escapes a string so it can be used in a RegExp - * Basically just replaces: \ ^ $ * + ? . ( ) | { } [ ] - * From http://stackoverflow.com/a/6969486 - */ -function escapeRegExp(str) { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); -} - module.exports = React.createClass({ displayName: "ChatInviteDialog", propTypes: { @@ -85,6 +77,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 +102,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 +181,59 @@ 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 the query isn't a user we know about, but is a - // valid address, add an entry for that - if (queryList.length == 0) { + 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 is a valid address, add an entry for that + // This is important, otherwise there's no way to invite + // a perfectly valid address if there are close matches. const addrType = getAddressType(query); if (addrType !== null) { - queryList[0] = { + queryList.unshift({ 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 +266,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 +308,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 +321,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 +342,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,49 +357,15 @@ module.exports = React.createClass({ _updateUserList: new rate_limited_func(function() { // Get all the users this._userList = MatrixClientPeg.get().getUsers(); + // Remove current user + const meIx = this._userList.findIndex((u) => { + return u.userId === MatrixClientPeg.get().credentials.userId; + }); + this._userList.splice(meIx, 1); + + this._fuse.set(this._userList); }, 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; - } - - return false; - }, - _isOnInviteList: function(uid) { for (let i = 0; i < this.state.inviteList.length; i++) { if ( @@ -386,8 +378,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/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js new file mode 100644 index 0000000000..fc9e55f666 --- /dev/null +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -0,0 +1,73 @@ +/* +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 a redaction. + */ +export default React.createClass({ + displayName: 'ConfirmRedactDialog', + propTypes: { + onFinished: React.PropTypes.func.isRequired, + }, + + defaultProps: { + danger: false, + }, + + onOk: function() { + this.props.onFinished(true); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const title = "Confirm Redaction"; + + const confirmButtonClass = classnames({ + 'mx_Dialog_primary': true, + 'danger': false, + }); + + return ( + +
    + Are you sure you wish to redact (delete) this event? + Note that if you redact a room name or topic change, it could undo the change. +
    +
    + + + +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 4bd9cb669c..6cfaac65d4 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -97,7 +97,7 @@ export default React.createClass({ >
    - +
    {this.props.member.name}
    {this.props.member.userId}
    diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index 54a4e99424..b4879982bf 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -18,7 +18,7 @@ import React from 'react'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Lifecycle from '../../../Lifecycle'; +import * as Lifecycle from '../../../Lifecycle'; import Velocity from 'velocity-vector'; export default class DeactivateAccountDialog extends React.Component { diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 66b662b23d..145b4b6453 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -27,6 +27,9 @@ 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({ @@ -49,22 +52,62 @@ export default React.createClass({ }; }, + getInitialState: function() { + return { + authError: null, + } + }, + + _onAuthFinished: function(success, result) { + if (success) { + this.props.onFinished(true, result); + } else { + this.setState({ + authError: result, + }); + } + }, + + _onDismissClick: function() { + this.props.onFinished(false); + }, + render: function() { const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + let content; + if (this.state.authError) { + content = ( +
    +
    {this.state.authError.message || this.state.authError.toString()}
    +
    + + Dismiss + +
    + ); + } else { + content = ( +
    + +
    + ); + } + return ( -
    - -
    + {content}
    ); }, diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 3f7f237c30..6012541b94 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -21,10 +21,8 @@ export default React.createClass({ displayName: 'QuestionDialog', propTypes: { title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, - ]), + description: React.PropTypes.node, + extraButtons: React.PropTypes.node, button: React.PropTypes.string, focus: React.PropTypes.bool, onFinished: React.PropTypes.func.isRequired, @@ -34,8 +32,10 @@ export default React.createClass({ return { title: "", description: "", + extraButtons: null, button: "OK", focus: true, + hasCancelButton: true, }; }, @@ -49,6 +49,11 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const cancelButton = this.props.hasCancelButton ? ( + + ) : null; return ( {this.props.button} - - + {this.props.extraButtons} + {cancelButton}
    ); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 3bebb8fdda..da9c8e8f65 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, @@ -125,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.

    ); @@ -151,8 +149,7 @@ export default React.createClass({ >

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

    { warning } Unknown devices: @@ -160,6 +157,13 @@ export default React.createClass({
    +