diff --git a/CHANGELOG.md b/CHANGELOG.md index 488a9814e6..97dda666de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,270 @@ +Changes in [0.8.8](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.8) (2017-04-25) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.8-rc.2...v0.8.8) + + * No changes + + +Changes in [0.8.8-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.8-rc.2) (2017-04-24) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.8-rc.1...v0.8.8-rc.2) + + * Fix bug where links to Riot would fail to open. + + +Changes in [0.8.8-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.8-rc.1) (2017-04-21) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7...v0.8.8-rc.1) + + * Update js-sdk to fix registration without a captcha (https://github.com/vector-im/riot-web/issues/3621) + + +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/karma.conf.js b/karma.conf.js index 6d3047bb3b..3495a981be 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -135,17 +135,24 @@ module.exports = function (config) { }, ], noParse: [ + // for cross platform compatibility use [\\\/] as the path separator + // this ensures that the regex trips on both Windows and *nix + // don't parse the languages within highlight.js. They // cause stack overflows // (https://github.com/webpack/webpack/issues/1721), and // there is no need for webpack to parse them - they can // just be included as-is. - /highlight\.js\/lib\/languages/, + /highlight\.js[\\\/]lib[\\\/]languages/, + + // olm takes ages for webpack to process, and it's already heavily + // optimised, so there is little to gain by us uglifying it. + /olm[\\\/](javascript[\\\/])?olm\.js$/, // also disable parsing for sinon, because it // tries to do voodoo with 'require' which upsets // webpack (https://github.com/webpack/webpack/issues/304) - /sinon\/pkg\/sinon\.js$/, + /sinon[\\\/]pkg[\\\/]sinon\.js$/, ], }, resolve: { diff --git a/package.json b/package.json index 1015eb3fe9..8a1baa6a0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.8.6", + "version": "0.8.8", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -32,8 +32,8 @@ }, "scripts": { "reskindex": "scripts/reskindex.js -h header", - "build": "node scripts/babelcheck.js && babel src -d lib --source-maps", - "start": "node scripts/babelcheck.js && babel src -w -d lib --source-maps", + "build": "babel src -d lib --source-maps", + "start": "babel src -w -d lib --source-maps", "lint": "eslint src/", "lintall": "eslint src/ test/", "clean": "rimraf lib", @@ -53,7 +53,7 @@ "draft-js-export-markdown": "^0.2.0", "emojione": "2.2.3", "file-saver": "^1.3.3", - "filesize": "^3.1.2", + "filesize": "3.5.6", "flux": "^2.0.3", "glob": "^5.0.14", "highlight.js": "^8.9.1", @@ -63,11 +63,12 @@ "lodash": "^4.13.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", + "prop-types": "^15.5.8", "q": "^1.4.1", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", "react-dom": "^15.4.0", - "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", + "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#39d858c", "sanitize-html": "^1.11.1", "text-encoding-utf-8": "^1.0.1", "velocity-vector": "vector-im/velocity#059e3b2", diff --git a/scripts/babelcheck.js b/scripts/babelcheck.js deleted file mode 100644 index 14e4a28a70..0000000000 --- a/scripts/babelcheck.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node - -var exec = require('child_process').exec; - -// Makes sure the babel executable in the path is babel 6 (or greater), not -// babel 5, which it is if you upgrade from an older version of react-sdk and -// run 'npm install' since the package has changed to babel-cli, so 'babel' -// remains installed and the executable in node_modules/.bin remains as babel -// 5. - -exec("babel -V", function (error, stdout, stderr) { - if ((error && error.code) || parseInt(stdout.substr(0,1), 10) < 6) { - console.log("\033[31m\033[1m"+ - '*****************************************\n'+ - '* matrix-react-sdk has moved to babel 6 *\n'+ - '* Please "rm -rf node_modules && npm i" *\n'+ - '* then restore links as appropriate *\n'+ - '*****************************************\n'+ - "\033[91m"); - process.exit(1); - } -}); 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/Avatar.js b/src/Avatar.js index 76f5e55ff0..c0127d49af 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -22,8 +22,8 @@ module.exports = { avatarUrlForMember: function(member, width, height, resizeMethod) { var url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), - width, - height, + Math.floor(width * window.devicePixelRatio), + Math.floor(height * window.devicePixelRatio), resizeMethod, false, false @@ -40,7 +40,9 @@ module.exports = { avatarUrlForUser: function(user, width, height, resizeMethod) { var url = ContentRepo.getHttpUriForMxc( MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, - width, height, resizeMethod + Math.floor(width * window.devicePixelRatio), + Math.floor(height * window.devicePixelRatio), + resizeMethod ); if (!url || url.length === 0) { return null; @@ -57,4 +59,3 @@ module.exports = { return 'img/' + images[total % images.length] + '.png'; } }; - 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 bb46056d19..5199ef0a67 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -310,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. " + ((err && err.message) ? err.message : ""), }); }); } diff --git a/src/ConstantTimeDispatcher.js b/src/ConstantTimeDispatcher.js new file mode 100644 index 0000000000..6c2c3266aa --- /dev/null +++ b/src/ConstantTimeDispatcher.js @@ -0,0 +1,62 @@ +/* +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. +*/ + +// singleton which dispatches invocations of a given type & argument +// rather than just a type (as per EventEmitter and Flux's dispatcher etc) +// +// This means you can have a single point which listens for an EventEmitter event +// and then dispatches out to one of thousands of RoomTiles (for instance) rather than +// having each RoomTile register for the EventEmitter event and having to +// iterate over all of them. +class ConstantTimeDispatcher { + constructor() { + // type -> arg -> [ listener(arg, params) ] + this.listeners = {}; + } + + register(type, arg, listener) { + if (!this.listeners[type]) this.listeners[type] = {}; + if (!this.listeners[type][arg]) this.listeners[type][arg] = []; + this.listeners[type][arg].push(listener); + } + + unregister(type, arg, listener) { + if (this.listeners[type] && this.listeners[type][arg]) { + var i = this.listeners[type][arg].indexOf(listener); + if (i > -1) { + this.listeners[type][arg].splice(i, 1); + } + } + else { + console.warn("Unregistering unrecognised listener (type=" + type + ", arg=" + arg + ")"); + } + } + + dispatch(type, arg, params) { + if (!this.listeners[type] || !this.listeners[type][arg]) { + //console.warn("No registered listeners for dispatch (type=" + type + ", arg=" + arg + ")"); + return; + } + this.listeners[type][arg].forEach(listener=>{ + listener.call(arg, params); + }); + } +} + +if (!global.constantTimeDispatcher) { + global.constantTimeDispatcher = new ConstantTimeDispatcher(); +} +module.exports = global.constantTimeDispatcher; 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 c500076783..a31601790f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -25,6 +25,9 @@ import emojione from 'emojione'; import classNames from 'classnames'; emojione.imagePathSVG = 'emojione/svg/'; +// Store PNG path for displaying many flags at once (for increased performance over SVG) +emojione.imagePathPNG = 'emojione/png/'; +// Use SVGs for emojis emojione.imageType = 'svg'; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); @@ -58,6 +61,29 @@ 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 useSvg {boolean} Whether to use SVG image src. If False, PNG will be used. + * @param unicode {integer} One or more integers representing unicode characters + * @returns A img node with the corresponding emoji + */ +export function charactersToImageNode(alt, useSvg, ...unicode) { + const fileName = unicode.map((u) => { + return u.toString(16); + }).join('-'); + const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG; + const fileType = useSvg ? 'svg' : 'png'; + return {alt}; +} + + export function stripParagraphs(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; @@ -85,8 +111,7 @@ var sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown - // deliberately no h1/h2 to stop people shouting. - 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'h1', 'h2', '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', 'span', ], @@ -98,6 +123,7 @@ var sanitizeHtmlParams = { // 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'], diff --git a/src/KeyCode.js b/src/KeyCode.js index c9cac01239..f164dbc15c 100644 --- a/src/KeyCode.js +++ b/src/KeyCode.js @@ -32,4 +32,5 @@ module.exports = { DELETE: 46, KEY_D: 68, KEY_E: 69, + KEY_K: 75, }; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index fc8087e12d..f34aeae0e5 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -49,7 +49,7 @@ import sdk from './index'; * If any of steps 1-4 are successful, it will call {setLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * - * It returns a promise which resolves when the above process completes. + * @param {object} opts * * @param {object} opts.realQueryParams: string->string map of the * query-parameters extracted from the real query-string of the starting @@ -67,6 +67,7 @@ import sdk from './index'; * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * true; defines the IS to use. * + * @returns {Promise} a promise which resolves when the above process completes. */ export function loadSession(opts) { const realQueryParams = opts.realQueryParams || {}; @@ -127,7 +128,7 @@ export function loadSession(opts) { function _loginWithToken(queryParams, defaultDeviceDisplayName) { // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: queryParams.homeserver, }); @@ -159,7 +160,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { // Not really sure where the right home for it is. // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: hsUrl, }); @@ -188,30 +189,30 @@ function _restoreFromLocalStorage() { if (!localStorage) { return q(false); } - const hs_url = localStorage.getItem("mx_hs_url"); - const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; - const access_token = localStorage.getItem("mx_access_token"); - const user_id = localStorage.getItem("mx_user_id"); - const device_id = localStorage.getItem("mx_device_id"); + const hsUrl = localStorage.getItem("mx_hs_url"); + const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org'; + const accessToken = localStorage.getItem("mx_access_token"); + const userId = localStorage.getItem("mx_user_id"); + const deviceId = localStorage.getItem("mx_device_id"); - let is_guest; + let isGuest; if (localStorage.getItem("mx_is_guest") !== null) { - is_guest = localStorage.getItem("mx_is_guest") === "true"; + isGuest = localStorage.getItem("mx_is_guest") === "true"; } else { // legacy key name - is_guest = localStorage.getItem("matrix-is-guest") === "true"; + isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - if (access_token && user_id && hs_url) { - console.log("Restoring session for %s", user_id); + if (accessToken && userId && hsUrl) { + console.log("Restoring session for %s", userId); try { setLoggedIn({ - userId: user_id, - deviceId: device_id, - accessToken: access_token, - homeserverUrl: hs_url, - identityServerUrl: is_url, - guest: is_guest, + userId: userId, + deviceId: deviceId, + accessToken: accessToken, + homeserverUrl: hsUrl, + identityServerUrl: isUrl, + guest: isGuest, }); return q(true); } catch (e) { @@ -273,9 +274,18 @@ export function initRtsClient(url) { */ export function setLoggedIn(credentials) { credentials.guest = Boolean(credentials.guest); - console.log("setLoggedIn => %s (guest=%s) hs=%s", - credentials.userId, credentials.guest, - credentials.homeserverUrl); + + console.log( + "setLoggedIn: mxid:", credentials.userId, + "deviceId:", credentials.deviceId, + "guest:", credentials.guest, + "hs:", 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); @@ -347,7 +357,7 @@ export function logout() { return; } - return MatrixClientPeg.get().logout().then(onLoggedOut, + MatrixClientPeg.get().logout().then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and @@ -358,8 +368,8 @@ export function logout() { // change your password). console.log("Failed to call logout API: token will not be invalidated"); onLoggedOut(); - } - ); + }, + ).done(); } /** @@ -415,7 +425,7 @@ export function stopMatrixClient() { UserActivity.stop(); Presence.stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); - var cli = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); cli.removeAllListeners(); diff --git a/src/Login.js b/src/Login.js index 96f953c130..107a8825e9 100644 --- a/src/Login.js +++ b/src/Login.js @@ -105,21 +105,48 @@ export default class Login { }); } - 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; + 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 { - loginParams.user = username; + identifier = { + type: 'm.id.user', + user: username, + }; + legacyParams = { + user: username, + }; } - var client = this._createTemporaryClient(); + 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, diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index baa3293073..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 { @@ -125,12 +137,12 @@ class MatrixClientPeg { // FIXME: bodge to remove old database. Remove this after a few weeks. window.indexedDB.deleteDatabase("matrix-js-sdk:default"); - opts.store = new Matrix.IndexedDBStore( - new Matrix.IndexedDBStoreBackend(window.indexedDB, "riot-web-sync"), - new Matrix.SyncAccumulator(), { - localStorage: localStorage, - } - ); + 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..6473ab4d9c 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,13 +15,13 @@ 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'); -var Avatar = require('./Avatar'); -var dis = require("./dispatcher"); +import MatrixClientPeg from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import TextForEvent from './TextForEvent'; +import Avatar from './Avatar'; +import dis from './dispatcher'; +import sdk from './index'; +import Modal from './Modal'; /* * Dispatches: @@ -30,7 +31,7 @@ var dis = require("./dispatcher"); * } */ -var Notifier = { +const Notifier = { notifsByRoom: {}, notificationMessageForEvent: function(ev) { @@ -49,16 +50,16 @@ var Notifier = { return; } - var msg = this.notificationMessageForEvent(ev); + let msg = this.notificationMessageForEvent(ev); if (!msg) return; - var title; - if (!ev.sender || room.name == ev.sender.name) { + let title; + if (!ev.sender || room.name === ev.sender.name) { title = room.name; // notificationMessageForEvent includes sender, // but we already have the sender here if (ev.getContent().body) msg = ev.getContent().body; - } else if (ev.getType() == 'm.room.member') { + } else if (ev.getType() === 'm.room.member') { // context is all in the message here, we don't need // to display sender info title = room.name; @@ -69,7 +70,7 @@ var Notifier = { if (ev.getContent().body) msg = ev.getContent().body; } - var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( + const avatarUrl = ev.sender ? Avatar.avatarUrlForMember( ev.sender, 40, 40, 'crop' ) : null; @@ -84,7 +85,7 @@ var Notifier = { }, _playAudioNotification: function(ev, room) { - var e = document.getElementById("messageAudio"); + const e = document.getElementById("messageAudio"); if (e) { e.load(); e.play(); @@ -96,19 +97,19 @@ var Notifier = { this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); + 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('Room.receipt', this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } - this.isPrepared = false; + this.isSyncing = false; }, supportsDesktopNotifications: function() { @@ -122,7 +123,7 @@ var Notifier = { // make sure that we persist the current setting audio_enabled setting // before changing anything if (global.localStorage) { - if(global.localStorage.getItem('audio_notifications_enabled') == null) { + if (global.localStorage.getItem('audio_notifications_enabled') === null) { this.setAudioEnabled(this.isEnabled()); } } @@ -132,6 +133,16 @@ var Notifier = { plaf.requestNotificationPermission().done((result) => { if (result !== 'granted') { // The permission request was dismissed or denied + const description = result === 'denied' + ? 'Riot does not have permission to send you notifications' + + ' - please check your browser settings' + : 'Riot was not given permission to send notifications' + + ' - please try again'; + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createDialog(ErrorDialog, { + title: 'Unable to enable Notifications', + description, + }); return; } @@ -142,7 +153,7 @@ var Notifier = { if (callback) callback(); dis.dispatch({ action: "notifier_enabled", - value: true + value: true, }); }); // clear the notifications_hidden flag, so that if notifications are @@ -153,7 +164,7 @@ var Notifier = { global.localStorage.setItem('notifications_enabled', 'false'); dis.dispatch({ action: "notifier_enabled", - value: false + value: false, }); } }, @@ -166,7 +177,7 @@ var Notifier = { if (!global.localStorage) return true; - var enabled = global.localStorage.getItem('notifications_enabled'); + const enabled = global.localStorage.getItem('notifications_enabled'); if (enabled === null) return true; return enabled === 'true'; }, @@ -174,12 +185,12 @@ var Notifier = { setAudioEnabled: function(enable) { if (!global.localStorage) return; global.localStorage.setItem('audio_notifications_enabled', - enable ? 'true' : 'false'); + enable ? 'true' : 'false'); }, isAudioEnabled: function(enable) { if (!global.localStorage) return true; - var enabled = global.localStorage.getItem( + const enabled = global.localStorage.getItem( 'audio_notifications_enabled'); // default to true if the popups are enabled if (enabled === null) return this.isEnabled(); @@ -193,7 +204,7 @@ var Notifier = { // this is nothing to do with notifier_enabled dis.dispatch({ action: "notifier_enabled", - value: this.isEnabled() + value: this.isEnabled(), }); // update the info to localStorage for persistent settings @@ -214,22 +225,21 @@ var Notifier = { }, onSyncStateChange: function(state) { - if (state === "PREPARED" || state === "SYNCING") { - this.isPrepared = true; - } - else if (state === "STOPPED" || state === "ERROR") { - this.isPrepared = false; + if (state === "SYNCING") { + this.isSyncing = true; + } else if (state === "STOPPED" || state === "ERROR") { + 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 (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; + 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; - var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { if (this.isEnabled()) { this._displayPopupNotification(ev, room); @@ -241,7 +251,7 @@ var Notifier = { }, onRoomReceipt: function(ev, room) { - if (room.getUnreadNotificationCount() == 0) { + if (room.getUnreadNotificationCount() === 0) { // ideally we would clear each notification when it was read, // but we have no way, given a read receipt, to know whether // the receipt comes before or after an event, so we can't @@ -256,7 +266,7 @@ var Notifier = { } delete this.notifsByRoom[room.roomId]; } - } + }, }; if (!global.mxNotifier) { 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/TextForEvent.js b/src/TextForEvent.js index 3f772e9cfb..3f200a089d 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(); @@ -63,8 +65,8 @@ function textForMemberEvent(ev) { } else if (!ev.getPrevContent().avatar_url && ev.getContent().avatar_url) { return senderName + " set a profile picture"; } else { - // hacky hack for https://github.com/vector-im/vector-web/issues/2020 - return senderName + " rejoined the room."; + // suppress null rejoins + return ''; } } else { if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); @@ -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 index 88f4f57fe4..2aa0573e22 100644 --- a/src/UnknownDeviceErrorHandler.js +++ b/src/UnknownDeviceErrorHandler.js @@ -1,14 +1,34 @@ +/* +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') { + 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); diff --git a/src/UserActivity.js b/src/UserActivity.js index e7338e17e9..1ae272f5df 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -32,7 +32,7 @@ class UserActivity { start() { document.onmousedown = this._onUserActivity.bind(this); document.onmousemove = this._onUserActivity.bind(this); - document.onkeypress = this._onUserActivity.bind(this); + document.onkeydown = this._onUserActivity.bind(this); // can't use document.scroll here because that's only the document // itself being scrolled. Need to use addEventListener's useCapture. // also this needs to be the wheel event, not scroll, as scroll is @@ -50,7 +50,7 @@ class UserActivity { stop() { document.onmousedown = undefined; document.onmousemove = undefined; - document.onkeypress = undefined; + document.onkeydown = undefined; window.removeEventListener('wheel', this._onUserActivity.bind(this), { passive: true, capture: true }); } diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 0ee78b4f2e..9de291249f 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -15,9 +15,9 @@ limitations under the License. */ 'use strict'; -var q = require("q"); -var MatrixClientPeg = require("./MatrixClientPeg"); -var Notifier = require("./Notifier"); +import q from 'q'; +import MatrixClientPeg from './MatrixClientPeg'; +import Notifier from './Notifier'; /* * TODO: Make things use this. This is all WIP - see UserSettings.js for usage. @@ -33,7 +33,7 @@ module.exports = { ], loadProfileInfo: function() { - var cli = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); return cli.getProfileInfo(cli.credentials.userId); }, @@ -44,7 +44,7 @@ module.exports = { loadThreePids: function() { if (MatrixClientPeg.get().isGuest()) { return q({ - threepids: [] + threepids: [], }); // guests can't poke 3pid endpoint } return MatrixClientPeg.get().getThreePids(); @@ -73,19 +73,19 @@ module.exports = { Notifier.setAudioEnabled(enable); }, - changePassword: function(old_password, new_password) { - var cli = MatrixClientPeg.get(); + changePassword: function(oldPassword, newPassword) { + const cli = MatrixClientPeg.get(); - var authDict = { + const authDict = { type: 'm.login.password', user: cli.credentials.userId, - password: old_password + password: oldPassword, }; - return cli.setPassword(authDict, new_password); + return cli.setPassword(authDict, newPassword); }, - /** + /* * Returns the email pusher (pusher of type 'email') for a given * email address. Email pushers all have the same app ID, so since * pushers are unique over (app ID, pushkey), there will be at most @@ -95,8 +95,8 @@ module.exports = { if (pushers === undefined) { return undefined; } - for (var i = 0; i < pushers.length; ++i) { - if (pushers[i].kind == 'email' && pushers[i].pushkey == address) { + for (let i = 0; i < pushers.length; ++i) { + if (pushers[i].kind === 'email' && pushers[i].pushkey === address) { return pushers[i]; } } @@ -110,7 +110,7 @@ module.exports = { addEmailPusher: function(address, data) { return MatrixClientPeg.get().setPusher({ kind: 'email', - app_id: "m.email", + app_id: 'm.email', pushkey: address, app_display_name: 'Email Notifications', device_display_name: address, @@ -121,46 +121,46 @@ module.exports = { }, getUrlPreviewsDisabled: function() { - var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls"); + const event = MatrixClientPeg.get().getAccountData('org.matrix.preview_urls'); return (event && event.getContent().disable); }, setUrlPreviewsDisabled: function(disabled) { // FIXME: handle errors - return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", { - disable: disabled + return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', { + disable: disabled, }); }, getSyncedSettings: function() { - var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings"); + const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings'); return event ? event.getContent() : {}; }, getSyncedSetting: function(type, defaultValue = null) { - var settings = this.getSyncedSettings(); + const settings = this.getSyncedSettings(); return settings.hasOwnProperty(type) ? settings[type] : defaultValue; }, setSyncedSetting: function(type, value) { - var settings = this.getSyncedSettings(); + const settings = this.getSyncedSettings(); settings[type] = value; // FIXME: handle errors - return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings); + return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings); }, getLocalSettings: function() { - var localSettingsString = localStorage.getItem('mx_local_settings') || '{}'; + const localSettingsString = localStorage.getItem('mx_local_settings') || '{}'; return JSON.parse(localSettingsString); }, getLocalSetting: function(type, defaultValue = null) { - var settings = this.getLocalSettings(); + const settings = this.getLocalSettings(); return settings.hasOwnProperty(type) ? settings[type] : defaultValue; }, setLocalSetting: function(type, value) { - var settings = this.getLocalSettings(); + const settings = this.getLocalSettings(); settings[type] = value; // FIXME: handle errors localStorage.setItem('mx_local_settings', JSON.stringify(settings)); @@ -171,8 +171,8 @@ module.exports = { if (MatrixClientPeg.get().isGuest()) return false; if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) { - for (var i = 0; i < this.LABS_FEATURES.length; i++) { - var f = this.LABS_FEATURES[i]; + for (let i = 0; i < this.LABS_FEATURES.length; i++) { + const f = this.LABS_FEATURES[i]; if (f.id === feature) { return f.default; } @@ -183,5 +183,5 @@ module.exports = { setFeatureEnabled: function(feature: string, enabled: boolean) { localStorage.setItem(`mx_labs_feature_${feature}`, enabled); - } + }, }; diff --git a/src/component-index.js b/src/component-index.js index c705150e12..090a27d5ed 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'; @@ -99,26 +103,40 @@ import views$dialogs$UnknownDeviceDialog from './components/views/dialogs/Unknow views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog); import views$elements$AccessibleButton from './components/views/elements/AccessibleButton'; views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton); +import views$elements$ActionButton from './components/views/elements/ActionButton'; +views$elements$ActionButton && (module.exports.components['views.elements.ActionButton'] = views$elements$ActionButton); import views$elements$AddressSelector from './components/views/elements/AddressSelector'; views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector); import views$elements$AddressTile from './components/views/elements/AddressTile'; views$elements$AddressTile && (module.exports.components['views.elements.AddressTile'] = views$elements$AddressTile); +import views$elements$CreateRoomButton from './components/views/elements/CreateRoomButton'; +views$elements$CreateRoomButton && (module.exports.components['views.elements.CreateRoomButton'] = views$elements$CreateRoomButton); import views$elements$DeviceVerifyButtons from './components/views/elements/DeviceVerifyButtons'; 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'; views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer); import views$elements$EmojiText from './components/views/elements/EmojiText'; views$elements$EmojiText && (module.exports.components['views.elements.EmojiText'] = views$elements$EmojiText); +import views$elements$HomeButton from './components/views/elements/HomeButton'; +views$elements$HomeButton && (module.exports.components['views.elements.HomeButton'] = views$elements$HomeButton); import views$elements$MemberEventListSummary from './components/views/elements/MemberEventListSummary'; views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary); import views$elements$PowerSelector from './components/views/elements/PowerSelector'; views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector); import views$elements$ProgressBar from './components/views/elements/ProgressBar'; views$elements$ProgressBar && (module.exports.components['views.elements.ProgressBar'] = views$elements$ProgressBar); +import views$elements$RoomDirectoryButton from './components/views/elements/RoomDirectoryButton'; +views$elements$RoomDirectoryButton && (module.exports.components['views.elements.RoomDirectoryButton'] = views$elements$RoomDirectoryButton); +import views$elements$SettingsButton from './components/views/elements/SettingsButton'; +views$elements$SettingsButton && (module.exports.components['views.elements.SettingsButton'] = views$elements$SettingsButton); +import views$elements$StartChatButton from './components/views/elements/StartChatButton'; +views$elements$StartChatButton && (module.exports.components['views.elements.StartChatButton'] = views$elements$StartChatButton); import views$elements$TintableSvg from './components/views/elements/TintableSvg'; views$elements$TintableSvg && (module.exports.components['views.elements.TintableSvg'] = views$elements$TintableSvg); import views$elements$TruncatedList from './components/views/elements/TruncatedList'; @@ -129,6 +147,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 +241,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 71fee883be..7c8a5b8065 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -140,13 +140,20 @@ export default React.createClass({ }); }, - _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; } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index c2243820cd..c4eeb03d5f 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.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. @@ -81,6 +82,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 @@ -99,9 +107,21 @@ 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) { + if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { var action = ev.keyCode == KeyCode.UP ? 'view_prev_room' : 'view_next_room'; dis.dispatch({action: action}); @@ -111,13 +131,15 @@ export default React.createClass({ case KeyCode.PAGE_UP: case KeyCode.PAGE_DOWN: - this._onScrollKeyPressed(ev); - handled = true; + if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { + this._onScrollKeyPressed(ev); + handled = true; + } break; case KeyCode.HOME: case KeyCode.END: - if (ev.ctrlKey) { + if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this._onScrollKeyPressed(ev); handled = true; } @@ -135,22 +157,25 @@ export default React.createClass({ if (this.refs.roomView) { this.refs.roomView.handleScrollKey(ev); } + else if (this.refs.roomDirectory) { + this.refs.roomDirectory.handleScrollKey(ev); + } }, render: function() { - var LeftPanel = sdk.getComponent('structures.LeftPanel'); - var RightPanel = sdk.getComponent('structures.RightPanel'); - var RoomView = sdk.getComponent('structures.RoomView'); - var UserSettings = sdk.getComponent('structures.UserSettings'); - var CreateRoom = sdk.getComponent('structures.CreateRoom'); - var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); - var HomePage = sdk.getComponent('structures.HomePage'); - var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); - var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar'); - var NewVersionBar = sdk.getComponent('globals.NewVersionBar'); + const LeftPanel = sdk.getComponent('structures.LeftPanel'); + const RightPanel = sdk.getComponent('structures.RightPanel'); + const RoomView = sdk.getComponent('structures.RoomView'); + const UserSettings = sdk.getComponent('structures.UserSettings'); + const CreateRoom = sdk.getComponent('structures.CreateRoom'); + const RoomDirectory = sdk.getComponent('structures.RoomDirectory'); + const HomePage = sdk.getComponent('structures.HomePage'); + const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); + const GuestWarningBar = sdk.getComponent('globals.GuestWarningBar'); + const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); - var page_element; - var right_panel = ''; + let page_element; + let right_panel = ''; switch (this.props.page_type) { case PageTypes.RoomView: @@ -195,10 +220,9 @@ export default React.createClass({ case PageTypes.RoomDirectory: page_element = ; - if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.HomePage: diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 44fdfcf23e..9b8aa3426a 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -29,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'); @@ -63,6 +59,13 @@ 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, @@ -89,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, @@ -104,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, @@ -184,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; @@ -280,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. @@ -307,7 +312,7 @@ module.exports = React.createClass({ const newState = { screen: undefined, viewUserId: null, - logged_in: false, + loggedIn: false, ready: false, upgradeUsername: null, guestAccessToken: null, @@ -317,14 +322,13 @@ 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': @@ -344,14 +348,20 @@ module.exports = React.createClass({ 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; @@ -359,8 +369,8 @@ module.exports = React.createClass({ // also stash our credentials, then if we restore the session, // we can just do it the same way whether we started upgrade // registration or explicitly logged out - this.guestCreds = MatrixClientPeg.getCredentials(); this.setStateForNewScreen({ + guestCreds: MatrixClientPeg.getCredentials(), screen: "register", upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), guestAccessToken: MatrixClientPeg.get().getAccessToken(), @@ -375,35 +385,60 @@ module.exports = React.createClass({ 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: (err && err.message ? err.message : "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() }); }); @@ -530,6 +565,9 @@ 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(payload.teamToken); break; @@ -603,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() { @@ -658,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(); + } }, /** @@ -708,18 +756,46 @@ module.exports = React.createClass({ * Called when a new logged in session has started */ _onLoggedIn: function(teamToken) { - this.guestCreds = null; - this.notifyNewScreen(''); 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; - this._setPage(PageTypes.HomePage); + dis.dispatch({action: 'view_home_page'}); } else if (this._is_registered) { - this._setPage(PageTypes.UserSettings); + // 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'}); } }, @@ -729,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, @@ -745,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") { @@ -755,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({ @@ -903,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) { @@ -1002,9 +1052,9 @@ 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}); } }, @@ -1086,14 +1136,12 @@ module.exports = React.createClass({ }, 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 (
    @@ -1102,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 (
    @@ -1133,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 0981b7b706..d4bf147ad5 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -279,23 +279,25 @@ module.exports = React.createClass({ this.currentGhostEventId = null; } - var isMembershipChange = (e) => - e.getType() === 'm.room.member' - && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); + var isMembershipChange = (e) => e.getType() === 'm.room.member'; for (i = 0; i < this.props.events.length; i++) { - var mxEv = this.props.events[i]; - var wantTile = true; - var eventId = mxEv.getId(); + let mxEv = this.props.events[i]; + let wantTile = true; + let eventId = mxEv.getId(); + let readMarkerInMels = false; if (!EventTile.haveTileForEvent(mxEv)) { wantTile = false; } - var last = (i == lastShownEventIndex); + let 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 @@ -331,6 +333,9 @@ module.exports = React.createClass({ let eventTiles = summarisedEvents.map( (e) => { + if (e.getId() === this.props.readMarkerEventId) { + readMarkerInMels = true; + } // In order to prevent DateSeparators from appearing in the expanded form // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the @@ -349,12 +354,16 @@ module.exports = React.createClass({ {eventTiles} ); + + if (readMarkerInMels) { + ret.push(this._getReadMarkerTile(visible)); + } + continue; } @@ -385,6 +394,8 @@ module.exports = React.createClass({ isVisibleReadMarker = visible; } + // XXX: there should be no need for a ghost tile - we should just use a + // a dispatch (user_activity_end) to start the RM animation. if (eventId == this.currentGhostEventId) { // if we're showing an animation, continue to show it. ret.push(this._getReadMarkerGhostTile()); @@ -408,7 +419,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; @@ -459,8 +472,9 @@ module.exports = React.createClass({ ret.push(
  1. + data-scroll-tokens={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 626c376d9f..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,33 +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 || - props.tabComplete.isTabCompleting() + _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 diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 936d88c0ee..9f84657912 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -26,6 +26,7 @@ var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); +var UserSettingsStore = require('../../UserSettingsStore'); var MatrixClientPeg = require("../../MatrixClientPeg"); var ContentMessages = require("../../ContentMessages"); var Modal = require("../../Modal"); @@ -270,6 +271,7 @@ module.exports = React.createClass({ this._updateConfCallNotification(); + window.addEventListener('beforeunload', this.onPageUnload); window.addEventListener('resize', this.onResize); this.onResize(); @@ -352,6 +354,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("accountData", this.onAccountData); } + window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('resize', this.onResize); document.removeEventListener("keydown", this.onKeyDown); @@ -364,6 +367,17 @@ module.exports = React.createClass({ // Tinter.tint(); // reset colourscheme }, + onPageUnload(event) { + if (ContentMessages.getCurrentUploads().length > 0) { + return event.returnValue = + 'You seem to be uploading files, are you sure you want to quit?'; + } else if (this._getCallForRoom() && this.state.callState !== 'ended') { + return event.returnValue = + 'You seem to be in a call, are you sure you want to quit?'; + } + }, + + onKeyDown: function(ev) { let handled = false; const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; @@ -489,6 +503,13 @@ 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) { @@ -914,8 +935,6 @@ module.exports = React.createClass({ }, uploadFile: function(file) { - var self = this; - if (MatrixClientPeg.get().isGuest()) { var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { @@ -927,11 +946,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: ((error && error.message) ? error.message : "Server may be unavailable, overloaded, or the file too big"), }); }); }, @@ -1015,9 +1043,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: ((error && error.message) ? error.message : "Server may be unavailable, overloaded, or search timed out :("), }); }).finally(function() { self.setState({ @@ -1165,6 +1194,7 @@ module.exports = React.createClass({ console.log("updateTint from onCancelClick"); this.updateTint(); this.setState({editingRoomSettings: false}); + dis.dispatch({action: 'focus_composer'}); }, onLeaveClick: function() { @@ -1238,6 +1268,7 @@ module.exports = React.createClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { this.refs.messagePanel.jumpToLiveTimeline(); + dis.dispatch({action: 'focus_composer'}); }, // jump up to wherever our read marker is @@ -1257,12 +1288,7 @@ module.exports = React.createClass({ return; } - var pos = this.refs.messagePanel.getReadMarkerPosition(); - - // we want to show the bar if the read-marker is off the top of the - // screen. - var showBar = (pos < 0); - + const showBar = this.refs.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}, this.onChildResize); @@ -1701,7 +1727,7 @@ module.exports = React.createClass({ var messagePanel = (
  2. ); } @@ -171,7 +182,7 @@ export default class MessageComposer extends React.Component { } onUpArrow() { - return this.refs.autocomplete.onUpArrow(); + return this.refs.autocomplete.onUpArrow(); } onDownArrow() { @@ -299,6 +310,7 @@ export default class MessageComposer extends React.Component { tryComplete={this._tryComplete} onUpArrow={this.onUpArrow} onDownArrow={this.onDownArrow} + onUploadFileSelected={this.onUploadFileSelected} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete onContentChanged={this.onInputContentChanged} onInputStateChanged={this.onInputStateChanged} />, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 2a0a62ebf7..2d16b202d1 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -96,8 +96,20 @@ export default class MessageComposerInput extends React.Component { constructor(props, context) { super(props, context); + this.onAction = this.onAction.bind(this); + this.handleReturn = this.handleReturn.bind(this); + this.handleKeyCommand = this.handleKeyCommand.bind(this); + this.handlePastedFiles = this.handlePastedFiles.bind(this); + this.onEditorContentChanged = this.onEditorContentChanged.bind(this); + this.setEditorState = this.setEditorState.bind(this); + this.onUpArrow = this.onUpArrow.bind(this); + this.onDownArrow = this.onDownArrow.bind(this); + this.onTab = this.onTab.bind(this); + this.onEscape = this.onEscape.bind(this); + this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); + this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); - const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); + const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); this.state = { // whether we're in rich text or markdown mode @@ -261,6 +273,7 @@ export default class MessageComposerInput extends React.Component { } sendTyping(isTyping) { + if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, this.isTyping, TYPING_SERVER_TIMEOUT, @@ -404,10 +417,14 @@ export default class MessageComposerInput extends React.Component { } return false; - }; + } - handleReturn = (ev) => { - if(ev.shiftKey) { + handlePastedFiles(files) { + this.props.onUploadFileSelected(files, true); + } + + handleReturn(ev) { + if (ev.shiftKey) { this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); return true; } @@ -442,7 +459,7 @@ export default class MessageComposerInput extends React.Component { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Server error", - description: err.message, + description: ((err && err.message) ? err.message : "Server unavailable, overloaded, or something else went wrong."), }); }); } else if (cmd.error) { @@ -473,9 +490,9 @@ export default class MessageComposerInput extends React.Component { let sendTextFn = this.client.sendTextMessage; if (contentText.startsWith('/me')) { - contentText = contentText.replace('/me', ''); + contentText = contentText.replace('/me ', ''); // bit of a hack, but the alternative would be quite complicated - if (contentHTML) contentHTML = contentHTML.replace('/me', ''); + if (contentHTML) contentHTML = contentHTML.replace('/me ', ''); sendHtmlFn = this.client.sendHtmlEmote; sendTextFn = this.client.sendEmoteMessage; } @@ -686,6 +703,7 @@ export default class MessageComposerInput extends React.Component { keyBindingFn={MessageComposerInput.getKeyBinding} handleKeyCommand={this.handleKeyCommand} handleReturn={this.handleReturn} + handlePastedFiles={this.handlePastedFiles} stripPastedStyles={!this.state.isRichtextEnabled} onTab={this.onTab} onUpArrow={this.onUpArrow} @@ -697,3 +715,28 @@ export default class MessageComposerInput extends React.Component { ); } } + +MessageComposerInput.propTypes = { + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, + + // js-sdk Room object + room: React.PropTypes.object.isRequired, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func, + + onUpArrow: React.PropTypes.func, + + onDownArrow: React.PropTypes.func, + + onUploadFileSelected: React.PropTypes.func, + + // attempts to confirm currently selected completion, returns whether actually confirmed + tryComplete: React.PropTypes.func, + + onInputStateChanged: React.PropTypes.func, +}; diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js index 9f6464b69b..378644478c 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -20,6 +20,7 @@ var SlashCommands = require("../../../SlashCommands"); var Modal = require("../../../Modal"); var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; var sdk = require('../../../index'); +import UserSettingsStore from "../../../UserSettingsStore"; var dis = require("../../../dispatcher"); var KeyCode = require("../../../KeyCode"); @@ -311,7 +312,7 @@ export default React.createClass({ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Server error", - description: err.message + description: ((err && err.message) ? err.message : "Server unavailable, overloaded, or something else went wrong."), }); }); } @@ -420,6 +421,7 @@ export default React.createClass({ }, sendTyping: function(isTyping) { + if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, this.isTyping, TYPING_SERVER_TIMEOUT diff --git a/src/components/views/rooms/PresenceLabel.js b/src/components/views/rooms/PresenceLabel.js index 2ece4c771e..52d831fcf6 100644 --- a/src/components/views/rooms/PresenceLabel.js +++ b/src/components/views/rooms/PresenceLabel.js @@ -75,7 +75,7 @@ module.exports = React.createClass({ render: function() { if (this.props.activeAgo >= 0) { - var ago = this.props.currentlyActive ? "now" : (this.getDuration(this.props.activeAgo) + " ago"); + var ago = this.props.currentlyActive ? "" : "for " + (this.getDuration(this.props.activeAgo)); // var ago = this.getDuration(this.props.activeAgo) + " ago"; // if (this.props.currentlyActive) ago += " (now?)"; return ( diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 1a8776cd96..94f2691f2c 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -115,9 +115,10 @@ module.exports = React.createClass({ changeAvatar.onFileSelected(ev).catch(function(err) { var errMsg = (typeof err === "string") ? err : (err.error || ""); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to set avatar: " + errMsg); Modal.createDialog(ErrorDialog, { title: "Error", - description: "Failed to set avatar. " + errMsg + description: "Failed to set avatar.", }); }).done(); }, diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index c3ee5f1730..8d396b5536 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.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. @@ -21,15 +22,23 @@ var GeminiScrollbar = require('react-gemini-scrollbar'); var MatrixClientPeg = require("../../../MatrixClientPeg"); var CallHandler = require('../../../CallHandler'); var RoomListSorter = require("../../../RoomListSorter"); -var Unread = require('../../../Unread'); var dis = require("../../../dispatcher"); var sdk = require('../../../index'); var rate_limited_func = require('../../../ratelimitedfunc'); var Rooms = require('../../../Rooms'); import DMRoomMap from '../../../utils/DMRoomMap'; var Receipt = require('../../../utils/Receipt'); +var constantTimeDispatcher = require('../../../ConstantTimeDispatcher'); +import AccessibleButton from '../elements/AccessibleButton'; -var HIDE_CONFERENCE_CHANS = true; +const HIDE_CONFERENCE_CHANS = true; + +const VERBS = { + 'm.favourite': 'favourite', + 'im.vector.fake.direct': 'tag direct chat', + 'im.vector.fake.recent': 'restore', + 'm.lowpriority': 'demote', +}; module.exports = React.createClass({ displayName: 'RoomList', @@ -37,13 +46,23 @@ module.exports = React.createClass({ propTypes: { ConferenceHandler: React.PropTypes.any, collapsed: React.PropTypes.bool.isRequired, - currentRoom: React.PropTypes.string, + selectedRoom: React.PropTypes.string, searchFilter: React.PropTypes.string, }, + shouldComponentUpdate: function(nextProps, nextState) { + if (nextProps.collapsed !== this.props.collapsed) return true; + if (nextProps.searchFilter !== this.props.searchFilter) return true; + if (nextState.lists !== this.state.lists || + nextState.isLoadingLeftRooms !== this.state.isLoadingLeftRooms || + nextState.incomingCall !== this.state.incomingCall) return true; + return false; + }, + getInitialState: function() { return { isLoadingLeftRooms: false, + totalRoomCount: null, lists: {}, incomingCall: null, }; @@ -57,12 +76,21 @@ module.exports = React.createClass({ cli.on("Room.name", this.onRoomName); cli.on("Room.tags", this.onRoomTags); cli.on("Room.receipt", this.onRoomReceipt); - cli.on("RoomState.events", this.onRoomStateEvents); + cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomMember.name", this.onRoomMemberName); cli.on("accountData", this.onAccountData); - var s = this.getRoomLists(); - this.setState(s); + // lookup for which lists a given roomId is currently in. + this.listsForRoomId = {}; + + this.refreshRoomList(); + + // order of the sublists + //this.listOrder = []; + + // loop count to stop a stack overflow if the user keeps waggling the + // mouse for >30s in a row, or if running under mocha + this._delayedRefreshRoomListLoopCount = 0 }, componentDidMount: function() { @@ -71,7 +99,22 @@ module.exports = React.createClass({ this._updateStickyHeaders(true); }, - componentDidUpdate: function() { + componentWillReceiveProps: function(nextProps) { + // short-circuit react when the room changes + // to avoid rerendering all the sublists everywhere + if (nextProps.selectedRoom !== this.props.selectedRoom) { + if (this.props.selectedRoom) { + constantTimeDispatcher.dispatch( + "RoomTile.select", this.props.selectedRoom, {} + ); + } + constantTimeDispatcher.dispatch( + "RoomTile.select", nextProps.selectedRoom, { selected: true } + ); + } + }, + + componentDidUpdate: function(prevProps, prevState) { // Reinitialise the stickyHeaders when the component is updated this._updateStickyHeaders(true); this._repositionIncomingCallBox(undefined, false); @@ -95,6 +138,26 @@ module.exports = React.createClass({ incomingCall: null }); } + break; + case 'on_room_read': + // poke the right RoomTile to refresh, using the constantTimeDispatcher + // to avoid each and every RoomTile registering to the 'on_room_read' event + // XXX: if we like the constantTimeDispatcher we might want to dispatch + // directly from TimelinePanel rather than needlessly bouncing via here. + constantTimeDispatcher.dispatch( + "RoomTile.refresh", payload.room.roomId, {} + ); + + // also have to poke the right list(s) + var lists = this.listsForRoomId[payload.room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch( + "RoomSubList.refreshHeader", list, { room: payload.room } + ); + }); + } + break; } }, @@ -108,7 +171,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); - MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); + MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); MatrixClientPeg.get().removeListener("accountData", this.onAccountData); } @@ -117,10 +180,14 @@ module.exports = React.createClass({ }, onRoom: function(room) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, onDeleteRoom: function(roomId) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, @@ -143,6 +210,10 @@ module.exports = React.createClass({ } }, + _onMouseOver: function(ev) { + this._lastMouseOverTs = Date.now(); + }, + onSubListHeaderClick: function(isHidden, scrollToPosition) { // The scroll area has expanded or contracted, so re-calculate sticky headers positions this._updateStickyHeaders(true, scrollToPosition); @@ -152,41 +223,98 @@ module.exports = React.createClass({ if (toStartOfTimeline) return; if (!room) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; - this._delayedRefreshRoomList(); + + // rather than regenerate our full roomlists, which is very heavy, we poke the + // correct sublists to just re-sort themselves. This isn't enormously reacty, + // but is much faster than the default react reconciler, or having to do voodoo + // with shouldComponentUpdate and a pleaseRefresh property or similar. + var lists = this.listsForRoomId[room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch("RoomSubList.sort", list, { room: room }); + }); + } + + // we have to explicitly hit the roomtile which just changed + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); }, onRoomReceipt: function(receiptEvent, room) { // because if we read a notification, it will affect notification count // only bother updating if there's a receipt from us if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) { - this._delayedRefreshRoomList(); + var lists = this.listsForRoomId[room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch( + "RoomSubList.refreshHeader", list, { room: room } + ); + }); + } + + // we have to explicitly hit the roomtile which just changed + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); } }, onRoomName: function(room) { - this._delayedRefreshRoomList(); + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); }, onRoomTags: function(event, room) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, - onRoomStateEvents: function(ev, state) { - this._delayedRefreshRoomList(); + onRoomStateMember: function(ev, state, member) { + if (ev.getStateKey() === MatrixClientPeg.get().credentials.userId && + ev.getPrevContent() && ev.getPrevContent().membership === "invite") + { + this._delayedRefreshRoomList(); + } + else { + constantTimeDispatcher.dispatch( + "RoomTile.refresh", member.roomId, {} + ); + } }, onRoomMemberName: function(ev, member) { - this._delayedRefreshRoomList(); + constantTimeDispatcher.dispatch( + "RoomTile.refresh", member.roomId, {} + ); }, onAccountData: function(ev) { if (ev.getType() == 'm.direct') { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) + this._delayedRefreshRoomList(); + } + else if (ev.getType() == 'm.push_rules') { this._delayedRefreshRoomList(); } }, _delayedRefreshRoomList: new rate_limited_func(function() { - this.refreshRoomList(); + // if the mouse has been moving over the RoomList in the last 500ms + // then delay the refresh further to avoid bouncing around under the + // cursor + if (Date.now() - this._lastMouseOverTs > 500 || this._delayedRefreshRoomListLoopCount > 60) { + this.refreshRoomList(); + this._delayedRefreshRoomListLoopCount = 0; + } + else { + this._delayedRefreshRoomListLoopCount++; + this._delayedRefreshRoomList(); + } }, 500), refreshRoomList: function() { @@ -194,26 +322,36 @@ module.exports = React.createClass({ // (!this._lastRefreshRoomListTs ? "-" : (Date.now() - this._lastRefreshRoomListTs)) // ); - // TODO: rather than bluntly regenerating and re-sorting everything - // every time we see any kind of room change from the JS SDK - // we could do incremental updates on our copy of the state - // based on the room which has actually changed. This would stop - // us re-rendering all the sublists every time anything changes anywhere - // in the state of the client. - this.setState(this.getRoomLists()); - this._lastRefreshRoomListTs = Date.now(); + // TODO: ideally we'd calculate this once at start, and then maintain + // any changes to it incrementally, updating the appropriate sublists + // as needed. + // Alternatively we'd do something magical with Immutable.js or similar. + const lists = this.getRoomLists(); + let totalRooms = 0; + for (const l of Object.values(lists)) { + totalRooms += l.length; + } + this.setState({ + lists: this.getRoomLists(), + totalRoomCount: totalRooms, + }); + + // this._lastRefreshRoomListTs = Date.now(); }, getRoomLists: function() { var self = this; - var s = { lists: {} }; + const lists = {}; - s.lists["im.vector.fake.invite"] = []; - s.lists["m.favourite"] = []; - s.lists["im.vector.fake.recent"] = []; - s.lists["im.vector.fake.direct"] = []; - s.lists["m.lowpriority"] = []; - s.lists["im.vector.fake.archived"] = []; + lists["im.vector.fake.invite"] = []; + lists["m.favourite"] = []; + lists["im.vector.fake.recent"] = []; + lists["im.vector.fake.direct"] = []; + lists["m.lowpriority"] = []; + lists["im.vector.fake.archived"] = []; + + this.listsForRoomId = {}; + var otherTagNames = {}; const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); @@ -226,8 +364,13 @@ module.exports = React.createClass({ // ", target = " + me.events.member.getStateKey() + // ", prevMembership = " + me.events.member.getPrevContent().membership); + if (!self.listsForRoomId[room.roomId]) { + self.listsForRoomId[room.roomId] = []; + } + if (me.membership == "invite") { - s.lists["im.vector.fake.invite"].push(room); + self.listsForRoomId[room.roomId].push("im.vector.fake.invite"); + lists["im.vector.fake.invite"].push(room); } else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) { // skip past this room & don't put it in any lists @@ -237,81 +380,62 @@ module.exports = React.createClass({ { // Used to split rooms via tags var tagNames = Object.keys(room.tags); - if (tagNames.length) { for (var i = 0; i < tagNames.length; i++) { var tagName = tagNames[i]; - s.lists[tagName] = s.lists[tagName] || []; - s.lists[tagNames[i]].push(room); + lists[tagName] = lists[tagName] || []; + lists[tagName].push(room); + self.listsForRoomId[room.roomId].push(tagName); + otherTagNames[tagName] = 1; } } else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { // "Direct Message" rooms (that we're still in and that aren't otherwise tagged) - s.lists["im.vector.fake.direct"].push(room); + self.listsForRoomId[room.roomId].push("im.vector.fake.direct"); + lists["im.vector.fake.direct"].push(room); } else { - s.lists["im.vector.fake.recent"].push(room); + self.listsForRoomId[room.roomId].push("im.vector.fake.recent"); + lists["im.vector.fake.recent"].push(room); } } else if (me.membership === "leave") { - s.lists["im.vector.fake.archived"].push(room); + self.listsForRoomId[room.roomId].push("im.vector.fake.archived"); + lists["im.vector.fake.archived"].push(room); } else { console.error("unrecognised membership: " + me.membership + " - this should never happen"); } }); - if (s.lists["im.vector.fake.direct"].length == 0 && - MatrixClientPeg.get().getAccountData('m.direct') === undefined && - !MatrixClientPeg.get().isGuest()) - { - // scan through the 'recents' list for any rooms which look like DM rooms - // and make them DM rooms - const oldRecents = s.lists["im.vector.fake.recent"]; - s.lists["im.vector.fake.recent"] = []; - - for (const room of oldRecents) { - const me = room.getMember(MatrixClientPeg.get().credentials.userId); - - if (me && Rooms.looksLikeDirectMessageRoom(room, me)) { - s.lists["im.vector.fake.direct"].push(room); - } else { - s.lists["im.vector.fake.recent"].push(room); - } - } - - // save these new guessed DM rooms into the account data - const newMDirectEvent = {}; - for (const room of s.lists["im.vector.fake.direct"]) { - const me = room.getMember(MatrixClientPeg.get().credentials.userId); - const otherPerson = Rooms.getOnlyOtherMember(room, me); - if (!otherPerson) continue; - - const roomList = newMDirectEvent[otherPerson.userId] || []; - roomList.push(room.roomId); - newMDirectEvent[otherPerson.userId] = roomList; - } - - // if this fails, fine, we'll just do the same thing next time we get the room lists - MatrixClientPeg.get().setAccountData('m.direct', newMDirectEvent).done(); - } - - //console.log("calculated new roomLists; im.vector.fake.recent = " + s.lists["im.vector.fake.recent"]); - // we actually apply the sorting to this when receiving the prop in RoomSubLists. - return s; + // we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down +/* + this.listOrder = [ + "im.vector.fake.invite", + "m.favourite", + "im.vector.fake.recent", + "im.vector.fake.direct", + Object.keys(otherTagNames).filter(tagName=>{ + return (!tagName.match(/^m\.(favourite|lowpriority)$/)); + }).sort(), + "m.lowpriority", + "im.vector.fake.archived" + ]; +*/ + + return lists; }, _getScrollNode: function() { var panel = ReactDOM.findDOMNode(this); if (!panel) return null; - if (panel.classList.contains('gm-prevented')) { - return panel; - } else { - return panel.children[2]; // XXX: Fragile! - } + // empirically, if we have gm-prevented for some reason, the scroll node + // is still the 3rd child (i.e. the view child). This looks to be due + // to vdh's improved resize updater logic...? + return panel.children[2]; // XXX: Fragile! }, _whenScrolling: function(e) { @@ -331,10 +455,11 @@ module.exports = React.createClass({ var incomingCallBox = document.getElementById("incomingCallBox"); if (incomingCallBox && incomingCallBox.parentElement) { var scrollArea = this._getScrollNode(); + if (!scrollArea) return; // Use the offset of the top of the scroll area from the window // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset; - // Use the offset of the top of the componet from the window + // Use the offset of the top of the component from the window // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; @@ -354,10 +479,11 @@ module.exports = React.createClass({ // properly through React _initAndPositionStickyHeaders: function(initialise, scrollToPosition) { var scrollArea = this._getScrollNode(); + if (!scrollArea) return; // Use the offset of the top of the scroll area from the window // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset; - // Use the offset of the top of the componet from the window + // Use the offset of the top of the component from the window // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; @@ -451,21 +577,74 @@ module.exports = React.createClass({ this.refs.gemscroll.forceUpdate(); }, + _getEmptyContent: function(section) { + const RoomDropTarget = sdk.getComponent('rooms.RoomDropTarget'); + + if (this.props.collapsed) { + return ; + } + + const StartChatButton = sdk.getComponent('elements.StartChatButton'); + const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton'); + const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton'); + if (this.state.totalRoomCount === 0) { + const TintableSvg = sdk.getComponent('elements.TintableSvg'); + switch (section) { + case 'im.vector.fake.direct': + return
    + Press + + to start a chat with someone +
    ; + case 'im.vector.fake.recent': + return
    + You're not in any rooms yet! Press + + to make a room or + + to browse the directory +
    ; + } + } + + const labelText = 'Drop here to ' + (VERBS[section] || 'tag ' + section); + + return ; + }, + + _getHeaderItems: function(section) { + const StartChatButton = sdk.getComponent('elements.StartChatButton'); + const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton'); + const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton'); + switch (section) { + case 'im.vector.fake.direct': + return + + ; + case 'im.vector.fake.recent': + return + + + ; + } + }, + render: function() { var RoomSubList = sdk.getComponent('structures.RoomSubList'); var self = this; return ( -
    + autoshow={true} onScroll={ self._whenScrolling } onResize={ self._whenScrolling } ref="gemscroll"> +
    @@ -473,51 +652,57 @@ module.exports = React.createClass({ - { Object.keys(self.state.lists).map(function(tagName) { + { Object.keys(self.state.lists).sort().map(function(tagName) { if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { return ; @@ -528,22 +713,23 @@ module.exports = React.createClass({ { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to unban: " + err); Modal.createDialog(ErrorDialog, { - title: "Failed to unban", - description: err.message, + title: "Error", + description: "Failed to unban", }); }).done(); }, @@ -128,14 +129,17 @@ module.exports = React.createClass({ console.error("Failed to get room visibility: " + err); }); - this.scalarClient = new ScalarAuthClient(); - this.scalarClient.connect().done(() => { - this.forceUpdate(); - }, (err) => { - this.setState({ - scalar_error: err + this.scalarClient = null; + if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { + this.scalarClient = new ScalarAuthClient(); + this.scalarClient.connect().done(() => { + this.forceUpdate(); + }, (err) => { + this.setState({ + scalar_error: err + }); }); - }); + } dis.dispatch({ action: 'ui_opacity', @@ -489,7 +493,7 @@ module.exports = React.createClass({ ev.preventDefault(); var IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); Modal.createDialog(IntegrationsManager, { - src: this.scalarClient.hasCredentials() ? + src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) : null, onFinished: ()=>{ @@ -764,36 +768,39 @@ module.exports = React.createClass({
    ; } - var integrationsButton; - var integrationsError; - if (this.state.showIntegrationsError && this.state.scalar_error) { - console.error(this.state.scalar_error); - integrationsError = ( - - Could not connect to the integration server - - ); - } + let integrationsButton; + let integrationsError; - if (this.scalarClient.hasCredentials()) { - integrationsButton = ( + if (this.scalarClient !== null) { + if (this.state.showIntegrationsError && this.state.scalar_error) { + console.error(this.state.scalar_error); + integrationsError = ( + + Could not connect to the integration server + + ); + } + + if (this.scalarClient.hasCredentials()) { + integrationsButton = (
    - Manage Integrations -
    - ); - } else if (this.state.scalar_error) { - integrationsButton = ( + Manage Integrations +
    + ); + } else if (this.state.scalar_error) { + integrationsButton = (
    - Integrations Error - { integrationsError } -
    - ); - } else { - integrationsButton = ( -
    - Manage Integrations -
    - ); + Integrations Error + { integrationsError } +
    + ); + } else { + integrationsButton = ( +
    + Manage Integrations +
    + ); + } } return ( diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index f6c0f7034e..3b37d4608f 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -19,7 +19,6 @@ limitations under the License. var React = require('react'); var ReactDOM = require("react-dom"); var classNames = require('classnames'); -var dis = require("../../../dispatcher"); var MatrixClientPeg = require('../../../MatrixClientPeg'); import DMRoomMap from '../../../utils/DMRoomMap'; var sdk = require('../../../index'); @@ -28,6 +27,8 @@ var RoomNotifs = require('../../../RoomNotifs'); var FormattingUtils = require('../../../utils/FormattingUtils'); import AccessibleButton from '../elements/AccessibleButton'; var UserSettingsStore = require('../../../UserSettingsStore'); +var constantTimeDispatcher = require('../../../ConstantTimeDispatcher'); +var Unread = require('../../../Unread'); module.exports = React.createClass({ displayName: 'RoomTile', @@ -35,13 +36,12 @@ module.exports = React.createClass({ propTypes: { connectDragSource: React.PropTypes.func, connectDropTarget: React.PropTypes.func, + onClick: React.PropTypes.func, isDragging: React.PropTypes.bool, + selectedRoom: React.PropTypes.string, room: React.PropTypes.object.isRequired, collapsed: React.PropTypes.bool.isRequired, - selected: React.PropTypes.bool.isRequired, - unread: React.PropTypes.bool.isRequired, - highlight: React.PropTypes.bool.isRequired, isInvite: React.PropTypes.bool.isRequired, incomingCall: React.PropTypes.object, }, @@ -54,11 +54,11 @@ module.exports = React.createClass({ getInitialState: function() { return({ - hover : false, - badgeHover : false, - notificationTagMenu: false, - roomTagMenu: false, + hover: false, + badgeHover: false, + menuDisplayed: false, notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), + selected: this.props.room ? (this.props.selectedRoom === this.props.room.roomId) : false, }); }, @@ -80,32 +80,40 @@ module.exports = React.createClass({ } }, - onAccountData: function(accountDataEvent) { - if (accountDataEvent.getType() == 'm.push_rules') { - this.setState({ - notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), - }); - } - }, - componentWillMount: function() { - MatrixClientPeg.get().on("accountData", this.onAccountData); + constantTimeDispatcher.register("RoomTile.refresh", this.props.room.roomId, this.onRefresh); + constantTimeDispatcher.register("RoomTile.select", this.props.room.roomId, this.onSelect); + this.onRefresh(); }, componentWillUnmount: function() { - var cli = MatrixClientPeg.get(); - if (cli) { - MatrixClientPeg.get().removeListener("accountData", this.onAccountData); - } + constantTimeDispatcher.unregister("RoomTile.refresh", this.props.room.roomId, this.onRefresh); + constantTimeDispatcher.unregister("RoomTile.select", this.props.room.roomId, this.onSelect); }, - onClick: function() { - dis.dispatch({ - action: 'view_room', - room_id: this.props.room.roomId, + componentWillReceiveProps: function(nextProps) { + this.onRefresh(); + }, + + onRefresh: function(params) { + this.setState({ + unread: Unread.doesRoomHaveUnreadMessages(this.props.room), + highlight: this.props.room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite, }); }, + onSelect: function(params) { + this.setState({ + selected: params.selected, + }); + }, + + onClick: function(ev) { + if (this.props.onClick) { + this.props.onClick(this.props.room.roomId, ev); + } + }, + onMouseEnter: function() { this.setState( { hover : true }); this.badgeOnMouseEnter(); @@ -137,62 +145,32 @@ module.exports = React.createClass({ this.setState({ hover: false }); } - var NotificationStateMenu = sdk.getComponent('context_menus.NotificationStateContextMenu'); + var RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu'); var elementRect = e.target.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page - var x = elementRect.right + window.pageXOffset + 3; - var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 53; + const x = elementRect.right + window.pageXOffset + 3; + const chevronOffset = 12; + let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); + y = y - (chevronOffset + 8); // where 8 is half the height of the chevron + var self = this; - ContextualMenu.createMenu(NotificationStateMenu, { - menuWidth: 188, - menuHeight: 126, - chevronOffset: 45, + ContextualMenu.createMenu(RoomTileContextMenu, { + chevronOffset: chevronOffset, left: x, top: y, room: this.props.room, onFinished: function() { - self.setState({ notificationTagMenu: false }); + self.setState({ menuDisplayed: false }); self.props.refreshSubList(); } }); - this.setState({ notificationTagMenu: true }); + this.setState({ menuDisplayed: true }); } // Prevent the RoomTile onClick event firing as well e.stopPropagation(); }, - onAvatarClicked: function(e) { - // Only allow none guests to access the context menu - if (!MatrixClientPeg.get().isGuest() && !this.props.collapsed) { - - // If the badge is clicked, then no longer show tooltip - if (this.props.collapsed) { - this.setState({ hover: false }); - } - - var RoomTagMenu = sdk.getComponent('context_menus.RoomTagContextMenu'); - var elementRect = e.target.getBoundingClientRect(); - // The window X and Y offsets are to adjust position when zoomed in to page - var x = elementRect.right + window.pageXOffset + 3; - var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 19; - var self = this; - ContextualMenu.createMenu(RoomTagMenu, { - chevronOffset: 10, - // XXX: fix horrid hardcoding - menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF", - left: x, - top: y, - room: this.props.room, - onFinished: function() { - self.setState({ roomTagMenu: false }); - } - }); - this.setState({ roomTagMenu: true }); - // Prevent the RoomTile onClick event firing as well - e.stopPropagation(); - } - }, - render: function() { var myUserId = MatrixClientPeg.get().credentials.userId; var me = this.props.room.currentState.members[myUserId]; @@ -201,17 +179,17 @@ module.exports = React.createClass({ // var highlightCount = this.props.room.getUnreadNotificationCount("highlight"); const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(); - const mentionBadges = this.props.highlight && this._shouldShowMentionBadge(); + const mentionBadges = this.state.highlight && this._shouldShowMentionBadge(); const badges = notifBadges || mentionBadges; var classes = classNames({ 'mx_RoomTile': true, - 'mx_RoomTile_selected': this.props.selected, - 'mx_RoomTile_unread': this.props.unread, + 'mx_RoomTile_selected': this.state.selected, + 'mx_RoomTile_unread': this.state.unread, 'mx_RoomTile_unreadNotify': notifBadges, 'mx_RoomTile_highlight': mentionBadges, 'mx_RoomTile_invited': (me && me.membership == 'invite'), - 'mx_RoomTile_notificationTagMenu': this.state.notificationTagMenu, + 'mx_RoomTile_menuDisplayed': this.state.menuDisplayed, 'mx_RoomTile_noBadges': !badges, }); @@ -219,14 +197,9 @@ module.exports = React.createClass({ 'mx_RoomTile_avatar': true, }); - var avatarContainerClasses = classNames({ - 'mx_RoomTile_avatar_container': true, - 'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu, - }); - var badgeClasses = classNames({ 'mx_RoomTile_badge': true, - 'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.notificationTagMenu, + 'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menuDisplayed, }); // XXX: We should never display raw room IDs, but sometimes the @@ -237,7 +210,7 @@ module.exports = React.createClass({ var badge; var badgeContent; - if (this.state.badgeHover || this.state.notificationTagMenu) { + if (this.state.badgeHover || this.state.menuDisplayed) { badgeContent = "\u00B7\u00B7\u00B7"; } else if (badges) { var limitedCount = FormattingUtils.formatCount(notificationCount); @@ -255,10 +228,10 @@ module.exports = React.createClass({ var nameClasses = classNames({ 'mx_RoomTile_name': true, 'mx_RoomTile_invite': this.props.isInvite, - 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.notificationTagMenu, + 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed, }); - if (this.props.selected) { + if (this.state.selected) { let nameSelected = {name}; label =
    { nameSelected }
    ; @@ -292,13 +265,12 @@ module.exports = React.createClass({ let ret = (
    { /* Only native elements can be wrapped in a DnD object. */} - +
    -
    -
    - - {directMessageIndicator} -
    +
    + + {directMessageIndicator}
    diff --git a/src/components/views/rooms/SearchResultTile.js b/src/components/views/rooms/SearchResultTile.js index 7fac244481..1aba7c9196 100644 --- a/src/components/views/rooms/SearchResultTile.js +++ b/src/components/views/rooms/SearchResultTile.js @@ -60,7 +60,7 @@ module.exports = React.createClass({ } } return ( -
  3. +
  4. {ret}
  5. ); }, diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js index 40995d2a72..a6f342af86 100644 --- a/src/components/views/rooms/SimpleRoomHeader.js +++ b/src/components/views/rooms/SimpleRoomHeader.js @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import dis from '../../../dispatcher'; import AccessibleButton from '../elements/AccessibleButton'; +import sdk from '../../../index'; // cancel button which is shared between room header and simple room header export function CancelButton(props) { @@ -45,6 +46,9 @@ export default React.createClass({ // is the RightPanel collapsed? collapsedRhs: React.PropTypes.bool, + + // `src` to a TintableSvg. Optional. + icon: React.PropTypes.string, }, onShowRhsClick: function(ev) { @@ -53,9 +57,17 @@ export default React.createClass({ render: function() { let cancelButton; + let icon; if (this.props.onCancelClick) { cancelButton = ; } + if (this.props.icon) { + const TintableSvg = sdk.getComponent('elements.TintableSvg'); + icon = ; + } let showRhsButton; /* // don't bother cluttering things up with this for now. @@ -73,6 +85,7 @@ export default React.createClass({
    + { icon } { this.props.title } { showRhsButton } { cancelButton } diff --git a/src/components/views/rooms/TopUnreadMessagesBar.js b/src/components/views/rooms/TopUnreadMessagesBar.js index 82149c150b..72b489a406 100644 --- a/src/components/views/rooms/TopUnreadMessagesBar.js +++ b/src/components/views/rooms/TopUnreadMessagesBar.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. @@ -32,10 +33,10 @@ module.exports = React.createClass({
    - Scroll to unread messages - Unread messages. Mark all read + Jump to first unread message.
    { + this._promptForMsisdnVerificationCode(resp.msisdn); + }).catch((err) => { + console.error("Unable to add phone number: " + err); + let msg = err.message; + Modal.createDialog(ErrorDialog, { + title: "Error", + description: msg, + }); + }).finally(() => { + if (this._unmounted) return; + this.setState({msisdn_add_pending: false}); + }).done(); + this._addMsisdnInput.blur(); + this.setState({msisdn_add_pending: true}); + }, + + _promptForMsisdnVerificationCode:function (msisdn, err) { + if (this._unmounted) return; + const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); + let msgElements = [ +
    A text message has been sent to +{msisdn}. + Please enter the verification code it contains
    + ]; + if (err) { + let msg = err.error; + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + msg = "Incorrect verification code"; + } + msgElements.push(
    {msg}
    ); + } + Modal.createDialog(TextInputDialog, { + title: "Enter Code", + description:
    {msgElements}
    , + button: "Submit", + onFinished: (should_verify, token) => { + if (!should_verify) { + this._addThreepid = null; + return; + } + if (this._unmounted) return; + this.setState({msisdn_add_pending: true}); + this._addThreepid.haveMsisdnToken(token).then(() => { + this._addThreepid = null; + this.setState({phoneNumber: ''}); + if (this.props.onThreepidAdded) this.props.onThreepidAdded(); + }).catch((err) => { + this._promptForMsisdnVerificationCode(msisdn, err); + }).finally(() => { + if (this._unmounted) return; + this.setState({msisdn_add_pending: false}); + }).done(); + } + }); + }, + + render: function() { + const Loader = sdk.getComponent("elements.Spinner"); + if (this.state.msisdn_add_pending) { + return ; + } else if (this.props.matrixClient.isGuest()) { + return
    ; + } + + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + // XXX: This CSS relies on the CSS surrounding it in UserSettings as its in + // a tabular format to align the submit buttons + return ( + +
    +
    +
    +
    + + +
    +
    +
    + +
    + + ); + } +})) diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index 8b53a0e779..20ce45e5dd 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -73,11 +73,17 @@ module.exports = React.createClass({ description:
    Changing password will currently reset any end-to-end encryption keys on all devices, - making encrypted chat history unreadable. - This will be improved shortly, - 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) { var authDict = { @@ -105,6 +111,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(), + } + ); + }, + onClickChange: function() { var old_password = this.refs.old_input.value; var new_password = this.refs.new_input.value; diff --git a/src/createRoom.js b/src/createRoom.js index 2a23fb0787..674fe23d28 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -102,9 +102,10 @@ function createRoom(opts) { }); return roomId; }, function(err) { + console.error("Failed to create room " + roomId + " " + err); Modal.createDialog(ErrorDialog, { title: "Failure to create room", - description: err.toString() + description: "Server may be unavailable, overloaded, or you hit a bug.", }); return null; }); diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 68f7a66bda..d9b0b78982 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -122,7 +122,7 @@ var escapeRegExp = function(string) { // anyone else really should be using matrix.to. matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:" + escapeRegExp(window.location.host + window.location.pathname) + "|" - + "(?:www\\.)?vector\\.im/(?:beta|staging|develop)/" + + "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/" + ")(#.*)"; matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)"; diff --git a/src/phonenumber.js b/src/phonenumber.js new file mode 100644 index 0000000000..aaf018ba26 --- /dev/null +++ b/src/phonenumber.js @@ -0,0 +1,1273 @@ +/* +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. +*/ + +const PHONE_NUMBER_REGEXP = /^[0-9 -\.]+$/; + +/* + * Do basic validation to determine if the given input could be + * a valid phone number. + * + * @param {String} phoneNumber The string to validate. This could be + * either an international format number (MSISDN or e.164) or + * a national-format number. + * @return True if the number could be a valid phone number, otherwise false. + */ +export function looksValid(phoneNumber) { + return PHONE_NUMBER_REGEXP.test(phoneNumber); +} + +export const COUNTRIES = [ + { + "iso2": "GB", + "name": "United Kingdom", + "prefix": "44", + }, + { + "iso2": "US", + "name": "United States", + "prefix": "1", + }, + { + "iso2": "AF", + "name": "Afghanistan", + "prefix": "93", + }, + { + "iso2": "AX", + "name": "\u00c5land Islands", + "prefix": "358", + }, + { + "iso2": "AL", + "name": "Albania", + "prefix": "355", + }, + { + "iso2": "DZ", + "name": "Algeria", + "prefix": "213", + }, + { + "iso2": "AS", + "name": "American Samoa", + "prefix": "1", + }, + { + "iso2": "AD", + "name": "Andorra", + "prefix": "376", + }, + { + "iso2": "AO", + "name": "Angola", + "prefix": "244", + }, + { + "iso2": "AI", + "name": "Anguilla", + "prefix": "1", + }, + { + "iso2": "AQ", + "name": "Antarctica", + "prefix": "672", + }, + { + "iso2": "AG", + "name": "Antigua & Barbuda", + "prefix": "1", + }, + { + "iso2": "AR", + "name": "Argentina", + "prefix": "54", + }, + { + "iso2": "AM", + "name": "Armenia", + "prefix": "374", + }, + { + "iso2": "AW", + "name": "Aruba", + "prefix": "297", + }, + { + "iso2": "AU", + "name": "Australia", + "prefix": "61", + }, + { + "iso2": "AT", + "name": "Austria", + "prefix": "43", + }, + { + "iso2": "AZ", + "name": "Azerbaijan", + "prefix": "994", + }, + { + "iso2": "BS", + "name": "Bahamas", + "prefix": "1", + }, + { + "iso2": "BH", + "name": "Bahrain", + "prefix": "973", + }, + { + "iso2": "BD", + "name": "Bangladesh", + "prefix": "880", + }, + { + "iso2": "BB", + "name": "Barbados", + "prefix": "1", + }, + { + "iso2": "BY", + "name": "Belarus", + "prefix": "375", + }, + { + "iso2": "BE", + "name": "Belgium", + "prefix": "32", + }, + { + "iso2": "BZ", + "name": "Belize", + "prefix": "501", + }, + { + "iso2": "BJ", + "name": "Benin", + "prefix": "229", + }, + { + "iso2": "BM", + "name": "Bermuda", + "prefix": "1", + }, + { + "iso2": "BT", + "name": "Bhutan", + "prefix": "975", + }, + { + "iso2": "BO", + "name": "Bolivia", + "prefix": "591", + }, + { + "iso2": "BA", + "name": "Bosnia", + "prefix": "387", + }, + { + "iso2": "BW", + "name": "Botswana", + "prefix": "267", + }, + { + "iso2": "BV", + "name": "Bouvet Island", + "prefix": "47", + }, + { + "iso2": "BR", + "name": "Brazil", + "prefix": "55", + }, + { + "iso2": "IO", + "name": "British Indian Ocean Territory", + "prefix": "246", + }, + { + "iso2": "VG", + "name": "British Virgin Islands", + "prefix": "1", + }, + { + "iso2": "BN", + "name": "Brunei", + "prefix": "673", + }, + { + "iso2": "BG", + "name": "Bulgaria", + "prefix": "359", + }, + { + "iso2": "BF", + "name": "Burkina Faso", + "prefix": "226", + }, + { + "iso2": "BI", + "name": "Burundi", + "prefix": "257", + }, + { + "iso2": "KH", + "name": "Cambodia", + "prefix": "855", + }, + { + "iso2": "CM", + "name": "Cameroon", + "prefix": "237", + }, + { + "iso2": "CA", + "name": "Canada", + "prefix": "1", + }, + { + "iso2": "CV", + "name": "Cape Verde", + "prefix": "238", + }, + { + "iso2": "BQ", + "name": "Caribbean Netherlands", + "prefix": "599", + }, + { + "iso2": "KY", + "name": "Cayman Islands", + "prefix": "1", + }, + { + "iso2": "CF", + "name": "Central African Republic", + "prefix": "236", + }, + { + "iso2": "TD", + "name": "Chad", + "prefix": "235", + }, + { + "iso2": "CL", + "name": "Chile", + "prefix": "56", + }, + { + "iso2": "CN", + "name": "China", + "prefix": "86", + }, + { + "iso2": "CX", + "name": "Christmas Island", + "prefix": "61", + }, + { + "iso2": "CC", + "name": "Cocos (Keeling) Islands", + "prefix": "61", + }, + { + "iso2": "CO", + "name": "Colombia", + "prefix": "57", + }, + { + "iso2": "KM", + "name": "Comoros", + "prefix": "269", + }, + { + "iso2": "CG", + "name": "Congo - Brazzaville", + "prefix": "242", + }, + { + "iso2": "CD", + "name": "Congo - Kinshasa", + "prefix": "243", + }, + { + "iso2": "CK", + "name": "Cook Islands", + "prefix": "682", + }, + { + "iso2": "CR", + "name": "Costa Rica", + "prefix": "506", + }, + { + "iso2": "HR", + "name": "Croatia", + "prefix": "385", + }, + { + "iso2": "CU", + "name": "Cuba", + "prefix": "53", + }, + { + "iso2": "CW", + "name": "Cura\u00e7ao", + "prefix": "599", + }, + { + "iso2": "CY", + "name": "Cyprus", + "prefix": "357", + }, + { + "iso2": "CZ", + "name": "Czech Republic", + "prefix": "420", + }, + { + "iso2": "CI", + "name": "C\u00f4te d\u2019Ivoire", + "prefix": "225", + }, + { + "iso2": "DK", + "name": "Denmark", + "prefix": "45", + }, + { + "iso2": "DJ", + "name": "Djibouti", + "prefix": "253", + }, + { + "iso2": "DM", + "name": "Dominica", + "prefix": "1", + }, + { + "iso2": "DO", + "name": "Dominican Republic", + "prefix": "1", + }, + { + "iso2": "EC", + "name": "Ecuador", + "prefix": "593", + }, + { + "iso2": "EG", + "name": "Egypt", + "prefix": "20", + }, + { + "iso2": "SV", + "name": "El Salvador", + "prefix": "503", + }, + { + "iso2": "GQ", + "name": "Equatorial Guinea", + "prefix": "240", + }, + { + "iso2": "ER", + "name": "Eritrea", + "prefix": "291", + }, + { + "iso2": "EE", + "name": "Estonia", + "prefix": "372", + }, + { + "iso2": "ET", + "name": "Ethiopia", + "prefix": "251", + }, + { + "iso2": "FK", + "name": "Falkland Islands", + "prefix": "500", + }, + { + "iso2": "FO", + "name": "Faroe Islands", + "prefix": "298", + }, + { + "iso2": "FJ", + "name": "Fiji", + "prefix": "679", + }, + { + "iso2": "FI", + "name": "Finland", + "prefix": "358", + }, + { + "iso2": "FR", + "name": "France", + "prefix": "33", + }, + { + "iso2": "GF", + "name": "French Guiana", + "prefix": "594", + }, + { + "iso2": "PF", + "name": "French Polynesia", + "prefix": "689", + }, + { + "iso2": "TF", + "name": "French Southern Territories", + "prefix": "262", + }, + { + "iso2": "GA", + "name": "Gabon", + "prefix": "241", + }, + { + "iso2": "GM", + "name": "Gambia", + "prefix": "220", + }, + { + "iso2": "GE", + "name": "Georgia", + "prefix": "995", + }, + { + "iso2": "DE", + "name": "Germany", + "prefix": "49", + }, + { + "iso2": "GH", + "name": "Ghana", + "prefix": "233", + }, + { + "iso2": "GI", + "name": "Gibraltar", + "prefix": "350", + }, + { + "iso2": "GR", + "name": "Greece", + "prefix": "30", + }, + { + "iso2": "GL", + "name": "Greenland", + "prefix": "299", + }, + { + "iso2": "GD", + "name": "Grenada", + "prefix": "1", + }, + { + "iso2": "GP", + "name": "Guadeloupe", + "prefix": "590", + }, + { + "iso2": "GU", + "name": "Guam", + "prefix": "1", + }, + { + "iso2": "GT", + "name": "Guatemala", + "prefix": "502", + }, + { + "iso2": "GG", + "name": "Guernsey", + "prefix": "44", + }, + { + "iso2": "GN", + "name": "Guinea", + "prefix": "224", + }, + { + "iso2": "GW", + "name": "Guinea-Bissau", + "prefix": "245", + }, + { + "iso2": "GY", + "name": "Guyana", + "prefix": "592", + }, + { + "iso2": "HT", + "name": "Haiti", + "prefix": "509", + }, + { + "iso2": "HM", + "name": "Heard & McDonald Islands", + "prefix": "672", + }, + { + "iso2": "HN", + "name": "Honduras", + "prefix": "504", + }, + { + "iso2": "HK", + "name": "Hong Kong", + "prefix": "852", + }, + { + "iso2": "HU", + "name": "Hungary", + "prefix": "36", + }, + { + "iso2": "IS", + "name": "Iceland", + "prefix": "354", + }, + { + "iso2": "IN", + "name": "India", + "prefix": "91", + }, + { + "iso2": "ID", + "name": "Indonesia", + "prefix": "62", + }, + { + "iso2": "IR", + "name": "Iran", + "prefix": "98", + }, + { + "iso2": "IQ", + "name": "Iraq", + "prefix": "964", + }, + { + "iso2": "IE", + "name": "Ireland", + "prefix": "353", + }, + { + "iso2": "IM", + "name": "Isle of Man", + "prefix": "44", + }, + { + "iso2": "IL", + "name": "Israel", + "prefix": "972", + }, + { + "iso2": "IT", + "name": "Italy", + "prefix": "39", + }, + { + "iso2": "JM", + "name": "Jamaica", + "prefix": "1", + }, + { + "iso2": "JP", + "name": "Japan", + "prefix": "81", + }, + { + "iso2": "JE", + "name": "Jersey", + "prefix": "44", + }, + { + "iso2": "JO", + "name": "Jordan", + "prefix": "962", + }, + { + "iso2": "KZ", + "name": "Kazakhstan", + "prefix": "7", + }, + { + "iso2": "KE", + "name": "Kenya", + "prefix": "254", + }, + { + "iso2": "KI", + "name": "Kiribati", + "prefix": "686", + }, + { + "iso2": "KW", + "name": "Kuwait", + "prefix": "965", + }, + { + "iso2": "KG", + "name": "Kyrgyzstan", + "prefix": "996", + }, + { + "iso2": "LA", + "name": "Laos", + "prefix": "856", + }, + { + "iso2": "LV", + "name": "Latvia", + "prefix": "371", + }, + { + "iso2": "LB", + "name": "Lebanon", + "prefix": "961", + }, + { + "iso2": "LS", + "name": "Lesotho", + "prefix": "266", + }, + { + "iso2": "LR", + "name": "Liberia", + "prefix": "231", + }, + { + "iso2": "LY", + "name": "Libya", + "prefix": "218", + }, + { + "iso2": "LI", + "name": "Liechtenstein", + "prefix": "423", + }, + { + "iso2": "LT", + "name": "Lithuania", + "prefix": "370", + }, + { + "iso2": "LU", + "name": "Luxembourg", + "prefix": "352", + }, + { + "iso2": "MO", + "name": "Macau", + "prefix": "853", + }, + { + "iso2": "MK", + "name": "Macedonia", + "prefix": "389", + }, + { + "iso2": "MG", + "name": "Madagascar", + "prefix": "261", + }, + { + "iso2": "MW", + "name": "Malawi", + "prefix": "265", + }, + { + "iso2": "MY", + "name": "Malaysia", + "prefix": "60", + }, + { + "iso2": "MV", + "name": "Maldives", + "prefix": "960", + }, + { + "iso2": "ML", + "name": "Mali", + "prefix": "223", + }, + { + "iso2": "MT", + "name": "Malta", + "prefix": "356", + }, + { + "iso2": "MH", + "name": "Marshall Islands", + "prefix": "692", + }, + { + "iso2": "MQ", + "name": "Martinique", + "prefix": "596", + }, + { + "iso2": "MR", + "name": "Mauritania", + "prefix": "222", + }, + { + "iso2": "MU", + "name": "Mauritius", + "prefix": "230", + }, + { + "iso2": "YT", + "name": "Mayotte", + "prefix": "262", + }, + { + "iso2": "MX", + "name": "Mexico", + "prefix": "52", + }, + { + "iso2": "FM", + "name": "Micronesia", + "prefix": "691", + }, + { + "iso2": "MD", + "name": "Moldova", + "prefix": "373", + }, + { + "iso2": "MC", + "name": "Monaco", + "prefix": "377", + }, + { + "iso2": "MN", + "name": "Mongolia", + "prefix": "976", + }, + { + "iso2": "ME", + "name": "Montenegro", + "prefix": "382", + }, + { + "iso2": "MS", + "name": "Montserrat", + "prefix": "1", + }, + { + "iso2": "MA", + "name": "Morocco", + "prefix": "212", + }, + { + "iso2": "MZ", + "name": "Mozambique", + "prefix": "258", + }, + { + "iso2": "MM", + "name": "Myanmar", + "prefix": "95", + }, + { + "iso2": "NA", + "name": "Namibia", + "prefix": "264", + }, + { + "iso2": "NR", + "name": "Nauru", + "prefix": "674", + }, + { + "iso2": "NP", + "name": "Nepal", + "prefix": "977", + }, + { + "iso2": "NL", + "name": "Netherlands", + "prefix": "31", + }, + { + "iso2": "NC", + "name": "New Caledonia", + "prefix": "687", + }, + { + "iso2": "NZ", + "name": "New Zealand", + "prefix": "64", + }, + { + "iso2": "NI", + "name": "Nicaragua", + "prefix": "505", + }, + { + "iso2": "NE", + "name": "Niger", + "prefix": "227", + }, + { + "iso2": "NG", + "name": "Nigeria", + "prefix": "234", + }, + { + "iso2": "NU", + "name": "Niue", + "prefix": "683", + }, + { + "iso2": "NF", + "name": "Norfolk Island", + "prefix": "672", + }, + { + "iso2": "KP", + "name": "North Korea", + "prefix": "850", + }, + { + "iso2": "MP", + "name": "Northern Mariana Islands", + "prefix": "1", + }, + { + "iso2": "NO", + "name": "Norway", + "prefix": "47", + }, + { + "iso2": "OM", + "name": "Oman", + "prefix": "968", + }, + { + "iso2": "PK", + "name": "Pakistan", + "prefix": "92", + }, + { + "iso2": "PW", + "name": "Palau", + "prefix": "680", + }, + { + "iso2": "PS", + "name": "Palestine", + "prefix": "970", + }, + { + "iso2": "PA", + "name": "Panama", + "prefix": "507", + }, + { + "iso2": "PG", + "name": "Papua New Guinea", + "prefix": "675", + }, + { + "iso2": "PY", + "name": "Paraguay", + "prefix": "595", + }, + { + "iso2": "PE", + "name": "Peru", + "prefix": "51", + }, + { + "iso2": "PH", + "name": "Philippines", + "prefix": "63", + }, + { + "iso2": "PN", + "name": "Pitcairn Islands", + "prefix": "870", + }, + { + "iso2": "PL", + "name": "Poland", + "prefix": "48", + }, + { + "iso2": "PT", + "name": "Portugal", + "prefix": "351", + }, + { + "iso2": "PR", + "name": "Puerto Rico", + "prefix": "1", + }, + { + "iso2": "QA", + "name": "Qatar", + "prefix": "974", + }, + { + "iso2": "RO", + "name": "Romania", + "prefix": "40", + }, + { + "iso2": "RU", + "name": "Russia", + "prefix": "7", + }, + { + "iso2": "RW", + "name": "Rwanda", + "prefix": "250", + }, + { + "iso2": "RE", + "name": "R\u00e9union", + "prefix": "262", + }, + { + "iso2": "WS", + "name": "Samoa", + "prefix": "685", + }, + { + "iso2": "SM", + "name": "San Marino", + "prefix": "378", + }, + { + "iso2": "SA", + "name": "Saudi Arabia", + "prefix": "966", + }, + { + "iso2": "SN", + "name": "Senegal", + "prefix": "221", + }, + { + "iso2": "RS", + "name": "Serbia", + "prefix": "381 p", + }, + { + "iso2": "SC", + "name": "Seychelles", + "prefix": "248", + }, + { + "iso2": "SL", + "name": "Sierra Leone", + "prefix": "232", + }, + { + "iso2": "SG", + "name": "Singapore", + "prefix": "65", + }, + { + "iso2": "SX", + "name": "Sint Maarten", + "prefix": "1", + }, + { + "iso2": "SK", + "name": "Slovakia", + "prefix": "421", + }, + { + "iso2": "SI", + "name": "Slovenia", + "prefix": "386", + }, + { + "iso2": "SB", + "name": "Solomon Islands", + "prefix": "677", + }, + { + "iso2": "SO", + "name": "Somalia", + "prefix": "252", + }, + { + "iso2": "ZA", + "name": "South Africa", + "prefix": "27", + }, + { + "iso2": "GS", + "name": "South Georgia & South Sandwich Islands", + "prefix": "500", + }, + { + "iso2": "KR", + "name": "South Korea", + "prefix": "82", + }, + { + "iso2": "SS", + "name": "South Sudan", + "prefix": "211", + }, + { + "iso2": "ES", + "name": "Spain", + "prefix": "34", + }, + { + "iso2": "LK", + "name": "Sri Lanka", + "prefix": "94", + }, + { + "iso2": "BL", + "name": "St. Barth\u00e9lemy", + "prefix": "590", + }, + { + "iso2": "SH", + "name": "St. Helena", + "prefix": "290 n", + }, + { + "iso2": "KN", + "name": "St. Kitts & Nevis", + "prefix": "1", + }, + { + "iso2": "LC", + "name": "St. Lucia", + "prefix": "1", + }, + { + "iso2": "MF", + "name": "St. Martin", + "prefix": "590", + }, + { + "iso2": "PM", + "name": "St. Pierre & Miquelon", + "prefix": "508", + }, + { + "iso2": "VC", + "name": "St. Vincent & Grenadines", + "prefix": "1", + }, + { + "iso2": "SD", + "name": "Sudan", + "prefix": "249", + }, + { + "iso2": "SR", + "name": "Suriname", + "prefix": "597", + }, + { + "iso2": "SJ", + "name": "Svalbard & Jan Mayen", + "prefix": "47", + }, + { + "iso2": "SZ", + "name": "Swaziland", + "prefix": "268", + }, + { + "iso2": "SE", + "name": "Sweden", + "prefix": "46", + }, + { + "iso2": "CH", + "name": "Switzerland", + "prefix": "41", + }, + { + "iso2": "SY", + "name": "Syria", + "prefix": "963", + }, + { + "iso2": "ST", + "name": "S\u00e3o Tom\u00e9 & Pr\u00edncipe", + "prefix": "239", + }, + { + "iso2": "TW", + "name": "Taiwan", + "prefix": "886", + }, + { + "iso2": "TJ", + "name": "Tajikistan", + "prefix": "992", + }, + { + "iso2": "TZ", + "name": "Tanzania", + "prefix": "255", + }, + { + "iso2": "TH", + "name": "Thailand", + "prefix": "66", + }, + { + "iso2": "TL", + "name": "Timor-Leste", + "prefix": "670", + }, + { + "iso2": "TG", + "name": "Togo", + "prefix": "228", + }, + { + "iso2": "TK", + "name": "Tokelau", + "prefix": "690", + }, + { + "iso2": "TO", + "name": "Tonga", + "prefix": "676", + }, + { + "iso2": "TT", + "name": "Trinidad & Tobago", + "prefix": "1", + }, + { + "iso2": "TN", + "name": "Tunisia", + "prefix": "216", + }, + { + "iso2": "TR", + "name": "Turkey", + "prefix": "90", + }, + { + "iso2": "TM", + "name": "Turkmenistan", + "prefix": "993", + }, + { + "iso2": "TC", + "name": "Turks & Caicos Islands", + "prefix": "1", + }, + { + "iso2": "TV", + "name": "Tuvalu", + "prefix": "688", + }, + { + "iso2": "VI", + "name": "U.S. Virgin Islands", + "prefix": "1", + }, + { + "iso2": "UG", + "name": "Uganda", + "prefix": "256", + }, + { + "iso2": "UA", + "name": "Ukraine", + "prefix": "380", + }, + { + "iso2": "AE", + "name": "United Arab Emirates", + "prefix": "971", + }, + { + "iso2": "UY", + "name": "Uruguay", + "prefix": "598", + }, + { + "iso2": "UZ", + "name": "Uzbekistan", + "prefix": "998", + }, + { + "iso2": "VU", + "name": "Vanuatu", + "prefix": "678", + }, + { + "iso2": "VA", + "name": "Vatican City", + "prefix": "39", + }, + { + "iso2": "VE", + "name": "Venezuela", + "prefix": "58", + }, + { + "iso2": "VN", + "name": "Vietnam", + "prefix": "84", + }, + { + "iso2": "WF", + "name": "Wallis & Futuna", + "prefix": "681", + }, + { + "iso2": "EH", + "name": "Western Sahara", + "prefix": "212", + }, + { + "iso2": "YE", + "name": "Yemen", + "prefix": "967", + }, + { + "iso2": "ZM", + "name": "Zambia", + "prefix": "260", + }, + { + "iso2": "ZW", + "name": "Zimbabwe", + "prefix": "263", + }, +]; diff --git a/test/components/structures/ScrollPanel-test.js b/test/components/structures/ScrollPanel-test.js index eacaeb5fb4..7ecb74be6f 100644 --- a/test/components/structures/ScrollPanel-test.js +++ b/test/components/structures/ScrollPanel-test.js @@ -115,7 +115,7 @@ var Tester = React.createClass({ // // there is an extra 50 pixels of margin at the bottom. return ( -
  6. +
  7. {key} diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index da8fc17001..b8a8e49769 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -68,48 +68,49 @@ describe('InteractiveAuthDialog', function () { onFinished={onFinished} />, parentDiv); - // at this point there should be a password box and a submit button - const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form"); - const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag( - dlg, "input" - ); - let passwordNode; - let submitNode; - for (const node of inputNodes) { - if (node.type == 'password') { - passwordNode = node; - } else if (node.type == 'submit') { - submitNode = node; + // wait for a password box and a submit button + test_utils.waitForRenderedDOMComponentWithTag(dlg, "form").then((formNode) => { + const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag( + dlg, "input" + ); + let passwordNode; + let submitNode; + for (const node of inputNodes) { + if (node.type == 'password') { + passwordNode = node; + } else if (node.type == 'submit') { + submitNode = node; + } } - } - expect(passwordNode).toExist(); - expect(submitNode).toExist(); + expect(passwordNode).toExist(); + expect(submitNode).toExist(); - // submit should be disabled - expect(submitNode.disabled).toBe(true); + // submit should be disabled + expect(submitNode.disabled).toBe(true); - // put something in the password box, and hit enter; that should - // trigger a request - passwordNode.value = "s3kr3t"; - ReactTestUtils.Simulate.change(passwordNode); - expect(submitNode.disabled).toBe(false); - ReactTestUtils.Simulate.submit(formNode, {}); + // put something in the password box, and hit enter; that should + // trigger a request + passwordNode.value = "s3kr3t"; + ReactTestUtils.Simulate.change(passwordNode); + expect(submitNode.disabled).toBe(false); + ReactTestUtils.Simulate.submit(formNode, {}); - expect(doRequest.callCount).toEqual(1); - expect(doRequest.calledWithExactly({ - session: "sess", - type: "m.login.password", - password: "s3kr3t", - user: "@user:id", - })).toBe(true); + expect(doRequest.callCount).toEqual(1); + expect(doRequest.calledWithExactly({ + session: "sess", + type: "m.login.password", + password: "s3kr3t", + user: "@user:id", + })).toBe(true); - // there should now be a spinner - ReactTestUtils.findRenderedComponentWithType( - dlg, sdk.getComponent('elements.Spinner'), - ); + // there should now be a spinner + ReactTestUtils.findRenderedComponentWithType( + dlg, sdk.getComponent('elements.Spinner'), + ); - // let the request complete - q.delay(1).then(() => { + // let the request complete + return q.delay(1); + }).then(() => { expect(onFinished.callCount).toEqual(1); expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); }).done(done, done); diff --git a/test/test-utils.js b/test/test-utils.js index aca91ad399..5209465362 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,11 +1,51 @@ "use strict"; -var sinon = require('sinon'); -var q = require('q'); +import sinon from 'sinon'; +import q from 'q'; +import ReactTestUtils from 'react-addons-test-utils'; -var peg = require('../src/MatrixClientPeg.js'); -var jssdk = require('matrix-js-sdk'); -var MatrixEvent = jssdk.MatrixEvent; +import peg from '../src/MatrixClientPeg.js'; +import jssdk from 'matrix-js-sdk'; +const MatrixEvent = jssdk.MatrixEvent; + +/** + * Wrapper around window.requestAnimationFrame that returns a promise + * @private + */ +function _waitForFrame() { + const def = q.defer(); + window.requestAnimationFrame(() => { + def.resolve(); + }); + return def.promise; +} + +/** + * Waits a small number of animation frames for a component to appear + * in the DOM. Like findRenderedDOMComponentWithTag(), but allows + * for the element to appear a short time later, eg. if a promise needs + * to resolve first. + * @return a promise that resolves once the component appears, or rejects + * if it doesn't appear after a nominal number of animation frames. + */ +export function waitForRenderedDOMComponentWithTag(tree, tag, attempts) { + if (attempts === undefined) { + // Let's start by assuming we'll only need to wait a single frame, and + // we can try increasing this if necessary. + attempts = 1; + } else if (attempts == 0) { + return q.reject("Gave up waiting for component with tag: " + tag); + } + + return _waitForFrame().then(() => { + const result = ReactTestUtils.scryRenderedDOMComponentsWithTag(tree, tag); + if (result.length > 0) { + return result[0]; + } else { + return waitForRenderedDOMComponentWithTag(tree, tag, attempts - 1); + } + }); +} /** * Perform common actions before each test case, e.g. printing the test case