diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf5..0000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.babelrc b/.babelrc index 8c7b66269d..6ba0e0dae0 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,4 @@ { "presets": ["react", "es2015", "es2016"], - "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-generator", "transform-runtime", "add-module-exports"] + "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-bluebird", "transform-runtime", "add-module-exports"] } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..880331a09e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# Copyright 2017 Aviral Dasgupta +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +root = true + +[*] +charset=utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..c4c7fe5067 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +src/component-index.js diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles new file mode 100644 index 0000000000..f501f373cd --- /dev/null +++ b/.eslintignore.errorfiles @@ -0,0 +1,109 @@ +# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. + +src/autocomplete/AutocompleteProvider.js +src/autocomplete/Autocompleter.js +src/autocomplete/EmojiProvider.js +src/autocomplete/UserProvider.js +src/CallHandler.js +src/component-index.js +src/components/structures/ContextualMenu.js +src/components/structures/CreateRoom.js +src/components/structures/LoggedInView.js +src/components/structures/login/ForgotPassword.js +src/components/structures/login/Login.js +src/components/structures/login/Registration.js +src/components/structures/MessagePanel.js +src/components/structures/NotificationPanel.js +src/components/structures/RoomStatusBar.js +src/components/structures/RoomView.js +src/components/structures/ScrollPanel.js +src/components/structures/TimelinePanel.js +src/components/structures/UploadBar.js +src/components/views/avatars/BaseAvatar.js +src/components/views/avatars/MemberAvatar.js +src/components/views/create_room/RoomAlias.js +src/components/views/dialogs/ChatCreateOrReuseDialog.js +src/components/views/dialogs/DeactivateAccountDialog.js +src/components/views/dialogs/UnknownDeviceDialog.js +src/components/views/elements/AddressSelector.js +src/components/views/elements/DeviceVerifyButtons.js +src/components/views/elements/DirectorySearchBox.js +src/components/views/elements/EditableText.js +src/components/views/elements/MemberEventListSummary.js +src/components/views/elements/TintableSvg.js +src/components/views/elements/UserSelector.js +src/components/views/login/CountryDropdown.js +src/components/views/login/InteractiveAuthEntryComponents.js +src/components/views/login/PasswordLogin.js +src/components/views/login/RegistrationForm.js +src/components/views/login/ServerConfig.js +src/components/views/messages/MFileBody.js +src/components/views/messages/MImageBody.js +src/components/views/messages/RoomAvatarEvent.js +src/components/views/messages/TextualBody.js +src/components/views/room_settings/AliasSettings.js +src/components/views/room_settings/ColorSettings.js +src/components/views/room_settings/UrlPreviewSettings.js +src/components/views/rooms/Autocomplete.js +src/components/views/rooms/AuxPanel.js +src/components/views/rooms/EntityTile.js +src/components/views/rooms/EventTile.js +src/components/views/rooms/LinkPreviewWidget.js +src/components/views/rooms/MemberDeviceInfo.js +src/components/views/rooms/MemberInfo.js +src/components/views/rooms/MemberList.js +src/components/views/rooms/MemberTile.js +src/components/views/rooms/MessageComposer.js +src/components/views/rooms/MessageComposerInput.js +src/components/views/rooms/ReadReceiptMarker.js +src/components/views/rooms/RoomList.js +src/components/views/rooms/RoomPreviewBar.js +src/components/views/rooms/RoomSettings.js +src/components/views/rooms/RoomTile.js +src/components/views/rooms/SearchableEntityList.js +src/components/views/rooms/SearchResultTile.js +src/components/views/rooms/TopUnreadMessagesBar.js +src/components/views/rooms/UserTile.js +src/components/views/settings/AddPhoneNumber.js +src/components/views/settings/ChangeAvatar.js +src/components/views/settings/ChangeDisplayName.js +src/components/views/settings/ChangePassword.js +src/components/views/settings/DevicesPanel.js +src/ContentMessages.js +src/HtmlUtils.js +src/ImageUtils.js +src/languageHandler.js +src/linkify-matrix.js +src/Login.js +src/Markdown.js +src/MatrixClientPeg.js +src/Modal.js +src/Notifier.js +src/PlatformPeg.js +src/Presence.js +src/ratelimitedfunc.js +src/RichText.js +src/Roles.js +src/Rooms.js +src/ScalarAuthClient.js +src/UiEffects.js +src/Unread.js +src/utils/DecryptFile.js +src/utils/DMRoomMap.js +src/utils/FormattingUtils.js +src/utils/MultiInviter.js +src/utils/Receipt.js +src/Velociraptor.js +src/VelocityBounce.js +src/WhoIsTyping.js +src/wrappers/withMatrixClient.js +test/components/structures/login/Registration-test.js +test/components/structures/MessagePanel-test.js +test/components/structures/ScrollPanel-test.js +test/components/structures/TimelinePanel-test.js +test/components/views/dialogs/InteractiveAuthDialog-test.js +test/components/views/elements/MemberEventListSummary-test.js +test/components/views/login/RegistrationForm-test.js +test/components/views/rooms/MessageComposerInput-test.js +test/mock-clock.js +test/stores/RoomViewStore-test.js diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index e2baaed5a6..0000000000 --- a/.eslintrc +++ /dev/null @@ -1,117 +0,0 @@ -{ - "parser": "babel-eslint", - "plugins": [ - "react", - "flowtype" - ], - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true, - "impliedStrict": true - } - }, - "env": { - "browser": true, - "amd": true, - "es6": true, - "node": true, - "mocha": true - }, - "extends": ["eslint:recommended", "plugin:react/recommended"], - "rules": { - "no-undef": ["warn"], - "global-strict": ["off"], - "no-extra-semi": ["warn"], - "no-underscore-dangle": ["off"], - "no-console": ["off"], - "no-unused-vars": ["off"], - "no-trailing-spaces": ["warn", { - "skipBlankLines": true - }], - "no-unreachable": ["warn"], - "no-spaced-func": ["warn"], - "no-new-func": ["error"], - "no-new-wrappers": ["error"], - "no-invalid-regexp": ["error"], - "no-extra-bind": ["error"], - "no-magic-numbers": ["error", { - "ignore": [-1, 0, 1], // usually used in array/string indexing - "ignoreArrayIndexes": true, - "enforceConst": true, - "detectObjects": true - }], - "consistent-return": ["error"], - "valid-jsdoc": ["error"], - "no-use-before-define": ["error"], - "camelcase": ["warn"], - "array-callback-return": ["error"], - "dot-location": ["warn", "property"], - "guard-for-in": ["error"], - "no-useless-call": ["warn"], - "no-useless-escape": ["warn"], - "no-useless-concat": ["warn"], - "brace-style": ["warn", "1tbs"], - "comma-style": ["warn", "last"], - "space-before-function-paren": ["warn", "never"], - "space-before-blocks": ["warn", "always"], - "keyword-spacing": ["warn", { - "before": true, - "after": true - }], - - // dangling commas required, but only for multiline objects/arrays - "comma-dangle": ["warn", "always-multiline"], - // always === instead of ==, unless dealing with null/undefined - "eqeqeq": ["error", "smart"], - // always use curly braces, even with single statements - "curly": ["error", "all"], - // phasing out var in favour of let/const is a good idea - "no-var": ["warn"], - // always require semicolons - "semi": ["error", "always"], - // prefer rest and spread over the Old Ways - "prefer-spread": ["warn"], - "prefer-rest-params": ["warn"], - - /** react **/ - - // bind or arrow function in props causes performance issues - "react/jsx-no-bind": ["error", { - "ignoreRefs": true - }], - "react/jsx-key": ["error"], - "react/prefer-stateless-function": ["warn"], - - /** flowtype **/ - "flowtype/require-parameter-type": [ - 1, - { - "excludeArrowFunctions": true - } - ], - "flowtype/define-flow-type": 1, - "flowtype/require-return-type": [ - 1, - "always", - { - "annotateUndefined": "never", - "excludeArrowFunctions": true - } - ], - "flowtype/space-after-type-colon": [ - 1, - "always" - ], - "flowtype/space-before-type-colon": [ - 1, - "never" - ] - }, - "settings": { - "flowtype": { - "onlyFilesWithFlowAnnotation": true - } - } -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..c6aeb0d1be --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,102 @@ +const path = require('path'); + +// get the path of the js-sdk so we can extend the config +// eslint supports loading extended configs by module, +// but only if they come from a module that starts with eslint-config- +// So we load the filename directly (and it could be in node_modules/ +// or or ../node_modules/ etc) +const matrixJsSdkPath = path.dirname(require.resolve('matrix-js-sdk')); + +module.exports = { + parser: "babel-eslint", + extends: [matrixJsSdkPath + "/.eslintrc.js"], + plugins: [ + "react", + "flowtype", + "babel" + ], + env: { + es6: true, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + } + }, + rules: { + // eslint's built in no-invalid-this rule breaks with class properties + "no-invalid-this": "off", + // so we replace it with a version that is class property aware + "babel/no-invalid-this": "error", + + // We appear to follow this most of the time, so let's enforce it instead + // of occasionally following it (or catching it in review) + "keyword-spacing": "error", + + /** react **/ + // This just uses the react plugin to help eslint known when + // variables have been used in JSX + "react/jsx-uses-vars": "error", + // Don't mark React as unused if we're using JSX + "react/jsx-uses-react": "error", + + // bind or arrow function in props causes performance issues + "react/jsx-no-bind": ["error", { + "ignoreRefs": true, + }], + "react/jsx-key": ["error"], + + // Assert no spacing in JSX curly brackets + // + // + // https://github.com/yannickcr/eslint-plugin-react/blob/HEAD/docs/rules/jsx-curly-spacing.md + "react/jsx-curly-spacing": ["error", {"when": "never", "children": {"when": "always"}}], + + // Assert spacing before self-closing JSX tags, and no spacing before or + // after the closing slash, and no spacing after the opening bracket of + // the opening tag or closing tag. + // + // https://github.com/yannickcr/eslint-plugin-react/blob/HEAD/docs/rules/jsx-tag-spacing.md + "react/jsx-tag-spacing": ["error"], + + /** flowtype **/ + "flowtype/require-parameter-type": ["warn", { + "excludeArrowFunctions": true, + }], + "flowtype/define-flow-type": "warn", + "flowtype/require-return-type": ["warn", + "always", + { + "annotateUndefined": "never", + "excludeArrowFunctions": true, + } + ], + "flowtype/space-after-type-colon": ["warn", "always"], + "flowtype/space-before-type-colon": ["warn", "never"], + + /* + * things that are errors in the js-sdk config that the current + * code does not adhere to, turned down to warn + */ + "max-len": ["warn", { + // apparently people believe the length limit shouldn't apply + // to JSX. + ignorePattern: '^\\s*<', + ignoreComments: true, + code: 120, + }], + "valid-jsdoc": ["warn"], + "new-cap": ["warn"], + "key-spacing": ["warn"], + "arrow-parens": ["warn"], + "prefer-const": ["warn"], + + // crashes currently: https://github.com/eslint/eslint/issues/6274 + "generator-star-spacing": "off", + }, + settings: { + flowtype: { + onlyFilesWithFlowAnnotation: true + }, + }, +}; diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..81770c6585 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,6 @@ +[include] +src/**/*.js +test/**/*.js + +[ignore] +node_modules/ diff --git a/.gitignore b/.gitignore index 5139d614ad..f828c37393 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ npm-debug.log # test reports created by karma /karma-reports + +/.idea +/src/component-index.js + +.DS_Store diff --git a/.travis-test-riot.sh b/.travis-test-riot.sh new file mode 100755 index 0000000000..87200871a5 --- /dev/null +++ b/.travis-test-riot.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# +# script which is run by the travis build (after `npm run test`). +# +# clones riot-web develop and runs the tests against our version of react-sdk. + +set -ev + +RIOT_WEB_DIR=riot-web +REACT_SDK_DIR=`pwd` + +curbranch="${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH}" +echo "Determined branch to be $curbranch" + +git clone https://github.com/vector-im/riot-web.git \ + "$RIOT_WEB_DIR" + +cd "$RIOT_WEB_DIR" + +git checkout "$curbranch" || git checkout develop + +mkdir node_modules +npm install + +# use the version of js-sdk we just used in the react-sdk tests +rm -r node_modules/matrix-js-sdk +ln -s "$REACT_SDK_DIR/node_modules/matrix-js-sdk" node_modules/matrix-js-sdk + +# ... and, of course, the version of react-sdk we just built +rm -r node_modules/matrix-react-sdk +ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk + +npm run test diff --git a/.travis.yml b/.travis.yml index 9d6a114391..954f14a4da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,20 @@ +# we need trusty for the chrome addon +dist: trusty + +# we don't need sudo, so can run in a container, which makes startup much +# quicker. +# +# unfortunately we do temporarily require sudo as a workaround for +# https://github.com/travis-ci/travis-ci/issues/8836 +sudo: required + language: node_js node_js: - node # Latest stable version of nodejs. +addons: + chrome: stable +install: + - npm install + - (cd node_modules/matrix-js-sdk && npm install) +script: + ./scripts/travis.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f7f62b05..87459882c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,1741 @@ +Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3) + + * Bump js-sdk version to pull in fix for [setting room publicity in a group](https://github.com/matrix-org/matrix-js-sdk/commit/aa3201ebb0fff5af2fb733080aa65ed1f7213de6). + +Changes in [0.11.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.2) (2017-11-28) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.1...v0.11.2) + + * Ignore unrecognised login flows + [\#1633](https://github.com/matrix-org/matrix-react-sdk/pull/1633) + +Changes in [0.11.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.1) (2017-11-17) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0...v0.11.1) + + * Fix the force TURN option + [\#1621](https://github.com/matrix-org/matrix-react-sdk/pull/1621) + +Changes in [0.11.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0) (2017-11-15) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.3...v0.11.0) + + +Changes in [0.11.0-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.3) (2017-11-14) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.2...v0.11.0-rc.3) + + +Changes in [0.11.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.2) (2017-11-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.1...v0.11.0-rc.2) + + * Make groups a fully-fleged baked-in feature + [\#1603](https://github.com/matrix-org/matrix-react-sdk/pull/1603) + +Changes in [0.11.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.1) (2017-11-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7...v0.11.0-rc.1) + + * Improve widget rendering on prop updates + [\#1548](https://github.com/matrix-org/matrix-react-sdk/pull/1548) + * Display group member profile (avatar/displayname) in ConfirmUserActionDialog + [\#1595](https://github.com/matrix-org/matrix-react-sdk/pull/1595) + * Don't crash if there isn't a room notif rule + [\#1602](https://github.com/matrix-org/matrix-react-sdk/pull/1602) + * Show group name in flair tooltip if one is set + [\#1596](https://github.com/matrix-org/matrix-react-sdk/pull/1596) + * Convert group avatar URL to HTTP before handing to BaseAvatar + [\#1597](https://github.com/matrix-org/matrix-react-sdk/pull/1597) + * Add group features as starting points for ILAG + [\#1601](https://github.com/matrix-org/matrix-react-sdk/pull/1601) + * Modify the group room visibility API to reflect the js-sdk changes + [\#1598](https://github.com/matrix-org/matrix-react-sdk/pull/1598) + * Update from Weblate. + [\#1599](https://github.com/matrix-org/matrix-react-sdk/pull/1599) + * Revert "UnknownDeviceDialog: get devices from SDK" + [\#1594](https://github.com/matrix-org/matrix-react-sdk/pull/1594) + * Order users in the group member list with admins first + [\#1591](https://github.com/matrix-org/matrix-react-sdk/pull/1591) + * Fetch group members after accepting an invite + [\#1592](https://github.com/matrix-org/matrix-react-sdk/pull/1592) + * Improve address picker for rooms + [\#1589](https://github.com/matrix-org/matrix-react-sdk/pull/1589) + * Fix FlairStore getPublicisedGroupsCached to give the correct, existing + promise + [\#1590](https://github.com/matrix-org/matrix-react-sdk/pull/1590) + * Use the getProfileInfo API for group inviter profile + [\#1585](https://github.com/matrix-org/matrix-react-sdk/pull/1585) + * Add checkbox to GroupAddressPicker for determining visibility of group rooms + [\#1587](https://github.com/matrix-org/matrix-react-sdk/pull/1587) + * Alter group member api + [\#1581](https://github.com/matrix-org/matrix-react-sdk/pull/1581) + * Improve group creation UX + [\#1580](https://github.com/matrix-org/matrix-react-sdk/pull/1580) + * Disable RoomDetailList in GroupView when editing + [\#1583](https://github.com/matrix-org/matrix-react-sdk/pull/1583) + * Default to no read pins if there is no applicable account data + [\#1586](https://github.com/matrix-org/matrix-react-sdk/pull/1586) + * UnknownDeviceDialog: get devices from SDK + [\#1584](https://github.com/matrix-org/matrix-react-sdk/pull/1584) + * Add a small indicator for when a new event is pinned + [\#1486](https://github.com/matrix-org/matrix-react-sdk/pull/1486) + * Implement tooltip for group rooms + [\#1582](https://github.com/matrix-org/matrix-react-sdk/pull/1582) + * Room notifs in autocomplete & composer + [\#1577](https://github.com/matrix-org/matrix-react-sdk/pull/1577) + * Ignore img tags in HTML if src is not specified + [\#1579](https://github.com/matrix-org/matrix-react-sdk/pull/1579) + * Indicate admins in the group member list with a sheriff badge + [\#1578](https://github.com/matrix-org/matrix-react-sdk/pull/1578) + * Remember whether widget drawer was hidden per-room + [\#1533](https://github.com/matrix-org/matrix-react-sdk/pull/1533) + * Throw an error when trying to create a group store with falsey groupId + [\#1576](https://github.com/matrix-org/matrix-react-sdk/pull/1576) + * Fixes React warning + [\#1571](https://github.com/matrix-org/matrix-react-sdk/pull/1571) + * Fix Flair not appearing due to missing this._usersInFlight + [\#1575](https://github.com/matrix-org/matrix-react-sdk/pull/1575) + * Use, if possible, a room's canonical or first alias when viewing the … + [\#1574](https://github.com/matrix-org/matrix-react-sdk/pull/1574) + * Add CSS classes to group ID input in CreateGroupDialog + [\#1573](https://github.com/matrix-org/matrix-react-sdk/pull/1573) + * Give autocomplete providers the room they're in + [\#1568](https://github.com/matrix-org/matrix-react-sdk/pull/1568) + * Fix multiple pills on one line + [\#1572](https://github.com/matrix-org/matrix-react-sdk/pull/1572) + * Fix group invites such that they look similar to room invites + [\#1570](https://github.com/matrix-org/matrix-react-sdk/pull/1570) + * Add a GeminiScrollbar to Your Communities + [\#1569](https://github.com/matrix-org/matrix-react-sdk/pull/1569) + * Fix multiple requests for publicised groups of given user + [\#1567](https://github.com/matrix-org/matrix-react-sdk/pull/1567) + * Add toggle to alter visibility of a room-group association + [\#1566](https://github.com/matrix-org/matrix-react-sdk/pull/1566) + * Pillify room notifs in the timeline + [\#1564](https://github.com/matrix-org/matrix-react-sdk/pull/1564) + * Implement simple GroupRoomInfo + [\#1563](https://github.com/matrix-org/matrix-react-sdk/pull/1563) + * turn NPE on flair resolution errors into a logged error + [\#1565](https://github.com/matrix-org/matrix-react-sdk/pull/1565) + * Less translation in parts + [\#1484](https://github.com/matrix-org/matrix-react-sdk/pull/1484) + * Redact group IDs from analytics + [\#1562](https://github.com/matrix-org/matrix-react-sdk/pull/1562) + * Display whether the group summary/room list is loading + [\#1560](https://github.com/matrix-org/matrix-react-sdk/pull/1560) + * Change client-side validation of group IDs to match synapse + [\#1558](https://github.com/matrix-org/matrix-react-sdk/pull/1558) + * Prevent non-members from opening group settings + [\#1559](https://github.com/matrix-org/matrix-react-sdk/pull/1559) + * Alter UI for disinviting a group member + [\#1556](https://github.com/matrix-org/matrix-react-sdk/pull/1556) + * Only show admin tools to privileged users + [\#1555](https://github.com/matrix-org/matrix-react-sdk/pull/1555) + * Try lowercase username on login + [\#1550](https://github.com/matrix-org/matrix-react-sdk/pull/1550) + * Don't refresh page on password change prompt + [\#1554](https://github.com/matrix-org/matrix-react-sdk/pull/1554) + * Fix initial in GroupAvatar in GroupView + [\#1553](https://github.com/matrix-org/matrix-react-sdk/pull/1553) + * Use "crop" method to scale group avatars in MyGroups + [\#1549](https://github.com/matrix-org/matrix-react-sdk/pull/1549) + * Lowercase all usernames + [\#1547](https://github.com/matrix-org/matrix-react-sdk/pull/1547) + * Add sensible missing entry generator for MELS tests + [\#1546](https://github.com/matrix-org/matrix-react-sdk/pull/1546) + * Fix prompt to re-use chat room + [\#1545](https://github.com/matrix-org/matrix-react-sdk/pull/1545) + * Add unregiseterListener to GroupStore + [\#1544](https://github.com/matrix-org/matrix-react-sdk/pull/1544) + * Fix groups invited users err for non members + [\#1543](https://github.com/matrix-org/matrix-react-sdk/pull/1543) + * Add Mention button to MemberInfo + [\#1532](https://github.com/matrix-org/matrix-react-sdk/pull/1532) + * Only show group settings cog to members + [\#1541](https://github.com/matrix-org/matrix-react-sdk/pull/1541) + * Use correct icon for group room deletion and make themeable + [\#1540](https://github.com/matrix-org/matrix-react-sdk/pull/1540) + * Add invite button to MemberInfo if user has left or wasn't in room + [\#1534](https://github.com/matrix-org/matrix-react-sdk/pull/1534) + * Add option to mirror local video feed + [\#1539](https://github.com/matrix-org/matrix-react-sdk/pull/1539) + * Use the correct userId when displaying who redacted a message + [\#1538](https://github.com/matrix-org/matrix-react-sdk/pull/1538) + * Only show editing UI for aliases/related_groups for users /w power + [\#1529](https://github.com/matrix-org/matrix-react-sdk/pull/1529) + * Swap from `ui_opacity` to `panel_disabled` + [\#1535](https://github.com/matrix-org/matrix-react-sdk/pull/1535) + * Fix room address picker tiles default name + [\#1536](https://github.com/matrix-org/matrix-react-sdk/pull/1536) + * T3chguy/hide level change on 50 + [\#1531](https://github.com/matrix-org/matrix-react-sdk/pull/1531) + * fix missing date sep caused by hidden event at start of day + [\#1537](https://github.com/matrix-org/matrix-react-sdk/pull/1537) + * Add a delete confirmation dialog for widgets + [\#1520](https://github.com/matrix-org/matrix-react-sdk/pull/1520) + * When dispatching view_[my_]group[s], reset RoomViewStore + [\#1530](https://github.com/matrix-org/matrix-react-sdk/pull/1530) + * Prevent editing of UI requiring user privilege if user unprivileged + [\#1528](https://github.com/matrix-org/matrix-react-sdk/pull/1528) + * Use the correct property of the API room objects + [\#1526](https://github.com/matrix-org/matrix-react-sdk/pull/1526) + * Don't include the |other in the translation value + [\#1527](https://github.com/matrix-org/matrix-react-sdk/pull/1527) + * Re-run gen-i18n after fixing https://github.com/matrix-org/matrix-react- + sdk/pull/1521 + [\#1525](https://github.com/matrix-org/matrix-react-sdk/pull/1525) + * Fix some react warnings in GroupMemberList + [\#1522](https://github.com/matrix-org/matrix-react-sdk/pull/1522) + * Fix bug with gen-i18n/js when adding new plurals + [\#1521](https://github.com/matrix-org/matrix-react-sdk/pull/1521) + * Make GroupStoreCache global for cross-package access + [\#1524](https://github.com/matrix-org/matrix-react-sdk/pull/1524) + * Add fields needed by RoomDetailList to groupRoomFromApiObject + [\#1523](https://github.com/matrix-org/matrix-react-sdk/pull/1523) + * Only show flair for groups with avatars set + [\#1519](https://github.com/matrix-org/matrix-react-sdk/pull/1519) + * Refresh group member lists after inviting users + [\#1518](https://github.com/matrix-org/matrix-react-sdk/pull/1518) + * Invalidate the user's public groups cache when changing group publicity + [\#1517](https://github.com/matrix-org/matrix-react-sdk/pull/1517) + * Make the gen-i18n script validate _t calls + [\#1515](https://github.com/matrix-org/matrix-react-sdk/pull/1515) + * Add placeholder to MyGroups page, adjust CSS classes + [\#1514](https://github.com/matrix-org/matrix-react-sdk/pull/1514) + * Rxl881/parallelshell + [\#1338](https://github.com/matrix-org/matrix-react-sdk/pull/1338) + * Run prunei18n + [\#1513](https://github.com/matrix-org/matrix-react-sdk/pull/1513) + * Update from Weblate. + [\#1512](https://github.com/matrix-org/matrix-react-sdk/pull/1512) + * Add script to prune unused translations + [\#1502](https://github.com/matrix-org/matrix-react-sdk/pull/1502) + * Fix creation of DM rooms + [\#1510](https://github.com/matrix-org/matrix-react-sdk/pull/1510) + * Group create dialog: only enter localpart + [\#1507](https://github.com/matrix-org/matrix-react-sdk/pull/1507) + * Improve MyGroups UI + [\#1509](https://github.com/matrix-org/matrix-react-sdk/pull/1509) + * Use object URLs to load Files in to images + [\#1508](https://github.com/matrix-org/matrix-react-sdk/pull/1508) + * Add clientside error for non-alphanumeric group ID + [\#1506](https://github.com/matrix-org/matrix-react-sdk/pull/1506) + * Fix invites to groups without names + [\#1505](https://github.com/matrix-org/matrix-react-sdk/pull/1505) + * Add warning when adding group rooms/users + [\#1504](https://github.com/matrix-org/matrix-react-sdk/pull/1504) + * More Groups->Communities + [\#1503](https://github.com/matrix-org/matrix-react-sdk/pull/1503) + * Groups -> Communities + [\#1501](https://github.com/matrix-org/matrix-react-sdk/pull/1501) + * Factor out Flair cache into FlairStore + [\#1500](https://github.com/matrix-org/matrix-react-sdk/pull/1500) + * Add i18n script to package.json + [\#1499](https://github.com/matrix-org/matrix-react-sdk/pull/1499) + * Make gen-i18n support 'HTML' + [\#1498](https://github.com/matrix-org/matrix-react-sdk/pull/1498) + * fix editing visuals on groupview header + [\#1497](https://github.com/matrix-org/matrix-react-sdk/pull/1497) + * Script to generate the translations base file + [\#1493](https://github.com/matrix-org/matrix-react-sdk/pull/1493) + * Update from Weblate. + [\#1495](https://github.com/matrix-org/matrix-react-sdk/pull/1495) + * Attempt to relate a group to a room when adding it + [\#1494](https://github.com/matrix-org/matrix-react-sdk/pull/1494) + * Shuffle GroupView UI + [\#1490](https://github.com/matrix-org/matrix-react-sdk/pull/1490) + * Fix bug preventing partial group profile + [\#1491](https://github.com/matrix-org/matrix-react-sdk/pull/1491) + * Don't show room IDs when picking rooms + [\#1492](https://github.com/matrix-org/matrix-react-sdk/pull/1492) + * Only show invited section if there are invited group members + [\#1489](https://github.com/matrix-org/matrix-react-sdk/pull/1489) + * Show "Invited" section in the user list + [\#1488](https://github.com/matrix-org/matrix-react-sdk/pull/1488) + * Refactor class names for an entity tile being hovered over + [\#1487](https://github.com/matrix-org/matrix-react-sdk/pull/1487) + * Modify GroupView UI + [\#1475](https://github.com/matrix-org/matrix-react-sdk/pull/1475) + * Message/event pinning + [\#1439](https://github.com/matrix-org/matrix-react-sdk/pull/1439) + * Remove duplicate declaration that breaks the build + [\#1483](https://github.com/matrix-org/matrix-react-sdk/pull/1483) + * Include magnet scheme in sanitize HTML params + [\#1301](https://github.com/matrix-org/matrix-react-sdk/pull/1301) + * Add a way to jump to a user's Read Receipt from MemberInfo + [\#1454](https://github.com/matrix-org/matrix-react-sdk/pull/1454) + * Use standard subsitution syntax in _tJsx + [\#1462](https://github.com/matrix-org/matrix-react-sdk/pull/1462) + * Don't suggest grey as a color scheme for a room + [\#1442](https://github.com/matrix-org/matrix-react-sdk/pull/1442) + * allow hiding of notification body for privacy reasons + [\#1362](https://github.com/matrix-org/matrix-react-sdk/pull/1362) + * Suggest to invite people when speaking in an empty room + [\#1466](https://github.com/matrix-org/matrix-react-sdk/pull/1466) + * Buttons to remove room/self avatar + [\#1478](https://github.com/matrix-org/matrix-react-sdk/pull/1478) + * T3chguy/fix memberlist + [\#1480](https://github.com/matrix-org/matrix-react-sdk/pull/1480) + * add option to disable BigEmoji + [\#1481](https://github.com/matrix-org/matrix-react-sdk/pull/1481) + +Changes in [0.10.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7) (2017-10-16) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.3...v0.10.7) + + * Update to latest js-sdk + +Changes in [0.10.7-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.3) (2017-10-13) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.2...v0.10.7-rc.3) + + * Fix the enableLabs flag, again + [\#1474](https://github.com/matrix-org/matrix-react-sdk/pull/1474) + +Changes in [0.10.7-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.2) (2017-10-13) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.1...v0.10.7-rc.2) + + * Honour the (now legacy) enableLabs flag + [\#1473](https://github.com/matrix-org/matrix-react-sdk/pull/1473) + * Don't show labs features by default + [\#1472](https://github.com/matrix-org/matrix-react-sdk/pull/1472) + * Make features disabled by default + [\#1470](https://github.com/matrix-org/matrix-react-sdk/pull/1470) + +Changes in [0.10.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.1) (2017-10-13) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.6...v0.10.7-rc.1) + + * Add warm fuzzy dialog for inviting users to a group + [\#1459](https://github.com/matrix-org/matrix-react-sdk/pull/1459) + * enable/disable features in config.json + [\#1468](https://github.com/matrix-org/matrix-react-sdk/pull/1468) + * Update from Weblate. + [\#1469](https://github.com/matrix-org/matrix-react-sdk/pull/1469) + * Don't send RR or RM when peeking at a room + [\#1463](https://github.com/matrix-org/matrix-react-sdk/pull/1463) + * Fix bug that inserted emoji when typing + [\#1467](https://github.com/matrix-org/matrix-react-sdk/pull/1467) + * Ignore VS16 char in RTE + [\#1458](https://github.com/matrix-org/matrix-react-sdk/pull/1458) + * Show failures when sending messages + [\#1460](https://github.com/matrix-org/matrix-react-sdk/pull/1460) + * Run eslint --fix + [\#1461](https://github.com/matrix-org/matrix-react-sdk/pull/1461) + * Show who banned the user on hover + [\#1441](https://github.com/matrix-org/matrix-react-sdk/pull/1441) + * Enhancements to room power level settings + [\#1440](https://github.com/matrix-org/matrix-react-sdk/pull/1440) + * Added TextInputWithCheckbox dialog + [\#868](https://github.com/matrix-org/matrix-react-sdk/pull/868) + * Make it clearer which HS you're logging into + [\#1456](https://github.com/matrix-org/matrix-react-sdk/pull/1456) + * Remove redundant stale onKeyDown + [\#1451](https://github.com/matrix-org/matrix-react-sdk/pull/1451) + * Only allow event state event handlers on state events + [\#1453](https://github.com/matrix-org/matrix-react-sdk/pull/1453) + * Modify the group store to include group rooms + [\#1452](https://github.com/matrix-org/matrix-react-sdk/pull/1452) + * Factor-out GroupStore and create GroupStoreCache + [\#1449](https://github.com/matrix-org/matrix-react-sdk/pull/1449) + * Put related groups UI behind groups labs flag + [\#1448](https://github.com/matrix-org/matrix-react-sdk/pull/1448) + * Restrict Flair in the timeline to related groups of the room + [\#1447](https://github.com/matrix-org/matrix-react-sdk/pull/1447) + * Implement UI for editing related groups of a room + [\#1446](https://github.com/matrix-org/matrix-react-sdk/pull/1446) + * Fix a couple of bugs with EditableItemList + [\#1445](https://github.com/matrix-org/matrix-react-sdk/pull/1445) + * Factor out EditableItemList from AliasSettings + [\#1444](https://github.com/matrix-org/matrix-react-sdk/pull/1444) + * Add dummy translation function to mark translatable strings + [\#1421](https://github.com/matrix-org/matrix-react-sdk/pull/1421) + * Implement button to remove a room from a group + [\#1438](https://github.com/matrix-org/matrix-react-sdk/pull/1438) + * Fix showing 3pid invites in member list + [\#1443](https://github.com/matrix-org/matrix-react-sdk/pull/1443) + * Add button to get to MyGroups (view_my_groups or path #/groups) + [\#1435](https://github.com/matrix-org/matrix-react-sdk/pull/1435) + * Add eslint rule to disallow spaces inside of curly braces + [\#1436](https://github.com/matrix-org/matrix-react-sdk/pull/1436) + * Fix ability to invite existing mx users + [\#1437](https://github.com/matrix-org/matrix-react-sdk/pull/1437) + * Construct address picker message using provided `validAddressTypes` + [\#1434](https://github.com/matrix-org/matrix-react-sdk/pull/1434) + * Fix GroupView summary rooms displaying without avatars + [\#1433](https://github.com/matrix-org/matrix-react-sdk/pull/1433) + * Implement adding rooms to a group (or group summary) by room ID + [\#1432](https://github.com/matrix-org/matrix-react-sdk/pull/1432) + * Give flair avatars a tooltip = the group ID + [\#1431](https://github.com/matrix-org/matrix-react-sdk/pull/1431) + * Fix ability to feature self in a group summary + [\#1430](https://github.com/matrix-org/matrix-react-sdk/pull/1430) + * Implement "Add room to group" feature + [\#1429](https://github.com/matrix-org/matrix-react-sdk/pull/1429) + * Fix group membership publicity + [\#1428](https://github.com/matrix-org/matrix-react-sdk/pull/1428) + * Add support for Jitsi screensharing in electron app + [\#1355](https://github.com/matrix-org/matrix-react-sdk/pull/1355) + * Delint and DRY TextForEvent + [\#1424](https://github.com/matrix-org/matrix-react-sdk/pull/1424) + * Bust the flair caches after 30mins + [\#1427](https://github.com/matrix-org/matrix-react-sdk/pull/1427) + * Show displayname / avatar in group member info + [\#1426](https://github.com/matrix-org/matrix-react-sdk/pull/1426) + * Create GroupSummaryStore for storing group summary stuff + [\#1418](https://github.com/matrix-org/matrix-react-sdk/pull/1418) + * Add status & toggle for publicity + [\#1419](https://github.com/matrix-org/matrix-react-sdk/pull/1419) + * MemberList: show 100 more on overflow tile click + [\#1417](https://github.com/matrix-org/matrix-react-sdk/pull/1417) + * Fix NPE in MemberList + [\#1425](https://github.com/matrix-org/matrix-react-sdk/pull/1425) + * Fix incorrect variable in string + [\#1422](https://github.com/matrix-org/matrix-react-sdk/pull/1422) + * apply i18n _t to string which has already been translated + [\#1420](https://github.com/matrix-org/matrix-react-sdk/pull/1420) + * Make the invite section a truncatedlist too + [\#1416](https://github.com/matrix-org/matrix-react-sdk/pull/1416) + * Implement removal function of features users/rooms + [\#1415](https://github.com/matrix-org/matrix-react-sdk/pull/1415) + * Allow TruncatedList to get children via a callback + [\#1412](https://github.com/matrix-org/matrix-react-sdk/pull/1412) + * Experimental: Lazy load user autocomplete entries + [\#1413](https://github.com/matrix-org/matrix-react-sdk/pull/1413) + * Show displayname & avatar url in group member list + [\#1414](https://github.com/matrix-org/matrix-react-sdk/pull/1414) + * De-lint TruncatedList + [\#1411](https://github.com/matrix-org/matrix-react-sdk/pull/1411) + * Remove unneeded strings + [\#1409](https://github.com/matrix-org/matrix-react-sdk/pull/1409) + * Clean on prerelease + [\#1410](https://github.com/matrix-org/matrix-react-sdk/pull/1410) + * Redesign membership section in GroupView + [\#1408](https://github.com/matrix-org/matrix-react-sdk/pull/1408) + * Implement adding rooms to the group summary + [\#1406](https://github.com/matrix-org/matrix-react-sdk/pull/1406) + * Honour the is_privileged flag in GroupView + [\#1407](https://github.com/matrix-org/matrix-react-sdk/pull/1407) + * Update when a group arrives + [\#1405](https://github.com/matrix-org/matrix-react-sdk/pull/1405) + * Implement `view_group` dispatch when clicking flair + [\#1404](https://github.com/matrix-org/matrix-react-sdk/pull/1404) + * GroupView: Add a User + [\#1402](https://github.com/matrix-org/matrix-react-sdk/pull/1402) + * Track action button click event + [\#1403](https://github.com/matrix-org/matrix-react-sdk/pull/1403) + * Separate sender profile into elements with classes + [\#1401](https://github.com/matrix-org/matrix-react-sdk/pull/1401) + * Fix ugly integration button, use hover to show error + [\#1399](https://github.com/matrix-org/matrix-react-sdk/pull/1399) + * Fix promise error in flair + [\#1400](https://github.com/matrix-org/matrix-react-sdk/pull/1400) + * Flair! + [\#1351](https://github.com/matrix-org/matrix-react-sdk/pull/1351) + * Group Membership UI + [\#1328](https://github.com/matrix-org/matrix-react-sdk/pull/1328) + +Changes in [0.10.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.6) (2017-09-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.5...v0.10.6) + + * New version of js-sdk with fixed build + +Changes in [0.10.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.5) (2017-09-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.4...v0.10.5) + + * Fix build error (https://github.com/vector-im/riot-web/issues/5091) + +Changes in [0.10.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.4) (2017-09-20) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.4-rc.1...v0.10.4) + + * No changes + +Changes in [0.10.4-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.4-rc.1) (2017-09-19) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3...v0.10.4-rc.1) + + * Fix RoomView stuck in 'accept invite' state + [\#1396](https://github.com/matrix-org/matrix-react-sdk/pull/1396) + * Only show the integ management button if user is joined + [\#1398](https://github.com/matrix-org/matrix-react-sdk/pull/1398) + * suppressOnHover for member entity tiles which have no onClick + [\#1273](https://github.com/matrix-org/matrix-react-sdk/pull/1273) + * add /devtools command + [\#1268](https://github.com/matrix-org/matrix-react-sdk/pull/1268) + * Fix broken Link + [\#1359](https://github.com/matrix-org/matrix-react-sdk/pull/1359) + * Show who redacted an event on hover + [\#1387](https://github.com/matrix-org/matrix-react-sdk/pull/1387) + * start MELS expanded if it contains a highlighted/permalinked event. + [\#1388](https://github.com/matrix-org/matrix-react-sdk/pull/1388) + * Add ignore user API support + [\#1389](https://github.com/matrix-org/matrix-react-sdk/pull/1389) + * Add option to disable Emoji suggestions + [\#1392](https://github.com/matrix-org/matrix-react-sdk/pull/1392) + * sanitize the i18n for fn:textForHistoryVisibilityEvent + [\#1397](https://github.com/matrix-org/matrix-react-sdk/pull/1397) + * Don't check for only-emoji if there were none + [\#1394](https://github.com/matrix-org/matrix-react-sdk/pull/1394) + * Fix emojification of symbol characters + [\#1393](https://github.com/matrix-org/matrix-react-sdk/pull/1393) + * Update from Weblate. + [\#1395](https://github.com/matrix-org/matrix-react-sdk/pull/1395) + * Make /join join again + [\#1391](https://github.com/matrix-org/matrix-react-sdk/pull/1391) + * Display spinner not room preview after room create + [\#1390](https://github.com/matrix-org/matrix-react-sdk/pull/1390) + * Fix the avatar / room name in room preview + [\#1384](https://github.com/matrix-org/matrix-react-sdk/pull/1384) + * Remove spurious cancel button + [\#1381](https://github.com/matrix-org/matrix-react-sdk/pull/1381) + * Fix starting a chat by email address + [\#1386](https://github.com/matrix-org/matrix-react-sdk/pull/1386) + * respond on copy code block + [\#1363](https://github.com/matrix-org/matrix-react-sdk/pull/1363) + * fix DateUtils inconsistency with 12/24h + [\#1383](https://github.com/matrix-org/matrix-react-sdk/pull/1383) + * allow sending sub,sup and whitelist them on receive + [\#1382](https://github.com/matrix-org/matrix-react-sdk/pull/1382) + * Update roomlist when an event is decrypted + [\#1380](https://github.com/matrix-org/matrix-react-sdk/pull/1380) + * Update from Weblate. + [\#1379](https://github.com/matrix-org/matrix-react-sdk/pull/1379) + * fix radio for theme selection + [\#1368](https://github.com/matrix-org/matrix-react-sdk/pull/1368) + * fix some more zh_Hans - remove entirely broken lines + [\#1378](https://github.com/matrix-org/matrix-react-sdk/pull/1378) + * fix placeholder causing app to break when using zh + [\#1377](https://github.com/matrix-org/matrix-react-sdk/pull/1377) + * Avoid re-rendering RoomList on room switch + [\#1375](https://github.com/matrix-org/matrix-react-sdk/pull/1375) + * Fix 'Failed to load timeline position' regression + [\#1376](https://github.com/matrix-org/matrix-react-sdk/pull/1376) + * Fast path for emojifying strings + [\#1372](https://github.com/matrix-org/matrix-react-sdk/pull/1372) + * Consolidate the code copy button + [\#1374](https://github.com/matrix-org/matrix-react-sdk/pull/1374) + * Only add the code copy button for HTML messages + [\#1373](https://github.com/matrix-org/matrix-react-sdk/pull/1373) + * Don't re-render matrixchat unnecessarily + [\#1371](https://github.com/matrix-org/matrix-react-sdk/pull/1371) + * Don't wait for setState to run onHaveRoom + [\#1370](https://github.com/matrix-org/matrix-react-sdk/pull/1370) + * Introduce a RoomScrollStateStore + [\#1367](https://github.com/matrix-org/matrix-react-sdk/pull/1367) + * Don't always paginate when mounting a ScrollPanel + [\#1369](https://github.com/matrix-org/matrix-react-sdk/pull/1369) + * Remove unused scrollStateMap from LoggedinView + [\#1366](https://github.com/matrix-org/matrix-react-sdk/pull/1366) + * Revert "Implement sticky date separators" + [\#1365](https://github.com/matrix-org/matrix-react-sdk/pull/1365) + * Remove unused string "changing room on a RoomView is not supported" + [\#1361](https://github.com/matrix-org/matrix-react-sdk/pull/1361) + * Remove unused translation code translations + [\#1360](https://github.com/matrix-org/matrix-react-sdk/pull/1360) + * Implement sticky date separators + [\#1353](https://github.com/matrix-org/matrix-react-sdk/pull/1353) + +Changes in [0.10.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3) (2017-09-06) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.2...v0.10.3) + + * No changes + +Changes in [0.10.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.2) (2017-09-05) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.1...v0.10.3-rc.2) + + * Fix plurals in translations + [\#1358](https://github.com/matrix-org/matrix-react-sdk/pull/1358) + * Fix typo + [\#1357](https://github.com/matrix-org/matrix-react-sdk/pull/1357) + * Update from Weblate. + [\#1356](https://github.com/matrix-org/matrix-react-sdk/pull/1356) + +Changes in [0.10.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.1) (2017-09-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.2...v0.10.3-rc.1) + + * Fix room change sometimes being very slow + [\#1354](https://github.com/matrix-org/matrix-react-sdk/pull/1354) + * apply shouldHideEvent fn to onRoomTimeline for RoomStatusBar + [\#1346](https://github.com/matrix-org/matrix-react-sdk/pull/1346) + * text4event widget modified, used to show widget added each time. + [\#1345](https://github.com/matrix-org/matrix-react-sdk/pull/1345) + * separate concepts of showing and managing RRs to fix regression + [\#1352](https://github.com/matrix-org/matrix-react-sdk/pull/1352) + * Make staging widgets work with live and vice versa. + [\#1350](https://github.com/matrix-org/matrix-react-sdk/pull/1350) + * Avoid breaking /sync with uncaught exceptions + [\#1349](https://github.com/matrix-org/matrix-react-sdk/pull/1349) + * we need to pass whether it is an invite RoomSubList explicitly (i18n) + [\#1343](https://github.com/matrix-org/matrix-react-sdk/pull/1343) + * Percent encoding isn't a valid thing within _t + [\#1348](https://github.com/matrix-org/matrix-react-sdk/pull/1348) + * Fix spurious notifications + [\#1339](https://github.com/matrix-org/matrix-react-sdk/pull/1339) + * Unbreak password reset with a non-default HS + [\#1347](https://github.com/matrix-org/matrix-react-sdk/pull/1347) + * Remove unnecessary 'load' on notif audio element + [\#1341](https://github.com/matrix-org/matrix-react-sdk/pull/1341) + * _tJsx returns a React Object, the sub fn must return a React Object + [\#1340](https://github.com/matrix-org/matrix-react-sdk/pull/1340) + * Fix deprecation warning about promise.defer() + [\#1292](https://github.com/matrix-org/matrix-react-sdk/pull/1292) + * Fix click to insert completion + [\#1331](https://github.com/matrix-org/matrix-react-sdk/pull/1331) + +Changes in [0.10.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.2) (2017-08-24) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.1...v0.10.2) + + * Force update on timelinepanel when event decrypted + [\#1334](https://github.com/matrix-org/matrix-react-sdk/pull/1334) + * Dispatch incoming_call synchronously + [\#1337](https://github.com/matrix-org/matrix-react-sdk/pull/1337) + * Fix React crying on machines without internet due to return undefined + [\#1335](https://github.com/matrix-org/matrix-react-sdk/pull/1335) + * Catch the promise rejection if scalar fails + [\#1333](https://github.com/matrix-org/matrix-react-sdk/pull/1333) + * Update from Weblate. + [\#1329](https://github.com/matrix-org/matrix-react-sdk/pull/1329) + +Changes in [0.10.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.1) (2017-08-23) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.1-rc.1...v0.10.1) + + * [No changes] + +Changes in [0.10.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.1-rc.1) (2017-08-22) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.0-rc.2...v0.10.1-rc.1) + + * Matthew/multiple widgets + [\#1327](https://github.com/matrix-org/matrix-react-sdk/pull/1327) + * Fix proptypes on UserPickerDialog + [\#1326](https://github.com/matrix-org/matrix-react-sdk/pull/1326) + * AppsDrawer: Remove unnecessary bind + [\#1325](https://github.com/matrix-org/matrix-react-sdk/pull/1325) + * Position add app widget link + [\#1322](https://github.com/matrix-org/matrix-react-sdk/pull/1322) + * Remove app tile beta tag. + [\#1323](https://github.com/matrix-org/matrix-react-sdk/pull/1323) + * Add missing translation. + [\#1324](https://github.com/matrix-org/matrix-react-sdk/pull/1324) + * Note that apps are not E2EE + [\#1319](https://github.com/matrix-org/matrix-react-sdk/pull/1319) + * Only render appTile body (including warnings) if drawer shown. + [\#1321](https://github.com/matrix-org/matrix-react-sdk/pull/1321) + * Timeline improvements + [\#1320](https://github.com/matrix-org/matrix-react-sdk/pull/1320) + * Add a space between widget name and "widget" in widget event tiles + [\#1318](https://github.com/matrix-org/matrix-react-sdk/pull/1318) + * Move manage integrations button from settings page to room header as a + stand-alone component + [\#1286](https://github.com/matrix-org/matrix-react-sdk/pull/1286) + * Don't apply case logic to app names + [\#1316](https://github.com/matrix-org/matrix-react-sdk/pull/1316) + * Stop integ manager opening on every room switch + [\#1315](https://github.com/matrix-org/matrix-react-sdk/pull/1315) + * Add behaviour to toggle app draw on app tile header click + [\#1313](https://github.com/matrix-org/matrix-react-sdk/pull/1313) + * Change OOO so that MELS generation will continue over hidden events + [\#1308](https://github.com/matrix-org/matrix-react-sdk/pull/1308) + * Implement TextualEvent tiles for im.vector.modular.widgets + [\#1312](https://github.com/matrix-org/matrix-react-sdk/pull/1312) + * Don't show widget security warning to the person that added it to the room + [\#1314](https://github.com/matrix-org/matrix-react-sdk/pull/1314) + * remove unused strings introduced by string change + [\#1311](https://github.com/matrix-org/matrix-react-sdk/pull/1311) + * hotfix bad fn signature regression + [\#1310](https://github.com/matrix-org/matrix-react-sdk/pull/1310) + * Show a dialog if the maximum number of widgets allowed has been reached. + [\#1291](https://github.com/matrix-org/matrix-react-sdk/pull/1291) + * Fix Robot translation + [\#1309](https://github.com/matrix-org/matrix-react-sdk/pull/1309) + * Refactor ChatInviteDialog to be UserPickerDialog + [\#1300](https://github.com/matrix-org/matrix-react-sdk/pull/1300) + * Update Link to Translation status + [\#1302](https://github.com/matrix-org/matrix-react-sdk/pull/1302) + +Changes in [0.9.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.7) (2017-06-22) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.6...v0.9.7) + + * Fix ability to invite users with caps in their user IDs + [\#1128](https://github.com/matrix-org/matrix-react-sdk/pull/1128) + * Fix another race with first-sync + [\#1131](https://github.com/matrix-org/matrix-react-sdk/pull/1131) + * Make the indexeddb worker script work again + [\#1132](https://github.com/matrix-org/matrix-react-sdk/pull/1132) + * Use the web worker when clearing js-sdk stores + [\#1133](https://github.com/matrix-org/matrix-react-sdk/pull/1133) + +Changes in [0.9.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.6) (2017-06-20) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5...v0.9.6) + + * Fix infinite spinner on email registration + [\#1120](https://github.com/matrix-org/matrix-react-sdk/pull/1120) + * Translate help promots in room list + [\#1121](https://github.com/matrix-org/matrix-react-sdk/pull/1121) + * Internationalise the drop targets + [\#1122](https://github.com/matrix-org/matrix-react-sdk/pull/1122) + * Fix another infinite spin on register + [\#1124](https://github.com/matrix-org/matrix-react-sdk/pull/1124) + + +Changes in [0.9.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5) (2017-06-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5-rc.2...v0.9.5) + + * Don't peek when creating a room + [\#1113](https://github.com/matrix-org/matrix-react-sdk/pull/1113) + * More translations & translation fixes + + +Changes in [0.9.5-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5-rc.2) (2017-06-16) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5-rc.1...v0.9.5-rc.2) + + * Avoid getting stuck in a loop in CAS login + [\#1109](https://github.com/matrix-org/matrix-react-sdk/pull/1109) + * Update from Weblate. + [\#1101](https://github.com/matrix-org/matrix-react-sdk/pull/1101) + * Correctly inspect state when rejecting invite + [\#1108](https://github.com/matrix-org/matrix-react-sdk/pull/1108) + * Make sure to pass the roomAlias to the preview header if we have it + [\#1107](https://github.com/matrix-org/matrix-react-sdk/pull/1107) + * Make sure captcha disappears when container does + [\#1106](https://github.com/matrix-org/matrix-react-sdk/pull/1106) + * Fix URL previews + [\#1105](https://github.com/matrix-org/matrix-react-sdk/pull/1105) + +Changes in [0.9.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5-rc.1) (2017-06-15) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.4...v0.9.5-rc.1) + + * Groundwork for tests including a teamserver login + [\#1098](https://github.com/matrix-org/matrix-react-sdk/pull/1098) + * Show a spinner when accepting an invite and waitingForRoom + [\#1100](https://github.com/matrix-org/matrix-react-sdk/pull/1100) + * Display a spinner until new room object after join success + [\#1099](https://github.com/matrix-org/matrix-react-sdk/pull/1099) + * Luke/attempt fix peeking regression + [\#1097](https://github.com/matrix-org/matrix-react-sdk/pull/1097) + * Show correct text in set email password dialog (2) + [\#1096](https://github.com/matrix-org/matrix-react-sdk/pull/1096) + * Don't create a guest login if user went to /login + [\#1092](https://github.com/matrix-org/matrix-react-sdk/pull/1092) + * Give password confirmation correct title, description + [\#1095](https://github.com/matrix-org/matrix-react-sdk/pull/1095) + * Make enter submit change password form + [\#1094](https://github.com/matrix-org/matrix-react-sdk/pull/1094) + * When not specified, remove roomAlias state in RoomViewStore + [\#1093](https://github.com/matrix-org/matrix-react-sdk/pull/1093) + * Update from Weblate. + [\#1091](https://github.com/matrix-org/matrix-react-sdk/pull/1091) + * Fixed pagination infinite loop caused by long messages + [\#1045](https://github.com/matrix-org/matrix-react-sdk/pull/1045) + * Clear persistent storage on login and logout + [\#1085](https://github.com/matrix-org/matrix-react-sdk/pull/1085) + * DM guessing: prefer oldest joined member + [\#1087](https://github.com/matrix-org/matrix-react-sdk/pull/1087) + * Ask for email address after setting password for the first time + [\#1090](https://github.com/matrix-org/matrix-react-sdk/pull/1090) + * i18n for setting password flow + [\#1089](https://github.com/matrix-org/matrix-react-sdk/pull/1089) + * remove mx_filterFlipColor from verified e2e icon so its not purple :/ + [\#1088](https://github.com/matrix-org/matrix-react-sdk/pull/1088) + * width and height must be int otherwise synapse cries + [\#1083](https://github.com/matrix-org/matrix-react-sdk/pull/1083) + * remove RoomViewStore listener from MatrixChat on unmount + [\#1084](https://github.com/matrix-org/matrix-react-sdk/pull/1084) + * Add script to copy translations between files + [\#1082](https://github.com/matrix-org/matrix-react-sdk/pull/1082) + * Only process user_directory response if it's for the current query + [\#1081](https://github.com/matrix-org/matrix-react-sdk/pull/1081) + * Fix regressions with starting a 1-1. + [\#1080](https://github.com/matrix-org/matrix-react-sdk/pull/1080) + * allow forcing of TURN + [\#1079](https://github.com/matrix-org/matrix-react-sdk/pull/1079) + * Remove a bunch of dead code from react-sdk + [\#1077](https://github.com/matrix-org/matrix-react-sdk/pull/1077) + * Improve error logging/reporting in megolm import/export + [\#1061](https://github.com/matrix-org/matrix-react-sdk/pull/1061) + * Delinting + [\#1064](https://github.com/matrix-org/matrix-react-sdk/pull/1064) + * Show reason for a call hanging up unexpectedly. + [\#1071](https://github.com/matrix-org/matrix-react-sdk/pull/1071) + * Add reason for ban in room settings + [\#1072](https://github.com/matrix-org/matrix-react-sdk/pull/1072) + * adds mx_filterFlipColor so that the dark theme will invert this image + [\#1070](https://github.com/matrix-org/matrix-react-sdk/pull/1070) + +Changes in [0.9.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.4) (2017-06-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.3...v0.9.4) + + * Ask for email address after setting password for the first time + [\#1090](https://github.com/matrix-org/matrix-react-sdk/pull/1090) + * DM guessing: prefer oldest joined member + [\#1087](https://github.com/matrix-org/matrix-react-sdk/pull/1087) + * More translations + +Changes in [0.9.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.3) (2017-06-12) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.3-rc.2...v0.9.3) + + * Add more translations & fix some existing ones + +Changes in [0.9.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.3-rc.2) (2017-06-09) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.3-rc.1...v0.9.3-rc.2) + + * Fix flux dependency + * Fix translations on conference call bar + +Changes in [0.9.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.3-rc.1) (2017-06-09) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.2...v0.9.3-rc.1) + + * When ChatCreateOrReuseDialog is cancelled by a guest, go home + [\#1069](https://github.com/matrix-org/matrix-react-sdk/pull/1069) + * Update from Weblate. + [\#1065](https://github.com/matrix-org/matrix-react-sdk/pull/1065) + * Goto /home when forgetting the last room + [\#1067](https://github.com/matrix-org/matrix-react-sdk/pull/1067) + * Default to home page when settings is closed + [\#1066](https://github.com/matrix-org/matrix-react-sdk/pull/1066) + * Update from Weblate. + [\#1063](https://github.com/matrix-org/matrix-react-sdk/pull/1063) + * When joining, use a roomAlias if we have it + [\#1062](https://github.com/matrix-org/matrix-react-sdk/pull/1062) + * Control currently viewed event via RoomViewStore + [\#1058](https://github.com/matrix-org/matrix-react-sdk/pull/1058) + * Better error messages for login + [\#1060](https://github.com/matrix-org/matrix-react-sdk/pull/1060) + * Add remaining translations + [\#1056](https://github.com/matrix-org/matrix-react-sdk/pull/1056) + * Added button that copies code to clipboard + [\#1040](https://github.com/matrix-org/matrix-react-sdk/pull/1040) + * de-lint MegolmExportEncryption + test + [\#1059](https://github.com/matrix-org/matrix-react-sdk/pull/1059) + * Better RTL support + [\#1021](https://github.com/matrix-org/matrix-react-sdk/pull/1021) + * make mels emoji capable + [\#1057](https://github.com/matrix-org/matrix-react-sdk/pull/1057) + * Make travis check for lint on files which are clean to start with + [\#1055](https://github.com/matrix-org/matrix-react-sdk/pull/1055) + * Update from Weblate. + [\#1053](https://github.com/matrix-org/matrix-react-sdk/pull/1053) + * Add some logging around switching rooms + [\#1054](https://github.com/matrix-org/matrix-react-sdk/pull/1054) + * Update from Weblate. + [\#1052](https://github.com/matrix-org/matrix-react-sdk/pull/1052) + * Use user_directory endpoint to populate ChatInviteDialog + [\#1050](https://github.com/matrix-org/matrix-react-sdk/pull/1050) + * Various Analytics changes/fixes/improvements + [\#1046](https://github.com/matrix-org/matrix-react-sdk/pull/1046) + * Use an arrow function to allow `this` + [\#1051](https://github.com/matrix-org/matrix-react-sdk/pull/1051) + * New guest access + [\#937](https://github.com/matrix-org/matrix-react-sdk/pull/937) + * Translate src/components/structures + [\#1048](https://github.com/matrix-org/matrix-react-sdk/pull/1048) + * Cancel 'join room' action if 'log in' is clicked + [\#1049](https://github.com/matrix-org/matrix-react-sdk/pull/1049) + * fix copy and paste derp and rip out unused imports + [\#1015](https://github.com/matrix-org/matrix-react-sdk/pull/1015) + * Update from Weblate. + [\#1042](https://github.com/matrix-org/matrix-react-sdk/pull/1042) + * Reset 'first sync' flag / promise on log in + [\#1041](https://github.com/matrix-org/matrix-react-sdk/pull/1041) + * Remove DM-guessing code (again) + [\#1036](https://github.com/matrix-org/matrix-react-sdk/pull/1036) + * Cancel deferred actions + [\#1039](https://github.com/matrix-org/matrix-react-sdk/pull/1039) + * Merge develop, add i18n for SetMxIdDialog + [\#1034](https://github.com/matrix-org/matrix-react-sdk/pull/1034) + * Defer an intention for creating a room + [\#1038](https://github.com/matrix-org/matrix-react-sdk/pull/1038) + * Fix 'create room' button + [\#1037](https://github.com/matrix-org/matrix-react-sdk/pull/1037) + * Always show the spinner during the first sync + [\#1033](https://github.com/matrix-org/matrix-react-sdk/pull/1033) + * Only view welcome user if we are not looking at a room + [\#1032](https://github.com/matrix-org/matrix-react-sdk/pull/1032) + * Update from Weblate. + [\#1030](https://github.com/matrix-org/matrix-react-sdk/pull/1030) + * Keep deferred actions for view_user_settings and view_create_chat + [\#1031](https://github.com/matrix-org/matrix-react-sdk/pull/1031) + * Don't do a deferred start chat if user is welcome user + [\#1029](https://github.com/matrix-org/matrix-react-sdk/pull/1029) + * Introduce state `peekLoading` to avoid collision with `roomLoading` + [\#1028](https://github.com/matrix-org/matrix-react-sdk/pull/1028) + * Update from Weblate. + [\#1016](https://github.com/matrix-org/matrix-react-sdk/pull/1016) + * Fix accepting a 3pid invite + [\#1013](https://github.com/matrix-org/matrix-react-sdk/pull/1013) + * Propagate room join errors to the UI + [\#1007](https://github.com/matrix-org/matrix-react-sdk/pull/1007) + * Implement /user/@userid:domain?action=chat + [\#1006](https://github.com/matrix-org/matrix-react-sdk/pull/1006) + * Show People/Rooms emptySubListTip even when total rooms !== 0 + [\#967](https://github.com/matrix-org/matrix-react-sdk/pull/967) + * Fix to show the correct room + [\#995](https://github.com/matrix-org/matrix-react-sdk/pull/995) + * Remove cachedPassword from localStorage on_logged_out + [\#977](https://github.com/matrix-org/matrix-react-sdk/pull/977) + * Add /start to show the setMxId above HomePage + [\#964](https://github.com/matrix-org/matrix-react-sdk/pull/964) + * Allow pressing Enter to submit setMxId + [\#961](https://github.com/matrix-org/matrix-react-sdk/pull/961) + * add login link to SetMxIdDialog + [\#954](https://github.com/matrix-org/matrix-react-sdk/pull/954) + * Block user settings with view_set_mxid + [\#936](https://github.com/matrix-org/matrix-react-sdk/pull/936) + * Show "Something went wrong!" when errcode undefined + [\#935](https://github.com/matrix-org/matrix-react-sdk/pull/935) + * Reset store state when logging out + [\#930](https://github.com/matrix-org/matrix-react-sdk/pull/930) + * Set the displayname to the mxid once PWLU + [\#933](https://github.com/matrix-org/matrix-react-sdk/pull/933) + * Fix view_next_room, view_previous_room and view_indexed_room + [\#929](https://github.com/matrix-org/matrix-react-sdk/pull/929) + * Use RVS to indicate "joining" when setting a mxid + [\#928](https://github.com/matrix-org/matrix-react-sdk/pull/928) + * Don't show notif nag bar if guest + [\#932](https://github.com/matrix-org/matrix-react-sdk/pull/932) + * Show "Password" instead of "New Password" + [\#927](https://github.com/matrix-org/matrix-react-sdk/pull/927) + * Remove warm-fuzzy after setting mxid + [\#926](https://github.com/matrix-org/matrix-react-sdk/pull/926) + * Allow teamServerConfig to be missing + [\#925](https://github.com/matrix-org/matrix-react-sdk/pull/925) + * Remove GuestWarningBar + [\#923](https://github.com/matrix-org/matrix-react-sdk/pull/923) + * Make left panel better for new users (mk III) + [\#924](https://github.com/matrix-org/matrix-react-sdk/pull/924) + * Implement default welcome page and allow custom URL /w config + [\#922](https://github.com/matrix-org/matrix-react-sdk/pull/922) + * Implement a store for RoomView + [\#921](https://github.com/matrix-org/matrix-react-sdk/pull/921) + * Add prop to toggle whether new password input is autoFocused + [\#915](https://github.com/matrix-org/matrix-react-sdk/pull/915) + * Implement warm-fuzzy success dialog for SetMxIdDialog + [\#905](https://github.com/matrix-org/matrix-react-sdk/pull/905) + * Write some tests for the RTS UI + [\#893](https://github.com/matrix-org/matrix-react-sdk/pull/893) + * Make confirmation optional on ChangePassword + [\#890](https://github.com/matrix-org/matrix-react-sdk/pull/890) + * Remove "Current Password" input if mx_pass exists + [\#881](https://github.com/matrix-org/matrix-react-sdk/pull/881) + * Replace NeedToRegisterDialog /w SetMxIdDialog + [\#889](https://github.com/matrix-org/matrix-react-sdk/pull/889) + * Invite the welcome user after registration if configured + [\#882](https://github.com/matrix-org/matrix-react-sdk/pull/882) + * Prevent ROUs from creating new chats/new rooms + [\#879](https://github.com/matrix-org/matrix-react-sdk/pull/879) + * Redesign mxID chooser, add availability checking + [\#877](https://github.com/matrix-org/matrix-react-sdk/pull/877) + * Show password nag bar when user is PWLU + [\#864](https://github.com/matrix-org/matrix-react-sdk/pull/864) + * fix typo + [\#858](https://github.com/matrix-org/matrix-react-sdk/pull/858) + * Initial implementation: SetDisplayName -> SetMxIdDialog + [\#849](https://github.com/matrix-org/matrix-react-sdk/pull/849) + +Changes in [0.9.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.2) (2017-06-06) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.1...v0.9.2) + + * Hotfix: Allow password reset when logged in + [\#1044](https://github.com/matrix-org/matrix-react-sdk/pull/1044) + +Changes in [0.9.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.1) (2017-06-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.0...v0.9.1) + + * Update from Weblate. + [\#1012](https://github.com/matrix-org/matrix-react-sdk/pull/1012) + * typo, missing import and mis-casing + [\#1014](https://github.com/matrix-org/matrix-react-sdk/pull/1014) + * Update from Weblate. + [\#1010](https://github.com/matrix-org/matrix-react-sdk/pull/1010) + +Changes in [0.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.0) (2017-06-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.0-rc.2...v0.9.0) + + * sync pt with pt_BR + [\#1009](https://github.com/matrix-org/matrix-react-sdk/pull/1009) + * Update from Weblate. + [\#1008](https://github.com/matrix-org/matrix-react-sdk/pull/1008) + * Update from Weblate. + [\#1003](https://github.com/matrix-org/matrix-react-sdk/pull/1003) + * allow hiding redactions, restoring old behaviour + [\#1004](https://github.com/matrix-org/matrix-react-sdk/pull/1004) + * Add missing translations + [\#1005](https://github.com/matrix-org/matrix-react-sdk/pull/1005) + +Changes in [0.9.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.0-rc.2) (2017-06-02) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.0-rc.1...v0.9.0-rc.2) + + * Update from Weblate. + [\#1002](https://github.com/matrix-org/matrix-react-sdk/pull/1002) + * webrtc config electron + [\#850](https://github.com/matrix-org/matrix-react-sdk/pull/850) + * enable useCompactLayout user setting an add a class when it's enabled + [\#986](https://github.com/matrix-org/matrix-react-sdk/pull/986) + * Update from Weblate. + [\#987](https://github.com/matrix-org/matrix-react-sdk/pull/987) + * Translation fixes for everything but src/components + [\#990](https://github.com/matrix-org/matrix-react-sdk/pull/990) + * Fix tests + [\#1001](https://github.com/matrix-org/matrix-react-sdk/pull/1001) + * Fix tests for PR #989 + [\#999](https://github.com/matrix-org/matrix-react-sdk/pull/999) + * Revert "Revert "add labels to language picker"" + [\#1000](https://github.com/matrix-org/matrix-react-sdk/pull/1000) + * maybe fixxy [Electron] external thing? + [\#997](https://github.com/matrix-org/matrix-react-sdk/pull/997) + * travisci: Don't run the riot-web tests if the react-sdk tests fail + [\#992](https://github.com/matrix-org/matrix-react-sdk/pull/992) + * Support 12hr time on DateSeparator + [\#991](https://github.com/matrix-org/matrix-react-sdk/pull/991) + * Revert "add labels to language picker" + [\#994](https://github.com/matrix-org/matrix-react-sdk/pull/994) + * Call MatrixClient.clearStores on logout + [\#983](https://github.com/matrix-org/matrix-react-sdk/pull/983) + * Matthew/room avatar event + [\#988](https://github.com/matrix-org/matrix-react-sdk/pull/988) + * add labels to language picker + [\#989](https://github.com/matrix-org/matrix-react-sdk/pull/989) + * Update from Weblate. + [\#981](https://github.com/matrix-org/matrix-react-sdk/pull/981) + +Changes in [0.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.0-rc.1) (2017-06-01) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.9...v0.9.0-rc.1) + + * Fix rare case where presence duration is undefined + [\#982](https://github.com/matrix-org/matrix-react-sdk/pull/982) + * add concept of platform handling loudNotifications (bings/pings/whatHaveYou) + [\#985](https://github.com/matrix-org/matrix-react-sdk/pull/985) + * Fixes to i18n code + [\#984](https://github.com/matrix-org/matrix-react-sdk/pull/984) + * Update from Weblate. + [\#978](https://github.com/matrix-org/matrix-react-sdk/pull/978) + * Add partial support for RTL languages + [\#955](https://github.com/matrix-org/matrix-react-sdk/pull/955) + * Added two strings to translate + [\#975](https://github.com/matrix-org/matrix-react-sdk/pull/975) + * Update from Weblate. + [\#976](https://github.com/matrix-org/matrix-react-sdk/pull/976) + * Update from Weblate. + [\#974](https://github.com/matrix-org/matrix-react-sdk/pull/974) + * Initial Electron Settings - for Auto Launch + [\#920](https://github.com/matrix-org/matrix-react-sdk/pull/920) + * Fix missing string in the room settings + [\#973](https://github.com/matrix-org/matrix-react-sdk/pull/973) + * fix error in i18n string + [\#972](https://github.com/matrix-org/matrix-react-sdk/pull/972) + * Update from Weblate. + [\#970](https://github.com/matrix-org/matrix-react-sdk/pull/970) + * Support 12hr time in full date + [\#971](https://github.com/matrix-org/matrix-react-sdk/pull/971) + * Add _tJsx() + [\#968](https://github.com/matrix-org/matrix-react-sdk/pull/968) + * Update from Weblate. + [\#966](https://github.com/matrix-org/matrix-react-sdk/pull/966) + * Remove space between time and AM/PM + [\#969](https://github.com/matrix-org/matrix-react-sdk/pull/969) + * Piwik Analytics + [\#948](https://github.com/matrix-org/matrix-react-sdk/pull/948) + * Update from Weblate. + [\#965](https://github.com/matrix-org/matrix-react-sdk/pull/965) + * Improve ChatInviteDialog perf by ditching fuse, using indexOf and + lastActiveTs() + [\#960](https://github.com/matrix-org/matrix-react-sdk/pull/960) + * Say "X removed the room name" instead of showing nothing + [\#958](https://github.com/matrix-org/matrix-react-sdk/pull/958) + * roomview/roomheader fixes + [\#959](https://github.com/matrix-org/matrix-react-sdk/pull/959) + * Update from Weblate. + [\#953](https://github.com/matrix-org/matrix-react-sdk/pull/953) + * fix i18n in a situation where navigator.languages=[] + [\#956](https://github.com/matrix-org/matrix-react-sdk/pull/956) + * `t_` -> `_t` fix typo + [\#957](https://github.com/matrix-org/matrix-react-sdk/pull/957) + * Change redact -> remove for clarity + [\#831](https://github.com/matrix-org/matrix-react-sdk/pull/831) + * Update from Weblate. + [\#950](https://github.com/matrix-org/matrix-react-sdk/pull/950) + * fix mis-linting - missed it in code review :( + [\#952](https://github.com/matrix-org/matrix-react-sdk/pull/952) + * i18n fixes + [\#951](https://github.com/matrix-org/matrix-react-sdk/pull/951) + * Message Forwarding + [\#812](https://github.com/matrix-org/matrix-react-sdk/pull/812) + * don't focus_composer on window focus + [\#944](https://github.com/matrix-org/matrix-react-sdk/pull/944) + * Fix vector-im/riot-web#4042 + [\#947](https://github.com/matrix-org/matrix-react-sdk/pull/947) + * import _t, drop two unused imports + [\#946](https://github.com/matrix-org/matrix-react-sdk/pull/946) + * Fix punctuation in TextForEvent to be i18n'd consistently + [\#945](https://github.com/matrix-org/matrix-react-sdk/pull/945) + * actually wire up alwaysShowTimestamps + [\#940](https://github.com/matrix-org/matrix-react-sdk/pull/940) + * Update from Weblate. + [\#943](https://github.com/matrix-org/matrix-react-sdk/pull/943) + * Update from Weblate. + [\#942](https://github.com/matrix-org/matrix-react-sdk/pull/942) + * Update from Weblate. + [\#941](https://github.com/matrix-org/matrix-react-sdk/pull/941) + * Update from Weblate. + [\#938](https://github.com/matrix-org/matrix-react-sdk/pull/938) + * Fix PM being AM + [\#939](https://github.com/matrix-org/matrix-react-sdk/pull/939) + * pass call state through dispatcher, for poor electron + [\#918](https://github.com/matrix-org/matrix-react-sdk/pull/918) + * Translations! + [\#934](https://github.com/matrix-org/matrix-react-sdk/pull/934) + * Remove suffix and prefix from login input username + [\#906](https://github.com/matrix-org/matrix-react-sdk/pull/906) + * Kierangould/12hourtimestamp + [\#903](https://github.com/matrix-org/matrix-react-sdk/pull/903) + * Don't include src in the test resolve root + [\#931](https://github.com/matrix-org/matrix-react-sdk/pull/931) + * Make the linked versions open a new tab, turt2live complained :P + [\#910](https://github.com/matrix-org/matrix-react-sdk/pull/910) + * Fix lint errors in SlashCommands + [\#919](https://github.com/matrix-org/matrix-react-sdk/pull/919) + * autoFocus input box + [\#911](https://github.com/matrix-org/matrix-react-sdk/pull/911) + * Make travis test against riot-web new-guest-access + [\#917](https://github.com/matrix-org/matrix-react-sdk/pull/917) + * Add right-branch logic to travis test script + [\#916](https://github.com/matrix-org/matrix-react-sdk/pull/916) + * Group e2e keys into blocks of 4 characters + [\#914](https://github.com/matrix-org/matrix-react-sdk/pull/914) + * Factor out DeviceVerifyDialog + [\#913](https://github.com/matrix-org/matrix-react-sdk/pull/913) + * Fix 'missing page_type' error + [\#909](https://github.com/matrix-org/matrix-react-sdk/pull/909) + * code style update + [\#904](https://github.com/matrix-org/matrix-react-sdk/pull/904) + +Changes in [0.8.9](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.9) (2017-05-22) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.9-rc.1...v0.8.9) + + * No changes + + +Changes in [0.8.9-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.9-rc.1) (2017-05-19) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.8...v0.8.9-rc.1) + + * Prevent an exception getting scroll node + [\#902](https://github.com/matrix-org/matrix-react-sdk/pull/902) + * Fix a few remaining snags with country dd + [\#901](https://github.com/matrix-org/matrix-react-sdk/pull/901) + * Add left_aligned class to CountryDropdown + [\#900](https://github.com/matrix-org/matrix-react-sdk/pull/900) + * Swap to new flag files (which are stored as GB.png) + [\#899](https://github.com/matrix-org/matrix-react-sdk/pull/899) + * Improve phone number country dropdown for registration and login (Act. 2, + Return of the Prefix) + [\#897](https://github.com/matrix-org/matrix-react-sdk/pull/897) + * Support for pasting files into normal composer + [\#892](https://github.com/matrix-org/matrix-react-sdk/pull/892) + * tell guests they can't use filepanel until they register + [\#887](https://github.com/matrix-org/matrix-react-sdk/pull/887) + * Prevent reskindex -w from running when file names have not changed + [\#888](https://github.com/matrix-org/matrix-react-sdk/pull/888) + * I broke UserSettings for webpack-dev-server + [\#884](https://github.com/matrix-org/matrix-react-sdk/pull/884) + * various fixes to RoomHeader + [\#880](https://github.com/matrix-org/matrix-react-sdk/pull/880) + * remove /me whether or not it has a space after it + [\#885](https://github.com/matrix-org/matrix-react-sdk/pull/885) + * show error if we can't set a filter because no room + [\#883](https://github.com/matrix-org/matrix-react-sdk/pull/883) + * Fix RM not updating if RR event unpaginated + [\#874](https://github.com/matrix-org/matrix-react-sdk/pull/874) + * change roomsettings wording + [\#878](https://github.com/matrix-org/matrix-react-sdk/pull/878) + * make reskindex windows friendly + [\#875](https://github.com/matrix-org/matrix-react-sdk/pull/875) + * Fixes 2 issues with Dialog closing + [\#867](https://github.com/matrix-org/matrix-react-sdk/pull/867) + * Automatic Reskindex + [\#871](https://github.com/matrix-org/matrix-react-sdk/pull/871) + * Put room name in 'leave room' confirmation dialog + [\#873](https://github.com/matrix-org/matrix-react-sdk/pull/873) + * Fix this/self fail in LeftPanel + [\#872](https://github.com/matrix-org/matrix-react-sdk/pull/872) + * Don't show null URL previews + [\#870](https://github.com/matrix-org/matrix-react-sdk/pull/870) + * Fix keys for AddressSelector + [\#869](https://github.com/matrix-org/matrix-react-sdk/pull/869) + * Make left panel better for new users (mk II) + [\#859](https://github.com/matrix-org/matrix-react-sdk/pull/859) + * Explicitly save composer content onUnload + [\#866](https://github.com/matrix-org/matrix-react-sdk/pull/866) + * Warn on unload + [\#851](https://github.com/matrix-org/matrix-react-sdk/pull/851) + * Log deviceid at login + [\#862](https://github.com/matrix-org/matrix-react-sdk/pull/862) + * Guests can't send RR so no point trying + [\#860](https://github.com/matrix-org/matrix-react-sdk/pull/860) + * Remove babelcheck + [\#861](https://github.com/matrix-org/matrix-react-sdk/pull/861) + * T3chguy/settings versions improvements + [\#857](https://github.com/matrix-org/matrix-react-sdk/pull/857) + * Change max-len 90->120 + [\#852](https://github.com/matrix-org/matrix-react-sdk/pull/852) + * Remove DM-guessing code + [\#829](https://github.com/matrix-org/matrix-react-sdk/pull/829) + * Fix jumping to an unread event when in MELS + [\#855](https://github.com/matrix-org/matrix-react-sdk/pull/855) + * Validate phone number on login + [\#856](https://github.com/matrix-org/matrix-react-sdk/pull/856) + * Failed to enable HTML5 Notifications Error Dialogs + [\#827](https://github.com/matrix-org/matrix-react-sdk/pull/827) + * Pin filesize ver to fix break upstream + [\#854](https://github.com/matrix-org/matrix-react-sdk/pull/854) + * Improve RoomDirectory Look & Feel + [\#848](https://github.com/matrix-org/matrix-react-sdk/pull/848) + * Only show jumpToReadMarker bar when RM !== RR + [\#845](https://github.com/matrix-org/matrix-react-sdk/pull/845) + * Allow MELS to have its own RM + [\#846](https://github.com/matrix-org/matrix-react-sdk/pull/846) + * Use document.onkeydown instead of onkeypress + [\#844](https://github.com/matrix-org/matrix-react-sdk/pull/844) + * (Room)?Avatar: Request 96x96 avatars on high DPI screens + [\#808](https://github.com/matrix-org/matrix-react-sdk/pull/808) + * Add mx_EventTile_emote class + [\#842](https://github.com/matrix-org/matrix-react-sdk/pull/842) + * Fix dialog reappearing after hitting Enter + [\#841](https://github.com/matrix-org/matrix-react-sdk/pull/841) + * Fix spinner that shows until the first sync + [\#840](https://github.com/matrix-org/matrix-react-sdk/pull/840) + * Show spinner until first sync has completed + [\#839](https://github.com/matrix-org/matrix-react-sdk/pull/839) + * Style fixes for LoggedInView + [\#838](https://github.com/matrix-org/matrix-react-sdk/pull/838) + * Fix specifying custom server for registration + [\#834](https://github.com/matrix-org/matrix-react-sdk/pull/834) + * Improve country dropdown UX and expose +prefix + [\#833](https://github.com/matrix-org/matrix-react-sdk/pull/833) + * Fix user settings store + [\#836](https://github.com/matrix-org/matrix-react-sdk/pull/836) + * show the room name in the UDE Dialog + [\#832](https://github.com/matrix-org/matrix-react-sdk/pull/832) + * summarise profile changes in MELS + [\#826](https://github.com/matrix-org/matrix-react-sdk/pull/826) + * Transform h1 and h2 tags to h3 tags + [\#820](https://github.com/matrix-org/matrix-react-sdk/pull/820) + * limit our keyboard shortcut modifiers correctly + [\#825](https://github.com/matrix-org/matrix-react-sdk/pull/825) + * Specify cross platform regexes and add olm to noParse + [\#823](https://github.com/matrix-org/matrix-react-sdk/pull/823) + * Remember element that was in focus before rendering dialog + [\#822](https://github.com/matrix-org/matrix-react-sdk/pull/822) + * move user settings outward and use built in read receipts disabling + [\#824](https://github.com/matrix-org/matrix-react-sdk/pull/824) + * File Download Consistency + [\#802](https://github.com/matrix-org/matrix-react-sdk/pull/802) + * Show Access Token under Advanced in Settings + [\#806](https://github.com/matrix-org/matrix-react-sdk/pull/806) + * Link tags/commit hashes in the UserSettings version section + [\#810](https://github.com/matrix-org/matrix-react-sdk/pull/810) + * On return to RoomView from auxPanel, send focus back to Composer + [\#813](https://github.com/matrix-org/matrix-react-sdk/pull/813) + * Change presence status labels to 'for' instead of 'ago' + [\#817](https://github.com/matrix-org/matrix-react-sdk/pull/817) + * Disable Scalar Integrations if urls passed to it are falsey + [\#816](https://github.com/matrix-org/matrix-react-sdk/pull/816) + * Add option to hide other people's read receipts. + [\#818](https://github.com/matrix-org/matrix-react-sdk/pull/818) + * Add option to not send typing notifications + [\#819](https://github.com/matrix-org/matrix-react-sdk/pull/819) + * Sync RM across instances of Riot + [\#805](https://github.com/matrix-org/matrix-react-sdk/pull/805) + * First iteration on improving login UI + [\#811](https://github.com/matrix-org/matrix-react-sdk/pull/811) + * focus on composer after jumping to bottom + [\#809](https://github.com/matrix-org/matrix-react-sdk/pull/809) + * Improve RoomList performance via side-stepping React + [\#807](https://github.com/matrix-org/matrix-react-sdk/pull/807) + * Don't show link preview when link is inside of a quote + [\#762](https://github.com/matrix-org/matrix-react-sdk/pull/762) + * Escape closes UserSettings + [\#765](https://github.com/matrix-org/matrix-react-sdk/pull/765) + * Implement user power-level changes in timeline + [\#794](https://github.com/matrix-org/matrix-react-sdk/pull/794) + +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) + + * Update to matrix-js-sdk 0.7.5 (no changes from 0.7.5-rc.3) + +Changes in [0.8.6-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.3) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.2...v0.8.6-rc.3) + + * Update to matrix-js-sdk 0.7.5-rc.3 + * Fix deviceverifybuttons + [5fd7410](https://github.com/matrix-org/matrix-react-sdk/commit/827b5a6811ac6b9d1f9a3002a94f9f6ac3f1d49c) + + +Changes in [0.8.6-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.2) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.1...v0.8.6-rc.2) + + * Update to new matrix-js-sdk to get support for new device change notifications interface + + +Changes in [0.8.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.1) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5...v0.8.6-rc.1) + + * Fix timeline & notifs panel spuriously being empty + [\#675](https://github.com/matrix-org/matrix-react-sdk/pull/675) + * UI for blacklisting unverified devices per-room & globally + [\#636](https://github.com/matrix-org/matrix-react-sdk/pull/636) + * Show better error message in statusbar after UnkDevDialog + [\#674](https://github.com/matrix-org/matrix-react-sdk/pull/674) + * Make default avatars clickable + [\#673](https://github.com/matrix-org/matrix-react-sdk/pull/673) + * Fix one read receipt randomly not appearing + [\#672](https://github.com/matrix-org/matrix-react-sdk/pull/672) + * very barebones support for warning users when rooms contain unknown devices + [\#635](https://github.com/matrix-org/matrix-react-sdk/pull/635) + * Fix expanding/unexapnding read receipts + [\#671](https://github.com/matrix-org/matrix-react-sdk/pull/671) + * show placeholder when timeline empty + [\#670](https://github.com/matrix-org/matrix-react-sdk/pull/670) + * Make read receipt's titles more explanatory + [\#669](https://github.com/matrix-org/matrix-react-sdk/pull/669) + * Fix spurious HTML tags being passed through literally + [\#667](https://github.com/matrix-org/matrix-react-sdk/pull/667) + * Reinstate max-len lint configs + [\#665](https://github.com/matrix-org/matrix-react-sdk/pull/665) + * Throw errors on !==200 status codes from RTS + [\#662](https://github.com/matrix-org/matrix-react-sdk/pull/662) + * Exempt lines which look like pure JSX from the maxlen line + [\#664](https://github.com/matrix-org/matrix-react-sdk/pull/664) + * Make tests pass on Chrome again + [\#663](https://github.com/matrix-org/matrix-react-sdk/pull/663) + * Add referral section to user settings + [\#661](https://github.com/matrix-org/matrix-react-sdk/pull/661) + * Two megolm export fixes: + [\#660](https://github.com/matrix-org/matrix-react-sdk/pull/660) + * GET /teams from RTS instead of config.json + [\#658](https://github.com/matrix-org/matrix-react-sdk/pull/658) + * Guard onStatusBarVisible/Hidden with this.unmounted + [\#656](https://github.com/matrix-org/matrix-react-sdk/pull/656) + * Fix cancel button on e2e import/export dialogs + [\#654](https://github.com/matrix-org/matrix-react-sdk/pull/654) + * Look up email addresses in ChatInviteDialog + [\#653](https://github.com/matrix-org/matrix-react-sdk/pull/653) + * Move BugReportDialog to riot-web + [\#652](https://github.com/matrix-org/matrix-react-sdk/pull/652) + * Fix dark theme styling of roomheader cancel button + [\#651](https://github.com/matrix-org/matrix-react-sdk/pull/651) + * Allow modals to stack up + [\#649](https://github.com/matrix-org/matrix-react-sdk/pull/649) + * Add bug report UI + [\#642](https://github.com/matrix-org/matrix-react-sdk/pull/642) + * Better feedback in invite dialog + [\#625](https://github.com/matrix-org/matrix-react-sdk/pull/625) + * Import and export for Megolm session data + [\#647](https://github.com/matrix-org/matrix-react-sdk/pull/647) + * Overhaul MELS to deal with causality, kicks, etc. + [\#613](https://github.com/matrix-org/matrix-react-sdk/pull/613) + * Re-add dispatcher as alt-up/down uses it + [\#650](https://github.com/matrix-org/matrix-react-sdk/pull/650) + * Create a common BaseDialog + [\#645](https://github.com/matrix-org/matrix-react-sdk/pull/645) + * Fix SetDisplayNameDialog + [\#648](https://github.com/matrix-org/matrix-react-sdk/pull/648) + * Sync typing indication with avatar typing indication + [\#643](https://github.com/matrix-org/matrix-react-sdk/pull/643) + * Warn users of E2E key loss when changing/resetting passwords or logging out + [\#646](https://github.com/matrix-org/matrix-react-sdk/pull/646) + * Better user interface for screen readers and keyboard navigation + [\#616](https://github.com/matrix-org/matrix-react-sdk/pull/616) + * Reduce log spam: Revert a16aeeef2a0f16efedf7e6616cdf3c2c8752a077 + [\#644](https://github.com/matrix-org/matrix-react-sdk/pull/644) + * Expand timeline in situations when _getIndicator not null + [\#641](https://github.com/matrix-org/matrix-react-sdk/pull/641) + * Correctly get the path of the js-sdk .eslintrc.js + [\#640](https://github.com/matrix-org/matrix-react-sdk/pull/640) + * Add 'searching known users' to the user picker + [\#621](https://github.com/matrix-org/matrix-react-sdk/pull/621) + * Add mocha env for tests in eslint config + [\#639](https://github.com/matrix-org/matrix-react-sdk/pull/639) + * Fix typing avatars displaying "me" + [\#637](https://github.com/matrix-org/matrix-react-sdk/pull/637) + * Fix device verification from e2e info + [\#638](https://github.com/matrix-org/matrix-react-sdk/pull/638) + * Make user search do a bit better on word boundary + [\#623](https://github.com/matrix-org/matrix-react-sdk/pull/623) + * Use an eslint config based on the js-sdk + [\#634](https://github.com/matrix-org/matrix-react-sdk/pull/634) + * Fix error display in account deactivate dialog + [\#633](https://github.com/matrix-org/matrix-react-sdk/pull/633) + * Configure travis to test riot-web after building + [\#629](https://github.com/matrix-org/matrix-react-sdk/pull/629) + * Sanitize ChatInviteDialog + [\#626](https://github.com/matrix-org/matrix-react-sdk/pull/626) + * (hopefully) fix theming on Chrome + [\#630](https://github.com/matrix-org/matrix-react-sdk/pull/630) + * Megolm session import and export + [\#617](https://github.com/matrix-org/matrix-react-sdk/pull/617) + * Allow Modal to be used with async-loaded components + [\#618](https://github.com/matrix-org/matrix-react-sdk/pull/618) + * Fix escaping markdown by rendering plaintext + [\#622](https://github.com/matrix-org/matrix-react-sdk/pull/622) + * Implement auto-join rooms on registration + [\#628](https://github.com/matrix-org/matrix-react-sdk/pull/628) + * Matthew/fix theme npe + [\#627](https://github.com/matrix-org/matrix-react-sdk/pull/627) + * Implement theming via alternate stylesheets + [\#624](https://github.com/matrix-org/matrix-react-sdk/pull/624) + * Replace marked with commonmark + [\#575](https://github.com/matrix-org/matrix-react-sdk/pull/575) + * Fix vector-im/riot-web#2833 : Fail nicely when people try to register + numeric user IDs + [\#619](https://github.com/matrix-org/matrix-react-sdk/pull/619) + * Show the error dialog when requests to PUT power levels fail + [\#614](https://github.com/matrix-org/matrix-react-sdk/pull/614) + +Changes in [0.8.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.5) (2017-01-16) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5-rc.1...v0.8.5) + + * Pull in newer matrix-js-sdk for video calling fix + +Changes in [0.8.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.5-rc.1) (2017-01-13) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.4...v0.8.5-rc.1) + + * Build the js-sdk in the CI script + [\#612](https://github.com/matrix-org/matrix-react-sdk/pull/612) + * Fix redacted member events being visible + [\#609](https://github.com/matrix-org/matrix-react-sdk/pull/609) + * Use `getStateKey` instead of `getSender` + [\#611](https://github.com/matrix-org/matrix-react-sdk/pull/611) + * Move screen sharing error check into platform + [\#608](https://github.com/matrix-org/matrix-react-sdk/pull/608) + * Fix 'create account' link in 'forgot password' + [\#606](https://github.com/matrix-org/matrix-react-sdk/pull/606) + * Let electron users complete captchas in a web browser + [\#601](https://github.com/matrix-org/matrix-react-sdk/pull/601) + * Add support for deleting threepids + [\#597](https://github.com/matrix-org/matrix-react-sdk/pull/597) + * Display msisdn threepids as 'Phone' + [\#598](https://github.com/matrix-org/matrix-react-sdk/pull/598) + +Changes in [0.8.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.4) (2016-12-24) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.3...v0.8.4) + + * Fix signup by working around the fact that reCapture doesn't work on electron + * Fix windows shortcut link + +Changes in [0.8.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.3) (2016-12-22) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.2...v0.8.3) + + * Revert performance fix for wantsDateSeperator which was causing date separators to + be shown at the wrong time of day. + * Unbranded error messages + [\#599](https://github.com/matrix-org/matrix-react-sdk/pull/599) + * Fix scroll jumping when a video is decrypted + [\#594](https://github.com/matrix-org/matrix-react-sdk/pull/594) + Changes in [0.8.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.2) (2016-12-16) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.1...v0.8.2) diff --git a/README.md b/README.md index dfc1a6e6ec..c3106ccec7 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,21 @@ a 'skin'. A skin provides: * Zero or more 'modules' containing non-UI functionality **WARNING: As of July 2016, the skinning abstraction is broken due to rapid -development of `matrix-react-sdk` to meet the needs of Vector, the first app -to be built on top of the SDK** (https://github.com/vector-im/vector-web). -Right now `matrix-react-sdk` depends on some functionality from `vector-web` -(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour -(grep for 'vector'). This layering will be fixed asap once Vector development +development of `matrix-react-sdk` to meet the needs of Riot (codenamed Vector), the first app +to be built on top of the SDK** (https://github.com/vector-im/riot-web). +Right now `matrix-react-sdk` depends on some functionality from `riot-web` +(e.g. CSS), and `matrix-react-sdk` contains some Riot specific behaviour +(grep for 'vector'). This layering will be fixed asap once Riot development has stabilised, but for now we do not advise trying to create new skins for matrix-react-sdk until the layers are clearly separated again. -In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should +In the interim, `vector-im/riot-web` and `matrix-org/matrix-react-sdk` should be considered as a single project (for instance, matrix-react-sdk bugs -are currently filed against vector-im/vector-web rather than this project). +are currently filed against vector-im/riot-web rather than this project). + +Translation Status +================== +[![Translation status](https://translate.riot.im/widgets/riot-web/-/multi-auto.svg)](https://translate.riot.im/engage/riot-web/?utm_source=widget) Developer Guide =============== @@ -42,17 +46,17 @@ Please follow the standard Matrix contributor's guide: https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst Please follow the Matrix JS/React code style as per: -https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst +https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md -Whilst the layering separation between matrix-react-sdk and Vector is broken +Whilst the layering separation between matrix-react-sdk and Riot is broken (as of July 2016), code should be committed as follows: * All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components - * Vector-specific components: https://github.com/vector-im/vector-web/tree/master/src/components + * Riot-specific components: https://github.com/vector-im/riot-web/tree/master/src/components * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance - burden of customising and overriding these components for Vector can seriously - impede development. So right now, there should be very few (if any) customisations for Vector. - * CSS for Matrix SDK components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk - * CSS for Vector-specific overrides and components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/vector-web + burden of customising and overriding these components for Riot can seriously + impede development. So right now, there should be very few (if any) customisations for Riot. + * CSS for Matrix SDK components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk + * CSS for Riot-specific overrides and components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/riot-web React components in matrix-react-sdk are come in two different flavours: 'structures' and 'views'. Structures are stateful components which handle the @@ -76,7 +80,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: * The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css). CSS for matrix-react-sdk currently resides in - https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk. + https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk. * Per-view CSS is optional - it could choose to inherit all its styling from the context of the rest of the app, although this is unusual for any but @@ -129,7 +133,7 @@ from it. Github Issues ============= -All issues should be filed under https://github.com/vector-im/vector-web/issues +All issues should be filed under https://github.com/vector-im/riot-web/issues for now. OUTDATED: To Create Your Own Skin @@ -190,4 +194,3 @@ Alternative instructions: * Create an index.html file pulling in your compiled javascript and the CSS bundle from the skin you use. For now, you'll also need to manually import CSS from any skins that your skin inherts from. - diff --git a/code_style.md b/code_style.md index f0eca75ffc..2cac303e54 100644 --- a/code_style.md +++ b/code_style.md @@ -69,25 +69,41 @@ General Style console.log("I am a fish"); // Bad } ``` +- No new line before else, catch, finally, etc: + + ```javascript + if (x) { + console.log("I am a fish"); + } else { + console.log("I am a chimp"); // Good + } + + if (x) { + console.log("I am a fish"); + } + else { + console.log("I am a chimp"); // Bad + } + ``` - Declare one variable per var statement (consistent with Node). Unless they are simple and closely related. If you put the next declaration on a new line, treat yourself to another `var`: ```javascript - var key = "foo", + const key = "foo", comparator = function(x, y) { return x - y; }; // Bad - var key = "foo"; - var comparator = function(x, y) { + const key = "foo"; + const comparator = function(x, y) { return x - y; }; // Good - var x = 0, y = 0; // Fine + let x = 0, y = 0; // Fine - var x = 0; - var y = 0; // Also fine + let x = 0; + let y = 0; // Also fine ``` - A single line `if` is fine, all others have braces. This prevents errors when adding to the code.: diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000000..d41aebad3c --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,151 @@ +# Settings Reference + +This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify different values for a setting at particular levels of interest. For example, a user may say that in a particular room they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity of dealing with the different levels and exposes easy to use getters and setters. + + +## Levels + +Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of prioirty, are: +* `device` - The current user's device +* `room-device` - The current user's device, but only when in a specific room +* `room-account` - The current user's account, but only when in a specific room +* `account` - The current user's account +* `room` - A specific room (setting for all members of the room) +* `config` - Values are defined by `config.json` +* `default` - The hardcoded default for the settings + +Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants. + + +## Settings + +Settings are the different options a user may set or experience in the application. These are pre-defined in `src/settings/Settings.js` under the `SETTINGS` constant and have the following minimum requirements: +``` +// The ID is used to reference the setting throughout the application. This must be unique. +"theSettingId": { + // The levels this setting supports is required. In `src/settings/Settings.js` there are various pre-set arrays + // for this option - they should be used where possible to avoid copy/pasting arrays across settings. + supportedLevels: [...], + + // The default for this setting serves two purposes: It provides a value if the setting is not defined at other + // levels, and it serves to demonstrate the expected type to other developers. The value isn't enforced, but it + // should be respected throughout the code. The default may be any data type. + default: false, + + // The display name has two notations: string and object. The object notation allows for different translatable + // strings to be used for different levels, while the string notation represents the string for all levels. + + displayName: _td("Change something"), // effectively `displayName: { "default": _td("Change something") }` + displayName: { + "room": _td("Change something for participants of this room"), + + // Note: the default will be used if the level requested (such as `device`) does not have a string defined here. + "default": _td("Change something"), + } +} +``` + +### Getting values for a setting + +After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value returned is best represented in the room, particularly if the setting ever gets a per-room level in the future. + +In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the target level. + +### Setting values for a setting + +Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue although there are circumstances where this changes. An example of a safe call is: +```javascript +const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM); +if (isSupported) { + const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM); + if (canSetValue) { + SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue); + } +} +``` + +These checks may also be performed in different areas of the application to avoid the verbose example above. For instance, the component which allows changing the setting may be hidden conditionally on the above conditions. + +##### `SettingsFlag` component + +Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use. +```html + +``` + +### Getting the display name for a setting + +Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated for you. If a display name cannot be found, it will return `null`. + + +## Features + +Occasionally some parts of the application may be undergoing testing and are not quite production ready. These are commonly known to be behind a "labs flag". Features behind lab flags must go through the granular settings system, and look and act very much normal settings. The exception is that they must supply `isFeature: true` as part of the setting definition and should go through the helper functions on `SettingsStore`. + +### Determining if a feature is enabled + +A simple call to `SettingsStore.isFeatureEnabled` will tell you if the feature is enabled. This will perform all the required calculations to determine if the feature is enabled based upon the configuration and user selection. + +### Enabling a feature + +Features can only be enabled if the feature is in the `labs` state, otherwise this is a no-op. To find the current set of features in the `labs` state, call `SettingsStore.getLabsFeatures`. To set the value, call `SettingsStore.setFeatureEnabled`. + + +## Setting controllers + +Settings may have environmental factors that affect their value or need additional code to be called when they are modified. A setting controller is able to override the calculated value for a setting and react to changes in that setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings: they can only be considered enabled if the platform supports notifications, and enabling notifications requires additional steps to actually enable notifications. + +For more information, see `src/settings/controllers/SettingController.js`. + + +## Local echo + +`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that the request will go through successfully. The local echo only takes effect until the request to save a setting has completed (either successfully or otherwise). + +```javascript +SettingsStore.setValue(...).then(() => { + // The value has actually been stored at this point. +}); +SettingsStore.getValue(...); // this will return the value set in `setValue` above. +``` + + + +# Maintainers Reference + +The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is supposed to work. + +### General information + +The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each level should be defined in this array, including `default`. + +Handlers (`src/settings/handlers/SettingsHandler.js`) represent a single level and are responsible for getting and setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for their level (for example, a setting being renamed or using a different key from other settings in the underlying store). Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by only considering handlers that are supported on the platform. + +Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.js` which acts as a wrapper around a given handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated immediately upon the proxied save call succeeding or failing. + +Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code. + +### Features + +Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enable_labs` is false/not set. Features are always checked against the configuration before going through the level order as they have the option of being forced-on or forced-off for the application. This is done by the `features` section and looks something like this: + +``` +"features": { + "feature_groups": "enable", + "feature_pinning": "disable", // the default + "feature_presence": "labs" +} +``` + +If `enableLabs` is true in the configuration, the default for features becomes `"labs"`. diff --git a/header b/header index 060709b82e..beee1ebe89 100644 --- a/header +++ b/header @@ -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. diff --git a/jenkins.sh b/jenkins.sh index b318b586e2..3a2d66739e 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -2,21 +2,26 @@ set -e -export KARMAFLAGS="--no-colors" -export NVM_DIR="/home/jenkins/.nvm" +export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" -nvm use 4 +nvm use 6 set -x # install the other dependencies npm install +# we may be using a dev branch of js-sdk in which case we need to build it +(cd node_modules/matrix-js-sdk && npm install) + # run the mocha tests -npm run test +npm run test -- --no-colors # run eslint -npm run lint -- -f checkstyle -o eslint.xml || true +npm run lintall -- -f checkstyle -o eslint.xml || true + +# re-run the linter, excluding any files known to have errors or warnings. +npm run lintwithexclusions # delete the old tarball, if it exists rm -f matrix-react-sdk-*.tgz diff --git a/karma.conf.js b/karma.conf.js index 131a03ce79..164cd9ce59 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -55,11 +55,18 @@ module.exports = function (config) { // some images to reduce noise from the tests {pattern: 'test/img/*', watched: false, included: false, served: true, nocache: false}, + // translation files + {pattern: 'src/i18n/strings/*', watcheed: false, included: false, served: true}, + {pattern: 'test/i18n/*', watched: false, included: false, served: true}, ], - // redirect img links to the karma server proxies: { + // redirect img links to the karma server "/img/": "/base/test/img/", + // special languages.json file for the tests + "/i18n/languages.json": "/base/test/i18n/languages.json", + // and redirect i18n requests + "/i18n/": "/base/src/i18n/strings/", }, // list of files to exclude @@ -86,7 +93,18 @@ module.exports = function (config) { // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['progress', 'junit'], + reporters: ['logcapture', 'spec', 'junit', 'summary'], + + specReporter: { + suppressErrorSummary: false, // do print error summary + suppressFailed: false, // do print information about failed tests + suppressPassed: false, // do print information about passed tests + showSpecTiming: true, // print the time elapsed for each spec + }, + + client: { + captureLogs: true, + }, // web server port port: 9876, @@ -97,7 +115,10 @@ module.exports = function (config) { // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, + // + // This is strictly for logs that would be generated by the browser itself and we + // don't want to log about missing images, which are emitted on LOG_WARN. + logLevel: config.LOG_ERROR, // enable / disable watching file and executing tests whenever any file // changes @@ -109,11 +130,25 @@ module.exports = function (config) { browsers: [ 'Chrome', //'PhantomJS', + //'ChromeHeadless', ], + customLaunchers: { + 'ChromeHeadless': { + base: 'Chrome', + flags: [ + // See https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md + '--headless', + '--disable-gpu', + // Without a remote debugging port, Google Chrome exits immediately. + '--remote-debugging-port=9222', + ], + } + }, + // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits - singleRun: true, + // singleRun: false, // Concurrency level // how many browser should be started simultaneous @@ -135,17 +170,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: { @@ -159,12 +201,24 @@ module.exports = function (config) { 'sinon': 'sinon/pkg/sinon.js', }, root: [ - path.resolve('./src'), path.resolve('./test'), ], }, devtool: 'inline-source-map', + externals: { + // Don't try to bundle electron: leave it as a commonjs dependency + // (the 'commonjs' here means it will output a 'require') + "electron": "commonjs electron", + }, }, + + webpackMiddleware: { + stats: { + // don't fill the console up with a mahoosive list of modules + chunks: false, + }, + }, + browserNoActivityTimeout: 15000, }); }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..6dd02674be --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6551 @@ +{ + "name": "matrix-react-sdk", + "version": "0.10.7", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "accepts": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", + "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "dev": true, + "requires": { + "mime-types": "2.1.17", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.1.2.tgz", + "integrity": "sha512-o96FZLJBPY1lvTuJylGA9Bk3t/GKPPJG8H0ydQQl01crzwJgspa4AEIq/pVTXigmK0PHVQhiAtn8WMBLL9D2WA==", + "dev": true + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "3.3.0" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=", + "dev": true + }, + "ajv": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.2.3.tgz", + "integrity": "sha1-wG9Zh3jETGsWGrr+NGa4GtGBTtI=", + "requires": { + "co": "4.6.0", + "fast-deep-equal": "1.0.0", + "json-schema-traverse": "0.3.1", + "json-stable-stringify": "1.0.1" + } + }, + "ajv-keywords": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", + "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "dev": true, + "requires": { + "kind-of": "3.2.2", + "longest": "1.0.1", + "repeat-string": "1.6.1" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "another-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", + "integrity": "sha1-tfQBnJc7bdXGUGotk0acttMq7tw=" + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "dev": true, + "requires": { + "micromatch": "2.3.11", + "normalize-path": "2.1.1" + } + }, + "argparse": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", + "integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=", + "dev": true, + "requires": { + "sprintf-js": "1.0.3" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + } + } + }, + "arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", + "dev": true, + "requires": { + "arr-flatten": "1.1.0" + } + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "array-includes": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz", + "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=", + "dev": true, + "requires": { + "define-properties": "1.1.2", + "es-abstract": "1.9.0" + } + }, + "array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU=", + "dev": true + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "1.0.3" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", + "dev": true + }, + "arraybuffer.slice": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz", + "integrity": "sha1-8zshWfBTKj8xB6JywMz70a0peco=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "dev": true, + "requires": { + "util": "0.10.3" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=", + "dev": true + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + }, + "babel-cli": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-cli/-/babel-cli-6.26.0.tgz", + "integrity": "sha1-UCq1SHTX24itALiHoGODzgPQAvE=", + "dev": true, + "requires": { + "babel-core": "6.26.0", + "babel-polyfill": "6.26.0", + "babel-register": "6.26.0", + "babel-runtime": "6.26.0", + "chokidar": "1.7.0", + "commander": "2.11.0", + "convert-source-map": "1.5.0", + "fs-readdir-recursive": "1.0.0", + "glob": "7.1.2", + "lodash": "4.17.4", + "output-file-sync": "1.1.2", + "path-is-absolute": "1.0.1", + "slash": "1.0.0", + "source-map": "0.5.7", + "v8flags": "2.1.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "1.1.3", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + } + }, + "babel-core": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz", + "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=", + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "babel-generator": "6.26.0", + "babel-helpers": "6.24.1", + "babel-messages": "6.23.0", + "babel-register": "6.26.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "convert-source-map": "1.5.0", + "debug": "2.6.9", + "json5": "0.5.1", + "lodash": "4.17.4", + "minimatch": "3.0.4", + "path-is-absolute": "1.0.1", + "private": "0.1.8", + "slash": "1.0.0", + "source-map": "0.5.7" + } + }, + "babel-eslint": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-6.1.2.tgz", + "integrity": "sha1-UpNBn+NnLWZZjTJ9qWlFZ7pqXy8=", + "dev": true, + "requires": { + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "lodash.assign": "4.2.0", + "lodash.pickby": "4.6.0" + } + }, + "babel-generator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.0.tgz", + "integrity": "sha1-rBriAHC3n248odMmlhMFN3TyDcU=", + "dev": true, + "requires": { + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "detect-indent": "4.0.0", + "jsesc": "1.3.0", + "lodash": "4.17.4", + "source-map": "0.5.7", + "trim-right": "1.0.1" + } + }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "dev": true, + "requires": { + "babel-helper-explode-assignable-expression": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-builder-react-jsx": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz", + "integrity": "sha1-Of+DE7dci2Xc7/HzHTg+D/KkCKA=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "esutils": "2.0.2" + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "6.24.1", + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.4" + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "dev": true, + "requires": { + "babel-helper-get-function-arity": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.4" + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "dev": true, + "requires": { + "babel-helper-optimise-call-expression": "6.24.1", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-loader": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-6.4.1.tgz", + "integrity": "sha1-CzQRLVsHSKjc2/Uaz2+b1C1QuMo=", + "dev": true, + "requires": { + "find-cache-dir": "0.1.1", + "loader-utils": "0.2.17", + "mkdirp": "0.5.1", + "object-assign": "4.1.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-add-module-exports": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz", + "integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU=", + "dev": true + }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=", + "dev": true + }, + "babel-plugin-syntax-class-properties": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz", + "integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94=", + "dev": true + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", + "dev": true + }, + "babel-plugin-syntax-flow": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz", + "integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0=", + "dev": true + }, + "babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=", + "dev": true + }, + "babel-plugin-syntax-object-rest-spread": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=", + "dev": true + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=", + "dev": true + }, + "babel-plugin-transform-async-to-bluebird": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-bluebird/-/babel-plugin-transform-async-to-bluebird-1.1.1.tgz", + "integrity": "sha1-Ruo+fFr2KXgqyfHtG3zTj4Qlr9Q=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-plugin-syntax-async-functions": "6.13.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0" + } + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "dev": true, + "requires": { + "babel-helper-remap-async-to-generator": "6.24.1", + "babel-plugin-syntax-async-functions": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-class-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz", + "integrity": "sha1-anl2PqYdM9NvN7YRqp3vgagbRqw=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-plugin-syntax-class-properties": "6.13.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "lodash": "4.17.4" + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "dev": true, + "requires": { + "babel-helper-define-map": "6.26.0", + "babel-helper-function-name": "6.24.1", + "babel-helper-optimise-call-expression": "6.24.1", + "babel-helper-replace-supers": "6.24.1", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "6.26.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz", + "integrity": "sha1-DYOUApt9xqvhqX7xgeAHWN0uXYo=", + "dev": true, + "requires": { + "babel-plugin-transform-strict-mode": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "dev": true, + "requires": { + "babel-helper-replace-supers": "6.24.1", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "dev": true, + "requires": { + "babel-helper-call-delegate": "6.24.1", + "babel-helper-get-function-arity": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "dev": true, + "requires": { + "babel-helper-regex": "6.26.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "dev": true, + "requires": { + "babel-helper-regex": "6.26.0", + "babel-runtime": "6.26.0", + "regexpu-core": "2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "dev": true, + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "6.24.1", + "babel-plugin-syntax-exponentiation-operator": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-flow-strip-types": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz", + "integrity": "sha1-hMtnKTXUNxT9wyvOhFaNh0Qc988=", + "dev": true, + "requires": { + "babel-plugin-syntax-flow": "6.18.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-object-rest-spread": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz", + "integrity": "sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=", + "dev": true, + "requires": { + "babel-plugin-syntax-object-rest-spread": "6.13.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-react-display-name": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz", + "integrity": "sha1-Z+K/Hx6ck6sI25Z5LgU5K/LMKNE=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-react-jsx": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz", + "integrity": "sha1-hAoCjn30YN/DotKfDA2R9jduZqM=", + "dev": true, + "requires": { + "babel-helper-builder-react-jsx": "6.26.0", + "babel-plugin-syntax-jsx": "6.18.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-react-jsx-self": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz", + "integrity": "sha1-322AqdomEqEh5t3XVYvL7PBuY24=", + "dev": true, + "requires": { + "babel-plugin-syntax-jsx": "6.18.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-react-jsx-source": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz", + "integrity": "sha1-ZqwSFT9c0tF7PBkmj0vwGX9E7NY=", + "dev": true, + "requires": { + "babel-plugin-syntax-jsx": "6.18.0", + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "dev": true, + "requires": { + "regenerator-transform": "0.10.1" + } + }, + "babel-plugin-transform-runtime": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.23.0.tgz", + "integrity": "sha1-iEkNRGUC6puOfvsP4J7E2ZR5se4=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-polyfill": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", + "integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "core-js": "2.5.1", + "regenerator-runtime": "0.10.5" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=", + "dev": true + } + } + }, + "babel-preset-es2015": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz", + "integrity": "sha1-1EBQ1rwsn+6nAqrzjXJ6AhBTiTk=", + "dev": true, + "requires": { + "babel-plugin-check-es2015-constants": "6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "6.22.0", + "babel-plugin-transform-es2015-block-scoping": "6.26.0", + "babel-plugin-transform-es2015-classes": "6.24.1", + "babel-plugin-transform-es2015-computed-properties": "6.24.1", + "babel-plugin-transform-es2015-destructuring": "6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "6.24.1", + "babel-plugin-transform-es2015-for-of": "6.23.0", + "babel-plugin-transform-es2015-function-name": "6.24.1", + "babel-plugin-transform-es2015-literals": "6.22.0", + "babel-plugin-transform-es2015-modules-amd": "6.24.1", + "babel-plugin-transform-es2015-modules-commonjs": "6.26.0", + "babel-plugin-transform-es2015-modules-systemjs": "6.24.1", + "babel-plugin-transform-es2015-modules-umd": "6.24.1", + "babel-plugin-transform-es2015-object-super": "6.24.1", + "babel-plugin-transform-es2015-parameters": "6.24.1", + "babel-plugin-transform-es2015-shorthand-properties": "6.24.1", + "babel-plugin-transform-es2015-spread": "6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "6.24.1", + "babel-plugin-transform-es2015-template-literals": "6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "6.24.1", + "babel-plugin-transform-regenerator": "6.26.0" + } + }, + "babel-preset-es2016": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-es2016/-/babel-preset-es2016-6.24.1.tgz", + "integrity": "sha1-+QC/k+LrwNJ235uKtZck6/2Vn4s=", + "dev": true, + "requires": { + "babel-plugin-transform-exponentiation-operator": "6.24.1" + } + }, + "babel-preset-es2017": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-es2017/-/babel-preset-es2017-6.24.1.tgz", + "integrity": "sha1-WXvq37n38gi8/YoS6bKym4svFNE=", + "dev": true, + "requires": { + "babel-plugin-syntax-trailing-function-commas": "6.22.0", + "babel-plugin-transform-async-to-generator": "6.24.1" + } + }, + "babel-preset-flow": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz", + "integrity": "sha1-5xIYiHCFrpoktb5Baa/7WZgWxJ0=", + "dev": true, + "requires": { + "babel-plugin-transform-flow-strip-types": "6.22.0" + } + }, + "babel-preset-react": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz", + "integrity": "sha1-umnfrqRfw+xjm2pOzqbhdwLJE4A=", + "dev": true, + "requires": { + "babel-plugin-syntax-jsx": "6.18.0", + "babel-plugin-transform-react-display-name": "6.25.0", + "babel-plugin-transform-react-jsx": "6.24.1", + "babel-plugin-transform-react-jsx-self": "6.22.0", + "babel-plugin-transform-react-jsx-source": "6.22.0", + "babel-preset-flow": "6.23.0" + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "dev": true, + "requires": { + "babel-core": "6.26.0", + "babel-runtime": "6.26.0", + "core-js": "2.5.1", + "home-or-tmp": "2.0.0", + "lodash": "4.17.4", + "mkdirp": "0.5.1", + "source-map-support": "0.4.18" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "2.5.1", + "regenerator-runtime": "0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "lodash": "4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "debug": "2.6.9", + "globals": "9.18.0", + "invariant": "2.2.2", + "lodash": "4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "esutils": "2.0.2", + "lodash": "4.17.4", + "to-fast-properties": "1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=", + "dev": true + }, + "base64-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.1.tgz", + "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw==", + "dev": true + }, + "base64id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", + "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "dev": true, + "requires": { + "callsite": "1.0.0" + } + }, + "big.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", + "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==", + "dev": true + }, + "binary-extensions": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.10.0.tgz", + "integrity": "sha1-muuabF6IY4qtFx4Wf1kAq+JINdA=", + "dev": true + }, + "blob": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz", + "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=", + "dev": true + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, + "blueimp-canvas-to-blob": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.14.0.tgz", + "integrity": "sha512-i6I2CiX1VR8YwUNYBo+dM8tg89ns4TTHxSpWjaDeHKcYS3yFalpLCwDaY21/EsJMufLy2tnG4j0JN5L8OVNkKQ==" + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "content-type": "1.0.4", + "debug": "2.6.9", + "depd": "1.1.1", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "1.6.15" + } + }, + "boom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", + "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", + "requires": { + "hoek": "4.2.0" + } + }, + "brace-expansion": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", + "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", + "dev": true, + "requires": { + "expand-range": "1.8.2", + "preserve": "0.2.0", + "repeat-element": "1.1.2" + } + }, + "browser-encrypt-attachment": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/browser-encrypt-attachment/-/browser-encrypt-attachment-0.3.0.tgz", + "integrity": "sha1-IFqUyq3w3H6BQTlBgS9lW9GQ/xw=" + }, + "browser-request": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/browser-request/-/browser-request-0.3.3.tgz", + "integrity": "sha1-ns5bWsqJopkyJC4Yv5M975h2zBc=" + }, + "browserify-aes": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-0.4.0.tgz", + "integrity": "sha1-BnFJtmjfMcS1hTPgLQHoBthgjiw=", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "browserify-zlib": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", + "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=", + "dev": true, + "requires": { + "pako": "0.2.9" + } + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "dev": true, + "requires": { + "base64-js": "1.2.1", + "ieee754": "1.1.8", + "isarray": "1.0.0" + } + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "dev": true + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "0.2.0" + } + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", + "dev": true + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "requires": { + "align-text": "0.1.4", + "lazy-cache": "1.0.4" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "chokidar": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", + "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=", + "dev": true, + "requires": { + "anymatch": "1.3.2", + "async-each": "1.0.1", + "fsevents": "1.1.2", + "glob-parent": "2.0.0", + "inherits": "2.0.3", + "is-binary-path": "1.0.1", + "is-glob": "2.0.1", + "path-is-absolute": "1.0.1", + "readdirp": "2.1.0" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "classnames": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", + "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "dev": true, + "requires": { + "restore-cursor": "1.0.1" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "requires": { + "center-align": "0.1.3", + "right-align": "0.1.3", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true + } + } + }, + "clone": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz", + "integrity": "sha1-Jgt6meux7f4kdTgXX3gyQ8sZ0Uk=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", + "dev": true + }, + "combine-lists": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/combine-lists/-/combine-lists-1.0.1.tgz", + "integrity": "sha1-RYwH4J4NkA/Ci3Cj/sLazR0st/Y=", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "combined-stream": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", + "requires": { + "delayed-stream": "1.0.0" + } + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "commonmark": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.27.0.tgz", + "integrity": "sha1-2GwmK5YoIelIPGnFR7xYhAwEezQ=", + "requires": { + "entities": "1.1.1", + "mdurl": "1.0.1", + "minimist": "1.2.0", + "string.prototype.repeat": "0.2.0" + } + }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=", + "dev": true + }, + "component-emitter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz", + "integrity": "sha1-KWWU8nU9qmOZbSrwjRWpURbJrsM=", + "dev": true + }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz", + "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=", + "dev": true, + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.3", + "typedarray": "0.0.6" + } + }, + "connect": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.5.tgz", + "integrity": "sha1-+43ee6B2OHfQ7J352sC0tA5yx9o=", + "dev": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.0.6", + "parseurl": "1.3.2", + "utils-merge": "1.0.1" + } + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "0.1.4" + } + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.0.tgz", + "integrity": "sha1-ms1whRxtXf3ZPZKC5e35SgP/RrU=", + "dev": true + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", + "dev": true + }, + "core-js": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz", + "integrity": "sha1-rmh03GaTd4m4B1T/VCjfZoGcpQs=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "counterpart": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/counterpart/-/counterpart-0.18.3.tgz", + "integrity": "sha512-tli4qPAFeYB34LvvCc/1xYRLCWjf4WsUt6sXfpggDfGDKoI8rhnabz0SljDoBpAK8z1u8GBCg0YDkbvWb16uUQ==", + "requires": { + "date-names": "0.1.10", + "except": "0.1.3", + "extend": "3.0.1", + "pluralizers": "0.1.6", + "sprintf-js": "1.1.1" + } + }, + "create-react-class": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.2.tgz", + "integrity": "sha1-zx7RXxKq1/FO9fLf4F5sQvke8Co=", + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1" + } + }, + "cryptiles": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", + "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", + "requires": { + "boom": "5.2.0" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "requires": { + "hoek": "4.2.0" + } + } + } + }, + "crypto-browserify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.3.0.tgz", + "integrity": "sha1-ufx1u0oO1h3PHNXa6W6zDJw+UGw=", + "dev": true, + "requires": { + "browserify-aes": "0.4.0", + "pbkdf2-compat": "2.0.1", + "ripemd160": "0.2.0", + "sha.js": "2.2.6" + } + }, + "custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=", + "dev": true + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "dev": true, + "requires": { + "es5-ext": "0.10.35" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "date-names": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/date-names/-/date-names-0.1.10.tgz", + "integrity": "sha1-YvjZMyKVBEZX8852FtijQCH47ys=" + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "dev": true, + "requires": { + "foreach": "2.0.5", + "object-keys": "1.0.11" + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "5.0.0", + "is-path-cwd": "1.0.0", + "is-path-in-cwd": "1.0.0", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "rimraf": "2.6.2" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=", + "dev": true + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, + "requires": { + "repeating": "2.0.1" + } + }, + "di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", + "dev": true + }, + "diff": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz", + "integrity": "sha1-fyjS657nsVqX79ic5j3P2qPMur8=", + "dev": true + }, + "doctrine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.0.0.tgz", + "integrity": "sha1-xz2NKQnSIpHhoAejlYBNqLZl/mM=", + "dev": true, + "requires": { + "esutils": "2.0.2", + "isarray": "1.0.0" + } + }, + "dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=", + "dev": true, + "requires": { + "custom-event": "1.0.1", + "ent": "2.2.0", + "extend": "3.0.1", + "void-elements": "2.0.1" + } + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "requires": { + "domelementtype": "1.1.3", + "entities": "1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=" + } + } + }, + "domain-browser": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz", + "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=", + "dev": true + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=" + }, + "domhandler": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.1.tgz", + "integrity": "sha1-iS5HAAqZvlW783dP/qBWHYh5wlk=", + "requires": { + "domelementtype": "1.3.0" + } + }, + "domutils": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.6.2.tgz", + "integrity": "sha1-GVjMC0yUJuntNn+xyOhUiRsPo/8=", + "requires": { + "dom-serializer": "0.1.0", + "domelementtype": "1.3.0" + } + }, + "draft-js": { + "version": "0.11.0-alpha", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.11.0-alpha.tgz", + "integrity": "sha1-MtshCPkn6bhEbaH3nkR1wrf4aK4=", + "requires": { + "fbjs": "0.8.16", + "immutable": "3.7.6", + "object-assign": "4.1.1" + } + }, + "draft-js-export-html": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/draft-js-export-html/-/draft-js-export-html-0.6.0.tgz", + "integrity": "sha1-zIDwVExD0Kf+28U8DLCRToCQ92k=", + "requires": { + "draft-js-utils": "1.2.0" + } + }, + "draft-js-export-markdown": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/draft-js-export-markdown/-/draft-js-export-markdown-0.3.0.tgz", + "integrity": "sha1-hjkOA86vHTR/xhaGerf1Net2v0I=", + "requires": { + "draft-js-utils": "1.2.0" + } + }, + "draft-js-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/draft-js-utils/-/draft-js-utils-1.2.0.tgz", + "integrity": "sha1-9csj6xZzJf/tPXmIL9wxdyHS/RI=" + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "emojione": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/emojione/-/emojione-2.2.7.tgz", + "integrity": "sha1-RkV89rmy+NoTroouTlR94G7hXpY=" + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "encodeurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", + "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA=", + "dev": true + }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "0.4.19" + } + }, + "engine.io": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.3.tgz", + "integrity": "sha1-jef5eJXSDTm4X4ju7nd7K9QrE9Q=", + "dev": true, + "requires": { + "accepts": "1.3.3", + "base64id": "1.0.0", + "cookie": "0.3.1", + "debug": "2.3.3", + "engine.io-parser": "1.3.2", + "ws": "1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "dev": true, + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=", + "dev": true + } + } + }, + "engine.io-client": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.3.tgz", + "integrity": "sha1-F5jtk0USRkU9TG9jXXogH+lA1as=", + "dev": true, + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "2.3.3", + "engine.io-parser": "1.3.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parsejson": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "1.1.2", + "xmlhttprequest-ssl": "1.5.3", + "yeast": "0.1.2" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "dev": true, + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=", + "dev": true + } + } + }, + "engine.io-parser": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz", + "integrity": "sha1-k3sHnwAH0Ik+xW1GyyILjLQ1Igo=", + "dev": true, + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "0.0.6", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.4", + "has-binary": "0.1.7", + "wtf-8": "1.0.0" + } + }, + "enhanced-resolve": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", + "integrity": "sha1-TW5omzcl+GCQknzMhs2fFjW4ni4=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "memory-fs": "0.2.0", + "tapable": "0.1.10" + }, + "dependencies": { + "memory-fs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", + "integrity": "sha1-8rslNovBIeORwlIN6Slpyu4KApA=", + "dev": true + } + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "dev": true + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=" + }, + "errno": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz", + "integrity": "sha1-uJbiOp5ei6M4cfyZar02NfyaHH0=", + "dev": true, + "requires": { + "prr": "0.0.0" + } + }, + "es-abstract": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.9.0.tgz", + "integrity": "sha512-kk3IJoKo7A3pWJc0OV8yZ/VEX2oSUytfekrJiqoxBlKJMFAJVJVpGdHClCCTdv+Fn2zHfpDHHIelMFhZVfef3Q==", + "dev": true, + "requires": { + "es-to-primitive": "1.1.1", + "function-bind": "1.1.1", + "has": "1.0.1", + "is-callable": "1.1.3", + "is-regex": "1.0.4" + } + }, + "es-to-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", + "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", + "dev": true, + "requires": { + "is-callable": "1.1.3", + "is-date-object": "1.0.1", + "is-symbol": "1.0.1" + } + }, + "es5-ext": { + "version": "0.10.35", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.35.tgz", + "integrity": "sha1-GO6FjOajxFx9eekcFfzKnsVoSU8=", + "dev": true, + "requires": { + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35", + "es6-symbol": "3.1.1" + } + }, + "es6-map": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", + "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35", + "es6-iterator": "2.0.3", + "es6-set": "0.1.5", + "es6-symbol": "3.1.1", + "event-emitter": "0.3.5" + } + }, + "es6-set": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", + "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35", + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1", + "event-emitter": "0.3.5" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35", + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escope": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz", + "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", + "dev": true, + "requires": { + "es6-map": "0.1.5", + "es6-weak-map": "2.0.2", + "esrecurse": "4.2.0", + "estraverse": "4.2.0" + } + }, + "eslint": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-3.19.0.tgz", + "integrity": "sha1-yPxiAcf0DdCJQbh8CFdnOGpnmsw=", + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "chalk": "1.1.3", + "concat-stream": "1.6.0", + "debug": "2.6.9", + "doctrine": "2.0.0", + "escope": "3.6.0", + "espree": "3.5.1", + "esquery": "1.0.0", + "estraverse": "4.2.0", + "esutils": "2.0.2", + "file-entry-cache": "2.0.0", + "glob": "7.1.2", + "globals": "9.18.0", + "ignore": "3.3.5", + "imurmurhash": "0.1.4", + "inquirer": "0.12.0", + "is-my-json-valid": "2.16.1", + "is-resolvable": "1.0.0", + "js-yaml": "3.10.0", + "json-stable-stringify": "1.0.1", + "levn": "0.3.0", + "lodash": "4.17.4", + "mkdirp": "0.5.1", + "natural-compare": "1.4.0", + "optionator": "0.8.2", + "path-is-inside": "1.0.2", + "pluralize": "1.2.1", + "progress": "1.1.8", + "require-uncached": "1.0.3", + "shelljs": "0.7.8", + "strip-bom": "3.0.0", + "strip-json-comments": "2.0.1", + "table": "3.8.3", + "text-table": "0.2.0", + "user-home": "2.0.0" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha1-nHC/2Babwdy/SGBODwS4tJzenp8=", + "dev": true, + "requires": { + "os-homedir": "1.0.2" + } + } + } + }, + "eslint-config-google": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.7.1.tgz", + "integrity": "sha1-VZj4SY6eB4Qg80uASVuNlZ9lH7I=", + "dev": true + }, + "eslint-plugin-babel": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-babel/-/eslint-plugin-babel-4.1.2.tgz", + "integrity": "sha1-eSAqDjV1fdkngJGbIzbx+i/lPB4=", + "dev": true + }, + "eslint-plugin-flowtype": { + "version": "2.39.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.39.1.tgz", + "integrity": "sha512-RiQv+7Z9QDJuzt+NO8sYgkLGT+h+WeCrxP7y8lI7wpU41x3x/2o3PGtHk9ck8QnA9/mlbNcy/hG0eKvmd7npaA==", + "dev": true, + "requires": { + "lodash": "4.17.4" + } + }, + "eslint-plugin-react": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.4.0.tgz", + "integrity": "sha512-tvjU9u3VqmW2vVuYnE8Qptq+6ji4JltjOjJ9u7VAOxVYkUkyBZWRvNYKbDv5fN+L6wiA+4we9+qQahZ0m63XEA==", + "dev": true, + "requires": { + "doctrine": "2.0.0", + "has": "1.0.1", + "jsx-ast-utils": "2.0.1", + "prop-types": "15.6.0" + } + }, + "espree": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.1.tgz", + "integrity": "sha1-DJiLirRttTEAoZVK5LqZXd0n2H4=", + "dev": true, + "requires": { + "acorn": "5.1.2", + "acorn-jsx": "3.0.1" + } + }, + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==", + "dev": true + }, + "esquery": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.0.tgz", + "integrity": "sha1-z7qLV9f7qT8XKYqKAGoEzaE9gPo=", + "dev": true, + "requires": { + "estraverse": "4.2.0" + } + }, + "esrecurse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.0.tgz", + "integrity": "sha1-+pVo2Y04I/mkHZHpAtyrnqblsWM=", + "dev": true, + "requires": { + "estraverse": "4.2.0", + "object-assign": "4.1.1" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "estree-walker": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.5.0.tgz", + "integrity": "sha512-/bEAy+yKAZQrEWUhGmS3H9XpGqSDBtRzX0I2PgMw9kA2n1jN22uV5B5p7MFdZdvWdXCRJztXAfx6ZeRfgkEETg==", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.35" + } + }, + "eventemitter3": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", + "integrity": "sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg=", + "dev": true + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "dev": true + }, + "except": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/except/-/except-0.1.3.tgz", + "integrity": "sha1-mCYckZWFUVNrREgiOOl4P7c9KSo=", + "requires": { + "indexof": "0.0.1" + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", + "dev": true + }, + "expand-braces": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/expand-braces/-/expand-braces-0.1.2.tgz", + "integrity": "sha1-SIsdHSRRyz06axks/AMPRMWFX+o=", + "dev": true, + "requires": { + "array-slice": "0.2.3", + "array-unique": "0.2.1", + "braces": "0.1.5" + }, + "dependencies": { + "braces": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-0.1.5.tgz", + "integrity": "sha1-wIVxEIUpHYt1/ddOqw+FlygHEeY=", + "dev": true, + "requires": { + "expand-range": "0.1.1" + } + }, + "expand-range": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-0.1.1.tgz", + "integrity": "sha1-TLjtoJk8pW+k9B/ELzy7TMrf8EQ=", + "dev": true, + "requires": { + "is-number": "0.1.1", + "repeat-string": "0.2.2" + } + }, + "is-number": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-0.1.1.tgz", + "integrity": "sha1-aaevEWlj1HIG7JvZtIoUIW8eOAY=", + "dev": true + }, + "repeat-string": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-0.2.2.tgz", + "integrity": "sha1-x6jTI2BoNiBZp+RlH8aITosftK4=", + "dev": true + } + } + }, + "expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", + "dev": true, + "requires": { + "is-posix-bracket": "0.1.1" + } + }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "dev": true, + "requires": { + "fill-range": "2.2.3" + } + }, + "expect": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-1.20.2.tgz", + "integrity": "sha1-1Fj+TFYAQDa64yMkFqP2Nh8E+WU=", + "dev": true, + "requires": { + "define-properties": "1.1.2", + "has": "1.0.1", + "is-equal": "1.5.5", + "is-regex": "1.0.4", + "object-inspect": "1.3.0", + "object-keys": "1.0.11", + "tmatch": "2.0.1" + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" + }, + "extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", + "dev": true, + "requires": { + "is-extglob": "1.0.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", + "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fbemitter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-2.1.1.tgz", + "integrity": "sha1-Uj4U/a9SSIBbsC9i78M75wP1GGU=", + "requires": { + "fbjs": "0.8.16" + } + }, + "fbjs": { + "version": "0.8.16", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz", + "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=", + "requires": { + "core-js": "1.2.7", + "isomorphic-fetch": "2.2.1", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "promise": "7.3.1", + "setimmediate": "1.0.5", + "ua-parser-js": "0.7.17" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + } + } + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "1.0.5", + "object-assign": "4.1.1" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "1.3.0", + "object-assign": "4.1.1" + } + }, + "file-saver": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.3.tgz", + "integrity": "sha1-zdTETTqiZOrC9o7BZbx5HDSvEjI=" + }, + "filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=", + "dev": true + }, + "filesize": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.5.6.tgz", + "integrity": "sha1-X9mPPqyU7JUW747VeC+thKAaCho=" + }, + "fill-range": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", + "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=", + "dev": true, + "requires": { + "is-number": "2.1.0", + "isobject": "2.1.0", + "randomatic": "1.1.7", + "repeat-element": "1.1.2", + "repeat-string": "1.6.1" + } + }, + "finalhandler": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.0.6.tgz", + "integrity": "sha1-AHrqM9Gk0+QgF/YkhIrVjSEvgU8=", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "on-finished": "2.3.0", + "parseurl": "1.3.2", + "statuses": "1.3.1", + "unpipe": "1.0.0" + } + }, + "find-cache-dir": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz", + "integrity": "sha1-yN765XyKUqinhPnjHFfHQumToLk=", + "dev": true, + "requires": { + "commondir": "1.0.1", + "mkdirp": "0.5.1", + "pkg-dir": "1.0.0" + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "0.3.3", + "del": "2.2.2", + "graceful-fs": "4.1.11", + "write": "0.2.1" + } + }, + "flow-parser": { + "version": "0.57.3", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.57.3.tgz", + "integrity": "sha1-uNJBobHLrgQ6+nl2458mmYjY/jQ=", + "dev": true + }, + "flux": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/flux/-/flux-2.1.1.tgz", + "integrity": "sha1-LGrGUtQzdIiWhInGWG86/yajjqQ=", + "requires": { + "fbemitter": "2.1.1", + "fbjs": "0.1.0-alpha.7", + "immutable": "3.7.6" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "fbjs": { + "version": "0.1.0-alpha.7", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.1.0-alpha.7.tgz", + "integrity": "sha1-rUMIuPIy+zxzYDNJ6nJdHpw5Mjw=", + "requires": { + "core-js": "1.2.7", + "promise": "7.3.1", + "whatwg-fetch": "0.9.0" + } + }, + "whatwg-fetch": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-0.9.0.tgz", + "integrity": "sha1-DjaExsuZlbQ+/J3wPkw2XZX9nMA=" + } + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "dev": true, + "requires": { + "for-in": "1.0.2" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha1-VQKYfchxS+M5IJfzLgBxyd7gfPY=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", + "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.17" + } + }, + "formatio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.1.1.tgz", + "integrity": "sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek=", + "dev": true, + "requires": { + "samsam": "1.1.2" + } + }, + "fs-access": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", + "integrity": "sha1-1qh/JiJxzv6+wwxVNAf7mV2od3o=", + "dev": true, + "requires": { + "null-check": "1.0.0" + } + }, + "fs-readdir-recursive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz", + "integrity": "sha1-jNF0XItPiinIyuw5JHaSG6GV9WA=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.2.tgz", + "integrity": "sha512-Sn44E5wQW4bTHXvQmvSHwqbuiXtduD6Rrjm2ZtUEGbyrig+nUH3t/QD4M4/ZXViY556TBpRgZkHLDx3JxPwxiw==", + "dev": true, + "optional": true, + "requires": { + "nan": "2.7.0", + "node-pre-gyp": "0.6.36" + }, + "dependencies": { + "abbrev": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "ajv": { + "version": "4.11.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "co": "4.6.0", + "json-stable-stringify": "1.0.1" + } + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "1.0.0", + "readable-stream": "2.2.9" + } + }, + "asn1": { + "version": "0.2.3", + "bundled": true, + "dev": true, + "optional": true + }, + "assert-plus": { + "version": "0.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "asynckit": { + "version": "0.4.0", + "bundled": true, + "dev": true, + "optional": true + }, + "aws-sign2": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "aws4": { + "version": "1.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "balanced-match": { + "version": "0.4.2", + "bundled": true, + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "block-stream": { + "version": "0.0.9", + "bundled": true, + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "boom": { + "version": "2.10.1", + "bundled": true, + "dev": true, + "requires": { + "hoek": "2.16.3" + } + }, + "brace-expansion": { + "version": "1.1.7", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "0.4.2", + "concat-map": "0.0.1" + } + }, + "buffer-shims": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "caseless": { + "version": "0.12.0", + "bundled": true, + "dev": true, + "optional": true + }, + "co": { + "version": "4.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "combined-stream": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "cryptiles": { + "version": "2.0.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "boom": "2.10.1" + } + }, + "dashdash": { + "version": "1.14.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "debug": { + "version": "2.6.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.4.2", + "bundled": true, + "dev": true, + "optional": true + }, + "delayed-stream": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "ecc-jsbn": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "extend": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "extsprintf": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "optional": true + }, + "form-data": { + "version": "2.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.15" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "fstream": { + "version": "1.0.11", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "inherits": "2.0.3", + "mkdirp": "0.5.1", + "rimraf": "2.6.1" + } + }, + "fstream-ignore": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fstream": "1.0.11", + "inherits": "2.0.3", + "minimatch": "3.0.4" + } + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "1.1.1", + "console-control-strings": "1.1.0", + "has-unicode": "2.0.1", + "object-assign": "4.1.1", + "signal-exit": "3.0.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wide-align": "1.1.2" + } + }, + "getpass": { + "version": "0.1.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true, + "dev": true + }, + "har-schema": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "har-validator": { + "version": "4.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ajv": "4.11.8", + "har-schema": "1.0.5" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "hawk": { + "version": "3.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "boom": "2.10.1", + "cryptiles": "2.0.5", + "hoek": "2.16.3", + "sntp": "1.0.9" + } + }, + "hoek": { + "version": "2.16.3", + "bundled": true, + "dev": true + }, + "http-signature": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "0.2.0", + "jsprim": "1.4.0", + "sshpk": "1.13.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.4", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "isstream": { + "version": "0.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "jodid25519": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "jsbn": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "bundled": true, + "dev": true, + "optional": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "jsonify": "0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "jsonify": { + "version": "0.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "jsprim": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.0.2", + "json-schema": "0.2.3", + "verror": "1.3.6" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "mime-db": { + "version": "1.27.0", + "bundled": true, + "dev": true + }, + "mime-types": { + "version": "2.1.15", + "bundled": true, + "dev": true, + "requires": { + "mime-db": "1.27.0" + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "node-pre-gyp": { + "version": "0.6.36", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "mkdirp": "0.5.1", + "nopt": "4.0.1", + "npmlog": "4.1.0", + "rc": "1.2.1", + "request": "2.81.0", + "rimraf": "2.6.1", + "semver": "5.3.0", + "tar": "2.2.1", + "tar-pack": "3.4.0" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1.1.0", + "osenv": "0.1.4" + } + }, + "npmlog": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "1.1.4", + "console-control-strings": "1.1.0", + "gauge": "2.7.4", + "set-blocking": "2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "performance-now": { + "version": "0.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "1.0.7", + "bundled": true, + "dev": true + }, + "punycode": { + "version": "1.4.1", + "bundled": true, + "dev": true, + "optional": true + }, + "qs": { + "version": "6.4.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "0.4.2", + "ini": "1.3.4", + "minimist": "1.2.0", + "strip-json-comments": "2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.2.9", + "bundled": true, + "dev": true, + "requires": { + "buffer-shims": "1.0.0", + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "1.0.1", + "util-deprecate": "1.0.2" + } + }, + "request": { + "version": "2.81.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aws-sign2": "0.6.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.1.4", + "har-validator": "4.2.1", + "hawk": "3.1.3", + "http-signature": "1.1.1", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.15", + "oauth-sign": "0.8.2", + "performance-now": "0.2.0", + "qs": "6.4.0", + "safe-buffer": "5.0.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.2", + "tunnel-agent": "0.6.0", + "uuid": "3.0.1" + } + }, + "rimraf": { + "version": "2.6.1", + "bundled": true, + "dev": true, + "requires": { + "glob": "7.1.2" + } + }, + "safe-buffer": { + "version": "5.0.1", + "bundled": true, + "dev": true + }, + "semver": { + "version": "5.3.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sntp": { + "version": "1.0.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "hoek": "2.16.3" + } + }, + "sshpk": { + "version": "1.13.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jodid25519": "1.0.2", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "stringstream": { + "version": "0.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "2.2.1", + "bundled": true, + "dev": true, + "requires": { + "block-stream": "0.0.9", + "fstream": "1.0.11", + "inherits": "2.0.3" + } + }, + "tar-pack": { + "version": "3.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "2.6.8", + "fstream": "1.0.11", + "fstream-ignore": "1.0.5", + "once": "1.4.0", + "readable-stream": "2.2.9", + "rimraf": "2.6.1", + "tar": "2.2.1", + "uid-number": "0.0.6" + } + }, + "tough-cookie": { + "version": "2.3.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "punycode": "1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "bundled": true, + "dev": true, + "optional": true + }, + "uid-number": { + "version": "0.0.6", + "bundled": true, + "dev": true, + "optional": true + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "uuid": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "verror": { + "version": "1.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "extsprintf": "1.0.2" + } + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + } + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "fuse.js": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-2.7.4.tgz", + "integrity": "sha1-luQg/efvARrEnCWKYhMU/ldlNvk=" + }, + "gemini-scrollbar": { + "version": "github:matrix-org/gemini-scrollbar#b302279810d05319ac5ff1bd34910bff32325c7b" + }, + "generate-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", + "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", + "dev": true + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "dev": true, + "requires": { + "is-property": "1.0.2" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", + "dev": true, + "requires": { + "glob-parent": "2.0.0", + "is-glob": "2.0.1" + } + }, + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "dev": true, + "requires": { + "is-glob": "2.0.1" + } + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "1.0.2", + "arrify": "1.0.1", + "glob": "7.1.2", + "object-assign": "4.1.1", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "growl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "5.2.3", + "har-schema": "2.0.0" + } + }, + "has": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", + "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", + "dev": true, + "requires": { + "function-bind": "1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "has-binary": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz", + "integrity": "sha1-aOYesWIQyVRaClzOBqhzkS/h5ow=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=", + "dev": true + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "hawk": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", + "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "requires": { + "boom": "4.3.1", + "cryptiles": "3.1.2", + "hoek": "4.2.0", + "sntp": "2.0.2" + } + }, + "highlight.js": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-8.9.1.tgz", + "integrity": "sha1-uKnFSTISqTkvAiK2SclhFJfr+4g=" + }, + "hoek": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.0.tgz", + "integrity": "sha512-v0XCLxICi9nPfYrS9RL8HbYnXi9obYAeLbSP00BmnZwCK9+Ih9WOjoZ8YoHCoav2csqn4FOz4Orldsy2dmDwmQ==" + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "dev": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "htmlparser2": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz", + "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=", + "requires": { + "domelementtype": "1.3.0", + "domhandler": "2.4.1", + "domutils": "1.6.2", + "entities": "1.1.1", + "inherits": "2.0.3", + "readable-stream": "2.3.3" + } + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "dev": true, + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": "1.3.1" + } + }, + "http-proxy": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.16.2.tgz", + "integrity": "sha1-Bt/ykpUr9k2+hHH6nfcwZtTzd0I=", + "dev": true, + "requires": { + "eventemitter3": "1.2.0", + "requires-port": "1.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "1.0.0", + "jsprim": "1.4.1", + "sshpk": "1.13.1" + } + }, + "https-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.1.tgz", + "integrity": "sha1-P5E2XKvmC3ftDruiS0VOPgnZWoI=", + "dev": true + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=", + "dev": true + }, + "ignore": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.5.tgz", + "integrity": "sha512-JLH93mL8amZQhh/p6mfQgVBH3M6epNq3DfsXsTSuSrInVjwyYlFE1nv2AgfRCC8PoOhM0jwQ5v8s9LgbK7yGDw==", + "dev": true + }, + "immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "inquirer": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", + "dev": true, + "requires": { + "ansi-escapes": "1.4.0", + "ansi-regex": "2.1.1", + "chalk": "1.1.3", + "cli-cursor": "1.0.2", + "cli-width": "2.2.0", + "figures": "1.7.0", + "lodash": "4.17.4", + "readline2": "1.0.1", + "run-async": "0.1.0", + "rx-lite": "3.1.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "through": "2.3.8" + } + }, + "interpret": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.0.4.tgz", + "integrity": "sha1-ggzdWIuGj/sZGoCVBtbJyPISsbA=", + "dev": true + }, + "invariant": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", + "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=", + "dev": true, + "requires": { + "loose-envify": "1.3.1" + } + }, + "is-arrow-function": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-arrow-function/-/is-arrow-function-2.0.3.tgz", + "integrity": "sha1-Kb4sLY2UUIUri7r7Y1unuNjofsI=", + "dev": true, + "requires": { + "is-callable": "1.1.3" + } + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "1.10.0" + } + }, + "is-boolean-object": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.0.0.tgz", + "integrity": "sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=", + "dev": true + }, + "is-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz", + "integrity": "sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw=", + "dev": true + }, + "is-callable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz", + "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=", + "dev": true + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=", + "dev": true + }, + "is-equal": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.5.5.tgz", + "integrity": "sha1-XoXxlX4FKIMkf+s4aWWju6Ffuz0=", + "dev": true, + "requires": { + "has": "1.0.1", + "is-arrow-function": "2.0.3", + "is-boolean-object": "1.0.0", + "is-callable": "1.1.3", + "is-date-object": "1.0.1", + "is-generator-function": "1.0.6", + "is-number-object": "1.0.3", + "is-regex": "1.0.4", + "is-string": "1.0.4", + "is-symbol": "1.0.1", + "object.entries": "1.0.4" + } + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "dev": true, + "requires": { + "is-primitive": "2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-generator-function": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.6.tgz", + "integrity": "sha1-nnFlPNFf/zQcecQVFGChMdMen8Q=", + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "dev": true, + "requires": { + "is-extglob": "1.0.0" + } + }, + "is-my-json-valid": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz", + "integrity": "sha512-ochPsqWS1WXj8ZnMIV0vnNXooaMhp7cyL4FMSIPKTtnV0Ha/T19G2b9kkhcNsabV9bxYkze7/aLZJb/bYuFduQ==", + "dev": true, + "requires": { + "generate-function": "2.0.0", + "generate-object-property": "1.2.0", + "jsonpointer": "4.0.1", + "xtend": "4.0.1" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "is-number-object": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.3.tgz", + "integrity": "sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=", + "dev": true + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz", + "integrity": "sha1-ZHdYK4IU1gI0YJRWcAO+ip6sBNw=", + "dev": true, + "requires": { + "is-path-inside": "1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.0.tgz", + "integrity": "sha1-/AbloWg/vaE95mev9xe7wQpI838=", + "dev": true, + "requires": { + "path-is-inside": "1.0.2" + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=", + "dev": true + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=", + "dev": true + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", + "dev": true + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "1.0.1" + } + }, + "is-resolvable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.0.tgz", + "integrity": "sha1-jfV8YeouPFAUCNEA+wE8+NbgzGI=", + "dev": true, + "requires": { + "tryit": "1.0.3" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-string": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.4.tgz", + "integrity": "sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isbinaryfile": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.2.tgz", + "integrity": "sha1-Sj6XTsDLqQBNP8bN5yCeppNopiE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "1.7.3", + "whatwg-fetch": "1.1.1" + } + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jade": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", + "integrity": "sha1-jxDXl32NefL2/4YqgbBRPMslaGw=", + "dev": true, + "requires": { + "commander": "0.6.1", + "mkdirp": "0.3.0" + }, + "dependencies": { + "commander": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz", + "integrity": "sha1-+mihT2qUXVTbvlDYzbMyDp47GgY=", + "dev": true + }, + "mkdirp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", + "integrity": "sha1-G79asbqCevI1dRQ0kEJkVfSB/h4=", + "dev": true + } + } + }, + "jquery": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.2.1.tgz", + "integrity": "sha1-XE2d5lKvbNCncBVKYxu6ErAVx4c=" + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "js-yaml": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", + "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==", + "dev": true, + "requires": { + "argparse": "1.0.9", + "esprima": "4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, + "json-loader": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/json-loader/-/json-loader-0.5.7.tgz", + "integrity": "sha512-QLPs8Dj7lnf3e3QYS1zkCo+4ZwqOiF9d/nZnYozTISxXWCfNs9yuky5rJw4/W34s7POaNlbZmQGaB5NiXCbP4w==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "requires": { + "jsonify": "0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jsx-ast-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz", + "integrity": "sha1-6AGxs5mF4g//yHtA43SAgOLcrH8=", + "dev": true, + "requires": { + "array-includes": "3.0.3" + } + }, + "karma": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz", + "integrity": "sha512-k5pBjHDhmkdaUccnC7gE3mBzZjcxyxYsYVaqiL2G5AqlfLyBO5nw2VdNK+O16cveEPd/gIOWULH7gkiYYwVNHg==", + "dev": true, + "requires": { + "bluebird": "3.5.1", + "body-parser": "1.18.2", + "chokidar": "1.7.0", + "colors": "1.1.2", + "combine-lists": "1.0.1", + "connect": "3.6.5", + "core-js": "2.5.1", + "di": "0.0.1", + "dom-serialize": "2.2.1", + "expand-braces": "0.1.2", + "glob": "7.1.2", + "graceful-fs": "4.1.11", + "http-proxy": "1.16.2", + "isbinaryfile": "3.0.2", + "lodash": "3.10.1", + "log4js": "0.6.38", + "mime": "1.4.1", + "minimatch": "3.0.4", + "optimist": "0.6.1", + "qjobs": "1.1.5", + "range-parser": "1.2.0", + "rimraf": "2.6.2", + "safe-buffer": "5.1.1", + "socket.io": "1.7.3", + "source-map": "0.5.7", + "tmp": "0.0.31", + "useragent": "2.2.1" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + } + } + }, + "karma-chrome-launcher": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-0.2.3.tgz", + "integrity": "sha1-TG1wDRY6nTTGGO/YeRi+SeekqMk=", + "dev": true, + "requires": { + "fs-access": "1.0.1", + "which": "1.3.0" + } + }, + "karma-cli": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/karma-cli/-/karma-cli-0.1.2.tgz", + "integrity": "sha1-ys6oQ3Hs4Zh2JlyPoQLru5/uSow=", + "dev": true, + "requires": { + "resolve": "1.4.0" + } + }, + "karma-junit-reporter": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-0.4.2.tgz", + "integrity": "sha1-SSojZyj+TJKqz0GfzQEQpDJ+nX8=", + "dev": true, + "requires": { + "path-is-absolute": "1.0.1", + "xmlbuilder": "3.1.0" + } + }, + "karma-logcapture-reporter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/karma-logcapture-reporter/-/karma-logcapture-reporter-0.0.1.tgz", + "integrity": "sha1-vxsLHJFeDeKVoV/i8BedQoG6zdw=", + "dev": true + }, + "karma-mocha": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-0.2.2.tgz", + "integrity": "sha1-OI7ZF9oV3LGW0bkVwZNO+AMZP44=", + "dev": true + }, + "karma-sourcemap-loader": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.7.tgz", + "integrity": "sha1-kTIsd/jxPUb+0GKwQuEAnUxFBdg=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11" + } + }, + "karma-spec-reporter": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.31.tgz", + "integrity": "sha1-SDDccUihVcfXoYbmMjOaDYD63sM=", + "dev": true, + "requires": { + "colors": "1.1.2" + } + }, + "karma-summary-reporter": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/karma-summary-reporter/-/karma-summary-reporter-1.3.3.tgz", + "integrity": "sha1-nHQKJLYL+RNes59acylsTM0Q2Zs=", + "dev": true, + "requires": { + "chalk": "1.1.3" + } + }, + "karma-webpack": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-1.8.1.tgz", + "integrity": "sha1-OdX9Lt7qPMPvW0BZibN9Ww5qO04=", + "dev": true, + "requires": { + "async": "0.9.2", + "loader-utils": "0.2.17", + "lodash": "3.10.1", + "source-map": "0.1.43", + "webpack-dev-middleware": "1.12.0" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + }, + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true, + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.5" + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2", + "type-check": "0.3.2" + } + }, + "linkifyjs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-2.1.5.tgz", + "integrity": "sha512-8FqxPXQDLjI2nNHlM7eGewxE6DHvMbtiW0AiXzm0s4RkTwVZYRDTeVXkiRxLHTd4CuRBQY/JPtvtqJWdS7gHyA==", + "requires": { + "jquery": "3.2.1", + "react": "15.6.2", + "react-dom": "15.6.2" + } + }, + "loader-utils": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.17.tgz", + "integrity": "sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g=", + "dev": true, + "requires": { + "big.js": "3.2.0", + "emojis-list": "2.1.0", + "json5": "0.5.1", + "object-assign": "4.1.1" + } + }, + "lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" + }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=", + "dev": true + }, + "lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha1-feoh2MGNdwOifHBMFdO4SmfjOv8=", + "dev": true + }, + "log4js": { + "version": "0.6.38", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-0.6.38.tgz", + "integrity": "sha1-LElBFmldb7JUgJQ9P8hy5mKlIv0=", + "dev": true, + "requires": { + "readable-stream": "1.0.34", + "semver": "4.3.6" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "lolex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.3.2.tgz", + "integrity": "sha1-fD2mL/yzDw9agKJWbKJORdigHzE=", + "dev": true + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true + }, + "loose-envify": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", + "requires": { + "js-tokens": "3.0.2" + } + }, + "lru-cache": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.2.4.tgz", + "integrity": "sha1-bGWGGb7PFAMdDQtZSxYELOTcBj0=", + "dev": true + }, + "matrix-js-sdk": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-0.8.5.tgz", + "integrity": "sha1-1ZAVTx53ADVyZw+p28rH5APnbk8=", + "requires": { + "another-json": "0.2.0", + "bluebird": "3.5.1", + "browser-request": "0.3.3", + "content-type": "1.0.4", + "request": "2.83.0" + } + }, + "matrix-react-test-utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/matrix-react-test-utils/-/matrix-react-test-utils-0.1.1.tgz", + "integrity": "sha1-tUiETQ6+M46hucjxZHTDDRfDvfQ=", + "dev": true, + "requires": { + "react": "15.6.2", + "react-dom": "15.6.2" + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "0.1.4", + "readable-stream": "2.3.3" + } + }, + "micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", + "dev": true, + "requires": { + "arr-diff": "2.0.0", + "array-unique": "0.2.1", + "braces": "1.8.5", + "expand-brackets": "0.1.5", + "extglob": "0.3.2", + "filename-regex": "2.0.1", + "is-extglob": "1.0.0", + "is-glob": "2.0.1", + "kind-of": "3.2.2", + "normalize-path": "2.1.1", + "object.omit": "2.0.1", + "parse-glob": "3.0.4", + "regex-cache": "0.4.4" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", + "dev": true + }, + "mime-db": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", + "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=" + }, + "mime-types": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", + "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=", + "requires": { + "mime-db": "1.30.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "mocha": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", + "integrity": "sha1-FhvlvetJZ3HrmzV0UFC2IrWu/Fg=", + "dev": true, + "requires": { + "commander": "2.3.0", + "debug": "2.2.0", + "diff": "1.4.0", + "escape-string-regexp": "1.0.2", + "glob": "3.2.11", + "growl": "1.9.2", + "jade": "0.26.3", + "mkdirp": "0.5.1", + "supports-color": "1.2.0", + "to-iso-string": "0.0.2" + }, + "dependencies": { + "commander": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", + "integrity": "sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM=", + "dev": true + }, + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "dev": true, + "requires": { + "ms": "0.7.1" + } + }, + "escape-string-regexp": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz", + "integrity": "sha1-Tbwv5nTnGUnK8/smlc5/LcHZqNE=", + "dev": true + }, + "glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", + "integrity": "sha1-Spc/Y1uRkPcV0QmH1cAP0oFevj0=", + "dev": true, + "requires": { + "inherits": "2.0.3", + "minimatch": "0.3.0" + } + }, + "minimatch": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", + "integrity": "sha1-J12O2qxPG7MyZHIInnlJyDlGmd0=", + "dev": true, + "requires": { + "lru-cache": "2.2.4", + "sigmund": "1.0.1" + } + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=", + "dev": true + }, + "supports-color": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.2.0.tgz", + "integrity": "sha1-/x7R5hFp0Gs88tWI4YixjYhH4X4=", + "dev": true + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=", + "dev": true + }, + "nan": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.7.0.tgz", + "integrity": "sha1-2Vv3IeyHfgjbJ27T/G63j5CDrUY=", + "dev": true, + "optional": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", + "dev": true + }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "0.1.12", + "is-stream": "1.1.0" + } + }, + "node-libs-browser": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-0.7.0.tgz", + "integrity": "sha1-PicsCBnjCJNeJmdECNevDhSRuDs=", + "dev": true, + "requires": { + "assert": "1.4.1", + "browserify-zlib": "0.1.4", + "buffer": "4.9.1", + "console-browserify": "1.1.0", + "constants-browserify": "1.0.0", + "crypto-browserify": "3.3.0", + "domain-browser": "1.1.7", + "events": "1.1.1", + "https-browserify": "0.0.1", + "os-browserify": "0.2.1", + "path-browserify": "0.0.0", + "process": "0.11.10", + "punycode": "1.4.1", + "querystring-es3": "0.2.1", + "readable-stream": "2.3.3", + "stream-browserify": "2.0.1", + "stream-http": "2.7.2", + "string_decoder": "0.10.31", + "timers-browserify": "2.0.4", + "tty-browserify": "0.0.0", + "url": "0.11.0", + "util": "0.10.3", + "vm-browserify": "0.0.4" + }, + "dependencies": { + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "1.1.0" + } + }, + "null-check": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/null-check/-/null-check-1.0.0.tgz", + "integrity": "sha1-l33/1xdgErnsMNKjnbXPcqBDnt0=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=", + "dev": true + }, + "object-inspect": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.3.0.tgz", + "integrity": "sha512-OHHnLgLNXpM++GnJRyyhbr2bwl3pPVm4YvaraHrRvDt/N3r+s/gDVHciA7EJBTkijKXj61ssgSAikq1fb0IBRg==", + "dev": true + }, + "object-keys": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz", + "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=", + "dev": true + }, + "object.entries": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.0.4.tgz", + "integrity": "sha1-G/mk3SKI9bM/Opk9JXZh8F0WGl8=", + "dev": true, + "requires": { + "define-properties": "1.1.2", + "es-abstract": "1.9.0", + "function-bind": "1.1.1", + "has": "1.0.1" + } + }, + "object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", + "dev": true, + "requires": { + "for-own": "0.1.5", + "is-extendable": "0.1.1" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1.0.2" + } + }, + "onetime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", + "dev": true + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "0.0.10", + "wordwrap": "0.0.3" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "0.1.3", + "fast-levenshtein": "2.0.6", + "levn": "0.3.0", + "prelude-ls": "1.1.2", + "type-check": "0.3.2", + "wordwrap": "1.0.0" + }, + "dependencies": { + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + } + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=", + "dev": true + }, + "os-browserify": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.2.1.tgz", + "integrity": "sha1-Y/xMzuXS13Y9Jrv4YBB45sLgBE8=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "output-file-sync": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/output-file-sync/-/output-file-sync-1.1.2.tgz", + "integrity": "sha1-0KM+7+YaIF+suQCS6CZZjVJFznY=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "mkdirp": "0.5.1", + "object-assign": "4.1.1" + } + }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=", + "dev": true + }, + "parallelshell": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/parallelshell/-/parallelshell-3.0.2.tgz", + "integrity": "sha512-aW73W8tmYiFZtQi41pweV3WWT6o/EvSxAVQHbumOhN53H47OuWQwrRc11xQ2i44GFvR5AjtzhD92r8Kv9X+7Iw==", + "dev": true + }, + "parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", + "dev": true, + "requires": { + "glob-base": "0.3.0", + "is-dotfile": "1.0.3", + "is-extglob": "1.0.0", + "is-glob": "2.0.1" + } + }, + "parsejson": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz", + "integrity": "sha1-q343WfIJ7OmUN5c/fQ8fZK4OZKs=", + "dev": true, + "requires": { + "better-assert": "1.0.2" + } + }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "dev": true, + "requires": { + "better-assert": "1.0.2" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "dev": true, + "requires": { + "better-assert": "1.0.2" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=", + "dev": true + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=", + "dev": true + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "2.0.1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", + "dev": true + }, + "pbkdf2-compat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz", + "integrity": "sha1-tuDI+plJTZTgURV1gCpZpcFC8og=", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "2.0.4" + } + }, + "pkg-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz", + "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=", + "dev": true, + "requires": { + "find-up": "1.1.2" + } + }, + "pluralize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", + "integrity": "sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=", + "dev": true + }, + "pluralizers": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/pluralizers/-/pluralizers-0.1.6.tgz", + "integrity": "sha1-GrOLbnYOb5f5hGElCXuGK8l0yB4=" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "dev": true + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "2.0.6" + } + }, + "prop-types": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz", + "integrity": "sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=", + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1" + } + }, + "prr": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz", + "integrity": "sha1-GoS4WQgyVQFBGFPQCB7j+obikmo=", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "qjobs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.1.5.tgz", + "integrity": "sha1-ZZ3p8s+NzCehSBJ28gU3cnI4LnM=", + "dev": true + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "randomatic": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz", + "integrity": "sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how==", + "dev": true, + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "1.1.5" + } + } + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", + "dev": true + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + } + }, + "react": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz", + "integrity": "sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=", + "requires": { + "create-react-class": "15.6.2", + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "prop-types": "15.6.0" + } + }, + "react-addons-css-transition-group": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/react-addons-css-transition-group/-/react-addons-css-transition-group-15.3.2.tgz", + "integrity": "sha1-2PpSvsm7Yb396LnkZSuAKXy/9mc=" + }, + "react-addons-test-utils": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/react-addons-test-utils/-/react-addons-test-utils-15.6.2.tgz", + "integrity": "sha1-wStu/cIkfBDae4dw0YUICnsEcVY=", + "dev": true + }, + "react-dom": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz", + "integrity": "sha1-Qc+t9pO3V/rycIRDodH9WgK+9zA=", + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "prop-types": "15.6.0" + } + }, + "react-gemini-scrollbar": { + "version": "github:matrix-org/react-gemini-scrollbar#5e97aef7e034efc8db1431f4b0efe3b26e249ae9", + "requires": { + "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#b302279810d05319ac5ff1bd34910bff32325c7b" + } + }, + "readable-stream": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", + "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "safe-buffer": "5.1.1", + "string_decoder": "1.0.3", + "util-deprecate": "1.0.2" + } + }, + "readdirp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz", + "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=", + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "minimatch": "3.0.4", + "readable-stream": "2.3.3", + "set-immediate-shim": "1.0.1" + } + }, + "readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "mute-stream": "0.0.5" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "1.4.0" + } + }, + "regenerate": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz", + "integrity": "sha512-jVpo1GadrDAK59t/0jRx5VxYWQEDkkEKi6+HjE3joFVLfDOh9Xrdh0dF1eSq+BI/SwvTQ44gSscJ8N5zYL61sg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz", + "integrity": "sha512-/aA0kLeRb5N9K0d4fw7ooEbI+xDe+DKD499EQqygGqeS8N3xto15p09uY2xj7ixP81sNPXvRLnAQIqdVStgb1A==" + }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "private": "0.1.8" + } + }, + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "dev": true, + "requires": { + "is-equal-shallow": "0.1.3" + } + }, + "regexp-quote": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/regexp-quote/-/regexp-quote-0.0.0.tgz", + "integrity": "sha1-Hg9GUMhi3L/tVP1CsUjpuxch/PI=" + }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "dev": true, + "requires": { + "regenerate": "1.3.3", + "regjsgen": "0.2.0", + "regjsparser": "0.1.5" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "1.0.2" + } + }, + "request": { + "version": "2.83.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", + "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", + "requires": { + "aws-sign2": "0.7.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.3.1", + "har-validator": "5.0.3", + "hawk": "6.0.2", + "http-signature": "1.2.0", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.17", + "oauth-sign": "0.8.2", + "performance-now": "2.1.0", + "qs": "6.5.1", + "safe-buffer": "5.1.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.3", + "tunnel-agent": "0.6.0", + "uuid": "3.1.0" + } + }, + "require-json": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/require-json/-/require-json-0.0.1.tgz", + "integrity": "sha1-PIkU+T10Qt6Mv05oGsYqcqozZ/4=", + "dev": true + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "0.1.0", + "resolve-from": "1.0.1" + } + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz", + "integrity": "sha512-aW7sVKPufyHqOmyyLzg/J+8606v5nevBgaliIlV7nUpVMsDnoBGV/cbSLNjZAg9q0Cfd/+easKVKQ8vOu8fn1Q==", + "dev": true, + "requires": { + "path-parse": "1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "dev": true, + "requires": { + "exit-hook": "1.1.1", + "onetime": "1.1.0" + } + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "requires": { + "align-text": "0.1.4" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "7.1.2" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "ripemd160": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-0.2.0.tgz", + "integrity": "sha1-K/GYveFnys+lHAqSjoS2i74XH84=", + "dev": true + }, + "run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", + "dev": true, + "requires": { + "once": "1.4.0" + } + }, + "rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=", + "dev": true + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "samsam": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.2.tgz", + "integrity": "sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc=", + "dev": true + }, + "sanitize-html": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.14.1.tgz", + "integrity": "sha1-cw/6Ikm98YMz7/5FsoYXPJxa0Lg=", + "requires": { + "htmlparser2": "3.9.2", + "regexp-quote": "0.0.0", + "xtend": "4.0.1" + } + }, + "semver": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", + "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto=", + "dev": true + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=", + "dev": true + }, + "sha.js": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.2.6.tgz", + "integrity": "sha1-F93t3F9yL7ZlAWWIlUYZd4ZzFbo=", + "dev": true + }, + "shelljs": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.7.8.tgz", + "integrity": "sha1-3svPh0sNHl+3LhSxZKloMEjprLM=", + "dev": true, + "requires": { + "glob": "7.1.2", + "interpret": "1.0.4", + "rechoir": "0.6.2" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + } + } + }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=", + "dev": true + }, + "sinon": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.17.7.tgz", + "integrity": "sha1-RUKk9JugxFwF6y6d2dID4rjv4L8=", + "dev": true, + "requires": { + "formatio": "1.1.1", + "lolex": "1.3.2", + "samsam": "1.1.2", + "util": "0.10.3" + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + }, + "sntp": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.0.2.tgz", + "integrity": "sha1-UGQRDwr4X3z9t9a2ekACjOUrSys=", + "requires": { + "hoek": "4.2.0" + } + }, + "socket.io": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.7.3.tgz", + "integrity": "sha1-uK+cq6AJSeVo42nxMn6pvp6iRhs=", + "dev": true, + "requires": { + "debug": "2.3.3", + "engine.io": "1.8.3", + "has-binary": "0.1.7", + "object-assign": "4.1.0", + "socket.io-adapter": "0.5.0", + "socket.io-client": "1.7.3", + "socket.io-parser": "2.3.1" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "dev": true, + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=", + "dev": true + }, + "object-assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=", + "dev": true + } + } + }, + "socket.io-adapter": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz", + "integrity": "sha1-y21LuL7IHhB4uZZ3+c7QBGBmu4s=", + "dev": true, + "requires": { + "debug": "2.3.3", + "socket.io-parser": "2.3.1" + }, + "dependencies": { + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "dev": true, + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=", + "dev": true + } + } + }, + "socket.io-client": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.7.3.tgz", + "integrity": "sha1-sw6GqhDV7zVGYBwJzeR2Xjgdo3c=", + "dev": true, + "requires": { + "backo2": "1.0.2", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "2.3.3", + "engine.io-client": "1.8.3", + "has-binary": "0.1.7", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseuri": "0.0.5", + "socket.io-parser": "2.3.1", + "to-array": "0.1.4" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "dev": true, + "requires": { + "ms": "0.7.2" + } + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=", + "dev": true + } + } + }, + "socket.io-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz", + "integrity": "sha1-3VMgJRA85Clpcya+/WQAX8/ltKA=", + "dev": true, + "requires": { + "component-emitter": "1.1.2", + "debug": "2.2.0", + "isarray": "0.0.1", + "json3": "3.3.2" + }, + "dependencies": { + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "dev": true, + "requires": { + "ms": "0.7.1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=", + "dev": true + } + } + }, + "source-list-map": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.8.tgz", + "integrity": "sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-loader": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.1.6.tgz", + "integrity": "sha1-wJkD2m1zueU7ftjuUkVZcFHpjpE=", + "dev": true, + "requires": { + "async": "0.9.2", + "loader-utils": "0.2.17", + "source-map": "0.1.43" + }, + "dependencies": { + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true, + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "0.5.7" + } + }, + "sprintf-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz", + "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" + }, + "sshpk": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", + "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + } + }, + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=", + "dev": true + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "dev": true, + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.3" + } + }, + "stream-http": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.7.2.tgz", + "integrity": "sha512-c0yTD2rbQzXtSsFSVhtpvY/vS6u066PcXOX9kBB3mSO76RiUQzL340uJkGBWnlBg4/HZzqiUXtaVA7wcRcJgEw==", + "dev": true, + "requires": { + "builtin-status-codes": "3.0.0", + "inherits": "2.0.3", + "readable-stream": "2.3.3", + "to-arraybuffer": "1.0.1", + "xtend": "4.0.1" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string.prototype.repeat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-0.2.0.tgz", + "integrity": "sha1-q6Nt4I3O5qWjN9SbLqHaGyj8Ds8=" + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "stringstream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "table": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", + "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", + "dev": true, + "requires": { + "ajv": "4.11.8", + "ajv-keywords": "1.5.1", + "chalk": "1.1.3", + "lodash": "4.17.4", + "slice-ansi": "0.0.4", + "string-width": "2.1.1" + }, + "dependencies": { + "ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", + "dev": true, + "requires": { + "co": "4.6.0", + "json-stable-stringify": "1.0.1" + } + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + } + } + }, + "tapable": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz", + "integrity": "sha1-KcNXB8K3DlDQdIK10gLo7URtr9Q=", + "dev": true + }, + "text-encoding-utf-8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.1.tgz", + "integrity": "sha1-Uepsen6y+09nRnt2NzVmH1YDSS0=" + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "time-stamp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-2.0.0.tgz", + "integrity": "sha1-lcakRTDhW6jW9KPsuMOj+sRto1c=", + "dev": true + }, + "timers-browserify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.4.tgz", + "integrity": "sha512-uZYhyU3EX8O7HQP+J9fTVYwsq90Vr68xPEFo7yrVImIxYvHgukBEgOB/SgGoorWVTzGM/3Z+wUNnboA4M8jWrg==", + "dev": true, + "requires": { + "setimmediate": "1.0.5" + } + }, + "tmatch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-2.0.1.tgz", + "integrity": "sha1-DFYkbzPzDaG409colauvFmYPOM8=", + "dev": true + }, + "tmp": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.31.tgz", + "integrity": "sha1-jzirlDjhcxXl29izZX6L+yd65Kc=", + "dev": true, + "requires": { + "os-tmpdir": "1.0.2" + } + }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=", + "dev": true + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + }, + "to-iso-string": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/to-iso-string/-/to-iso-string-0.0.2.tgz", + "integrity": "sha1-TcGeZk38y+Jb2NtQiwDG2hWCVdE=", + "dev": true + }, + "tough-cookie": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", + "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", + "requires": { + "punycode": "1.4.1" + } + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "tryit": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.3.tgz", + "integrity": "sha1-OTvnMKlEb9Hq1tpZoBQwjzbCics=", + "dev": true + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "1.1.2" + } + }, + "type-is": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz", + "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "2.1.17" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "ua-parser-js": { + "version": "0.7.17", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz", + "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g==" + }, + "uglify-js": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.7.5.tgz", + "integrity": "sha1-RhLAx7qu4rp8SH3kkErhIgefLKg=", + "dev": true, + "requires": { + "async": "0.2.10", + "source-map": "0.5.7", + "uglify-to-browserify": "1.0.2", + "yargs": "3.10.0" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=", + "dev": true + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true + }, + "ultron": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", + "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", + "dev": true + }, + "useragent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.2.1.tgz", + "integrity": "sha1-z1k+9PLRdYdei7ZY6pLhik/QbY4=", + "dev": true, + "requires": { + "lru-cache": "2.2.4", + "tmp": "0.0.31" + } + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + }, + "v8flags": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz", + "integrity": "sha1-qrGh+jDUX4jdMhFIh1rALAtV5bQ=", + "dev": true, + "requires": { + "user-home": "1.1.1" + } + }, + "velocity-vector": { + "version": "github:vector-im/velocity#059e3b2348f1110888d033974d3109fd5a3af00f", + "requires": { + "jquery": "3.2.1" + } + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "1.3.0" + } + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "dev": true, + "requires": { + "indexof": "0.0.1" + } + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", + "dev": true + }, + "walk": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz", + "integrity": "sha1-MbTbZnjyrgHDnqn7hyWpAx5Vins=", + "dev": true, + "requires": { + "foreachasync": "3.0.0" + } + }, + "watchpack": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-0.2.9.tgz", + "integrity": "sha1-Yuqkq15bo1/fwBgnVibjwPXj+ws=", + "dev": true, + "requires": { + "async": "0.9.2", + "chokidar": "1.7.0", + "graceful-fs": "4.1.11" + } + }, + "webpack": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.15.0.tgz", + "integrity": "sha1-T/MfU9sDM55VFkqdRo7gMklo/pg=", + "dev": true, + "requires": { + "acorn": "3.3.0", + "async": "1.5.2", + "clone": "1.0.2", + "enhanced-resolve": "0.9.1", + "interpret": "0.6.6", + "loader-utils": "0.2.17", + "memory-fs": "0.3.0", + "mkdirp": "0.5.1", + "node-libs-browser": "0.7.0", + "optimist": "0.6.1", + "supports-color": "3.2.3", + "tapable": "0.1.10", + "uglify-js": "2.7.5", + "watchpack": "0.2.9", + "webpack-core": "0.6.9" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "interpret": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz", + "integrity": "sha1-/s16GOfOXKar+5U+H4YhOknxYls=", + "dev": true + }, + "memory-fs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.3.0.tgz", + "integrity": "sha1-e8xrYp46Q+hx1+Kaymrop/FcuyA=", + "dev": true, + "requires": { + "errno": "0.1.4", + "readable-stream": "2.3.3" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "1.0.0" + } + } + } + }, + "webpack-core": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.9.tgz", + "integrity": "sha1-/FcViMhVjad76e+23r3Fo7FyvcI=", + "dev": true, + "requires": { + "source-list-map": "0.1.8", + "source-map": "0.4.4" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "webpack-dev-middleware": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-1.12.0.tgz", + "integrity": "sha1-007++y7dp+HTtdvgcolRMhllFwk=", + "dev": true, + "requires": { + "memory-fs": "0.4.1", + "mime": "1.4.1", + "path-is-absolute": "1.0.1", + "range-parser": "1.2.0", + "time-stamp": "2.0.0" + } + }, + "whatwg-fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-1.1.1.tgz", + "integrity": "sha1-rDydOfMgxtzlM5lp0FTvQ90zMxk=" + }, + "which": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", + "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", + "dev": true, + "requires": { + "isexe": "2.0.0" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "0.5.1" + } + }, + "ws": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.2.tgz", + "integrity": "sha1-iiRPoFJAHgjJiGz0SoUYnh/UBn8=", + "dev": true, + "requires": { + "options": "0.0.6", + "ultron": "1.0.2" + } + }, + "wtf-8": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz", + "integrity": "sha1-OS2LotDxw00e4tYw8V0O+2jhBIo=", + "dev": true + }, + "xmlbuilder": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-3.1.0.tgz", + "integrity": "sha1-LIaIjy1OrehQ+jjKf3Ij9yCVFuE=", + "dev": true, + "requires": { + "lodash": "3.10.1" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + } + } + }, + "xmlhttprequest-ssl": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz", + "integrity": "sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "requires": { + "camelcase": "1.2.1", + "cliui": "2.1.0", + "decamelize": "1.2.0", + "window-size": "0.1.0" + } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=", + "dev": true + } + } +} diff --git a/package.json b/package.json index 15174f22b7..f81e72e556 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.8.2", + "version": "0.11.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -10,6 +10,7 @@ "license": "Apache-2.0", "main": "lib/index.js", "files": [ + ".eslintrc.js", "CHANGELOG.md", "CONTRIBUTING.rst", "LICENSE", @@ -27,56 +28,71 @@ "test" ], "bin": { - "reskindex": "scripts/reskindex.js" + "reskindex": "scripts/reskindex.js", + "matrix-gen-i18n": "scripts/gen-i18n.js", + "matrix-prune-i18n": "scripts/prune-i18n.js" }, "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", + "reskindex": "node scripts/reskindex.js -h header", + "reskindex:watch": "node scripts/reskindex.js -h header -w", + "i18n": "matrix-gen-i18n", + "prunei18n": "matrix-prune-i18n", + "build": "npm run reskindex && babel src -d lib --source-maps --copy-files", + "build:watch": "babel src -w -d lib --source-maps --copy-files", + "emoji-data-strip": "node scripts/emoji-data-strip.js", + "start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "lint": "eslint src/", "lintall": "eslint src/ test/", + "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", "clean": "rimraf lib", - "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", - "test": "karma start $KARMAFLAGS --browsers PhantomJS", - "test-multi": "karma start $KARMAFLAGS --single-run=false" + "prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt", + "test": "karma start --single-run=true --browsers ChromeHeadless", + "test-multi": "karma start" }, "dependencies": { "babel-runtime": "^6.11.6", + "bluebird": "^3.5.0", "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", - "draft-js": "^0.8.1", - "draft-js-export-html": "^0.5.0", - "draft-js-export-markdown": "^0.2.0", - "emojione": "2.2.3", - "filesize": "^3.1.2", - "flux": "^2.0.3", + "commonmark": "^0.28.1", + "counterpart": "^0.18.0", + "draft-js": "^0.11.0-alpha", + "draft-js-export-html": "^0.6.0", + "draft-js-export-markdown": "^0.3.0", + "emojione": "2.2.7", + "file-saver": "^1.3.3", + "filesize": "3.5.6", + "flux": "2.1.1", "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "marked": "^0.3.5", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "0.9.2", "optimist": "^0.6.1", - "q": "^1.4.1", + "prop-types": "^15.5.8", + "querystring": "^0.2.0", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", + "react-beautiful-dnd": "^4.0.0", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", - "sanitize-html": "^1.11.1", + "sanitize-html": "^1.14.1", + "text-encoding-utf-8": "^1.0.1", + "url": "^0.11.0", "velocity-vector": "vector-im/velocity#059e3b2", "whatwg-fetch": "^1.0.0" }, "devDependencies": { "babel-cli": "^6.5.2", "babel-core": "^6.14.0", - "babel-eslint": "^6.1.0", + "babel-eslint": "^6.1.2", "babel-loader": "^6.2.5", "babel-plugin-add-module-exports": "^0.2.1", - "babel-plugin-transform-async-to-generator": "^6.16.0", + "babel-plugin-transform-async-to-bluebird": "^1.1.1", "babel-plugin-transform-class-properties": "^6.16.0", "babel-plugin-transform-object-rest-spread": "^6.16.0", "babel-plugin-transform-runtime": "^6.15.0", @@ -85,26 +101,35 @@ "babel-preset-es2016": "^6.11.3", "babel-preset-es2017": "^6.14.0", "babel-preset-react": "^6.11.1", - "eslint": "^2.13.1", - "eslint-plugin-flowtype": "^2.17.0", - "eslint-plugin-react": "^6.2.1", + "chokidar": "^1.6.1", + "eslint": "^3.13.1", + "eslint-config-google": "^0.7.1", + "eslint-plugin-babel": "^4.0.1", + "eslint-plugin-flowtype": "^2.30.0", + "eslint-plugin-react": "^7.4.0", + "estree-walker": "^0.5.0", "expect": "^1.16.0", + "flow-parser": "^0.57.3", "json-loader": "^0.5.3", - "karma": "^0.13.22", + "karma": "^1.7.0", "karma-chrome-launcher": "^0.2.3", "karma-cli": "^0.1.2", "karma-junit-reporter": "^0.4.1", + "karma-logcapture-reporter": "0.0.1", "karma-mocha": "^0.2.2", - "karma-phantomjs-launcher": "^1.0.0", "karma-sourcemap-loader": "^0.3.7", + "karma-spec-reporter": "^0.0.31", + "karma-summary-reporter": "^1.3.3", "karma-webpack": "^1.7.0", + "matrix-react-test-utils": "^0.1.1", "mocha": "^2.4.5", - "phantomjs-prebuilt": "^2.1.7", + "parallelshell": "^3.0.2", "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", "source-map-loader": "^0.1.5", + "walk": "^2.3.9", "webpack": "^1.12.14" } } 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/scripts/check-i18n.pl b/scripts/check-i18n.pl new file mode 100755 index 0000000000..fa11bc5292 --- /dev/null +++ b/scripts/check-i18n.pl @@ -0,0 +1,192 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use Cwd 'abs_path'; + +# script which checks how out of sync the i18ns are drifting + +# example i18n format: +# "%(oneUser)sleft": "%(oneUser)sleft", + +$|=1; + +$0 =~ /^(.*\/)/; +my $i18ndir = abs_path($1."/../src/i18n/strings"); +my $srcdir = abs_path($1."/../src"); + +my $en = read_i18n($i18ndir."/en_EN.json"); + +my $src_strings = read_src_strings($srcdir); +my $src = {}; + +print "Checking strings in src\n"; +foreach my $tuple (@$src_strings) { + my ($s, $file) = (@$tuple); + $src->{$s} = $file; + if (!$en->{$s}) { + if ($en->{$s . '.'}) { + printf ("%50s %24s\t%s\n", $file, "en_EN has fullstop!", $s); + } + else { + $s =~ /^(.*)\.?$/; + if ($en->{$1}) { + printf ("%50s %24s\t%s\n", $file, "en_EN lacks fullstop!", $s); + } + else { + printf ("%50s %24s\t%s\n", $file, "Translation missing!", $s); + } + } + } +} + +print "\nChecking en_EN\n"; +my $count = 0; +my $remaining_src = {}; +foreach (keys %$src) { $remaining_src->{$_}++ }; + +foreach my $k (sort keys %$en) { + # crappy heuristic to ignore country codes for now... + next if ($k =~ /^(..|..-..)$/); + + if ($en->{$k} ne $k) { + printf ("%50s %24s\t%s\n", "en_EN", "en_EN is not symmetrical", $k); + } + + if (!$src->{$k}) { + if ($src->{$k. '.'}) { + printf ("%50s %24s\t%s\n", $src->{$k. '.'}, "src has fullstop!", $k); + } + else { + $k =~ /^(.*)\.?$/; + if ($src->{$1}) { + printf ("%50s %24s\t%s\n", $src->{$1}, "src lacks fullstop!", $k); + } + else { + printf ("%50s %24s\t%s\n", '???', "Not present in src?", $k); + } + } + } + else { + $count++; + delete $remaining_src->{$k}; + } +} +printf ("$count/" . (scalar keys %$src) . " strings found in src are present in en_EN\n"); +foreach (keys %$remaining_src) { + print "missing: $_\n"; +} + +opendir(DIR, $i18ndir) || die $!; +my @files = readdir(DIR); +closedir(DIR); +foreach my $lang (grep { -f "$i18ndir/$_" && !/(basefile|en_EN)\.json/ } @files) { + print "\nChecking $lang\n"; + + my $map = read_i18n($i18ndir."/".$lang); + my $count = 0; + + my $remaining_en = {}; + foreach (keys %$en) { $remaining_en->{$_}++ }; + + foreach my $k (sort keys %$map) { + { + no warnings 'uninitialized'; + my $vars = {}; + while ($k =~ /%\((.*?)\)s/g) { + $vars->{$1}++; + } + while ($map->{$k} =~ /%\((.*?)\)s/g) { + $vars->{$1}--; + } + foreach my $var (keys %$vars) { + if ($vars->{$var} != 0) { + printf ("%10s %24s\t%s\n", $lang, "Broken var ($var)s", $k); + } + } + } + + if ($en->{$k}) { + if ($map->{$k} eq $k) { + printf ("%10s %24s\t%s\n", $lang, "Untranslated string?", $k); + } + $count++; + delete $remaining_en->{$k}; + } + else { + if ($en->{$k . "."}) { + printf ("%10s %24s\t%s\n", $lang, "en_EN has fullstop!", $k); + next; + } + + $k =~ /^(.*)\.?$/; + if ($en->{$1}) { + printf ("%10s %24s\t%s\n", $lang, "en_EN lacks fullstop!", $k); + next; + } + + printf ("%10s %24s\t%s\n", $lang, "Not present in en_EN", $k); + } + } + + if (scalar keys %$remaining_en < 100) { + foreach (keys %$remaining_en) { + printf ("%10s %24s\t%s\n", $lang, "Not yet translated", $_); + } + } + + printf ("$count/" . (scalar keys %$en) . " strings translated\n"); +} + +sub read_i18n { + my $path = shift; + my $map = {}; + $path =~ /.*\/(.*)$/; + my $lang = $1; + + open(FILE, "<", $path) || die $!; + while() { + if ($_ =~ m/^(\s+)"(.*?)"(: *)"(.*?)"(,?)$/) { + my ($indent, $src, $colon, $dst, $comma) = ($1, $2, $3, $4, $5); + $src =~ s/\\"/"/g; + $dst =~ s/\\"/"/g; + + if ($map->{$src}) { + printf ("%10s %24s\t%s\n", $lang, "Duplicate translation!", $src); + } + $map->{$src} = $dst; + } + } + close(FILE); + + return $map; +} + +sub read_src_strings { + my $path = shift; + + use File::Find; + use File::Slurp; + + my $strings = []; + + my @files; + find( sub { push @files, $File::Find::name if (-f $_ && /\.jsx?$/) }, $path ); + foreach my $file (@files) { + my $src = read_file($file); + $src =~ s/'\s*\+\s*'//g; + $src =~ s/"\s*\+\s*"//g; + + $file =~ s/^.*\/src/src/; + while ($src =~ /_t(?:Jsx)?\(\s*'(.*?[^\\])'/sg) { + my $s = $1; + $s =~ s/\\'/'/g; + push @$strings, [$s, $file]; + } + while ($src =~ /_t(?:Jsx)?\(\s*"(.*?[^\\])"/sg) { + push @$strings, [$1, $file]; + } + } + + return $strings; +} \ No newline at end of file diff --git a/scripts/copy-i18n.py b/scripts/copy-i18n.py new file mode 100755 index 0000000000..07b1271239 --- /dev/null +++ b/scripts/copy-i18n.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +import json +import sys +import os + +if len(sys.argv) < 3: + print "Usage: %s " % (sys.argv[0],) + print "eg. %s pt_BR.json pt.json" % (sys.argv[0],) + print + print "Adds any translations to that exist in but not " + sys.exit(1) + +srcpath = sys.argv[1] +dstpath = sys.argv[2] +tmppath = dstpath + ".tmp" + +with open(srcpath) as f: + src = json.load(f) + +with open(dstpath) as f: + dst = json.load(f) + +toAdd = {} +for k,v in src.iteritems(): + if k not in dst: + print "Adding %s" % (k,) + toAdd[k] = v + +# don't just json.dumps as we'll probably re-order all the keys (and they're +# not in any given order so we can't just sort_keys). Append them to the end. +with open(dstpath) as ifp: + with open(tmppath, 'w') as ofp: + for line in ifp: + strippedline = line.strip() + if strippedline in ('{', '}'): + ofp.write(line) + elif strippedline.endswith(','): + ofp.write(line) + else: + ofp.write(' '+strippedline+',') + toAddStr = json.dumps(toAdd, indent=4, separators=(',', ': '), ensure_ascii=False, encoding="utf8").strip("{}\n") + ofp.write("\n") + ofp.write(toAddStr.encode('utf8')) + ofp.write("\n") + +os.rename(tmppath, dstpath) diff --git a/scripts/emoji-data-strip.js b/scripts/emoji-data-strip.js new file mode 100644 index 0000000000..40156471fe --- /dev/null +++ b/scripts/emoji-data-strip.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +const EMOJI_DATA = require('emojione/emoji.json'); +const EMOJI_SUPPORTED = Object.keys(require('emojione').emojioneList); +const fs = require('fs'); + +const output = Object.keys(EMOJI_DATA).map( + (key) => { + const datum = EMOJI_DATA[key]; + const newDatum = { + name: datum.name, + shortname: datum.shortname, + category: datum.category, + emoji_order: datum.emoji_order, + }; + if (datum.aliases_ascii.length > 0) { + newDatum.aliases_ascii = datum.aliases_ascii; + } + return newDatum; + } +).filter((datum) => { + return EMOJI_SUPPORTED.includes(datum.shortname); +}); + +// Write to a file in src. Changes should be checked into git. This file is copied by +// babel using --copy-files +fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output)); diff --git a/scripts/fix-i18n.pl b/scripts/fix-i18n.pl new file mode 100755 index 0000000000..def352463d --- /dev/null +++ b/scripts/fix-i18n.pl @@ -0,0 +1,114 @@ +#!/usr/bin/perl -ni + +use strict; +use warnings; + +# script which synchronises i18n strings to include punctuation. +# i've cherry-picked ones which seem to have diverged between the different translations +# from TextForEvent, causing missing events all over the place + +BEGIN { +$::fixups = [split(/\n/, < { + return q.value.raw; + }).join(''); + } + return null; +} + +function getFormatStrings(str) { + // Match anything that starts with % + // We could make a regex that matched the full placeholder, but this + // would just not match invalid placeholders and so wouldn't help us + // detect the invalid ones. + // Also note that for simplicity, this just matches a % character and then + // anything up to the next % character (or a single %, or end of string). + const formatStringRe = /%([^%]+|%|$)/g; + const formatStrings = new Set(); + + let match; + while ( (match = formatStringRe.exec(str)) !== null ) { + const placeholder = match[1]; // Minus the leading '%' + if (placeholder === '%') continue; // Literal % is %% + + const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/); + if (placeholderMatch === null) { + throw new Error("Invalid format specifier: '"+match[0]+"'"); + } + if (placeholderMatch.length < 3) { + throw new Error("Malformed format specifier"); + } + const placeholderName = placeholderMatch[1]; + const placeholderFormat = placeholderMatch[2]; + + if (placeholderFormat !== 's') { + throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`); + } + + formatStrings.add(placeholderName); + } + + return formatStrings; +} + +function getTranslationsJs(file) { + const tree = flowParser.parse(fs.readFileSync(file, { encoding: 'utf8' }), FLOW_PARSER_OPTS); + + const trs = new Set(); + + estreeWalker.walk(tree, { + enter: function(node, parent) { + if ( + node.type == 'CallExpression' && + TRANSLATIONS_FUNCS.includes(node.callee.name) + ) { + const tKey = getTKey(node.arguments[0]); + // This happens whenever we call _t with non-literals (ie. whenever we've + // had to use a _td to compensate) so is expected. + if (tKey === null) return; + + // check the format string against the args + // We only check _t: _td has no args + if (node.callee.name === '_t') { + try { + const placeholders = getFormatStrings(tKey); + for (const placeholder of placeholders) { + if (node.arguments.length < 2) { + throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`); + } + const value = getObjectValue(node.arguments[1], placeholder); + if (value === null) { + throw new Error(`No value found for placeholder '${placeholder}'`); + } + } + + // Validate tag replacements + if (node.arguments.length > 2) { + const tagMap = node.arguments[2]; + for (const prop of tagMap.properties) { + if (prop.key.type === 'Literal') { + const tag = prop.key.value; + // RegExp same as in src/languageHandler.js + const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`); + if (!tKey.match(regexp)) { + throw new Error(`No match for ${regexp} in ${tKey}`); + } + } + } + } + + } catch (e) { + console.log(); + console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); + process.exit(1); + } + } + + let isPlural = false; + if (node.arguments.length > 1 && node.arguments[1].type == 'ObjectExpression') { + const countVal = getObjectValue(node.arguments[1], 'count'); + if (countVal) { + isPlural = true; + } + } + + if (isPlural) { + trs.add(tKey + "|other"); + const plurals = enPlurals[tKey]; + if (plurals) { + for (const pluralType of Object.keys(plurals)) { + trs.add(tKey + "|" + pluralType); + } + } + } else { + trs.add(tKey); + } + } + } + }); + + return trs; +} + +function getTranslationsOther(file) { + const contents = fs.readFileSync(file, { encoding: 'utf8' }); + + const trs = new Set(); + + // Taken from riot-web src/components/structures/HomePage.js + const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg; + let matches; + while (matches = translationsRegex.exec(contents)) { + trs.add(matches[1]); + } + return trs; +} + +// gather en_EN plural strings from the input translations file: +// the en_EN strings are all in the source with the exception of +// pluralised strings, which we need to pull in from elsewhere. +const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); +const enPlurals = {}; + +for (const key of Object.keys(inputTranslationsRaw)) { + const parts = key.split("|"); + if (parts.length > 1) { + const plurals = enPlurals[parts[0]] || {}; + plurals[parts[1]] = inputTranslationsRaw[key]; + enPlurals[parts[0]] = plurals; + } +} + +const translatables = new Set(); + +const walkOpts = { + listeners: { + file: function(root, fileStats, next) { + const fullPath = path.join(root, fileStats.name); + + let ltrs; + if (fileStats.name.endsWith('.js')) { + trs = getTranslationsJs(fullPath); + } else if (fileStats.name.endsWith('.html')) { + trs = getTranslationsOther(fullPath); + } else { + return; + } + console.log(`${fullPath} (${trs.size} strings)`); + for (const tr of trs.values()) { + translatables.add(tr); + } + }, + } +}; + +for (const path of SEARCH_PATHS) { + if (fs.existsSync(path)) { + walk.walkSync(path, walkOpts); + } +} + +const trObj = {}; +for (const tr of translatables) { + if (tr.includes("|")) { + if (inputTranslationsRaw[tr]) { + trObj[tr] = inputTranslationsRaw[tr]; + } else { + trObj[tr] = tr.split("|")[0]; + } + } else { + trObj[tr] = tr; + } +} + +fs.writeFileSync( + OUTPUT_FILE, + JSON.stringify(trObj, translatables.values(), 4) + "\n" +); + +console.log(); +console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`); diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file new file mode 100755 index 0000000000..3a635f5a7d --- /dev/null +++ b/scripts/generate-eslint-error-ignore-file @@ -0,0 +1,21 @@ +#!/bin/sh +# +# generates .eslintignore.errorfiles to list the files which have errors in, +# so that they can be ignored in future automated linting. + +out=.eslintignore.errorfiles + +cd `dirname $0`/.. + +echo "generating $out" + +{ + cat < 0) | .filePath' | + sed -e 's/.*matrix-react-sdk\///'; +} > "$out" diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js new file mode 100755 index 0000000000..b4fe8d69f5 --- /dev/null +++ b/scripts/prune-i18n.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +/* +Copyright 2017 New Vector 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. +*/ + +/* + * Looks through all the translation files and removes any strings + * which don't appear in en_EN.json. + * Use this if you remove a translation, but merge any outstanding changes + * from weblate first or you'll need to resolve the conflict in weblate. + */ + +const fs = require('fs'); +const path = require('path'); + +const I18NDIR = 'src/i18n/strings'; + +const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json'))); + +const enStrings = new Set(); +for (const str of Object.keys(enStringsRaw)) { + const parts = str.split('|'); + if (parts.length > 1) { + enStrings.add(parts[0]); + } else { + enStrings.add(str); + } +} + +for (const filename of fs.readdirSync(I18NDIR)) { + if (filename === 'en_EN.json') continue; + if (filename === 'basefile.json') continue; + if (!filename.endsWith('.json')) continue; + + const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename))); + const oldLen = Object.keys(trs).length; + for (const tr of Object.keys(trs)) { + const parts = tr.split('|'); + const trKey = parts.length > 1 ? parts[0] : tr; + if (!enStrings.has(trKey)) { + delete trs[tr]; + } + } + + const removed = oldLen - Object.keys(trs).length; + if (removed > 0) { + console.log(`${filename}: removed ${removed} translations`); + // XXX: This is totally relying on the impl serialising the JSON object in the + // same order as they were parsed from the file. JSON.stringify() has a specific argument + // that can be used to control the order, but JSON.parse() lacks any kind of equivalent. + // Empirically this does maintain the order on my system, so I'm going to leave it like + // this for now. + fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n"); + } +} diff --git a/scripts/reskindex.js b/scripts/reskindex.js index f9cbc2a711..833151a298 100755 --- a/scripts/reskindex.js +++ b/scripts/reskindex.js @@ -1,53 +1,99 @@ #!/usr/bin/env node - var fs = require('fs'); var path = require('path'); var glob = require('glob'); - var args = require('optimist').argv; - -var header = args.h || args.header; - -var componentsDir = path.join('src', 'components'); +var chokidar = require('chokidar'); var componentIndex = path.join('src', 'component-index.js'); +var componentIndexTmp = componentIndex+".tmp"; +var componentsDir = path.join('src', 'components'); +var componentGlob = '**/*.js'; +var prevFiles = []; -var packageJson = JSON.parse(fs.readFileSync('./package.json')); +function reskindex() { + var files = glob.sync(componentGlob, {cwd: componentsDir}).sort(); + if (!filesHaveChanged(files, prevFiles)) { + return; + } + prevFiles = files; -var strm = fs.createWriteStream(componentIndex); + var header = args.h || args.header; + var packageJson = JSON.parse(fs.readFileSync('./package.json')); -if (header) { - strm.write(fs.readFileSync(header)); - strm.write('\n'); + var strm = fs.createWriteStream(componentIndexTmp); + + if (header) { + strm.write(fs.readFileSync(header)); + strm.write('\n'); + } + + strm.write("/*\n"); + strm.write(" * THIS FILE IS AUTO-GENERATED\n"); + strm.write(" * You can edit it you like, but your changes will be overwritten,\n"); + strm.write(" * so you'd just be trying to swim upstream like a salmon.\n"); + strm.write(" * You are not a salmon.\n"); + strm.write(" */\n\n"); + + if (packageJson['matrix-react-parent']) { + const parentIndex = packageJson['matrix-react-parent'] + + '/lib/component-index'; + strm.write( +`let components = require('${parentIndex}').components; +if (!components) { + throw new Error("'${parentIndex}' didn't export components"); +} +`); + } else { + strm.write("let components = {};\n"); + } + + for (var i = 0; i < files.length; ++i) { + var file = files[i].replace('.js', ''); + + var moduleName = (file.replace(/\//g, '.')); + var importName = moduleName.replace(/\./g, "$"); + + strm.write("import " + importName + " from './components/" + file + "';\n"); + strm.write(importName + " && (components['"+moduleName+"'] = " + importName + ");"); + strm.write('\n'); + strm.uncork(); + } + + strm.write("export {components};\n"); + strm.end(); + fs.rename(componentIndexTmp, componentIndex, function(err) { + if(err) { + console.error("Error moving new index into place: " + err); + } else { + console.log('Reskindex: completed'); + } + }); } -strm.write("/*\n"); -strm.write(" * THIS FILE IS AUTO-GENERATED\n"); -strm.write(" * You can edit it you like, but your changes will be overwritten,\n"); -strm.write(" * so you'd just be trying to swim upstream like a salmon.\n"); -strm.write(" * You are not a salmon.\n"); -strm.write(" *\n"); -strm.write(" * To update it, run:\n"); -strm.write(" * ./reskindex.js -h header\n"); -strm.write(" */\n\n"); - -if (packageJson['matrix-react-parent']) { - strm.write("module.exports.components = require('"+packageJson['matrix-react-parent']+"/lib/component-index').components;\n\n"); -} else { - strm.write("module.exports.components = {};\n"); +// Expects both arrays of file names to be sorted +function filesHaveChanged(files, prevFiles) { + if (files.length !== prevFiles.length) { + return true; + } + // Check for name changes + for (var i = 0; i < files.length; i++) { + if (prevFiles[i] !== files[i]) { + return true; + } + } + return false; } -var files = glob.sync('**/*.js', {cwd: componentsDir}).sort(); -for (var i = 0; i < files.length; ++i) { - var file = files[i].replace('.js', ''); - - var moduleName = (file.replace(/\//g, '.')); - var importName = moduleName.replace(/\./g, "$"); - - strm.write("import " + importName + " from './components/" + file + "';\n"); - strm.write(importName + " && (module.exports.components['"+moduleName+"'] = " + importName + ");"); - strm.write('\n'); - strm.uncork(); +// -w indicates watch mode where any FS events will trigger reskindex +if (!args.w) { + reskindex(); + return; } -strm.end(); +var watchDebouncer = null; +chokidar.watch(path.join(componentsDir, componentGlob)).on('all', (event, path) => { + if (path === componentIndex) return; + if (watchDebouncer) clearTimeout(watchDebouncer); + watchDebouncer = setTimeout(reskindex, 1000); +}); diff --git a/scripts/travis.sh b/scripts/travis.sh new file mode 100755 index 0000000000..c4a06c1bd1 --- /dev/null +++ b/scripts/travis.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -ex + +npm run test +./.travis-test-riot.sh + +# run the linter, but exclude any files known to have errors or warnings. +npm run lintwithexclusions diff --git a/src/ActiveRoomObserver.js b/src/ActiveRoomObserver.js new file mode 100644 index 0000000000..d6fbb460b5 --- /dev/null +++ b/src/ActiveRoomObserver.js @@ -0,0 +1,77 @@ +/* +Copyright 2017 New Vector 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 RoomViewStore from './stores/RoomViewStore'; + +/** + * Consumes changes from the RoomViewStore and notifies specific things + * about when the active room changes. Unlike listening for RoomViewStore + * changes, you can subscribe to only changes relevant to a particular + * room. + * + * TODO: If we introduce an observer for something else, factor out + * the adding / removing of listeners & emitting into a common class. + */ +class ActiveRoomObserver { + constructor() { + this._listeners = {}; + + this._activeRoomId = RoomViewStore.getRoomId(); + // TODO: We could self-destruct when the last listener goes away, or at least + // stop listening. + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this)); + } + + addListener(roomId, listener) { + if (!this._listeners[roomId]) this._listeners[roomId] = []; + this._listeners[roomId].push(listener); + } + + removeListener(roomId, listener) { + if (this._listeners[roomId]) { + const i = this._listeners[roomId].indexOf(listener); + if (i > -1) { + this._listeners[roomId].splice(i, 1); + } + } else { + console.warn("Unregistering unrecognised listener (roomId=" + roomId + ")"); + } + } + + _emit(roomId) { + if (!this._listeners[roomId]) return; + + for (const l of this._listeners[roomId]) { + l.call(); + } + } + + _onRoomViewStoreUpdate() { + // emit for the old room ID + if (this._activeRoomId) this._emit(this._activeRoomId); + + // update our cache + this._activeRoomId = RoomViewStore.getRoomId(); + + // and emit for the new one + if (this._activeRoomId) this._emit(this._activeRoomId); + } +} + +if (global.mx_ActiveRoomObserver === undefined) { + global.mx_ActiveRoomObserver = new ActiveRoomObserver(); +} +export default global.mx_ActiveRoomObserver; diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 5593d46ff7..337e38d867 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. @@ -14,14 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); +import MatrixClientPeg from './MatrixClientPeg'; +import { _t } from './languageHandler'; /** * Allows a user to add a third party identifier to their Home Server and, * optionally, the identity servers. * * This involves getting an email token from the identity server to "prove" that - * the client owns the given email address, which is then passed to the + * the client owns the given email address, which is then passed to the * add threepid API on the homeserver. */ class AddThreepid { @@ -42,8 +44,33 @@ class AddThreepid { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_IN_USE') { - err.message = "This email address is already in use"; + if (err.errcode === 'M_THREEPID_IN_USE') { + err.message = _t('This email address is already in use'); + } else if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + + /** + * 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 = _t('This phone number is already in use'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } @@ -53,26 +80,49 @@ class AddThreepid { /** * 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]; + const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; return MatrixClientPeg.get().addThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: identityServerDomain + id_server: identityServerDomain, }, this.bind).catch(function(err) { if (err.httpStatus === 401) { - err.message = "Failed to verify email address: make sure you clicked the link in the email"; - } - else if (err.httpStatus) { + err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); + } else if (err.httpStatus) { err.message += ` (Status ${err.httpStatus})`; } 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. + * @param {string} token phone number verification code as entered by the user + * @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/Analytics.js b/src/Analytics.js new file mode 100644 index 0000000000..5c39b48a35 --- /dev/null +++ b/src/Analytics.js @@ -0,0 +1,231 @@ +/* + Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> + + 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 { getCurrentLanguage, _t, _td } from './languageHandler'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig, { DEFAULTS } from './SdkConfig'; +import Modal from './Modal'; +import sdk from './index'; + +function getRedactedHash() { + return window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/"); +} + +function getRedactedUrl() { + // hardcoded url to make piwik happy + return 'https://riot.im/app/' + getRedactedHash(); +} + +const customVariables = { + 'App Platform': { + id: 1, + expl: _td('The platform you\'re on'), + }, + 'App Version': { + id: 2, + expl: _td('The version of Riot.im'), + }, + 'User Type': { + id: 3, + expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'), + }, + 'Chosen Language': { + id: 4, + expl: _td('Your language of choice'), + }, + 'Instance': { + id: 5, + expl: _td('Which officially provided instance you are using, if any'), + }, + 'RTE: Uses Richtext Mode': { + id: 6, + expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'), + }, + 'Homeserver URL': { + id: 7, + expl: _td('Your homeserver\'s URL'), + }, + 'Identity Server URL': { + id: 8, + expl: _td('Your identity server\'s URL'), + }, +}; + +function whitelistRedact(whitelist, str) { + if (whitelist.includes(str)) return str; + return ''; +} + +class Analytics { + constructor() { + this._paq = null; + this.disabled = true; + this.firstPage = true; + } + + /** + * Enable Analytics if initialized but disabled + * otherwise try and initalize, no-op if piwik config missing + */ + enable() { + if (this._paq || this._init()) { + this.disabled = false; + } + } + + /** + * Disable Analytics calls, will not fully unload Piwik until a refresh, + * but this is second best, Piwik should not pull anything implicitly. + */ + disable() { + this.trackEvent('Analytics', 'opt-out'); + this.disabled = true; + } + + _init() { + const config = SdkConfig.get(); + if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; + + const url = config.piwik.url; + const siteId = config.piwik.siteId; + const self = this; + + window._paq = this._paq = window._paq || []; + + this._paq.push(['setTrackerUrl', url+'piwik.php']); + this._paq.push(['setSiteId', siteId]); + + this._paq.push(['trackAllContentImpressions']); + this._paq.push(['discardHashTag', false]); + this._paq.push(['enableHeartBeatTimer']); + // this._paq.push(['enableLinkTracking', true]); + + const platform = PlatformPeg.get(); + this._setVisitVariable('App Platform', platform.getHumanReadableName()); + platform.getAppVersion().then((version) => { + this._setVisitVariable('App Version', version); + }).catch(() => { + this._setVisitVariable('App Version', 'unknown'); + }); + + this._setVisitVariable('Chosen Language', getCurrentLanguage()); + + if (window.location.hostname === 'riot.im') { + this._setVisitVariable('Instance', window.location.pathname); + } + + (function() { + const g = document.createElement('script'); + const s = document.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js'; + + g.onload = function() { + console.log('Initialised anonymous analytics'); + self._paq = window._paq; + }; + + s.parentNode.insertBefore(g, s); + })(); + + return true; + } + + trackPageChange() { + if (this.disabled) return; + if (this.firstPage) { + // De-duplicate first page + // router seems to hit the fn twice + this.firstPage = false; + return; + } + this._paq.push(['setCustomUrl', getRedactedUrl()]); + this._paq.push(['trackPageView']); + } + + trackEvent(category, action, name) { + if (this.disabled) return; + this._paq.push(['trackEvent', category, action, name]); + } + + logout() { + if (this.disabled) return; + this._paq.push(['deleteCookies']); + } + + _setVisitVariable(key, value) { + this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']); + } + + setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { + if (this.disabled) return; + + const config = SdkConfig.get(); + const whitelistedHSUrls = config.piwik.whitelistedHSUrls || DEFAULTS.piwik.whitelistedHSUrls; + const whitelistedISUrls = config.piwik.whitelistedISUrls || DEFAULTS.piwik.whitelistedISUrls; + + this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); + this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); + this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); + } + + setRichtextMode(state) { + if (this.disabled) return; + this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); + } + + showDetailsModal() { + const Tracker = window.Piwik.getAsyncTracker(); + const rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean); + + const resolution = `${window.screen.width}x${window.screen.height}`; + + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { + title: _t('Analytics'), + description:
    +
    + { _t('The information being sent to us to help make Riot.im better includes:') } +
    + + { rows.map((row) => + + + ) } +
    { _t(customVariables[row[0]].expl) }{ row[1] }
    +
    +
    + { _t('We also record each page you use in the app (currently ), your User Agent' + + ' () and your device resolution ().', + {}, + { + CurrentPageHash: { getRedactedHash() }, + CurrentUserAgent: { navigator.userAgent }, + CurrentDeviceResolution: { resolution }, + }, + ) } + + { _t('Where this page includes identifiable information, such as a room, ' + + 'user or group ID, that data is removed before being sent to the server.') } +
    +
    , + }); + } +} + +if (!global.mxAnalytics) { + global.mxAnalytics = new Analytics(); +} +module.exports = global.mxAnalytics; diff --git a/src/Avatar.js b/src/Avatar.js index 0ef6c8d07b..d41a3f6a79 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -15,18 +15,18 @@ limitations under the License. */ 'use strict'; -var ContentRepo = require("matrix-js-sdk").ContentRepo; -var MatrixClientPeg = require('./MatrixClientPeg'); +import {ContentRepo} from 'matrix-js-sdk'; +import MatrixClientPeg from './MatrixClientPeg'; module.exports = { avatarUrlForMember: function(member, width, height, resizeMethod) { - var url = member.getAvatarUrl( + let url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), - width, - height, + Math.floor(width * window.devicePixelRatio), + Math.floor(height * window.devicePixelRatio), resizeMethod, false, - false + false, ); if (!url) { // member can be null here currently since on invites, the JS SDK @@ -38,9 +38,11 @@ module.exports = { }, avatarUrlForUser: function(user, width, height, resizeMethod) { - var url = ContentRepo.getHttpUriForMxc( + const 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; @@ -49,12 +51,11 @@ module.exports = { }, defaultAvatarUrlForString: function(s) { - var images = [ '76cfa6', '50e2c2', 'f4c371' ]; - var total = 0; - for (var i = 0; i < s.length; ++i) { + const images = ['76cfa6', '50e2c2', 'f4c371']; + let total = 0; + for (let i = 0; i < s.length; ++i) { total += s.charCodeAt(i); } return 'img/' + images[total % images.length] + '.png'; - } -} - + }, +}; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index b8c400c6e3..abc9aa0bed 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -17,6 +17,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import dis from './dispatcher'; + /** * Base class for classes that provide platform-specific functionality * eg. Setting an application badge or displaying notifications @@ -27,6 +29,21 @@ export default class BasePlatform { constructor() { this.notificationCount = 0; this.errorDidOccur = false; + + dis.register(this._onAction.bind(this)); + } + + _onAction(payload: Object) { + switch (payload.action) { + case 'on_logged_out': + this.setNotificationCount(0); + break; + } + } + + // Used primarily for Analytics + getHumanReadableName(): string { + return 'Base Platform'; } setNotificationCount(count: number) { @@ -40,16 +57,18 @@ export default class BasePlatform { /** * Returns true if the platform supports displaying * notifications, otherwise false. + * @returns {boolean} whether the platform supports displaying notifications */ - supportsNotifications() : boolean { + supportsNotifications(): boolean { return false; } /** * Returns true if the application currently has permission * to display notifications. Otherwise false. + * @returns {boolean} whether the application has permission to display notifications */ - maySendNotifications() : boolean { + maySendNotifications(): boolean { return false; } @@ -60,17 +79,42 @@ export default class BasePlatform { * that is 'granted' if the user allowed the request or * 'denied' otherwise. */ - requestNotificationPermission() : Promise { + requestNotificationPermission(): Promise { } displayNotification(title: string, msg: string, avatarUrl: string, room: Object) { } + loudNotification(ev: Event, room: Object) { + } + /** * Returns a promise that resolves to a string representing * the current version of the application. */ - getAppVersion() { + getAppVersion(): Promise { throw new Error("getAppVersion not implemented!"); } + + /* + * If it's not expected that capturing the screen will work + * with getUserMedia, return a string explaining why not. + * Otherwise, return null. + */ + screenCaptureErrorString(): string { + return "Not implemented"; + } + + isElectron(): boolean { return false; } + + setupScreenSharingForIframe() { + } + + /** + * 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 2f931d8e3f..fd56d7f1b1 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -51,32 +52,34 @@ limitations under the License. * } */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var Modal = require('./Modal'); -var sdk = require('./index'); -var Matrix = require("matrix-js-sdk"); -var dis = require("./dispatcher"); +import MatrixClientPeg from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import Modal from './Modal'; +import sdk from './index'; +import { _t } from './languageHandler'; +import Matrix from 'matrix-js-sdk'; +import dis from './dispatcher'; +import { showUnknownDeviceDialogForCalls } from './cryptodevices'; global.mxCalls = { //room_id: MatrixCall }; -var calls = global.mxCalls; -var ConferenceHandler = null; +const calls = global.mxCalls; +let ConferenceHandler = null; -var audioPromises = {}; +const audioPromises = {}; function play(audioId) { // TODO: Attach an invisible element for this instead // which listens? - var audio = document.getElementById(audioId); + const audio = document.getElementById(audioId); if (audio) { if (audioPromises[audioId]) { audioPromises[audioId] = audioPromises[audioId].then(()=>{ audio.load(); return audio.play(); }); - } - else { + } else { audioPromises[audioId] = audio.play(); } } @@ -85,24 +88,67 @@ function play(audioId) { function pause(audioId) { // TODO: Attach an invisible element for this instead // which listens? - var audio = document.getElementById(audioId); + const audio = document.getElementById(audioId); if (audio) { if (audioPromises[audioId]) { audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause()); - } - else { + } else { // pause doesn't actually return a promise, but might as well do this for symmetry with play(); audioPromises[audioId] = audio.pause(); } } } +function _reAttemptCall(call) { + if (call.direction === 'outbound') { + dis.dispatch({ + action: 'place_call', + room_id: call.roomId, + type: call.type, + }); + } else { + call.answer(); + } +} + function _setCallListeners(call) { call.on("error", function(err) { console.error("Call error: %s", err); console.error(err.stack); - call.hangup(); - _setCallState(undefined, call.roomId, "ended"); + if (err.code === 'unknown_devices') { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + Modal.createTrackedDialog('Call Failed', '', QuestionDialog, { + title: _t('Call Failed'), + description: _t( + "There are unknown devices in this room: "+ + "if you proceed without verifying them, it will be "+ + "possible for someone to eavesdrop on your call." + ), + button: _t('Review Devices'), + onFinished: function(confirmed) { + if (confirmed) { + const room = MatrixClientPeg.get().getRoom(call.roomId); + showUnknownDeviceDialogForCalls( + MatrixClientPeg.get(), + room, + () => { + _reAttemptCall(call); + }, + call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"), + call.direction === 'outbound' ? _t("Call") : _t("Answer"), + ); + } + }, + }); + } else { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Call Failed'), + description: err.message, + }); + } }); call.on("hangup", function() { _setCallState(undefined, call.roomId, "ended"); @@ -113,38 +159,32 @@ function _setCallListeners(call) { if (newState === "ringing") { _setCallState(call, call.roomId, "ringing"); pause("ringbackAudio"); - } - else if (newState === "invite_sent") { + } else if (newState === "invite_sent") { _setCallState(call, call.roomId, "ringback"); play("ringbackAudio"); - } - else if (newState === "ended" && oldState === "connected") { + } else if (newState === "ended" && oldState === "connected") { _setCallState(undefined, call.roomId, "ended"); pause("ringbackAudio"); play("callendAudio"); - } - else if (newState === "ended" && oldState === "invite_sent" && + } else if (newState === "ended" && oldState === "invite_sent" && (call.hangupParty === "remote" || (call.hangupParty === "local" && call.hangupReason === "invite_timeout") )) { _setCallState(call, call.roomId, "busy"); pause("ringbackAudio"); play("busyAudio"); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Call Timeout", - description: "The remote side failed to pick up." + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { + title: _t('Call Timeout'), + description: _t('The remote side failed to pick up') + '.', }); - } - else if (oldState === "invite_sent") { + } else if (oldState === "invite_sent") { _setCallState(call, call.roomId, "stop_ringback"); pause("ringbackAudio"); - } - else if (oldState === "ringing") { + } else if (oldState === "ringing") { _setCallState(call, call.roomId, "stop_ringing"); pause("ringbackAudio"); - } - else if (newState === "connected") { + } else if (newState === "connected") { _setCallState(call, call.roomId, "connected"); pause("ringbackAudio"); } @@ -153,15 +193,14 @@ function _setCallListeners(call) { function _setCallState(call, roomId, status) { console.log( - "Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-") + "Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-"), ); calls[roomId] = call; if (status === "ringing") { - play("ringAudio") - } - else if (call && call.call_state === "ringing") { - pause("ringAudio") + play("ringAudio"); + } else if (call && call.call_state === "ringing") { + pause("ringAudio"); } if (call) { @@ -169,30 +208,38 @@ function _setCallState(call, roomId, status) { } dis.dispatch({ action: 'call_state', - room_id: roomId + room_id: roomId, + state: status, }); } function _onAction(payload) { function placeCall(newCall) { _setCallListeners(newCall); - _setCallState(newCall, newCall.roomId, "ringback"); if (payload.type === 'voice') { newCall.placeVoiceCall(); - } - else if (payload.type === 'video') { + } else if (payload.type === 'video') { newCall.placeVideoCall( payload.remote_element, - payload.local_element + payload.local_element, ); - } - else if (payload.type === 'screensharing') { + } else if (payload.type === 'screensharing') { + const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); + if (screenCapErrorString) { + _setCallState(undefined, newCall.roomId, "ended"); + console.log("Can't capture screen: " + screenCapErrorString); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { + title: _t('Unable to capture screen'), + description: screenCapErrorString, + }); + return; + } newCall.placeScreenSharingCall( payload.remote_element, - payload.local_element + payload.local_element, ); - } - else { + } else { console.error("Unknown conf call type: %s", payload.type); } } @@ -201,9 +248,9 @@ function _onAction(payload) { case 'place_call': if (module.exports.getAnyActiveCall()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Existing Call", - description: "You are already in a call." + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Existing Call'), + description: _t('You are already in a call.'), }); return; // don't allow >1 call to be placed. } @@ -211,9 +258,9 @@ function _onAction(payload) { // if the runtime env doesn't do VoIP, whine. if (!MatrixClientPeg.get().supportsVoip()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "VoIP is unsupported", - description: "You cannot place VoIP calls in this browser." + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), }); return; } @@ -227,25 +274,21 @@ function _onAction(payload) { var members = room.getJoinedMembers(); if (members.length <= 1) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - description: "You cannot place a call with yourself." + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { + description: _t('You cannot place a call with yourself.'), }); return; - } - else if (members.length === 2) { + } else if (members.length === 2) { console.log("Place %s call in %s", payload.type, payload.room_id); - var call = Matrix.createNewMatrixCall( - MatrixClientPeg.get(), payload.room_id - ); + const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); placeCall(call); - } - else { // > 2 + } else { // > 2 dis.dispatch({ action: "place_conference_call", room_id: payload.room_id, type: payload.type, remote_element: payload.remote_element, - local_element: payload.local_element + local_element: payload.local_element, }); } break; @@ -253,18 +296,16 @@ function _onAction(payload) { console.log("Place conference call in %s", payload.room_id); if (!ConferenceHandler) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - description: "Conference calls are not supported in this client" + Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, { + description: _t('Conference calls are not supported in this client'), }); - } - else if (!MatrixClientPeg.get().supportsVoip()) { + } else if (!MatrixClientPeg.get().supportsVoip()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "VoIP is unsupported", - description: "You cannot place VoIP calls in this browser." + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), }); - } - else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) { + } else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) { // Conference calls are implemented by sending the media to central // server which combines the audio from all the participants together // into a single stream. This is incompatible with end-to-end encryption @@ -272,26 +313,26 @@ function _onAction(payload) { // participant. // Therefore we disable conference calling in E2E rooms. const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - description: "Conference calls are not supported in encrypted rooms", + Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, { + description: _t('Conference calls are not supported in encrypted rooms'), }); - } - else { - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { - title: "Warning!", - description: "Conference calling in Riot is in development and may not be reliable.", - onFinished: confirm=>{ + } else { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, { + title: _t('Warning!'), + description: _t('Conference calling is in development and may not be reliable.'), + onFinished: (confirm)=>{ if (confirm) { ConferenceHandler.createNewMatrixCall( - MatrixClientPeg.get(), payload.room_id + MatrixClientPeg.get(), payload.room_id, ).done(function(call) { placeCall(call); }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failed to set up conference call", - description: "Conference call failed: " + err, + console.error("Conference call failed: " + err); + Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, { + title: _t('Failed to set up conference call'), + description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), }); }); } @@ -332,7 +373,7 @@ function _onAction(payload) { _setCallState(calls[payload.room_id], payload.room_id, "connected"); dis.dispatch({ action: "view_room", - room_id: payload.room_id + room_id: payload.room_id, }); break; } @@ -343,9 +384,9 @@ if (!global.mxCallHandler) { dis.register(_onAction); } -var callHandler = { +const callHandler = { getCallForRoom: function(roomId) { - var call = module.exports.getCall(roomId); + let call = module.exports.getCall(roomId); if (call) return call; if (ConferenceHandler) { @@ -361,8 +402,8 @@ var callHandler = { }, getAnyActiveCall: function() { - var roomsWithCalls = Object.keys(calls); - for (var i = 0; i < roomsWithCalls.length; i++) { + const roomsWithCalls = Object.keys(calls); + for (let i = 0; i < roomsWithCalls.length; i++) { if (calls[roomsWithCalls[i]] && calls[roomsWithCalls[i]].call_state !== "ended") { return calls[roomsWithCalls[i]]; @@ -377,7 +418,7 @@ var callHandler = { getConferenceHandler: function() { return ConferenceHandler; - } + }, }; // Only things in here which actually need to be global are the // calls list (done separately) and making sure we only register diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js new file mode 100644 index 0000000000..cdc5c61921 --- /dev/null +++ b/src/CallMediaHandler.js @@ -0,0 +1,62 @@ +/* + Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> + + 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 * as Matrix from 'matrix-js-sdk'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; + +export default { + getDevices: function() { + // Only needed for Electron atm, though should work in modern browsers + // once permission has been granted to the webapp + return navigator.mediaDevices.enumerateDevices().then(function(devices) { + const audioIn = []; + const videoIn = []; + + if (devices.some((device) => !device.label)) return false; + + devices.forEach((device) => { + switch (device.kind) { + case 'audioinput': audioIn.push(device); break; + case 'videoinput': videoIn.push(device); break; + } + }); + + // console.log("Loaded WebRTC Devices", mediaDevices); + return { + audioinput: audioIn, + videoinput: videoIn, + }; + }, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); }); + }, + + loadDevices: function() { + const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); + const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + + Matrix.setMatrixCallAudioInput(audioDeviceId); + Matrix.setMatrixCallVideoInput(videoDeviceId); + }, + + setAudioInput: function(deviceId) { + SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); + Matrix.setMatrixCallAudioInput(deviceId); + }, + + setVideoInput: function(deviceId) { + SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); + Matrix.setMatrixCallVideoInput(deviceId); + }, +}; diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..2757c5bd3d --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,84 @@ +//@flow +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ContentState, convertToRaw, convertFromRaw} from 'draft-js'; +import * as RichText from './RichText'; +import Markdown from './Markdown'; +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'html' | 'markdown'; + +class HistoryItem { + + // Keeping message for backwards-compatibility + message: string; + rawContentState: RawDraftContentState; + format: MessageFormat = 'html'; + + constructor(contentState: ?ContentState, format: ?MessageFormat) { + this.rawContentState = contentState ? convertToRaw(contentState) : null; + this.format = format; + } + + toContentState(outputFormat: MessageFormat): ContentState { + const contentState = convertFromRaw(this.rawContentState); + if (outputFormat === 'markdown') { + if (this.format === 'html') { + return ContentState.createFromText(RichText.stateToMarkdown(contentState)); + } + } else { + if (this.format === 'markdown') { + return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML()); + } + } + // history item has format === outputFormat + return contentState; + } +} + +export default class ComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; + currentIndex: number = 0; + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let item; + for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { + this.history.push( + Object.assign(new HistoryItem(), JSON.parse(item)), + ); + } + this.lastIndex = this.currentIndex; + } + + save(contentState: ContentState, format: MessageFormat) { + const item = new HistoryItem(contentState, format); + this.history.push(item); + this.currentIndex = this.lastIndex + 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); + } + + getItem(offset: number, format: MessageFormat): ?ContentState { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); + const item = this.history[this.currentIndex]; + return item ? item.toContentState(format) : null; + } +} diff --git a/src/ContentMessages.js b/src/ContentMessages.js index c169ce64b5..8d40b65124 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -16,14 +16,15 @@ limitations under the License. 'use strict'; -var q = require('q'); -var extend = require('./extend'); -var dis = require('./dispatcher'); -var MatrixClientPeg = require('./MatrixClientPeg'); -var sdk = require('./index'); -var Modal = require('./Modal'); +import Promise from 'bluebird'; +const extend = require('./extend'); +const dis = require('./dispatcher'); +const MatrixClientPeg = require('./MatrixClientPeg'); +const sdk = require('./index'); +import { _t } from './languageHandler'; +const Modal = require('./Modal'); -var encrypt = require("browser-encrypt-attachment"); +const encrypt = require("browser-encrypt-attachment"); // Polyfill for Canvas.toBlob API using Canvas.toDataURL require("blueimp-canvas-to-blob"); @@ -51,10 +52,10 @@ const MAX_HEIGHT = 600; * and a thumbnail key. */ function createThumbnail(element, inputWidth, inputHeight, mimeType) { - const deferred = q.defer(); + const deferred = Promise.defer(); - var targetWidth = inputWidth; - var targetHeight = inputHeight; + let targetWidth = inputWidth; + let targetHeight = inputHeight; if (targetHeight > MAX_HEIGHT) { targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); targetHeight = MAX_HEIGHT; @@ -80,7 +81,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) { w: inputWidth, h: inputHeight, }, - thumbnail: thumbnail + thumbnail: thumbnail, }); }, mimeType); @@ -94,27 +95,21 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) { * @return {Promise} A promise that resolves with the html image element. */ function loadImageElement(imageFile) { - const deferred = q.defer(); + const deferred = Promise.defer(); // Load the file into an html element const img = document.createElement("img"); + const objectUrl = URL.createObjectURL(imageFile); + img.src = objectUrl; - const reader = new FileReader(); - reader.onload = function(e) { - img.src = e.target.result; - - // Once ready, create a thumbnail - img.onload = function() { - deferred.resolve(img); - }; - img.onerror = function(e) { - deferred.reject(e); - }; + // Once ready, create a thumbnail + img.onload = function() { + URL.revokeObjectURL(objectUrl); + deferred.resolve(img); }; - reader.onerror = function(e) { + img.onerror = function(e) { deferred.reject(e); }; - reader.readAsDataURL(imageFile); return deferred.promise; } @@ -128,12 +123,12 @@ function loadImageElement(imageFile) { * @return {Promise} A promise that resolves with the attachment info. */ function infoForImageFile(matrixClient, roomId, imageFile) { - var thumbnailType = "image/png"; + let thumbnailType = "image/png"; if (imageFile.type == "image/jpeg") { thumbnailType = "image/jpeg"; } - var imageInfo; + let imageInfo; return loadImageElement(imageFile).then(function(img) { return createThumbnail(img, img.width, img.height, thumbnailType); }).then(function(result) { @@ -153,7 +148,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) { * @return {Promise} A promise that resolves with the video image element. */ function loadVideoElement(videoFile) { - const deferred = q.defer(); + const deferred = Promise.defer(); // Load the file into an html element const video = document.createElement("video"); @@ -190,7 +185,7 @@ function loadVideoElement(videoFile) { function infoForVideoFile(matrixClient, roomId, videoFile) { const thumbnailType = "image/jpeg"; - var videoInfo; + let videoInfo; return loadVideoElement(videoFile).then(function(video) { return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); }).then(function(result) { @@ -209,7 +204,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { * is read. */ function readFileAsArrayBuffer(file) { - const deferred = q.defer(); + const deferred = Promise.defer(); const reader = new FileReader(); reader.onload = function(e) { deferred.resolve(e.target.result); @@ -228,11 +223,13 @@ function readFileAsArrayBuffer(file) { * @param {MatrixClient} matrixClient The matrix client to upload the file with. * @param {String} roomId The ID of the room being uploaded to. * @param {File} file The file to upload. + * @param {Function?} progressHandler optional callback to be called when a chunk of + * data is uploaded. * @return {Promise} A promise that resolves with an object. * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -function uploadFile(matrixClient, roomId, file) { +function uploadFile(matrixClient, roomId, file, progressHandler) { if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. // First read the file into memory. @@ -244,7 +241,9 @@ function uploadFile(matrixClient, roomId, file) { const encryptInfo = encryptResult.info; // Pass the encrypted data as a Blob to the uploader. const blob = new Blob([encryptResult.data]); - return matrixClient.uploadContent(blob).then(function(url) { + return matrixClient.uploadContent(blob, { + progressHandler: progressHandler, + }).then(function(url) { // If the attachment is encrypted then bundle the URL along // with the information needed to decrypt the attachment and // add it under a file key. @@ -256,7 +255,9 @@ function uploadFile(matrixClient, roomId, file) { }); }); } else { - const basePromise = matrixClient.uploadContent(file); + const basePromise = matrixClient.uploadContent(file, { + progressHandler: progressHandler, + }); const promise1 = basePromise.then(function(url) { // If the attachment isn't encrypted then include the URL directly. return {"url": url}; @@ -276,10 +277,10 @@ class ContentMessages { sendContentToRoom(file, roomId, matrixClient) { const content = { - body: file.name, + body: file.name || 'Attachment', info: { size: file.size, - } + }, }; // if we have a mime type for the file, add it to the message metadata @@ -287,13 +288,13 @@ class ContentMessages { content.info.mimetype = file.type; } - const def = q.defer(); + const def = Promise.defer(); if (file.type.indexOf('image/') == 0) { content.msgtype = 'm.image'; - infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{ + infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{ extend(content.info, imageInfo); def.resolve(); - }, error=>{ + }, (error)=>{ console.error(error); content.msgtype = 'm.file'; def.resolve(); @@ -303,10 +304,10 @@ class ContentMessages { def.resolve(); } else if (file.type.indexOf('video/') == 0) { content.msgtype = 'm.video'; - infoForVideoFile(matrixClient, roomId, file).then(videoInfo=>{ + infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{ extend(content.info, videoInfo); def.resolve(); - }, error=>{ + }, (error)=>{ content.msgtype = 'm.file'; def.resolve(); }); @@ -316,7 +317,7 @@ class ContentMessages { } const upload = { - fileName: file.name, + fileName: file.name || 'Attachment', roomId: roomId, total: 0, loaded: 0, @@ -324,43 +325,44 @@ class ContentMessages { this.inprogress.push(upload); dis.dispatch({action: 'upload_started'}); - var error; + let error; + + function onProgress(ev) { + upload.total = ev.total; + upload.loaded = ev.loaded; + dis.dispatch({action: 'upload_progress', upload: upload}); + } + return def.promise.then(function() { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. upload.promise = uploadFile( - matrixClient, roomId, file + matrixClient, roomId, file, onProgress, ); return upload.promise.then(function(result) { content.file = result.file; content.url = result.url; }); - }).progress(function(ev) { - if (ev) { - upload.total = ev.total; - upload.loaded = ev.loaded; - dis.dispatch({action: 'upload_progress', upload: upload}); - } }).then(function(url) { return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; if (!upload.canceled) { - var desc = "The file '"+upload.fileName+"' failed to upload."; + let desc = _t('The file \'%(fileName)s\' failed to upload', {fileName: upload.fileName}) + '.'; if (err.http_status == 413) { - desc = "The file '"+upload.fileName+"' exceeds this home server's size limit for uploads"; + desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName}); } - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Upload Failed", - description: desc + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { + title: _t('Upload Failed'), + description: desc, }); } }).finally(() => { const inprogressKeys = Object.keys(this.inprogress); - for (var i = 0; i < this.inprogress.length; ++i) { - var k = inprogressKeys[i]; + for (let i = 0; i < this.inprogress.length; ++i) { + const k = inprogressKeys[i]; if (this.inprogress[k].promise === upload.promise) { this.inprogress.splice(k, 1); break; @@ -368,8 +370,7 @@ class ContentMessages { } if (error) { dis.dispatch({action: 'upload_failed', upload: upload}); - } - else { + } else { dis.dispatch({action: 'upload_finished', upload: upload}); } }); @@ -381,9 +382,9 @@ class ContentMessages { cancelUpload(promise) { const inprogressKeys = Object.keys(this.inprogress); - var upload; - for (var i = 0; i < this.inprogress.length; ++i) { - var k = inprogressKeys[i]; + let upload; + for (let i = 0; i < this.inprogress.length; ++i) { + const k = inprogressKeys[i]; if (this.inprogress[k].promise === promise) { upload = this.inprogress[k]; break; diff --git a/src/DateUtils.js b/src/DateUtils.js index 2b51c5903f..986525eec8 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.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,39 +15,113 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import { _t } from './languageHandler'; -var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - -module.exports = { - formatDate: function(date) { - // date.toLocaleTimeString is completely system dependent. - // just go 24h for now - function pad(n) { - return (n < 10 ? '0' : '') + n; - } - - var now = new Date(); - if (date.toDateString() === now.toDateString()) { - return pad(date.getHours()) + ':' + pad(date.getMinutes()); - } - else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { - return days[date.getDay()] + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); - } - else /* if (now.getFullYear() === date.getFullYear()) */ { - return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); - } - /* - else { - return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + date.getFullYear() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); - } - */ - }, - - formatTime: function(date) { - //return pad(date.getHours()) + ':' + pad(date.getMinutes()); - return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2); - } +function getDaysArray() { + return [ + _t('Sun'), + _t('Mon'), + _t('Tue'), + _t('Wed'), + _t('Thu'), + _t('Fri'), + _t('Sat'), + ]; } +function getMonthsArray() { + return [ + _t('Jan'), + _t('Feb'), + _t('Mar'), + _t('Apr'), + _t('May'), + _t('Jun'), + _t('Jul'), + _t('Aug'), + _t('Sep'), + _t('Oct'), + _t('Nov'), + _t('Dec'), + ]; +} + +function pad(n) { + return (n < 10 ? '0' : '') + n; +} + +function twelveHourTime(date) { + let hours = date.getHours() % 12; + const minutes = pad(date.getMinutes()); + const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); + hours = hours ? hours : 12; // convert 0 -> 12 + return `${hours}:${minutes}${ampm}`; +} + +export function formatDate(date, showTwelveHour=false) { + const now = new Date(); + const days = getDaysArray(); + const months = getMonthsArray(); + if (date.toDateString() === now.toDateString()) { + return formatTime(date, showTwelveHour); + } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { + // TODO: use standard date localize function provided in counterpart + return _t('%(weekDayName)s %(time)s', { + weekDayName: days[date.getDay()], + time: formatTime(date, showTwelveHour), + }); + } else if (now.getFullYear() === date.getFullYear()) { + // TODO: use standard date localize function provided in counterpart + return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + time: formatTime(date, showTwelveHour), + }); + } + return formatFullDate(date, showTwelveHour); +} + +export function formatFullDateNoTime(date) { + const days = getDaysArray(); + const months = getMonthsArray(); + return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + fullYear: date.getFullYear(), + }); +} + +export function formatFullDate(date, showTwelveHour=false) { + const days = getDaysArray(); + const months = getMonthsArray(); + return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + fullYear: date.getFullYear(), + time: formatTime(date, showTwelveHour), + }); +} + +export function formatTime(date, showTwelveHour=false) { + if (showTwelveHour) { + return twelveHourTime(date); + } + return pad(date.getHours()) + ':' + pad(date.getMinutes()); +} + +const MILLIS_IN_DAY = 86400000; +export function wantsDateSeparator(prevEventDate, nextEventDate) { + if (!nextEventDate || !prevEventDate) { + return false; + } + // Return early for events that are > 24h apart + if (Math.abs(prevEventDate.getTime() - nextEventDate.getTime()) > MILLIS_IN_DAY) { + return true; + } + + // Compare weekdays + return prevEventDate.getDay() !== nextEventDate.getDay(); +} diff --git a/src/Entities.js b/src/Entities.js index ac3c976797..21abd9c473 100644 --- a/src/Entities.js +++ b/src/Entities.js @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); -var sdk = require('./index'); +import sdk from './index'; function isMatch(query, name, uid) { query = query.toLowerCase(); @@ -33,8 +32,8 @@ function isMatch(query, name, uid) { } // split spaces in name and try matching constituent parts - var parts = name.split(" "); - for (var i = 0; i < parts.length; i++) { + const parts = name.split(" "); + for (let i = 0; i < parts.length; i++) { if (parts[i].indexOf(query) === 0) { return true; } @@ -67,7 +66,7 @@ class Entity { class MemberEntity extends Entity { getJsx() { - var MemberTile = sdk.getComponent("rooms.MemberTile"); + const MemberTile = sdk.getComponent("rooms.MemberTile"); return ( ); @@ -84,6 +83,7 @@ class UserEntity extends Entity { super(model); this.showInviteButton = Boolean(showInviteButton); this.inviteFn = inviteFn; + this.onClick = this.onClick.bind(this); } onClick() { @@ -93,15 +93,15 @@ class UserEntity extends Entity { } getJsx() { - var UserTile = sdk.getComponent("rooms.UserTile"); + const UserTile = sdk.getComponent("rooms.UserTile"); return ( + showInviteButton={this.showInviteButton} onClick={this.onClick} /> ); } matches(queryString) { - var name = this.model.displayName || this.model.userId; + const name = this.model.displayName || this.model.userId; return isMatch(queryString, name, this.model.userId); } } @@ -109,7 +109,7 @@ class UserEntity extends Entity { module.exports = { newEntity: function(jsx, matchFn) { - var entity = new Entity(); + const entity = new Entity(); entity.getJsx = function() { return jsx; }; @@ -136,6 +136,6 @@ module.exports = { fromUsers: function(users, showInviteButton, inviteFn) { return users.map(function(u) { return new UserEntity(u, showInviteButton, inviteFn); - }) - } + }); + }, }; diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js new file mode 100644 index 0000000000..ef9010cbf2 --- /dev/null +++ b/src/GroupAddressPicker.js @@ -0,0 +1,156 @@ +/* +Copyright 2017 New Vector 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 Modal from './Modal'; +import sdk from './'; +import MultiInviter from './utils/MultiInviter'; +import { _t } from './languageHandler'; +import MatrixClientPeg from './MatrixClientPeg'; +import GroupStoreCache from './stores/GroupStoreCache'; + +export function showGroupInviteDialog(groupId) { + const description =
    +
    { _t("Who would you like to add to this community?") }
    +
    + { _t( + "Warning: any person you add to a community will be publicly "+ + "visible to anyone who knows the community ID", + ) } +
    +
    ; + + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { + title: _t("Invite new community members"), + description: description, + placeholder: _t("Name or matrix ID"), + button: _t("Invite to Community"), + validAddressTypes: ['mx-user-id'], + onFinished: (success, addrs) => { + if (!success) return; + + _onGroupInviteFinished(groupId, addrs); + }, + }); +} + +export function showGroupAddRoomDialog(groupId) { + return new Promise((resolve, reject) => { + let addRoomsPublicly = false; + const onCheckboxClicked = (e) => { + addRoomsPublicly = e.target.checked; + }; + const description =
    +
    { _t("Which rooms would you like to add to this community?") }
    +
    ; + + const checkboxContainer = ; + + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, { + title: _t("Add rooms to the community"), + description: description, + extraNode: checkboxContainer, + placeholder: _t("Room name or alias"), + button: _t("Add to community"), + pickerType: 'room', + validAddressTypes: ['mx-room-id'], + onFinished: (success, addrs) => { + if (!success) return; + + _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject); + }, + }); + }); +} + +function _onGroupInviteFinished(groupId, addrs) { + const multiInviter = new MultiInviter(groupId); + + const addrTexts = addrs.map((addr) => addr.address); + + multiInviter.invite(addrTexts).then((completionStates) => { + // Show user any errors + const errorList = []; + for (const addr of Object.keys(completionStates)) { + if (addrs[addr] === "error") { + errorList.push(addr); + } + } + + if (errorList.length > 0) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, { + title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}), + description: errorList.join(", "), + }); + } + }).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { + title: _t("Failed to invite users to community"), + description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), + }); + }); +} + +function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { + const matrixClient = MatrixClientPeg.get(); + const groupStore = GroupStoreCache.getGroupStore(groupId); + const errorList = []; + return Promise.all(addrs.map((addr) => { + return groupStore + .addRoomToGroup(addr.address, addRoomsPublicly) + .catch(() => { errorList.push(addr.address); }) + .then(() => { + const roomId = addr.address; + const room = matrixClient.getRoom(roomId); + // Can the user change related groups? + if (!room || !room.currentState.mayClientSendStateEvent("m.room.related_groups", matrixClient)) { + return; + } + // Get the related groups + const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', ''); + const groups = relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : []; + + // Add this group as related + if (!groups.includes(groupId)) { + groups.push(groupId); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); + } + }).reflect(); + })).then(() => { + if (errorList.length === 0) { + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to add the following room to the group', + '', ErrorDialog, + { + title: _t( + "Failed to add the following rooms to %(groupId)s:", + {groupId}, + ), + description: errorList.join(", "), + }); + }); +} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index fc1630b6fb..0c262fe89a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,40 +17,67 @@ limitations under the License. 'use strict'; -var React = require('react'); -var sanitizeHtml = require('sanitize-html'); -var highlight = require('highlight.js'); -var linkifyMatrix = require('./linkify-matrix'); +const React = require('react'); +const sanitizeHtml = require('sanitize-html'); +const highlight = require('highlight.js'); +const linkifyMatrix = require('./linkify-matrix'); import escape from 'lodash/escape'; import emojione from 'emojione'; import classNames from 'classnames'; +import MatrixClientPeg from './MatrixClientPeg'; 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'; +// Anything outside the basic multilingual plane will be a surrogate pair +const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// And there a bunch more symbol characters that emojione has within the +// BMP, so this includes the ranges from 'letterlike symbols' to +// 'miscellaneous symbols and arrows' which should catch all of them +// (with plenty of false positives, but that's OK) +const SYMBOL_PATTERN = /([\u2100-\u2bff])/; + +// And this is emojione's complete regex const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); +const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/* + * Return true if the given string contains emoji + * Uses a much, much simpler regex than emojione's so will give false + * positives, but useful for fast-path testing strings to see if they + * need emojification. + * unicodeToImage uses this function. + */ +export function containsEmoji(str) { + return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); +} /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js * because we want to include emoji shortnames in title text */ -export function unicodeToImage(str) { - let replaceWith, unicode, alt; +function unicodeToImage(str) { + let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); str = str.replace(emojione.regUnicode, function(unicodeChar) { if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) { // if the unicodeChar doesnt exist just return the entire match return unicodeChar; - } - else { + } else { // get the unicode codepoint from the actual char unicode = emojione.jsEscapeMap[unicodeChar]; + short = mappedUnicode[unicode]; + fname = emojione.emojioneList[short].fname; + // depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode]; const title = mappedUnicode[unicode]; - replaceWith = `${alt}`; + replaceWith = `${alt}`; return replaceWith; } }); @@ -57,7 +85,30 @@ export function unicodeToImage(str) { return str; } -export function stripParagraphs(html: string): string { +/** + * 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 processHtmlForSending(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; @@ -66,10 +117,21 @@ export function stripParagraphs(html: string): string { } let contentHTML = ""; - for (let i=0; i'; + contentHTML += element.innerHTML; + // Don't add a
    for the last

    + if (i !== contentDiv.children.length - 1) { + contentHTML += '
    '; + } + } else if (element.tagName.toLowerCase() === 'pre') { + // Replace "
    \n" with "\n" within `

    ` tags because the 
    is + // redundant. This is a workaround for a bug in draft-js-export-html: + // https://github.com/sstur/draft-js-export-html/issues/62 + contentHTML += '
    ' +
    +                element.innerHTML.replace(/
    \n/g, '\n').trim() + + '
    '; } else { const temp = document.createElement('div'); temp.appendChild(element.cloneNode(true)); @@ -80,32 +142,39 @@ export function stripParagraphs(html: string): string { return contentHTML; } -var sanitizeHtmlParams = { +/* + * Given an untrusted HTML string, return a React node with an sanitized version + * of that HTML. + */ +export function sanitizedHtmlNode(insaneHtml) { + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + + return
    ; +} + +const 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', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', - 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre' + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], allowedAttributes: { // custom ones first: - font: [ 'color' ], // custom to matrix - a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix - // We don't currently allow img itself by default, but this - // would make sense if we did - img: [ 'src' ], + font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix + img: ['src', 'width', 'height', 'alt', 'title'], + ol: ['start'], + code: ['class'], // We don't actually allow all classes, we filter them in transformTags }, // 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' ], + selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], + allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], - // DO NOT USE. sanitize-html allows all URL starting with '//' - // so this will always allow links to whatever scheme the - // host page is served over. - allowedSchemesByTag: {}, + allowProtocolRelative: false, transformTags: { // custom to matrix // add blank targets to all hyperlinks except vector URLs @@ -113,28 +182,86 @@ var sanitizeHtmlParams = { if (attribs.href) { attribs.target = '_blank'; // by default - var m; + let m; // FIXME: horrible duplication with linkify-matrix m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); if (m) { attribs.href = m[1]; delete attribs.target; - } - - m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); - if (m) { - var entity = m[1]; - if (entity[0] === '@') { - attribs.href = '#/user/' + entity; + } else { + m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); + if (m) { + const entity = m[1]; + if (entity[0] === '@') { + attribs.href = '#/user/' + entity; + } else if (entity[0] === '#' || entity[0] === '!') { + attribs.href = '#/room/' + entity; + } + delete attribs.target; } - else if (entity[0] === '#' || entity[0] === '!') { - attribs.href = '#/room/' + entity; - } - delete attribs.target; } } attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ - return { tagName: tagName, attribs : attribs }; + return { tagName: tagName, attribs: attribs }; + }, + 'img': function(tagName, attribs) { + // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag + // because transformTags is used _before_ we filter by allowedSchemesByTag and + // we don't want to allow images with `https?` `src`s. + if (!attribs.src || !attribs.src.startsWith('mxc://')) { + return { tagName, attribs: {}}; + } + attribs.src = MatrixClientPeg.get().mxcUrlToHttp( + attribs.src, + attribs.width || 800, + attribs.height || 600, + ); + return { tagName: tagName, attribs: attribs }; + }, + 'code': function(tagName, attribs) { + if (typeof attribs.class !== 'undefined') { + // Filter out all classes other than ones starting with language- for syntax highlighting. + const classes = attribs.class.split(/\s+/).filter(function(cl) { + return cl.startsWith('language-'); + }); + attribs.class = classes.join(' '); + } + return { + tagName: tagName, + attribs: attribs, + }; + }, + '*': function(tagName, attribs) { + // Delete any style previously assigned, style is an allowedTag for font and span + // because attributes are stripped after transforming + delete attribs.style; + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper = { + 'data-mx-color': 'color', + 'data-mx-bg-color': 'background-color', + // $customAttributeKey: $cssAttributeKey + }; + + let style = ""; + Object.keys(customCSSMapper).forEach((customAttributeKey) => { + const cssAttributeKey = customCSSMapper[customAttributeKey]; + const customAttributeValue = attribs[customAttributeKey]; + if (customAttributeValue && + typeof customAttributeValue === 'string' && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";"; + delete attribs[customAttributeKey]; + } + }); + + if (style) { + attribs.style = style; + } + + return { tagName: tagName, attribs: attribs }; }, }, }; @@ -157,11 +284,11 @@ class BaseHighlighter { * TextHighlighter). */ applyHighlights(safeSnippet, safeHighlights) { - var lastOffset = 0; - var offset; - var nodes = []; + let lastOffset = 0; + let offset; + let nodes = []; - var safeHighlight = safeHighlights[0]; + const safeHighlight = safeHighlights[0]; while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { // handle preamble if (offset > lastOffset) { @@ -171,7 +298,7 @@ class BaseHighlighter { // do highlight. use the original string rather than safeHighlight // to preserve the original casing. - var endOffset = offset + safeHighlight.length; + const endOffset = offset + safeHighlight.length; nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true)); lastOffset = endOffset; @@ -189,8 +316,7 @@ class BaseHighlighter { if (safeHighlights[1]) { // recurse into this range to check for the next set of highlight matches return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); - } - else { + } else { // no more highlights to be found, just return the unhighlighted string return [this._processSnippet(safeSnippet, false)]; } @@ -211,7 +337,7 @@ class HtmlHighlighter extends BaseHighlighter { return snippet; } - var span = "" + let span = "" + snippet + ""; if (this.highlightLink) { @@ -236,15 +362,15 @@ class TextHighlighter extends BaseHighlighter { * returns a React node */ _processSnippet(snippet, highlight) { - var key = this._key++; + const key = this._key++; - var node = - + let node = + { snippet } ; if (highlight && this.highlightLink) { - node = {node}; + node = { node }; } return node; @@ -259,22 +385,23 @@ class TextHighlighter extends BaseHighlighter { * highlights: optional list of words to highlight, ordered by longest word first * * opts.highlightLink: optional href to add to highlighted words + * opts.disableBigEmoji: optional argument to disable the big emoji class. */ -export function bodyToHtml(content, highlights, opts) { - opts = opts || {}; +export function bodyToHtml(content, highlights, opts={}) { + const isHtml = (content.format === "org.matrix.custom.html"); + const body = isHtml ? content.formatted_body : escape(content.body); - var isHtml = (content.format === "org.matrix.custom.html"); - let body = isHtml ? content.formatted_body : escape(content.body); + let bodyHasEmoji = false; - var safeBody; + let safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either try { if (highlights && highlights.length > 0) { - var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - var safeHighlights = highlights.map(function(highlight) { + const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); + const safeHighlights = highlights.map(function(highlight) { return sanitizeHtml(highlight, sanitizeHtmlParams); }); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure. @@ -283,23 +410,26 @@ export function bodyToHtml(content, highlights, opts) { }; } safeBody = sanitizeHtml(body, sanitizeHtmlParams); - safeBody = unicodeToImage(safeBody); - } - finally { + bodyHasEmoji = containsEmoji(body); + if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); + } finally { delete sanitizeHtmlParams.textFilter; } - EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body.trim(); - let match = EMOJI_REGEX.exec(contentBodyTrimmed); - let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + let emojiBody = false; + if (!opts.disableBigEmoji && bodyHasEmoji) { + EMOJI_REGEX.lastIndex = 0; + const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; + const match = EMOJI_REGEX.exec(contentBodyTrimmed); + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + } const className = classNames({ 'mx_EventTile_body': true, 'mx_EventTile_bigEmoji': emojiBody, 'markdown-body': isHtml, }); - return ; + return ; } export function emojifyText(text) { diff --git a/src/ImageUtils.js b/src/ImageUtils.js index fdb12c7608..a83d94a633 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.js @@ -42,16 +42,15 @@ module.exports = { // no scaling needs to be applied return fullHeight; } - var widthMulti = thumbWidth / fullWidth; - var heightMulti = thumbHeight / fullHeight; + const widthMulti = thumbWidth / fullWidth; + const heightMulti = thumbHeight / fullHeight; if (widthMulti < heightMulti) { // width is the dominant dimension so scaling will be fixed on that return Math.floor(widthMulti * fullHeight); - } - else { + } else { // height is the dominant dimension so scaling will be fixed on that return Math.floor(heightMulti * fullHeight); } }, -} +}; diff --git a/src/Invite.js b/src/Invite.js deleted file mode 100644 index 6422812734..0000000000 --- a/src/Invite.js +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import MatrixClientPeg from './MatrixClientPeg'; -import MultiInviter from './utils/MultiInviter'; - -const emailRegex = /^\S+@\S+\.\S+$/; - -export function getAddressType(inputText) { - const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); - const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0; - - // sanity check the input for user IDs - if (isEmailAddress) { - return 'email'; - } else if (isMatrixId) { - return 'mx'; - } else { - return null; - } -} - -export function inviteToRoom(roomId, addr) { - const addrType = getAddressType(addr); - - if (addrType == 'email') { - return MatrixClientPeg.get().inviteByEmail(roomId, addr); - } else if (addrType == 'mx') { - return MatrixClientPeg.get().invite(roomId, addr); - } else { - throw new Error('Unsupported address'); - } -} - -/** - * Invites multiple addresses to a room - * Simpler interface to utils/MultiInviter but with - * no option to cancel. - * - * @param {roomId} The ID of the room to invite to - * @param {array} Array of strings of addresses to invite. May be matrix IDs or 3pids. - * @returns Promise - */ -export function inviteMultipleToRoom(roomId, addrs) { - this.inviter = new MultiInviter(roomId); - return this.inviter.invite(addrs); -} - -/** - * Checks is the supplied address is valid - * - * @param {addr} The mx userId or email address to check - * @returns true, false, or null for unsure - */ -export function isValidAddress(addr) { - // Check if the addr is a valid type - var addrType = this.getAddressType(addr); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(addr); - if (user) { - return true; - } else { - return null; - } - } else if (addrType === "email") { - return true; - } else { - return false; - } -} diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js new file mode 100644 index 0000000000..0b54d88e5f --- /dev/null +++ b/src/KeyRequestHandler.js @@ -0,0 +1,138 @@ +/* +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 sdk from './index'; +import Modal from './Modal'; + +export default class KeyRequestHandler { + constructor(matrixClient) { + this._matrixClient = matrixClient; + + // the user/device for which we currently have a dialog open + this._currentUser = null; + this._currentDevice = null; + + // userId -> deviceId -> [keyRequest] + this._pendingKeyRequests = Object.create(null); + } + + handleKeyRequest(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const requestId = keyRequest.requestId; + + if (!this._pendingKeyRequests[userId]) { + this._pendingKeyRequests[userId] = Object.create(null); + } + if (!this._pendingKeyRequests[userId][deviceId]) { + this._pendingKeyRequests[userId][deviceId] = []; + } + + // check if we already have this request + const requests = this._pendingKeyRequests[userId][deviceId]; + if (requests.find((r) => r.requestId === requestId)) { + console.log("Already have this key request, ignoring"); + return; + } + + requests.push(keyRequest); + + if (this._currentUser) { + // ignore for now + console.log("Key request, but we already have a dialog open"); + return; + } + + this._processNextRequest(); + } + + handleKeyRequestCancellation(cancellation) { + // see if we can find the request in the queue + const userId = cancellation.userId; + const deviceId = cancellation.deviceId; + const requestId = cancellation.requestId; + + if (userId === this._currentUser && deviceId === this._currentDevice) { + console.log( + "room key request cancellation for the user we currently have a" + + " dialog open for", + ); + // TODO: update the dialog. For now, we just ignore the + // cancellation. + return; + } + + if (!this._pendingKeyRequests[userId]) { + return; + } + const requests = this._pendingKeyRequests[userId][deviceId]; + if (!requests) { + return; + } + const idx = requests.findIndex((r) => r.requestId === requestId); + if (idx < 0) { + return; + } + console.log("Forgetting room key request"); + requests.splice(idx, 1); + if (requests.length === 0) { + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + } + } + + _processNextRequest() { + const userId = Object.keys(this._pendingKeyRequests)[0]; + if (!userId) { + return; + } + const deviceId = Object.keys(this._pendingKeyRequests[userId])[0]; + if (!deviceId) { + return; + } + console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`); + + const finished = (r) => { + this._currentUser = null; + this._currentDevice = null; + + if (r) { + for (const req of this._pendingKeyRequests[userId][deviceId]) { + req.share(); + } + } + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + + this._processNextRequest(); + }; + + const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); + Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, { + matrixClient: this._matrixClient, + userId: userId, + deviceId: deviceId, + onFinished: finished, + }); + this._currentUser = userId; + this._currentDevice = deviceId; + } +} + diff --git a/src/Keyboard.js b/src/Keyboard.js new file mode 100644 index 0000000000..bf83a1a05f --- /dev/null +++ b/src/Keyboard.js @@ -0,0 +1,79 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 New Vector 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. +*/ + +/* a selection of key codes, as used in KeyboardEvent.keyCode */ +export const KeyCode = { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + SHIFT: 16, + ESCAPE: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, + KEY_A: 65, + KEY_B: 66, + KEY_C: 67, + KEY_D: 68, + KEY_E: 69, + KEY_F: 70, + KEY_G: 71, + KEY_H: 72, + KEY_I: 73, + KEY_J: 74, + KEY_K: 75, + KEY_L: 76, + KEY_M: 77, + KEY_N: 78, + KEY_O: 79, + KEY_P: 80, + KEY_Q: 81, + KEY_R: 82, + KEY_S: 83, + KEY_T: 84, + KEY_U: 85, + KEY_V: 86, + KEY_W: 87, + KEY_X: 88, + KEY_Y: 89, + KEY_Z: 90, +}; + +export function isOnlyCtrlOrCmdKeyEvent(ev) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) { + return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; + } else { + return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; + } +} + +export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) { + return ev.metaKey && !ev.altKey && !ev.ctrlKey; + } else { + return ev.ctrlKey && !ev.altKey && !ev.metaKey; + } +} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0a61dc6105..efd5c20d5c 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,42 +15,38 @@ See the License for the specific language governing permissions and limitations under the License. */ -import q from 'q'; +import Promise from 'bluebird'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; -import Notifier from './Notifier' +import createMatrixClient from './utils/createMatrixClient'; +import Analytics from './Analytics'; +import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; +import RtsClient from './RtsClient'; +import Modal from './Modal'; +import sdk from './index'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * 0. if it looks like we are in the middle of a registration process, it does - * nothing. * - * 1. if we have a loginToken in the (real) query params, it uses that to log - * in. - * - * 2. if we have a guest access token in the fragment query params, it uses + * 1. if we have a guest access token in the fragment query params, it uses * that. * - * 3. if an access token is stored in local storage (from a previous session), + * 2. if an access token is stored in local storage (from a previous session), * it uses that. * - * 4. it attempts to auto-register as a guest user. + * 3. it attempts to auto-register as a guest user. * - * If any of steps 1-4 are successful, it will call {setLoggedIn}, which in + * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, 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.realQueryParams: string->string map of the - * query-parameters extracted from the real query-string of the starting - * URI. + * @param {object} opts * * @param {object} opts.fragmentQueryParams: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. @@ -63,66 +60,72 @@ import DMRoomMap from './utils/DMRoomMap'; * @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. + * Resolves to `true` if we ended up starting a session, or `false` if we + * failed. */ export function loadSession(opts) { - const realQueryParams = opts.realQueryParams || {}; const fragmentQueryParams = opts.fragmentQueryParams || {}; let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; const guestIsUrl = opts.guestIsUrl; const defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - if (fragmentQueryParams.client_secret && fragmentQueryParams.sid) { - // this happens during email validation: the email contains a link to the - // IS, which in turn redirects back to vector. We let MatrixChat create a - // Registration component which completes the next stage of registration. - console.log("Not registering as guest: registration already in progress."); - return q(); - } - if (!guestHsUrl) { console.warn("Cannot enable guest access: can't determine HS URL to use"); enableGuest = false; } - if (realQueryParams.loginToken) { - if (!realQueryParams.homeserver) { - console.warn("Cannot log in with token: can't determine HS URL to use"); - } else { - return _loginWithToken(realQueryParams, defaultDeviceDisplayName); - } - } - if (enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token ) { console.log("Using guest access credentials"); - setLoggedIn({ + return _doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, identityServerUrl: guestIsUrl, guest: true, - }); - return q(); + }, true).then(() => true); } - if (_restoreFromLocalStorage()) { - return q(); - } + return _restoreFromLocalStorage().then((success) => { + if (success) { + return true; + } - if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); - } + if (enableGuest) { + return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + } - // fall back to login screen - return q(); + // fall back to login screen + return false; + }); } -function _loginWithToken(queryParams, defaultDeviceDisplayName) { +/** + * @param {Object} queryParams string->string map of the + * query-parameters extracted from the real query-string of the starting + * URI. + * + * @param {String} defaultDeviceDisplayName + * + * @returns {Promise} promise which resolves to true if we completed the token + * login, else false + */ +export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { + if (!queryParams.loginToken) { + return Promise.resolve(false); + } + + if (!queryParams.homeserver) { + console.warn("Cannot log in with token: can't determine HS URL to use"); + return Promise.resolve(false); + } + // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: queryParams.homeserver, }); @@ -133,28 +136,32 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { }, ).then(function(data) { console.log("Logged in with token"); - setLoggedIn({ - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token, - homeserverUrl: queryParams.homeserver, - identityServerUrl: queryParams.identityServer, - guest: false, - }) - }, (err) => { + return _clearStorage().then(() => { + _persistCredentialsToLocalStorage({ + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + homeserverUrl: queryParams.homeserver, + identityServerUrl: queryParams.identityServer, + guest: false, + }); + return true; + }); + }).catch((err) => { console.error("Failed to log in with login token: " + err + " " + err.data); + return false; }); } function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { - console.log("Doing guest login on %s", hsUrl); + console.log(`Doing guest login on ${hsUrl}`); - // TODO: we should probably de-duplicate this and Signup.Login.loginAsGuest. + // TODO: we should probably de-duplicate this and Login.loginAsGuest. // Not really sure where the right home for it is. // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: hsUrl, }); @@ -163,119 +170,227 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { initial_device_display_name: defaultDeviceDisplayName, }, }).then((creds) => { - console.log("Registered as guest: %s", creds.user_id); - setLoggedIn({ + console.log(`Registered as guest: ${creds.user_id}`); + return _doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: true, - }); + }, true).then(() => true); }, (err) => { console.error("Failed to register as guest: " + err + " " + err.data); + return false; }); } -// returns true if a session is found in localstorage +// returns a promise which resolves to true if a session is found in +// localstorage +// +// N.B. Lifecycle.js should not maintain any further localStorage state, we +// are moving towards using SessionStore to keep track of state related +// to the current session (which is typically backed by localStorage). +// +// The plan is to gradually move the localStorage access done here into +// SessionStore to avoid bugs where the view becomes out-of-sync with +// localStorage (e.g. teamToken, isGuest etc.) function _restoreFromLocalStorage() { if (!localStorage) { - return false; + return Promise.resolve(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 ${userId}`); try { - setLoggedIn({ - userId: user_id, - deviceId: device_id, - accessToken: access_token, - homeserverUrl: hs_url, - identityServerUrl: is_url, - guest: is_guest, - }); - return true; + return _doSetLoggedIn({ + userId: userId, + deviceId: deviceId, + accessToken: accessToken, + homeserverUrl: hsUrl, + identityServerUrl: isUrl, + guest: isGuest, + }, false).then(() => true); } catch (e) { - console.log("Unable to restore session", e); - - var msg = e.message; - if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { - msg = "You need to log back in to generate end-to-end encryption keys " - + "for this device and submit the public key to your homeserver. " - + "This is a once off; sorry for the inconvenience."; - } - - // don't leak things into the new session - _clearLocalStorage(); - - throw new Error("Unable to restore previous session: " + msg); + return _handleRestoreFailure(e); } } else { console.log("No previous session found."); - return false; + return Promise.resolve(false); + } +} + +function _handleRestoreFailure(e) { + console.log("Unable to restore session", e); + + const def = Promise.defer(); + const SessionRestoreErrorDialog = + sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); + + Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { + error: e.message, + onFinished: (success) => { + def.resolve(success); + }, + }); + + return def.promise.then((success) => { + if (success) { + // user clicked continue. + _clearStorage(); + return false; + } + + // try, try again + return _restoreFromLocalStorage(); + }); +} + +let rtsClient = null; +export function initRtsClient(url) { + if (url) { + rtsClient = new RtsClient(url); + } else { + rtsClient = null; } } /** - * Transitions to a logged-in state using the given credentials + * Transitions to a logged-in state using the given credentials. + * + * Starts the matrix client and all other react-sdk services that + * listen for events while a session is logged in. + * + * Also stops the old MatrixClient and clears old credentials/etc out of + * storage before starting the new client. + * * @param {MatrixClientCreds} credentials The credentials to use + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ export function setLoggedIn(credentials) { - credentials.guest = Boolean(credentials.guest); - console.log("setLoggedIn => %s (guest=%s) hs=%s", - credentials.userId, credentials.guest, - credentials.homeserverUrl); + stopMatrixClient(); + return _doSetLoggedIn(credentials, true); +} + +/** + * fires on_logging_in, optionally clears localstorage, persists new credentials + * to localstorage, starts the new client. + * + * @param {MatrixClientCreds} credentials + * @param {Boolean} clearStorage + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started + */ +async function _doSetLoggedIn(credentials, clearStorage) { + credentials.guest = Boolean(credentials.guest); + + 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. + // + // we fire it *synchronously* to make sure it fires before on_logged_in. + // (dis.dispatch uses `setTimeout`, which does not guarantee ordering.) + dis.dispatch({action: 'on_logging_in'}, true); + + if (clearStorage) { + await _clearStorage(); + } + + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); + + // Resolves by default + let teamPromise = Promise.resolve(null); + - // persist the session if (localStorage) { try { - localStorage.setItem("mx_hs_url", credentials.homeserverUrl); - localStorage.setItem("mx_is_url", credentials.identityServerUrl); - localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); - localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + _persistCredentialsToLocalStorage(credentials); - // if we didn't get a deviceId from the login, leave mx_device_id unset, - // rather than setting it to "undefined". - // - // (in this case MatrixClient doesn't bother with the crypto stuff - // - that's fine for us). - if (credentials.deviceId) { - localStorage.setItem("mx_device_id", credentials.deviceId); + // The user registered as a PWLU (PassWord-Less User), the generated password + // is cached here such that the user can change it at a later time. + if (credentials.password) { + // Update SessionStore + dis.dispatch({ + action: 'cached_password', + cachedPassword: credentials.password, + }); } - - console.log("Session persisted for %s", credentials.userId); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } + + if (rtsClient && !credentials.guest) { + teamPromise = rtsClient.login(credentials.userId).then((body) => { + if (body.team_token) { + localStorage.setItem("mx_team_token", body.team_token); + } + return body.team_token; + }, (err) => { + console.warn(`Failed to get team token on login: ${err}` ); + return null; + }); + } } else { console.warn("No local storage available: can't persist session!"); } MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({action: 'on_logged_in'}); + teamPromise.then((teamToken) => { + dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); + }); startMatrixClient(); + return MatrixClientPeg.get(); +} + +function _persistCredentialsToLocalStorage(credentials) { + localStorage.setItem("mx_hs_url", credentials.homeserverUrl); + localStorage.setItem("mx_is_url", credentials.identityServerUrl); + localStorage.setItem("mx_user_id", credentials.userId); + localStorage.setItem("mx_access_token", credentials.accessToken); + localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + + // if we didn't get a deviceId from the login, leave mx_device_id unset, + // rather than setting it to "undefined". + // + // (in this case MatrixClient doesn't bother with the crypto stuff + // - that's fine for us). + if (credentials.deviceId) { + localStorage.setItem("mx_device_id", credentials.deviceId); + } + + console.log(`Session persisted for ${credentials.userId}`); } /** * Logs the current session out and transitions to the logged-out state */ export function logout() { + if (!MatrixClientPeg.get()) return; + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session @@ -289,7 +404,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 @@ -300,15 +415,17 @@ export function logout() { // change your password). console.log("Failed to call logout API: token will not be invalidated"); onLoggedOut(); - } - ); + }, + ).done(); } /** * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -export function startMatrixClient() { +function startMatrixClient() { + console.log(`Lifecycle: Starting MatrixClient`); + // dispatch this before starting the matrix client: it's used // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this @@ -321,43 +438,58 @@ export function startMatrixClient() { DMRoomMap.makeShared().start(); MatrixClientPeg.start(); + + // dispatch that we finished starting up to wire up any other bits + // of the matrix client that cannot be set prior to starting up. + dis.dispatch({action: 'client_started'}); } /* - * Stops a running client and all related services, used after - * a session has been logged out / ended. + * Stops a running client and all related services, and clears persistent + * storage. Used after a session has been logged out. */ export function onLoggedOut() { - _clearLocalStorage(); stopMatrixClient(); + _clearStorage().done(); dis.dispatch({action: 'on_logged_out'}); } -function _clearLocalStorage() { - if (!window.localStorage) { - return; - } - const hsUrl = window.localStorage.getItem("mx_hs_url"); - const isUrl = window.localStorage.getItem("mx_is_url"); - window.localStorage.clear(); +/** + * @returns {Promise} promise which resolves once the stores have been cleared + */ +function _clearStorage() { + Analytics.logout(); - // preserve our HS & IS URLs for convenience - // N.B. we cache them in hsUrl/isUrl and can't really inline them - // as getCurrentHsUrl() may call through to localStorage. - // NB. We do clear the device ID (as well as all the settings) - if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); - if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + if (window.localStorage) { + const hsUrl = window.localStorage.getItem("mx_hs_url"); + const isUrl = window.localStorage.getItem("mx_is_url"); + window.localStorage.clear(); + + // preserve our HS & IS URLs for convenience + // N.B. we cache them in hsUrl/isUrl and can't really inline them + // as getCurrentHsUrl() may call through to localStorage. + // NB. We do clear the device ID (as well as all the settings) + if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); + if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + } + + // create a temporary client to clear out the persistent stores. + const cli = createMatrixClient({ + // we'll never make any requests, so can pass a bogus HS URL + baseUrl: "", + }); + return cli.clearStores(); } /** - * Stop all the background processes related to the current client + * Stop all the background processes related to the current client. */ export function stopMatrixClient() { Notifier.stop(); 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 new file mode 100644 index 0000000000..61a14959d8 --- /dev/null +++ b/src/Login.js @@ -0,0 +1,243 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Matrix from "matrix-js-sdk"; +import { _t } from "./languageHandler"; + +import Promise from 'bluebird'; +import url from 'url'; + +export default class Login { + constructor(hsUrl, isUrl, fallbackHsUrl, opts) { + this._hsUrl = hsUrl; + this._isUrl = isUrl; + this._fallbackHsUrl = fallbackHsUrl; + this._currentFlowIndex = 0; + this._flows = []; + this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + } + + getHomeserverUrl() { + return this._hsUrl; + } + + getIdentityServerUrl() { + return this._isUrl; + } + + setHomeserverUrl(hsUrl) { + this._hsUrl = hsUrl; + } + + setIdentityServerUrl(isUrl) { + this._isUrl = isUrl; + } + + /** + * Get a temporary MatrixClient, which can be used for login or register + * requests. + */ + _createTemporaryClient() { + return Matrix.createClient({ + baseUrl: this._hsUrl, + idBaseUrl: this._isUrl, + }); + } + + getFlows() { + const self = this; + const client = this._createTemporaryClient(); + return client.loginFlows().then(function(result) { + self._flows = result.flows; + self._currentFlowIndex = 0; + // technically the UI should display options for all flows for the + // user to then choose one, so return all the flows here. + return self._flows; + }); + } + + chooseFlow(flowIndex) { + this._currentFlowIndex = flowIndex; + } + + getCurrentFlowStep() { + // technically the flow can have multiple steps, but no one does this + // for login so we can ignore it. + const flowStep = this._flows[this._currentFlowIndex]; + return flowStep ? flowStep.type : null; + } + + loginAsGuest() { + const client = this._createTemporaryClient(); + return client.registerGuest({ + body: { + initial_device_display_name: this._defaultDeviceDisplayName, + }, + }).then((creds) => { + return { + userId: creds.user_id, + deviceId: creds.device_id, + accessToken: creds.access_token, + homeserverUrl: this._hsUrl, + identityServerUrl: this._isUrl, + guest: true, + }; + }, (error) => { + throw error; + }); + } + + loginViaPassword(username, phoneCountry, phoneNumber, pass) { + const self = this; + + const isEmail = username.indexOf("@") > 0; + + let identifier; + let legacyParams; // parameters added to support old HSes + if (phoneCountry && phoneNumber) { + identifier = { + type: 'm.id.phone', + country: phoneCountry, + number: phoneNumber, + }; + // No legacy support for phone number login + } else if (isEmail) { + identifier = { + type: 'm.id.thirdparty', + medium: 'email', + address: username, + }; + legacyParams = { + medium: 'email', + address: username, + }; + } else { + identifier = { + type: 'm.id.user', + user: username, + }; + legacyParams = { + user: username, + }; + } + + const loginParams = { + password: pass, + identifier: identifier, + initial_device_display_name: this._defaultDeviceDisplayName, + }; + Object.assign(loginParams, legacyParams); + + const client = this._createTemporaryClient(); + + const tryFallbackHs = (originalError) => { + const fbClient = Matrix.createClient({ + baseUrl: self._fallbackHsUrl, + idBaseUrl: this._isUrl, + }); + + return fbClient.login('m.login.password', loginParams).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._fallbackHsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("fallback HS login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + const tryLowercaseUsername = (originalError) => { + const loginParamsLowercase = Object.assign({}, loginParams, { + user: username.toLowerCase(), + identifier: { + user: username.toLowerCase(), + }, + }); + return client.login('m.login.password', loginParamsLowercase).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("Lowercase username login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + + let originalLoginError = null; + return client.login('m.login.password', loginParams).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((error) => { + originalLoginError = error; + if (error.httpStatus === 403) { + if (self._fallbackHsUrl) { + return tryFallbackHs(originalLoginError); + } + } + throw originalLoginError; + }).catch((error) => { + // We apparently squash case at login serverside these days: + // https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475 + // so this wasn't needed after all. Keeping the code around in case the + // the situation changes... + + /* + if ( + error.httpStatus === 403 && + loginParams.identifier.type === 'm.id.user' && + username.search(/[A-Z]/) > -1 + ) { + return tryLowercaseUsername(originalLoginError); + } + */ + throw originalLoginError; + }).catch((error) => { + console.log("Login failed", error); + throw error; + }); + } + + redirectToCas() { + const client = this._createTemporaryClient(); + const parsedUrl = url.parse(window.location.href, true); + + // XXX: at this point, the fragment will always be #/login, which is no + // use to anyone. Ideally, we would get the intended fragment from + // MatrixChat.screenAfterLogin so that you could follow #/room links etc + // through a CAS login. + parsedUrl.hash = ""; + + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); + parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); + const casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + window.location.href = casUrl; + } +} diff --git a/src/Markdown.js b/src/Markdown.js index a7b267b110..aa1c7e45b1 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -14,115 +14,153 @@ See the License for the specific language governing permissions and limitations under the License. */ -import marked from 'marked'; +import commonmark from 'commonmark'; +import escape from 'lodash/escape'; -// marked only applies the default options on the high -// level marked() interface, so we do it here. -const marked_options = Object.assign({}, marked.defaults, { - gfm: true, - tables: true, - breaks: true, - pedantic: false, - sanitize: true, - smartLists: true, - smartypants: false, - xhtml: true, // return self closing tags (ie.
    not
    ) -}); +const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; + +// These types of node are definitely text +const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; + +function is_allowed_html_tag(node) { + // Regex won't work for tags with attrs, but we only + // allow anyway. + const matches = /^<\/?(.*)>$/.exec(node.literal); + if (matches && matches.length == 2) { + const tag = matches[1]; + return ALLOWED_HTML_TAGS.indexOf(tag) > -1; + } + return false; +} + +function html_if_tag_allowed(node) { + if (is_allowed_html_tag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } +} + +/* + * Returns true if the parse output containing the node + * comprises multiple block level elements (ie. lines), + * or false if it is only a single line. + */ +function is_multi_line(node) { + let par = node; + while (par.parent) { + par = par.parent; + } + return par.firstChild != par.lastChild; +} /** - * Class that wraps marked, adding the ability to see whether + * Class that wraps commonmark, adding the ability to see whether * a given message actually uses any markdown syntax or whether * it's plain text. */ export default class Markdown { constructor(input) { - const lexer = new marked.Lexer(marked_options); - this.tokens = lexer.lex(input); - } + this.input = input; - _copyTokens() { - // copy tokens (the parser modifies its input arg) - const tokens_copy = this.tokens.slice(); - // it also has a 'links' property, because this is javascript - // and why wouldn't you have an array that also has properties? - return Object.assign(tokens_copy, this.tokens); + const parser = new commonmark.Parser(); + this.parsed = parser.parse(this.input); } isPlainText() { - // we determine if the message requires markdown by - // running the parser on the tokens with a dummy - // rendered and seeing if any of the renderer's - // functions are called other than those noted below. - // In case you were wondering, no we can't just examine - // the tokens because the tokens we have are only the - // output of the *first* tokenizer: any line-based - // markdown is processed by marked within Parser by - // the 'inline lexer'... - let is_plain = true; + const walker = this.parsed.walker(); - function setNotPlain() { - is_plain = false; - } - - const dummy_renderer = {}; - for (const k of Object.keys(marked.Renderer.prototype)) { - dummy_renderer[k] = setNotPlain; - } - // text and paragraph are just text - dummy_renderer.text = function(t){return t;} - dummy_renderer.paragraph = function(t){return t;} - - // ignore links where text is just the url: - // this ignores plain URLs that markdown has - // detected whilst preserving markdown syntax links - dummy_renderer.link = function(href, title, text) { - if (text != href) { - is_plain = false; + let ev; + while ( (ev = walker.next()) ) { + const node = ev.node; + if (TEXT_NODES.indexOf(node.type) > -1) { + // definitely text + continue; + } else if (node.type == 'html_inline' || node.type == 'html_block') { + // if it's an allowed html tag, we need to render it and therefore + // we will need to use HTML. If it's not allowed, it's not HTML since + // we'll just be treating it as text. + if (is_allowed_html_tag(node)) { + return false; + } + } else { + return false; } } - - const dummy_options = Object.assign({}, marked_options, { - renderer: dummy_renderer, - }); - const dummy_parser = new marked.Parser(dummy_options); - dummy_parser.parse(this._copyTokens()); - - return is_plain; + return true; } toHTML() { - const real_renderer = new marked.Renderer(); - real_renderer.link = function(href, title, text) { - // prevent marked from turning plain URLs - // into links, because its algorithm is fairly - // poor. Let's send plain URLs rather than - // badly linkified ones (the linkifier Vector - // uses on message display is way better, eg. - // handles URLs with closing parens at the end). - if (text == href) { - return href; - } - return marked.Renderer.prototype.link.apply(this, arguments); - } + const renderer = new commonmark.HtmlRenderer({ + safe: false, - real_renderer.paragraph = (text) => { - // The tokens at the top level are the 'blocks', so if we - // have more than one, there are multiple 'paragraphs'. - // If there is only one top level token, just return the - // bare text: it's a single line of text and so should be - // 'inline', rather than necessarily wrapped in its own - // p tag. If, however, we have multiple tokens, each gets - // its own p tag to keep them as separate paragraphs. - if (this.tokens.length == 1) { - return text; - } - return '

    ' + text + '

    '; - } - - const real_options = Object.assign({}, marked_options, { - renderer: real_renderer, + // Set soft breaks to hard HTML breaks: commonmark + // puts softbreaks in for multiple lines in a blockquote, + // so if these are just newline characters then the + // block quote ends up all on one line + // (https://github.com/vector-im/riot-web/issues/3154) + softbreak: '
    ', }); - const real_parser = new marked.Parser(real_options); - return real_parser.parse(this._copyTokens()); + const real_paragraph = renderer.paragraph; + + renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + if (is_multi_line(node)) { + real_paragraph.call(this, node, entering); + } + }; + + renderer.html_inline = html_if_tag_allowed; + renderer.html_block = function(node) { + // as with `paragraph`, we only insert line breaks + // if there are multiple lines in the markdown. + const isMultiLine = is_multi_line(node); + + if (isMultiLine) this.cr(); + html_if_tag_allowed.call(this, node); + if (isMultiLine) this.cr(); + }; + + return renderer.render(this.parsed); + } + + /* + * Render the markdown message to plain text. That is, essentially + * just remove any backslashes escaping what would otherwise be + * markdown syntax + * (to fix https://github.com/vector-im/riot-web/issues/2870) + */ + toPlaintext() { + const renderer = new commonmark.HtmlRenderer({safe: false}); + const real_paragraph = renderer.paragraph; + + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. + renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + }; + + renderer.paragraph = function(node, entering) { + // as with toHTML, only append lines to paragraphs if there are + // multiple paragraphs + if (is_multi_line(node)) { + if (!entering && node.next) { + this.lit('\n\n'); + } + } + }; + renderer.html_block = function(node) { + this.lit(node.literal); + if (is_multi_line(node) && node.next) this.lit('\n\n'); + }; + + return renderer.render(this.parsed); } } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 9c0daf4726..14dfa91fa4 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,5 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd. +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,12 +18,12 @@ limitations under the License. 'use strict'; -import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; - -const localStorage = window.localStorage; +import createMatrixClient from './utils/createMatrixClient'; +import SettingsStore from './settings/SettingsStore'; +import MatrixActionCreators from './actions/MatrixActionCreators'; interface MatrixClientCreds { homeserverUrl: string, @@ -51,12 +53,25 @@ class MatrixClientPeg { }; } + /** + * 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) { + createMatrixClient.indexedDbWorkerScript = script; + } + get(): MatrixClient { return this.matrixClient; } unset() { this.matrixClient = null; + + MatrixActionCreators.stop(); } /** @@ -67,11 +82,42 @@ class MatrixClientPeg { this._createClient(creds); } - start() { + async start() { + // try to initialise e2e on the new client + try { + // check that we have a version of the js-sdk which includes initCrypto + if (this.matrixClient.initCrypto) { + await this.matrixClient.initCrypto(); + } + } catch (e) { + // this can happen for a number of reasons, the most likely being + // that the olm library was missing. It's not fatal. + console.warn("Unable to initialise e2e: " + e); + } + const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; + opts.disablePresence = true; // we do this manually + + try { + const promise = this.matrixClient.store.startup(); + console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`); + await promise; + } catch (err) { + // log any errors when starting up the database (if one exists) + console.error(`Error starting matrixclient store: ${err}`); + } + + // regardless of errors, start the client. If we did error out, we'll + // just end up doing a full initial /sync. + + // Connect the matrix client to the dispatcher + MatrixActionCreators.start(this.matrixClient); + + console.log(`MatrixClientPeg: really starting MatrixClient`); this.get().startClient(opts); + console.log(`MatrixClientPeg: MatrixClient started`); } getCredentials(): MatrixClientCreds { @@ -99,20 +145,17 @@ class MatrixClientPeg { } _createClient(creds: MatrixClientCreds) { - var opts = { + const opts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, userId: creds.userId, deviceId: creds.deviceId, timelineSupport: true, + forceTURN: SettingsStore.getValue('webRtcForceTURN', false), }; - if (localStorage) { - opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); - } - - this.matrixClient = Matrix.createClient(opts); + this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript); // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. @@ -120,8 +163,8 @@ class MatrixClientPeg { this.matrixClient.setGuest(Boolean(creds.guest)); - var notifTimelineSet = new EventTimelineSet(null, { - timelineSupport: true + const notifTimelineSet = new EventTimelineSet(null, { + timelineSupport: true, }); // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..c9f08772e7 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -17,46 +17,196 @@ limitations under the License. 'use strict'; -var React = require('react'); -var ReactDOM = require('react-dom'); +const React = require('react'); +const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; +import Analytics from './Analytics'; +import sdk from './index'; -module.exports = { - DialogContainerId: "mx_Dialog_Container", +const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; - getOrCreateContainer: function() { - var container = document.getElementById(this.DialogContainerId); +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +const AsyncWrapper = React.createClass({ + propTypes: { + /** A function which takes a 'callback' argument which it will call + * with the real component once it loads. + */ + loader: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + component: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); + this.props.loader((e) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('AsyncWrapper load completed with '+e.displayName); + if (this._unmounted) { + return; + } + this.setState({component: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + render: function() { + const {loader, ...otherProps} = this.props; + if (this.state.component) { + const Component = this.state.component; + return ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + +class ModalManager { + constructor() { + this._counter = 0; + + /** list of the modals we have stacked up, with the most recent at [0] */ + this._modals = [ + /* { + elem: React component for this dialog + onFinished: caller-supplied onFinished callback + className: CSS class for the dialog wrapper div + } */ + ]; + + this.closeAll = this.closeAll.bind(this); + } + + getOrCreateContainer() { + let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); - container.id = this.DialogContainerId; + container.id = DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; - }, + } - createDialog: function (Element, props, className) { - var self = this; + createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.createDialog(Element, props, className); + } - // never call this via modal.close() from onFinished() otherwise it will loop - var closeDialog = function() { + createDialog(Element, props, className) { + return this.createDialogAsync((cb) => {cb(Element);}, props, className); + } + + createTrackedDialogAsync(analyticsAction, analyticsInfo, loader, props, className) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.createDialogAsync(loader, props, className); + } + + /** + * Open a modal view. + * + * This can be used to display a react component which is loaded as an asynchronous + * webpack component. To do this, set 'loader' as: + * + * (cb) => { + * require([''], cb); + * } + * + * @param {Function} loader a function which takes a 'callback' argument, + * which it should call with a React component which will be displayed as + * the modal view. + * + * @param {Object} props properties to pass to the displayed + * component. (We will also pass an 'onFinished' property.) + * + * @param {String} className CSS class to apply to the modal wrapper + */ + createDialogAsync(loader, props, className) { + const self = this; + const modal = {}; + + // never call this from onFinished() otherwise it will loop + // + // nb explicit function() rather than arrow function, to get `arguments` + const closeDialog = function() { if (props && props.onFinished) props.onFinished.apply(null, arguments); - ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); + const i = self._modals.indexOf(modal); + if (i >= 0) { + self._modals.splice(i, 1); + } + self._reRender(); }; + // don't attempt to reuse the same AsyncWrapper for different dialogs, + // otherwise we'll get confused. + const modalCount = this._counter++; + // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished // property set here so you can't close the dialog from a button click! - var dialog = ( -
    + modal.elem = ( + + ); + modal.onFinished = props ? props.onFinished : null; + modal.className = className; + + this._modals.unshift(modal); + + this._reRender(); + return {close: closeDialog}; + } + + closeAll() { + const modals = this._modals; + this._modals = []; + + for (let i = 0; i < modals.length; i++) { + const m = modals[i]; + if (m.onFinished) { + m.onFinished(false); + } + } + + this._reRender(); + } + + _reRender() { + if (this._modals.length == 0) { + ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + return; + } + + const modal = this._modals[0]; + const dialog = ( +
    - + { modal.elem }
    -
    +
    ); ReactDOM.render(dialog, this.getOrCreateContainer()); + } +} - return {close: closeDialog}; - }, -}; +if (!global.singletonModalManager) { + global.singletonModalManager = new ModalManager(); +} +export default global.singletonModalManager; diff --git a/src/Notifier.js b/src/Notifier.js index a58fc0132f..e69bdf4461 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -1,5 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector 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 +16,16 @@ 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 Analytics from './Analytics'; +import Avatar from './Avatar'; +import dis from './dispatcher'; +import sdk from './index'; +import { _t } from './languageHandler'; +import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; /* * Dispatches: @@ -30,9 +35,16 @@ var dis = require("./dispatcher"); * } */ -var Notifier = { +const MAX_PENDING_ENCRYPTED = 20; + +const Notifier = { notifsByRoom: {}, + // A list of event IDs that we've received but need to wait until + // they're decrypted until we decide whether to notify for them + // or not + pendingEncryptedEventIds: [], + notificationMessageForEvent: function(ev) { return TextForEvent.textForEvent(ev); }, @@ -49,16 +61,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,10 +81,11 @@ var Notifier = { if (ev.getContent().body) msg = ev.getContent().body; } - var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( - ev.sender, 40, 40, 'crop' - ) : null; + if (!this.isBodyEnabled()) { + msg = ''; + } + const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null; const notif = plaf.displayNotification(title, msg, avatarUrl, room); // if displayNotification returns non-null, the platform supports @@ -84,31 +97,33 @@ var Notifier = { }, _playAudioNotification: function(ev, room) { - var e = document.getElementById("messageAudio"); + const e = document.getElementById("messageAudio"); if (e) { - e.load(); e.play(); - }; + } }, start: function() { - this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); + this.boundOnEvent = this.onEvent.bind(this); 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); + this.boundOnEventDecrypted = this.onEventDecrypted.bind(this); + MatrixClientPeg.get().on('event', this.boundOnEvent); + MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; - this.isPrepared = false; + this.isSyncing = false; }, stop: function() { - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); + if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { + MatrixClientPeg.get().removeListener('Event', this.boundOnEvent); + MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } - this.isPrepared = false; + this.isSyncing = false; }, supportsDesktopNotifications: function() { @@ -119,12 +134,17 @@ var Notifier = { setEnabled: function(enable, callback) { const plaf = PlatformPeg.get(); if (!plaf) return; + + // Dev note: We don't set the "notificationsEnabled" setting to true here because it is a + // calculated value. It is determined based upon whether or not the master rule is enabled + // and other flags. Setting it here would cause a circular reference. + + Analytics.trackEvent('Notifier', 'Set Enabled', enable); + // 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) { - this.setAudioEnabled(this.isEnabled()); - } + if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) { + SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled()); } if (enable) { @@ -132,116 +152,119 @@ var Notifier = { plaf.requestNotificationPermission().done((result) => { if (result !== 'granted') { // The permission request was dismissed or denied + // TODO: Support alternative branding in messaging + const description = result === 'denied' + ? _t('Riot does not have permission to send you notifications - please check your browser settings') + : _t('Riot was not given permission to send notifications - please try again'); + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { + title: _t('Unable to enable Notifications'), + description, + }); return; } - if (global.localStorage) { - global.localStorage.setItem('notifications_enabled', 'true'); - } - if (callback) callback(); dis.dispatch({ action: "notifier_enabled", - value: true + value: true, }); }); // clear the notifications_hidden flag, so that if notifications are // disabled again in the future, we will show the banner again. - this.setToolbarHidden(false); + this.setToolbarHidden(true); } else { - if (!global.localStorage) return; - global.localStorage.setItem('notifications_enabled', 'false'); dis.dispatch({ action: "notifier_enabled", - value: false + value: false, }); } }, isEnabled: function() { + return this.isPossible() && SettingsStore.getValue("notificationsEnabled"); + }, + + isPossible: function() { const plaf = PlatformPeg.get(); if (!plaf) return false; if (!plaf.supportsNotifications()) return false; if (!plaf.maySendNotifications()) return false; - if (!global.localStorage) return true; - - var enabled = global.localStorage.getItem('notifications_enabled'); - if (enabled === null) return true; - return enabled === 'true'; + return true; // possible, but not necessarily enabled }, - setAudioEnabled: function(enable) { - if (!global.localStorage) return; - global.localStorage.setItem('audio_notifications_enabled', - enable ? 'true' : 'false'); + isBodyEnabled: function() { + return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled"); }, - isAudioEnabled: function(enable) { - if (!global.localStorage) return true; - var enabled = global.localStorage.getItem( - 'audio_notifications_enabled'); - // default to true if the popups are enabled - if (enabled === null) return this.isEnabled(); - return enabled === 'true'; + isAudioEnabled: function() { + return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled"); }, setToolbarHidden: function(hidden, persistent = true) { this.toolbarHidden = hidden; + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + // XXX: why are we dispatching this here? // 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 if (persistent && global.localStorage) { - global.localStorage.setItem('notifications_hidden', hidden); + global.localStorage.setItem("notifications_hidden", hidden); } }, isToolbarHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { - if (global.localStorage.getItem('notifications_hidden') === 'true') { - return true; - } + return global.localStorage.getItem("notifications_hidden") === "true"; } return this.toolbarHidden; }, 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 (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + onEvent: function(ev) { + if (!this.isSyncing) return; // don't alert for any messages initially + if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; - var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); - if (actions && actions.notify) { - if (this.isEnabled()) { - this._displayPopupNotification(ev, room); - } - if (actions.tweaks.sound && this.isAudioEnabled()) { - this._playAudioNotification(ev, room); + // If it's an encrypted event and the type is still 'm.room.encrypted', + // it hasn't yet been decrypted, so wait until it is. + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.pendingEncryptedEventIds.push(ev.getId()); + // don't let the list fill up indefinitely + while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) { + this.pendingEncryptedEventIds.shift(); } + return; } + + this._evaluateEvent(ev); + }, + + onEventDecrypted: function(ev) { + const idx = this.pendingEncryptedEventIds.indexOf(ev.getId()); + if (idx === -1) return; + + this.pendingEncryptedEventIds.splice(idx, 1); + this._evaluateEvent(ev); }, 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 +279,21 @@ var Notifier = { } delete this.notifsByRoom[room.roomId]; } - } + }, + + _evaluateEvent: function(ev) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if (actions && actions.notify) { + if (this.isEnabled()) { + this._displayPopupNotification(ev, room); + } + if (actions.tweaks.sound && this.isAudioEnabled()) { + PlatformPeg.get().loudNotification(ev, room); + this._playAudioNotification(ev, room); + } + } + }, }; if (!global.mxNotifier) { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 07a16df501..07d8b465af 100644 --- a/src/ObjectUtils.js +++ b/src/ObjectUtils.js @@ -23,8 +23,8 @@ limitations under the License. * { key: $KEY, val: $VALUE, place: "add|del" } */ module.exports.getKeyValueArrayDiffs = function(before, after) { - var results = []; - var delta = {}; + const results = []; + const delta = {}; Object.keys(before).forEach(function(beforeKey) { delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially delta[beforeKey]--; // keys present in the past have -ve values @@ -46,9 +46,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { results.push({ place: "del", key: muxedKey, val: beforeVal }); }); break; - case 0: // A mix of added/removed keys + case 0: {// A mix of added/removed keys // compare old & new vals - var itemDelta = {}; + const itemDelta = {}; before[muxedKey].forEach(function(beforeVal) { itemDelta[beforeVal] = itemDelta[beforeVal] || 0; itemDelta[beforeVal]--; @@ -64,13 +64,13 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { } else if (itemDelta[item] === -1) { results.push({ place: "del", key: muxedKey, val: item }); } else { - // itemDelta of 0 means it was unchanged between before/after + // itemDelta of 0 means it was unchanged between before/after } }); break; + } default: - console.error("Calculated key delta of " + delta[muxedKey] + - " - this should never happen!"); + console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!"); break; } }); @@ -79,8 +79,10 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { }; /** - * Shallow-compare two objects for equality: each key and value must be - * identical + * Shallow-compare two objects for equality: each key and value must be identical + * @param {Object} objA First object to compare against the second + * @param {Object} objB Second object to compare against the first + * @return {boolean} whether the two objects have same key=values */ module.exports.shallowEqual = function(objA, objB) { if (objA === objB) { @@ -92,15 +94,15 @@ module.exports.shallowEqual = function(objA, objB) { return false; } - var keysA = Object.keys(objA); - var keysB = Object.keys(objB); + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } - for (var i = 0; i < keysA.length; i++) { - var key = keysA[i]; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { return false; } diff --git a/src/PageTypes.js b/src/PageTypes.js index b2e2ecf4bc..66d930c288 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.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. @@ -16,9 +17,12 @@ limitations under the License. /** The types of page which can be shown by the LoggedInView */ export default { + HomePage: "home_page", RoomView: "room_view", UserSettings: "user_settings", CreateRoom: "create_room", RoomDirectory: "room_directory", UserView: "user_view", + GroupView: "group_view", + MyGroups: "my_groups", }; diff --git a/src/PasswordReset.js b/src/PasswordReset.js index a03a565459..71fc4f6b31 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -var Matrix = require("matrix-js-sdk"); +import * as Matrix from 'matrix-js-sdk'; +import { _t } from './languageHandler'; /** * Allows a user to reset their password on a homeserver. @@ -33,7 +34,7 @@ class PasswordReset { constructor(homeserverUrl, identityUrl) { this.client = Matrix.createClient({ baseUrl: homeserverUrl, - idBaseUrl: identityUrl + idBaseUrl: identityUrl, }); this.clientSecret = this.client.generateClientSecret(); this.identityServerDomain = identityUrl.split("://")[1]; @@ -52,8 +53,8 @@ class PasswordReset { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_NOT_FOUND') { - err.message = "This email address was not found"; + if (err.errcode === 'M_THREEPID_NOT_FOUND') { + err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } @@ -74,16 +75,15 @@ class PasswordReset { threepid_creds: { sid: this.sessionId, client_secret: this.clientSecret, - id_server: this.identityServerDomain - } + id_server: this.identityServerDomain, + }, }, this.password).catch(function(err) { if (err.httpStatus === 401) { - err.message = "Failed to verify email address: make sure you clicked the link in the email"; - } - else if (err.httpStatus === 404) { - err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver."; - } - else if (err.httpStatus) { + err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); + } else if (err.httpStatus === 404) { + err.message = + _t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.'); + } else if (err.httpStatus) { err.message += ` (Status ${err.httpStatus})`; } throw err; diff --git a/src/Presence.js b/src/Presence.js index 4152d7a487..2652c64c96 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); -var dis = require("./dispatcher"); +const MatrixClientPeg = require("./MatrixClientPeg"); +const dis = require("./dispatcher"); // Time in ms after that a user is considered as unavailable/away -var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins -var PRESENCE_STATES = ["online", "offline", "unavailable"]; +const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins +const PRESENCE_STATES = ["online", "offline", "unavailable"]; class Presence { @@ -56,13 +56,27 @@ class Presence { return this.state; } + /** + * Get the current status message. + * @returns {String} the status message, may be null + */ + getStatusMessage() { + return this.statusMessage; + } + /** * Set the presence state. * If the state has changed, the Home Server will be notified. * @param {string} newState the new presence state (see PRESENCE enum) + * @param {String} statusMessage an optional status message for the presence + * @param {boolean} maintain true to have this status maintained by this tracker */ - setState(newState) { - if (newState === this.state) { + setState(newState, statusMessage=null, maintain=false) { + if (this.maintain) { + // Don't update presence if we're maintaining a particular status + return; + } + if (newState === this.state && statusMessage === this.statusMessage) { return; } if (PRESENCE_STATES.indexOf(newState) === -1) { @@ -71,22 +85,38 @@ class Presence { if (!this.running) { return; } - var old_state = this.state; + const old_state = this.state; + const old_message = this.statusMessage; this.state = newState; + this.statusMessage = statusMessage; + this.maintain = maintain; if (MatrixClientPeg.get().isGuest()) { return; // don't try to set presence when a guest; it won't work. } - var self = this; - MatrixClientPeg.get().setPresence(this.state).done(function() { + const updateContent = { + presence: this.state, + status_msg: this.statusMessage ? this.statusMessage : '', + }; + + const self = this; + MatrixClientPeg.get().setPresence(updateContent).done(function() { console.log("Presence: %s", newState); + + // We have to dispatch because the js-sdk is unreliable at telling us about our own presence + dis.dispatch({action: "self_presence_updated", statusInfo: updateContent}); }, function(err) { console.error("Failed to set presence: %s", err); self.state = old_state; + self.statusMessage = old_message; }); } + stopMaintainingStatus() { + this.maintain = false; + } + /** * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private @@ -95,7 +125,8 @@ class Presence { this.setState("unavailable"); } - _onUserActivity() { + _onUserActivity(payload) { + if (payload.action === "sync_state" || payload.action === "self_presence_updated") return; this._resetTimer(); } @@ -104,14 +135,14 @@ class Presence { * @private */ _resetTimer() { - var self = this; + const self = this; this.setState("online"); // Re-arm the timer clearTimeout(this.timer); this.timer = setTimeout(function() { self._onUnavailableTimerFire(); }, UNAVAILABLE_TIME_MS); - } + } } module.exports = new Presence(); diff --git a/src/Resend.js b/src/Resend.js index ecf504e780..4eaee16d1b 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -14,31 +14,44 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var dis = require('./dispatcher'); +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; +import { EventStatus } from 'matrix-js-sdk'; module.exports = { + resendUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.resend(event); + }); + }, + cancelUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.removeFromQueue(event); + }); + }, resend: function(event) { - MatrixClientPeg.get().resendEvent( - event, MatrixClientPeg.get().getRoom(event.getRoomId()) - ).done(function() { + const room = MatrixClientPeg.get().getRoom(event.getRoomId()); + MatrixClientPeg.get().resendEvent(event, room).done(function(res) { dis.dispatch({ action: 'message_sent', - event: event + event: event, }); - }, function() { + }, function(err) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Resend got send failure: ' + err.name + '('+err+')'); + dis.dispatch({ action: 'message_send_failed', - event: event + event: event, }); }); }, - removeFromQueue: function(event) { MatrixClientPeg.get().cancelPendingEvent(event); - dis.dispatch({ - action: 'message_send_cancelled', - event: event - }); }, }; diff --git a/src/RichText.js b/src/RichText.js index 5fe920fe50..12274ee9f3 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -12,10 +12,11 @@ import { SelectionState, Entity, } from 'draft-js'; -import * as sdk from './index'; +import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; +import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, @@ -30,17 +31,35 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); -export const contentStateToHTML = stateToHTML; +const ZWS_CODE = 8203; +const ZWS = String.fromCharCode(ZWS_CODE); // zero width space +export function stateToMarkdown(state) { + return __stateToMarkdown(state) + .replace( + ZWS, // draft-js-export-markdown adds these + ''); // this is *not* a zero width space, trust me :) +} -export function HTMLtoContentState(html: string): ContentState { - return ContentState.createFromBlockArray(convertFromHTML(html)); +export const contentStateToHTML = (contentState: ContentState) => { + return stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u', + }, + }, + }); +}; + +export function htmlToContentState(html: string): ContentState { + const blockArray = convertFromHTML(html).contentBlocks; + return ContentState.createFromBlockArray(blockArray); } function unicodeToEmojiUri(str) { let replaceWith, unicode, alt; if ((!emojione.unicodeAlt) || (emojione.sprites)) { // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames - let mappedUnicode = emojione.mapUnicodeToShort(); + const mappedUnicode = emojione.mapUnicodeToShort(); } str = str.replace(emojione.regUnicode, function(unicodeChar) { @@ -48,8 +67,14 @@ function unicodeToEmojiUri(str) { // if the unicodeChar doesnt exist just return the entire match return unicodeChar; } else { + // Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below + if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') { + unicodeChar = unicodeChar[0]; + } + // get the unicode codepoint from the actual char unicode = emojione.jsEscapeMap[unicodeChar]; + return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; } }); @@ -71,14 +96,14 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb } // Workaround for https://github.com/facebook/draft-js/issues/414 -let emojiDecorator = { - strategy: (contentBlock, callback) => { +const emojiDecorator = { + strategy: (contentState, contentBlock, callback) => { findWithRegex(EMOJI_REGEX, contentBlock, callback); }, component: (props) => { - let uri = unicodeToEmojiUri(props.children[0].props.text); - let shortname = emojione.toShort(props.children[0].props.text); - let style = { + const uri = unicodeToEmojiUri(props.children[0].props.text); + const shortname = emojione.toShort(props.children[0].props.text); + const style = { display: 'inline-block', width: '1em', maxHeight: '1em', @@ -87,7 +112,7 @@ let emojiDecorator = { backgroundPosition: 'center center', overflow: 'hidden', }; - return ({props.children}); + return ({ props.children }); }, }; @@ -95,60 +120,35 @@ let emojiDecorator = { * Returns a composite decorator which has access to provided scope. */ export function getScopedRTDecorators(scope: any): CompositeDecorator { - let MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); - - let usernameDecorator = { - strategy: (contentBlock, callback) => { - findWithRegex(USERNAME_REGEX, contentBlock, callback); - }, - component: (props) => { - let member = scope.room.getMember(props.children[0].props.text); - // unused until we make these decorators immutable (autocomplete needed) - let name = member ? member.name : null; - let avatar = member ? : null; - return {avatar}{props.children}; - } - }; - - let roomDecorator = { - strategy: (contentBlock, callback) => { - findWithRegex(ROOM_REGEX, contentBlock, callback); - }, - component: (props) => { - return {props.children}; - } - }; - - // TODO Re-enable usernameDecorator and roomDecorator return [emojiDecorator]; } export function getScopedMDDecorators(scope: any): CompositeDecorator { - let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( + const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); }, component: (props) => ( - {props.children} + { props.children } - ) + ), })); markdownDecorators.push({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); }, component: (props) => ( - {props.children} + { props.children } - ) + ), }); - markdownDecorators.push(emojiDecorator); - - return markdownDecorators; + // markdownDecorators.push(emojiDecorator); + // TODO Consider renabling "syntax highlighting" when we can do it properly + return [emojiDecorator]; } /** @@ -167,7 +167,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection for (let currentKey = startKey; currentKey && currentKey !== endKey; currentKey = contentState.getKeyAfter(currentKey)) { - let blockText = getText(currentKey); + const blockText = getText(currentKey); text += blockText.substring(startOffset, blockText.length); // from now on, we'll take whole blocks @@ -188,7 +188,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection export function selectionStateToTextOffsets(selectionState: SelectionState, contentBlocks: Array): {start: number, end: number} { let offset = 0, start = 0, end = 0; - for (let block of contentBlocks) { + for (const block of contentBlocks) { if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } @@ -208,31 +208,36 @@ export function selectionStateToTextOffsets(selectionState: SelectionState, export function textOffsetsToSelectionState({start, end}: SelectionRange, contentBlocks: Array): SelectionState { let selectionState = SelectionState.createEmpty(); - - for (let block of contentBlocks) { - let blockLength = block.getLength(); - - if (start !== -1 && start < blockLength) { - selectionState = selectionState.merge({ - anchorKey: block.getKey(), - anchorOffset: start, - }); - start = -1; - } else { - start -= blockLength; + // Subtract block lengths from `start` and `end` until they are less than the current + // block length (accounting for the NL at the end of each block). Set them to -1 to + // indicate that the corresponding selection state has been determined. + for (const block of contentBlocks) { + const blockLength = block.getLength(); + // -1 indicating that the position start position has been found + if (start !== -1) { + if (start < blockLength + 1) { + selectionState = selectionState.merge({ + anchorKey: block.getKey(), + anchorOffset: start, + }); + start = -1; // selection state for the start calculated + } else { + start -= blockLength + 1; // +1 to account for newline between blocks + } } - - if (end !== -1 && end <= blockLength) { - selectionState = selectionState.merge({ - focusKey: block.getKey(), - focusOffset: end, - }); - end = -1; - } else { - end -= blockLength; + // -1 indicating that the position end position has been found + if (end !== -1) { + if (end < blockLength + 1) { + selectionState = selectionState.merge({ + focusKey: block.getKey(), + focusOffset: end, + }); + end = -1; // selection state for the end calculated + } else { + end -= blockLength + 1; // +1 to account for newline between blocks + } } } - return selectionState; } @@ -249,7 +254,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor const existingEntityKey = block.getEntityAt(start); if (existingEntityKey) { // avoid manipulation in case the emoji already has an entity - const entity = Entity.get(existingEntityKey); + const entity = newContentState.getEntity(existingEntityKey); if (entity && entity.get('type') === 'emoji') { return; } @@ -259,7 +264,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor .set('anchorOffset', start) .set('focusOffset', end); const emojiText = plainText.substring(start, end); - const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); + newContentState = newContentState.createEntity( + 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }, + ); + const entityKey = newContentState.getLastCreatedEntityKey(); newContentState = Modifier.replaceText( newContentState, selection, @@ -286,3 +294,14 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor return editorState; } + +export function hasMultiLineSelection(editorState: EditorState): boolean { + const selectionState = editorState.getSelection(); + const anchorKey = selectionState.getAnchorKey(); + const currentContent = editorState.getCurrentContent(); + const currentContentBlock = currentContent.getBlockForKey(anchorKey); + const start = selectionState.getStartOffset(); + const end = selectionState.getEndOffset(); + const selectedText = currentContentBlock.getText().slice(start, end); + return selectedText.includes('\n'); +} diff --git a/src/Roles.js b/src/Roles.js new file mode 100644 index 0000000000..438b6c1236 --- /dev/null +++ b/src/Roles.js @@ -0,0 +1,35 @@ +/* +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 { _t } from './languageHandler'; + +export function levelRoleMap(usersDefault) { + return { + undefined: _t('Default'), + 0: _t('Restricted'), + [usersDefault]: _t('Default'), + 50: _t('Moderator'), + 100: _t('Admin'), + }; +} + +export function textualPowerLevel(level, usersDefault) { + const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault); + if (LEVEL_ROLE_MAP[level]) { + return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); + } else { + return level; + } +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js new file mode 100644 index 0000000000..31541148d9 --- /dev/null +++ b/src/RoomInvite.js @@ -0,0 +1,205 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 New Vector 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 MatrixClientPeg from './MatrixClientPeg'; +import MultiInviter from './utils/MultiInviter'; +import Modal from './Modal'; +import { getAddressType } from './UserAddress'; +import createRoom from './createRoom'; +import sdk from './'; +import dis from './dispatcher'; +import DMRoomMap from './utils/DMRoomMap'; +import { _t } from './languageHandler'; + +export function inviteToRoom(roomId, addr) { + const addrType = getAddressType(addr); + + if (addrType == 'email') { + return MatrixClientPeg.get().inviteByEmail(roomId, addr); + } else if (addrType == 'mx-user-id') { + return MatrixClientPeg.get().invite(roomId, addr); + } else { + throw new Error('Unsupported address'); + } +} + +/** + * Invites multiple addresses to a room + * Simpler interface to utils/MultiInviter but with + * no option to cancel. + * + * @param {string} roomId The ID of the room to invite to + * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @returns {Promise} Promise + */ +export function inviteMultipleToRoom(roomId, addrs) { + const inviter = new MultiInviter(roomId); + return inviter.invite(addrs); +} + +export function showStartChatInviteDialog() { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { + title: _t('Start a chat'), + description: _t("Who would you like to communicate with?"), + placeholder: _t("Email, name or matrix ID"), + button: _t("Start Chat"), + onFinished: _onStartChatFinished, + }); +} + +export function showRoomInviteDialog(roomId) { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { + title: _t('Invite new room members'), + description: _t('Who would you like to add to this room?'), + button: _t('Send Invites'), + placeholder: _t("Email, name or matrix ID"), + onFinished: (shouldInvite, addrs) => { + _onRoomInviteFinished(roomId, shouldInvite, addrs); + }, + }); +} + +function _onStartChatFinished(shouldInvite, addrs) { + if (!shouldInvite) return; + + const addrTexts = addrs.map((addr) => addr.address); + + if (_isDmChat(addrTexts)) { + const rooms = _getDirectMessageRooms(addrTexts[0]); + if (rooms.length > 0) { + // A Direct Message room already exists for this user, so select a + // room from a list that is similar to the one in MemberInfo panel + const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog"); + const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { + userId: addrTexts[0], + onNewDMClick: () => { + dis.dispatch({ + action: 'start_chat', + user_id: addrTexts[0], + }); + close(true); + }, + onExistingRoomSelected: (roomId) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + close(true); + }, + }).close; + } else { + // Start a new DM chat + createRoom({dmUserId: addrTexts[0]}).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { + title: _t("Failed to invite user"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } + } else if (addrTexts.length === 1) { + // Start a new DM chat + createRoom({dmUserId: addrTexts[0]}).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { + title: _t("Failed to invite user"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } else { + // Start multi user chat + let room; + createRoom().then((roomId) => { + room = MatrixClientPeg.get().getRoom(roomId); + return inviteMultipleToRoom(roomId, addrTexts); + }).then((addrs) => { + return _showAnyInviteErrors(addrs, room); + }).catch((err) => { + console.error(err.stack); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } +} + +function _onRoomInviteFinished(roomId, shouldInvite, addrs) { + if (!shouldInvite) return; + + const addrTexts = addrs.map((addr) => addr.address); + + // Invite new users to a room + inviteMultipleToRoom(roomId, addrTexts).then((addrs) => { + const room = MatrixClientPeg.get().getRoom(roomId); + return _showAnyInviteErrors(addrs, room); + }).catch((err) => { + console.error(err.stack); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); +} + +function _isDmChat(addrTexts) { + if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') { + return true; + } else { + return false; + } +} + +function _showAnyInviteErrors(addrs, room) { + // Show user any errors + const errorList = []; + for (const addr of Object.keys(addrs)) { + if (addrs[addr] === "error") { + errorList.push(addr); + } + } + + if (errorList.length > 0) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { + title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), + description: errorList.join(", "), + }); + } + return addrs; +} + +function _getDirectMessageRooms(addr) { + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); + const rooms = []; + dmRooms.forEach((dmRoom) => { + const room = MatrixClientPeg.get().getRoom(dmRoom); + if (room) { + const me = room.getMember(MatrixClientPeg.get().credentials.userId); + if (me.membership == 'join') { + rooms.push(room); + } + } + }); + return rooms; +} + diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 09f178dd3f..c06cc60c97 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -19,18 +19,17 @@ limitations under the License. function tsOfNewestEvent(room) { if (room.timeline.length) { return room.timeline[room.timeline.length - 1].getTs(); - } - else { + } else { return Number.MAX_SAFE_INTEGER; } } function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a,b) { + return roomList.sort(function(a, b) { return tsOfNewestEvent(b) - tsOfNewestEvent(a); }); } module.exports = { - mostRecentActivityFirst: mostRecentActivityFirst + mostRecentActivityFirst, }; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index d0cdd6ead7..5cc078dc59 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -16,7 +16,7 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; -import q from 'q'; +import Promise from 'bluebird'; export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES = 'all_messages'; @@ -52,7 +52,7 @@ export function getRoomNotifsState(roomId) { } export function setRoomNotifsState(roomId, newState) { - if (newState == MUTE) { + if (newState === MUTE) { return setRoomNotifsStateMuted(roomId); } else { return setRoomNotifsStateUnmuted(roomId, newState); @@ -80,14 +80,14 @@ function setRoomNotifsStateMuted(roomId) { kind: 'event_match', key: 'room_id', pattern: roomId, - } + }, ], actions: [ 'dont_notify', - ] + ], })); - return q.all(promises); + return Promise.all(promises); } function setRoomNotifsStateUnmuted(roomId, newState) { @@ -99,16 +99,16 @@ function setRoomNotifsStateUnmuted(roomId, newState) { promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); } - if (newState == 'all_messages') { + if (newState === 'all_messages') { const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); } - } else if (newState == 'mentions_only') { + } else if (newState === 'mentions_only') { promises.push(cli.addPushRule('global', 'room', roomId, { actions: [ 'dont_notify', - ] + ], })); // https://matrix.org/jira/browse/SPEC-400 promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); @@ -119,14 +119,14 @@ function setRoomNotifsStateUnmuted(roomId, newState) { { set_tweak: 'sound', value: 'default', - } - ] + }, + ], })); // https://matrix.org/jira/browse/SPEC-400 promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); } - return q.all(promises); + return Promise.all(promises); } function findOverrideMuteRule(roomId) { @@ -145,20 +145,10 @@ function isRuleForRoom(roomId, rule) { return false; } const cond = rule.conditions[0]; - if ( - cond.kind == 'event_match' && - cond.key == 'room_id' && - cond.pattern == roomId - ) { - return true; - } - return false; + return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId); } function isMuteRule(rule) { - return ( - rule.actions.length == 1 && - rule.actions[0] == 'dont_notify' - ); + return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); } diff --git a/src/Rooms.js b/src/Rooms.js index cf62f2dda0..ffa39141ff 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -15,8 +15,7 @@ limitations under the License. */ import MatrixClientPeg from './MatrixClientPeg'; -import DMRoomMap from './utils/DMRoomMap'; -import q from 'q'; +import Promise from 'bluebird'; /** * Given a room object, return the alias we should use for it, @@ -37,14 +36,14 @@ export function getOnlyOtherMember(room, me) { if (joinedMembers.length === 2) { return joinedMembers.filter(function(m) { - return m.userId !== me.userId + return m.userId !== me.userId; })[0]; } return null; } -export function isConfCallRoom(room, me, conferenceHandler) { +function _isConfCallRoom(room, me, conferenceHandler) { if (!conferenceHandler) return false; if (me.membership != "join") { @@ -59,12 +58,31 @@ export function isConfCallRoom(room, me, conferenceHandler) { if (conferenceHandler.isConferenceUser(otherMember.userId)) { return true; } + + return false; +} + +// Cache whether a room is a conference call. Assumes that rooms will always +// either will or will not be a conference call room. +const isConfCallRoomCache = { + // $roomId: bool +}; + +export function isConfCallRoom(room, me, conferenceHandler) { + if (isConfCallRoomCache[room.roomId] !== undefined) { + return isConfCallRoomCache[room.roomId]; + } + + const result = _isConfCallRoom(room, me, conferenceHandler); + + isConfCallRoomCache[room.roomId] = result; + + return result; } export function looksLikeDirectMessageRoom(room, me) { if (me.membership == "join" || me.membership === "ban" || - (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) - { + (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { // Used to split rooms via tags const tagNames = Object.keys(room.tags); // Used for 1:1 direct chats @@ -79,6 +97,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 @@ -89,7 +121,7 @@ export function looksLikeDirectMessageRoom(room, me) { */ export function setDMRoom(roomId, userId) { if (MatrixClientPeg.get().isGuest()) { - return q(); + return Promise.resolve(); } const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); @@ -131,7 +163,18 @@ export function guessDMRoomTarget(room, me) { let oldestTs; let oldestUser; - // Pick the user who's been here longest (and isn't us) + // Pick the joined user who's been here longest (and isn't us), + for (const user of room.getJoinedMembers()) { + if (user.userId == me.userId) continue; + + if (oldestTs === undefined || user.events.member.getTs() < oldestTs) { + oldestUser = user; + oldestTs = user.events.member.getTs(); + } + } + if (oldestUser) return oldestUser; + + // if there are no joined members other than us, use the oldest member for (const user of room.currentState.getMembers()) { if (user.userId == me.userId) continue; diff --git a/src/RtsClient.js b/src/RtsClient.js new file mode 100644 index 0000000000..493b19599c --- /dev/null +++ b/src/RtsClient.js @@ -0,0 +1,104 @@ +import 'whatwg-fetch'; + +let fetchFunction = fetch; + +function checkStatus(response) { + if (!response.ok) { + return response.text().then((text) => { + throw new Error(text); + }); + } + return response; +} + +function parseJson(response) { + return response.json(); +} + +function encodeQueryParams(params) { + return '?' + Object.keys(params).map((k) => { + return k + '=' + encodeURIComponent(params[k]); + }).join('&'); +} + +const request = (url, opts) => { + if (opts && opts.qs) { + url += encodeQueryParams(opts.qs); + delete opts.qs; + } + if (opts && opts.body) { + if (!opts.headers) { + opts.headers = {}; + } + opts.body = JSON.stringify(opts.body); + opts.headers['Content-Type'] = 'application/json'; + } + return fetchFunction(url, opts) + .then(checkStatus) + .then(parseJson); +}; + + +export default class RtsClient { + constructor(url) { + this._url = url; + } + + getTeamsConfig() { + return request(this._url + '/teams'); + } + + /** + * Track a referral with the Riot Team Server. This should be called once a referred + * user has been successfully registered. + * @param {string} referrer the user ID of one who referred the user to Riot. + * @param {string} sid the sign-up identity server session ID . + * @param {string} clientSecret the sign-up client secret. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + trackReferral(referrer, sid, clientSecret) { + return request(this._url + '/register', + { + body: { + referrer: referrer, + session_id: sid, + client_secret: clientSecret, + }, + method: 'POST', + }, + ); + } + + getTeam(teamToken) { + return request(this._url + '/teamConfiguration', + { + qs: { + team_token: teamToken, + }, + }, + ); + } + + /** + * Signal to the RTS that a login has occurred and that a user requires their team's + * token. + * @param {string} userId the user ID of the user who is a member of a team. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + login(userId) { + return request(this._url + '/login', + { + qs: { + user_id: userId, + }, + }, + ); + } + + // allow fetch to be replaced, for testing. + static setFetch(fn) { + fetchFunction = fn; + } +} diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index e1928e15d4..568dd6d185 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -var q = require("q"); -var request = require('browser-request'); +import Promise from 'bluebird'; +import SettingsStore from "./settings/SettingsStore"; +const request = require('browser-request'); -var SdkConfig = require('./SdkConfig'); -var MatrixClientPeg = require('./MatrixClientPeg'); +const SdkConfig = require('./SdkConfig'); +const MatrixClientPeg = require('./MatrixClientPeg'); class ScalarAuthClient { @@ -38,11 +39,53 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { - var tok = window.localStorage.getItem("mx_scalar_token"); - if (tok) return q(tok); + const token = window.localStorage.getItem("mx_scalar_token"); - // No saved token, so do the dance to get one. First, we - // need an openid bearer token from the HS. + if (!token) { + return this.registerForToken(); + } else { + return this.validateToken(token).then(userId => { + const me = MatrixClientPeg.get().getUserId(); + if (userId !== me) { + throw new Error("Scalar token is owned by someone else: " + me); + } + return token; + }).catch(err => { + console.error(err); + + // Something went wrong - try to get a new token. + console.warn("Registering for new scalar token"); + return this.registerForToken(); + }) + } + } + + validateToken(token) { + let url = SdkConfig.get().integrations_rest_url + "/account"; + + const defer = Promise.defer(); + request({ + method: "GET", + uri: url, + qs: {scalar_token: token}, + json: true, + }, (err, response, body) => { + if (err) { + defer.reject(err); + } else if (response.statusCode / 100 !== 2) { + defer.reject({statusCode: response.statusCode}); + } else if (!body || !body.user_id) { + defer.reject(new Error("Missing user_id in response")); + } else { + defer.resolve(body.user_id); + } + }); + + return defer.promise; + } + + registerForToken() { + // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((token_object) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(token_object); @@ -53,9 +96,9 @@ class ScalarAuthClient { } exchangeForScalarToken(openid_token_object) { - var defer = q.defer(); + const defer = Promise.defer(); - var scalar_rest_url = SdkConfig.get().integrations_rest_url; + const scalar_rest_url = SdkConfig.get().integrations_rest_url; request({ method: 'POST', uri: scalar_rest_url+'/register', @@ -76,10 +119,46 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId) { - var url = SdkConfig.get().integrations_ui_url; + getScalarPageTitle(url) { + const defer = Promise.defer(); + + let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup'; + scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); + scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); + request({ + method: 'GET', + uri: scalarPageLookupUrl, + json: true, + }, (err, response, body) => { + if (err) { + defer.reject(err); + } else if (response.statusCode / 100 !== 2) { + defer.reject({statusCode: response.statusCode}); + } else if (!body) { + defer.reject(new Error("Missing page title in response")); + } else { + let title = ""; + if (body.page_title_cache_item && body.page_title_cache_item.cached_title) { + title = body.page_title_cache_item.cached_title; + } + defer.resolve(title); + } + }); + + return defer.promise; + } + + getScalarInterfaceUrlForRoom(roomId, screen, id) { + let url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); + if (id) { + url += '&integ_id=' + encodeURIComponent(id); + } + if (screen) { + url += '&screen=' + encodeURIComponent(screen); + } return url; } @@ -89,4 +168,3 @@ class ScalarAuthClient { } module.exports = ScalarAuthClient; - diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 75062daaa2..3c164c6551 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.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. @@ -17,7 +18,7 @@ limitations under the License. /* Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed: { - action: "invite" | "membership_state" | "bot_options" | "set_bot_options", + action: "invite" | "membership_state" | "bot_options" | "set_bot_options" | etc... , room_id: $ROOM_ID, user_id: $USER_ID // additional request fields @@ -94,6 +95,115 @@ Example: } } +get_membership_count +-------------------- +Get the number of joined users in the room. + +Request: + - room_id is the room to get the count in. +Response: +78 +Example: +{ + action: "get_membership_count", + room_id: "!foo:bar", + response: 78 +} + +can_send_event +-------------- +Check if the client can send the given event into the given room. If the client +is unable to do this, an error response is returned instead of 'response: false'. + +Request: + - room_id is the room to do the check in. + - event_type is the event type which will be sent. + - is_state is true if the event to be sent is a state event. +Response: +true +Example: +{ + action: "can_send_event", + is_state: false, + event_type: "m.room.message", + room_id: "!foo:bar", + response: true +} + +set_widget +---------- +Set a new widget in the room. Clobbers based on the ID. + +Request: + - `room_id` (String) is the room to set the widget in. + - `widget_id` (String) is the ID of the widget to add (or replace if it already exists). + It can be an arbitrary UTF8 string and is purely for distinguishing between widgets. + - `url` (String) is the URL that clients should load in an iframe to run the widget. + All widgets must have a valid URL. If the URL is `null` (not `undefined`), the + widget will be removed from the room. + - `type` (String) is the type of widget, which is provided as a hint for matrix clients so they + can configure/lay out the widget in different ways. All widgets must have a type. + - `name` (String) is an optional human-readable string about the widget. + - `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs. +Response: +{ + success: true +} +Example: +{ + action: "set_widget", + room_id: "!foo:bar", + widget_id: "abc123", + url: "http://widget.url", + type: "example", + response: { + success: true + } +} + +get_widgets +----------- +Get a list of all widgets in the room. The response is an array +of state events. + +Request: + - `room_id` (String) is the room to get the widgets in. +Response: +[ + { + type: "im.vector.modular.widgets", + state_key: "wid1", + content: { + type: "grafana", + url: "https://grafanaurl", + name: "dashboard", + data: {key: "val"} + } + room_id: “!foo:bar”, + sender: "@alice:localhost" + } +] +Example: +{ + action: "get_widgets", + room_id: "!foo:bar", + response: [ + { + type: "im.vector.modular.widgets", + state_key: "wid1", + content: { + type: "grafana", + url: "https://grafanaurl", + name: "dashboard", + data: {key: "val"} + } + room_id: “!foo:bar”, + sender: "@alice:localhost" + } + ] +} + + membership_state AND bot_options -------------------------------- Get the content of the "m.room.member" or "m.room.bot.options" state event respectively. @@ -125,6 +235,7 @@ const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); const MatrixEvent = require("matrix-js-sdk").MatrixEvent; const dis = require("./dispatcher"); +import { _t } from './languageHandler'; function sendResponse(event, res) { const data = JSON.parse(JSON.stringify(event.data)); @@ -150,7 +261,7 @@ function inviteUser(event, roomId, userId) { console.log(`Received request to invite ${userId} into room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } const room = client.getRoom(roomId); @@ -170,10 +281,107 @@ function inviteUser(event, roomId, userId) { success: true, }); }, function(err) { - sendError(event, "You need to be able to invite users to do that.", err); + sendError(event, _t('You need to be able to invite users to do that.'), err); }); } +function setWidget(event, roomId) { + const widgetId = event.data.widget_id; + const widgetType = event.data.type; + const widgetUrl = event.data.url; + const widgetName = event.data.name; // optional + const widgetData = event.data.data; // optional + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + + // both adding/removing widgets need these checks + if (!widgetId || widgetUrl === undefined) { + sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields.")); + return; + } + + if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc + // check types of fields + if (widgetName !== undefined && typeof widgetName !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string.")); + return; + } + if (widgetData !== undefined && !(widgetData instanceof Object)) { + sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object.")); + return; + } + if (typeof widgetType !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string.")); + return; + } + if (typeof widgetUrl !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null.")); + return; + } + } + + let content = { + type: widgetType, + url: widgetUrl, + name: widgetName, + data: widgetData, + }; + if (widgetUrl === null) { // widget is being deleted + content = {}; + } + + client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, _t('Failed to send request.'), err); + }); +} + +function getWidgets(event, roomId) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + // Only return widgets which have required fields + const widgetStateEvents = []; + stateEvents.forEach((ev) => { + if (ev.getContent().type && ev.getContent().url) { + widgetStateEvents.push(ev.event); // return the raw event + } + }); + + sendResponse(event, widgetStateEvents); +} + +function getRoomEncState(event, roomId) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId); + + sendResponse(event, roomIsEncrypted); +} + function setPlumbingState(event, roomId, status) { if (typeof status !== 'string') { throw new Error('Plumbing state status should be a string'); @@ -181,15 +389,15 @@ function setPlumbingState(event, roomId, status) { console.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.plumbing", { status : status }).done(() => { + client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => { sendResponse(event, { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); } @@ -197,7 +405,7 @@ function setBotOptions(event, roomId, userId) { console.log(`Received request to set options for bot ${userId} in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => { @@ -205,29 +413,29 @@ function setBotOptions(event, roomId, userId) { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); } function setBotPower(event, roomId, userId, level) { if (!(Number.isInteger(level) && level >= 0)) { - sendError(event, "Power level must be positive integer."); + sendError(event, _t('Power level must be positive integer.')); return; } console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { - let powerEvent = new MatrixEvent( + const powerEvent = new MatrixEvent( { type: "m.room.power_levels", content: powerLevels, - } + }, ); client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { @@ -235,7 +443,7 @@ function setBotPower(event, roomId, userId, level) { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); }); } @@ -255,15 +463,65 @@ function botOptions(event, roomId, userId) { returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } -function returnStateEvent(event, roomId, eventType, stateKey) { +function getMembershipCount(event, roomId) { const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } const room = client.getRoom(roomId); if (!room) { - sendError(event, "This room is not recognised."); + sendError(event, _t('This room is not recognised.')); + return; + } + const count = room.getJoinedMembers().length; + sendResponse(event, count); +} + +function canSendEvent(event, roomId) { + const evType = "" + event.data.event_type; // force stringify + const isState = Boolean(event.data.is_state); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const me = client.credentials.userId; + const member = room.getMember(me); + if (!member || member.membership !== "join") { + sendError(event, _t('You are not in this room.')); + return; + } + + let canSend = false; + if (isState) { + canSend = room.currentState.maySendStateEvent(evType, me); + } else { + canSend = room.currentState.maySendEvent(evType, me); + } + + if (!canSend) { + sendError(event, _t('You do not have permission to do that in this room.')); + return; + } + + sendResponse(event, true); +} + +function returnStateEvent(event, roomId, eventType, stateKey) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); return; } const stateEvent = room.currentState.getStateEvents(eventType, stateKey); @@ -274,8 +532,8 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } -var currentRoomId = null; -var currentRoomAlias = null; +let currentRoomId = null; +let currentRoomAlias = null; // Listen for when a room is viewed dis.register(onAction); @@ -299,8 +557,16 @@ const onMessage = function(event) { // // All strings start with the empty string, so for sanity return if the length // of the event origin is 0. - let url = SdkConfig.get().integrations_ui_url; - if (event.origin.length === 0 || !url.startsWith(event.origin)) { + // + // TODO -- Scalar postMessage API should be namespaced with event.data.api field + // Fix following "if" statement to respond only to specific API messages. + const url = SdkConfig.get().integrations_ui_url; + if ( + event.origin.length === 0 || + !url.startsWith(event.origin) || + !event.data.action || + event.data.api // Ignore messages with specific API set + ) { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } @@ -313,13 +579,13 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; if (!roomId) { - sendError(event, "Missing room_id in request"); + sendError(event, _t('Missing room_id in request')); return; } let promise = Promise.resolve(currentRoomId); if (!currentRoomId) { if (!currentRoomAlias) { - sendError(event, "Must be viewing a room"); + sendError(event, _t('Must be viewing a room')); return; } // no room ID but there is an alias, look it up. @@ -331,21 +597,36 @@ const onMessage = function(event) { promise.then((viewingRoomId) => { if (roomId !== viewingRoomId) { - sendError(event, "Room " + roomId + " not visible"); + sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); return; } - // Getting join rules does not require userId + // These APIs don't require userId if (event.data.action === "join_rules_state") { getJoinRules(event, roomId); return; } else if (event.data.action === "set_plumbing_state") { setPlumbingState(event, roomId, event.data.status); return; + } else if (event.data.action === "get_membership_count") { + getMembershipCount(event, roomId); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, roomId); + return; + } else if (event.data.action === "get_widgets") { + getWidgets(event, roomId); + return; + } else if (event.data.action === "get_room_enc_state") { + getRoomEncState(event, roomId); + return; + } else if (event.data.action === "can_send_event") { + canSendEvent(event, roomId); + return; } if (!userId) { - sendError(event, "Missing user_id in request"); + sendError(event, _t('Missing user_id in request')); return; } switch (event.data.action) { @@ -370,16 +651,31 @@ const onMessage = function(event) { } }, (err) => { console.error(err); - sendError(event, "Failed to lookup current room."); - }) + sendError(event, _t('Failed to lookup current room') + '.'); + }); }; +let listenerCount = 0; module.exports = { startListening: function() { - window.addEventListener("message", onMessage, false); + if (listenerCount === 0) { + window.addEventListener("message", onMessage, false); + } + listenerCount += 1; }, stopListening: function() { - window.removeEventListener("message", onMessage); + listenerCount -= 1; + if (listenerCount === 0) { + window.removeEventListener("message", onMessage); + } + if (listenerCount < 0) { + // Make an error so we get a stack trace + const e = new Error( + "ScalarMessaging: mismatched startListening / stopListening detected." + + " Negative count", + ); + console.error(e); + } }, }; diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 1452aaa64b..64bf21ecf8 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -14,22 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -var DEFAULTS = { +const DEFAULTS = { // URL to a page we show in an iframe to configure integrations integrations_ui_url: "https://scalar.vector.im/", // Base URL to the REST interface of the integrations server integrations_rest_url: "https://scalar.vector.im/api", + // Where to send bug reports. If not specified, bugs cannot be sent. + bug_report_endpoint_url: null, + + piwik: { + url: "https://piwik.riot.im/", + whitelistedHSUrls: ["https://matrix.org"], + whitelistedISUrls: ["https://vector.im", "https://matrix.org"], + siteId: 1, + }, }; class SdkConfig { static get() { - return global.mxReactSdkConfig; + return global.mxReactSdkConfig || {}; } static put(cfg) { - var defaultKeys = Object.keys(DEFAULTS); - for (var i = 0; i < defaultKeys.length; ++i) { + const defaultKeys = Object.keys(DEFAULTS); + for (let i = 0; i < defaultKeys.length; ++i) { if (cfg[defaultKeys[i]] === undefined) { cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; } @@ -43,3 +52,4 @@ class SdkConfig { } module.exports = SdkConfig; +module.exports.DEFAULTS = DEFAULTS; diff --git a/src/Signup.js b/src/Signup.js deleted file mode 100644 index a76919f34e..0000000000 --- a/src/Signup.js +++ /dev/null @@ -1,451 +0,0 @@ -"use strict"; - -import Matrix from "matrix-js-sdk"; - -var MatrixClientPeg = require("./MatrixClientPeg"); -var SignupStages = require("./SignupStages"); -var dis = require("./dispatcher"); -var q = require("q"); -var url = require("url"); - -const EMAIL_STAGE_TYPE = "m.login.email.identity"; - -/** - * A base class for common functionality between Registration and Login e.g. - * storage of HS/IS URLs. - */ -class Signup { - constructor(hsUrl, isUrl, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - } - - getHomeserverUrl() { - return this._hsUrl; - } - - getIdentityServerUrl() { - return this._isUrl; - } - - setHomeserverUrl(hsUrl) { - this._hsUrl = hsUrl; - } - - setIdentityServerUrl(isUrl) { - this._isUrl = isUrl; - } - - /** - * Get a temporary MatrixClient, which can be used for login or register - * requests. - */ - _createTemporaryClient() { - return Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, - }); - } -} - -/** - * Registration logic class - * This exists for the lifetime of a user's attempt to register an account, - * so if their registration attempt fails for whatever reason and they - * try again, call register() on the same instance again. - * - * TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It - * would be nice to make use of that rather than rolling our own version of it. - */ -class Register extends Signup { - constructor(hsUrl, isUrl, opts) { - super(hsUrl, isUrl, opts); - this.setStep("START"); - this.data = null; // from the server - // random other stuff (e.g. query params, NOT params from the server) - this.params = {}; - this.credentials = null; - this.activeStage = null; - this.registrationPromise = null; - // These values MUST be undefined else we'll send "username: null" which - // will error on Synapse rather than having the key absent. - this.username = undefined; // desired - this.email = undefined; // desired - this.password = undefined; // desired - } - - setClientSecret(secret) { - this.params.clientSecret = secret; - } - - setSessionId(sessionId) { - this.params.sessionId = sessionId; - } - - setRegistrationUrl(regUrl) { - this.params.registrationUrl = regUrl; - } - - setIdSid(idSid) { - this.params.idSid = idSid; - } - - setGuestAccessToken(token) { - this.guestAccessToken = token; - } - - getStep() { - return this._step; - } - - getCredentials() { - return this.credentials; - } - - getServerData() { - return this.data || {}; - } - - getPromise() { - return this.registrationPromise; - } - - setStep(step) { - this._step = 'Register.' + step; - // TODO: - // It's a shame this is going to the global dispatcher, we only really - // want things which have an instance of this class to be able to add - // listeners... - console.log("Dispatching 'registration_step_update' for step %s", this._step); - dis.dispatch({ - action: "registration_step_update" - }); - } - - /** - * Starts the registration process from the first stage - */ - register(formVals) { - var {username, password, email} = formVals; - this.email = email; - this.username = username; - this.password = password; - const client = this._createTemporaryClient(); - this.activeStage = null; - - // If there hasn't been a client secret set by this point, - // generate one for this session. It will only be used if - // we do email verification, but far simpler to just make - // sure we have one. - // We re-use this same secret over multiple calls to register - // so that the identity server can honour the sendAttempt - // parameter and not re-send email unless we actually want - // another mail to be sent. - if (!this.params.clientSecret) { - this.params.clientSecret = client.generateClientSecret(); - } - return this._tryRegister(client); - } - - _tryRegister(client, authDict, poll_for_success) { - var self = this; - - var bindEmail; - - if (this.username && this.password) { - // only need to bind_email when sending u/p - sending it at other - // times clobbers the u/p resulting in M_MISSING_PARAM (password) - bindEmail = true; - } - - // TODO need to figure out how to send the device display name to /register. - return client.register( - this.username, this.password, this.params.sessionId, authDict, bindEmail, - this.guestAccessToken - ).then(function(result) { - self.credentials = result; - self.setStep("COMPLETE"); - return result; // contains the credentials - }, function(error) { - if (error.httpStatus === 401) { - if (error.data && error.data.flows) { - // Remember the session ID from the server: - // Either this is our first 401 in which case we need to store the - // session ID for future calls, or it isn't in which case this - // is just a no-op since it ought to be the same (or if it isn't, - // we should use the latest one from the server in any case). - self.params.sessionId = error.data.session; - self.data = error.data || {}; - var flow = self.chooseFlow(error.data.flows); - - if (flow) { - console.log("Active flow => %s", JSON.stringify(flow)); - var flowStage = self.firstUncompletedStage(flow); - if (!self.activeStage || flowStage != self.activeStage.type) { - return self._startStage(client, flowStage).catch(function(err) { - self.setStep('START'); - throw err; - }); - } - } - } - if (poll_for_success) { - return q.delay(5000).then(function() { - return self._tryRegister(client, authDict, poll_for_success); - }); - } else { - throw new Error("Authorisation failed!"); - } - } else { - if (error.errcode === 'M_USER_IN_USE') { - throw new Error("Username in use"); - } else if (error.errcode == 'M_INVALID_USERNAME') { - throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); - } else if (error.httpStatus >= 400 && error.httpStatus < 500) { - throw new Error(`Registration failed! (${error.httpStatus})`); - } else if (error.httpStatus >= 500 && error.httpStatus < 600) { - throw new Error( - `Server error during registration! (${error.httpStatus})` - ); - } else if (error.name == "M_MISSING_PARAM") { - // The HS hasn't remembered the login params from - // the first try when the login email was sent. - throw new Error( - "This home server does not support resuming registration." - ); - } - } - }); - } - - firstUncompletedStage(flow) { - for (var i = 0; i < flow.stages.length; ++i) { - if (!this.hasCompletedStage(flow.stages[i])) { - return flow.stages[i]; - } - } - } - - hasCompletedStage(stageType) { - var completed = (this.data || {}).completed || []; - return completed.indexOf(stageType) !== -1; - } - - _startStage(client, stageName) { - var self = this; - this.setStep(`STEP_${stageName}`); - var StageClass = SignupStages[stageName]; - if (!StageClass) { - // no idea how to handle this! - throw new Error("Unknown stage: " + stageName); - } - - var stage = new StageClass(client, this); - this.activeStage = stage; - return stage.complete().then(function(request) { - if (request.auth) { - console.log("Stage %s is returning an auth dict", stageName); - return self._tryRegister(client, request.auth, request.poll_for_success); - } - else { - // never resolve the promise chain. This is for things like email auth - // which display a "check your email" message and relies on the - // link in the email to actually register you. - console.log("Waiting for external action."); - return q.defer().promise; - } - }); - } - - chooseFlow(flows) { - // If the user gave us an email then we want to pick an email - // flow we can do, else any other flow. - var emailFlow = null; - var otherFlow = null; - flows.forEach(function(flow) { - var flowHasEmail = false; - for (var stageI = 0; stageI < flow.stages.length; ++stageI) { - var stage = flow.stages[stageI]; - - if (!SignupStages[stage]) { - // we can't do this flow, don't have a Stage impl. - return; - } - - if (stage === EMAIL_STAGE_TYPE) { - flowHasEmail = true; - } - } - - if (flowHasEmail) { - emailFlow = flow; - } else { - otherFlow = flow; - } - }); - - if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) { - // we've been given an email or we've already done an email part - return emailFlow; - } else { - return otherFlow; - } - } - - recheckState() { - // We've been given a bunch of data from a previous register step, - // this only happens for email auth currently. It's kinda ming we need - // to know this though. A better solution would be to ask the stages if - // they are ready to do something rather than accepting that we know about - // email auth and its internals. - this.params.hasEmailInfo = ( - this.params.clientSecret && this.params.sessionId && this.params.idSid - ); - - if (this.params.hasEmailInfo) { - const client = this._createTemporaryClient(); - this.registrationPromise = this._startStage(client, EMAIL_STAGE_TYPE); - } - return this.registrationPromise; - } - - tellStage(stageName, data) { - if (this.activeStage && this.activeStage.type === stageName) { - console.log("Telling stage %s about something..", stageName); - this.activeStage.onReceiveData(data); - } - } -} - - -class Login extends Signup { - constructor(hsUrl, isUrl, fallbackHsUrl, opts) { - super(hsUrl, isUrl, opts); - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - } - - getFlows() { - var self = this; - var client = this._createTemporaryClient(); - return client.loginFlows().then(function(result) { - self._flows = result.flows; - self._currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. - return self._flows; - }); - } - - chooseFlow(flowIndex) { - this._currentFlowIndex = flowIndex; - } - - getCurrentFlowStep() { - // technically the flow can have multiple steps, but no one does this - // for login so we can ignore it. - var flowStep = this._flows[this._currentFlowIndex]; - return flowStep ? flowStep.type : null; - } - - loginAsGuest() { - var client = this._createTemporaryClient(); - return client.registerGuest({ - body: { - initial_device_display_name: this._defaultDeviceDisplayName, - }, - }).then((creds) => { - return { - userId: creds.user_id, - deviceId: creds.device_id, - accessToken: creds.access_token, - homeserverUrl: this._hsUrl, - identityServerUrl: this._isUrl, - guest: true - }; - }, (error) => { - if (error.httpStatus === 403) { - error.friendlyText = "Guest access is disabled on this Home Server."; - } else { - error.friendlyText = "Failed to register as guest: " + error.data; - } - throw error; - }); - } - - loginViaPassword(username, pass) { - var self = this; - var isEmail = username.indexOf("@") > 0; - var loginParams = { - password: pass, - initial_device_display_name: this._defaultDeviceDisplayName, - }; - if (isEmail) { - loginParams.medium = 'email'; - loginParams.address = username; - } else { - loginParams.user = username; - } - - var client = this._createTemporaryClient(); - return client.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._hsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token - }); - }, function(error) { - if (error.httpStatus == 400 && loginParams.medium) { - error.friendlyText = ( - 'This Home Server does not support login using email address.' - ); - } - else if (error.httpStatus === 403) { - error.friendlyText = ( - 'Incorrect username and/or password.' - ); - if (self._fallbackHsUrl) { - var fbClient = Matrix.createClient({ - baseUrl: self._fallbackHsUrl, - idBaseUrl: this._isUrl, - }); - - return fbClient.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._fallbackHsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token - }); - }, function(fallback_error) { - // throw the original error - throw error; - }); - } - } - else { - error.friendlyText = ( - 'There was a problem logging in. (HTTP ' + error.httpStatus + ")" - ); - } - throw error; - }); - } - - redirectToCas() { - var client = this._createTemporaryClient(); - var parsedUrl = url.parse(window.location.href, true); - parsedUrl.query["homeserver"] = client.getHomeserverUrl(); - parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); - var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); - window.location.href = casUrl; - } -} - -module.exports.Register = Register; -module.exports.Login = Login; diff --git a/src/SignupStages.js b/src/SignupStages.js deleted file mode 100644 index 283b11afef..0000000000 --- a/src/SignupStages.js +++ /dev/null @@ -1,166 +0,0 @@ -"use strict"; -var q = require("q"); - -/** - * An interface class which login types should abide by. - */ -class Stage { - constructor(type, matrixClient, signupInstance) { - this.type = type; - this.client = matrixClient; - this.signupInstance = signupInstance; - } - - complete() { - // Return a promise which is: - // RESOLVED => With an Object which has an 'auth' key which is the auth dict - // to submit. - // REJECTED => With an Error if there was a problem with this stage. - // Has a "message" string and an "isFatal" flag. - return q.reject("NOT IMPLEMENTED"); - } - - onReceiveData() { - // NOP - } -} -Stage.TYPE = "NOT IMPLEMENTED"; - - -/** - * This stage requires no auth. - */ -class DummyStage extends Stage { - constructor(matrixClient, signupInstance) { - super(DummyStage.TYPE, matrixClient, signupInstance); - } - - complete() { - return q({ - auth: { - type: DummyStage.TYPE - } - }); - } -} -DummyStage.TYPE = "m.login.dummy"; - - -/** - * This stage uses Google's Recaptcha to do auth. - */ -class RecaptchaStage extends Stage { - constructor(matrixClient, signupInstance) { - super(RecaptchaStage.TYPE, matrixClient, signupInstance); - this.defer = q.defer(); // resolved with the captcha response - } - - // called when the recaptcha has been completed. - onReceiveData(data) { - if (!data || !data.response) { - return; - } - this.defer.resolve({ - auth: { - type: 'm.login.recaptcha', - response: data.response, - } - }); - } - - complete() { - return this.defer.promise; - } -} -RecaptchaStage.TYPE = "m.login.recaptcha"; - - -/** - * This state uses the IS to verify email addresses. - */ -class EmailIdentityStage extends Stage { - constructor(matrixClient, signupInstance) { - super(EmailIdentityStage.TYPE, matrixClient, signupInstance); - } - - _completeVerify() { - // pull out the host of the IS URL by creating an anchor element - var isLocation = document.createElement('a'); - isLocation.href = this.signupInstance.getIdentityServerUrl(); - - var clientSecret = this.clientSecret || this.signupInstance.params.clientSecret; - var sid = this.sid || this.signupInstance.params.idSid; - - return q({ - auth: { - type: 'm.login.email.identity', - threepid_creds: { - sid: sid, - client_secret: clientSecret, - id_server: isLocation.host - } - } - }); - } - - /** - * Complete the email stage. - * - * This is called twice under different circumstances: - * 1) When requesting an email token from the IS - * 2) When validating query parameters received from the link in the email - */ - complete() { - // TODO: The Registration class shouldn't really know this info. - if (this.signupInstance.params.hasEmailInfo) { - return this._completeVerify(); - } - - this.clientSecret = this.signupInstance.params.clientSecret; - if (!this.clientSecret) { - return q.reject(new Error("No client secret specified by Signup class!")); - } - - var nextLink = this.signupInstance.params.registrationUrl + - '?client_secret=' + - encodeURIComponent(this.clientSecret) + - "&hs_url=" + - encodeURIComponent(this.signupInstance.getHomeserverUrl()) + - "&is_url=" + - encodeURIComponent(this.signupInstance.getIdentityServerUrl()) + - "&session_id=" + - encodeURIComponent(this.signupInstance.getServerData().session); - - var self = this; - return this.client.requestRegisterEmailToken( - this.signupInstance.email, - this.clientSecret, - 1, // TODO: Multiple send attempts? - nextLink - ).then(function(response) { - self.sid = response.sid; - return self._completeVerify(); - }).then(function(request) { - request.poll_for_success = true; - return request; - }, function(error) { - console.error(error); - var e = { - isFatal: true - }; - if (error.errcode == 'M_THREEPID_IN_USE') { - e.message = "This email address is already registered"; - } else { - e.message = 'Unable to contact the given identity server'; - } - throw e; - }); - } -} -EmailIdentityStage.TYPE = "m.login.email.identity"; - -module.exports = { - [DummyStage.TYPE]: DummyStage, - [RecaptchaStage.TYPE]: RecaptchaStage, - [EmailIdentityStage.TYPE]: EmailIdentityStage -}; diff --git a/src/Skinner.js b/src/Skinner.js index 4482f2239c..1fe12f85ab 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -23,41 +23,46 @@ class Skinner { if (this.components === null) { throw new Error( "Attempted to get a component before a skin has been loaded."+ - "This is probably because either:"+ + " This is probably because either:"+ " a) Your app has not called sdk.loadSkin(), or"+ - " b) A component has called getComponent at the root level" + " b) A component has called getComponent at the root level", ); } - var comp = this.components[name]; - if (comp) { - return comp; - } + let comp = this.components[name]; // XXX: Temporarily also try 'views.' as we're currently // leaving the 'views.' off views. - var comp = this.components['views.'+name]; - if (comp) { - return comp; + if (!comp) { + comp = this.components['views.'+name]; } - throw new Error("No such component: "+name); + + if (!comp) { + throw new Error("No such component: "+name); + } + + // components have to be functions. + const validType = typeof comp === 'function'; + if (!validType) { + throw new Error(`Not a valid component: ${name}.`); + } + return comp; } load(skinObject) { if (this.components !== null) { throw new Error( "Attempted to load a skin while a skin is already loaded"+ - "If you want to change the active skin, call resetSkin first" - ); + "If you want to change the active skin, call resetSkin first"); } this.components = {}; - var compKeys = Object.keys(skinObject.components); - for (var i = 0; i < compKeys.length; ++i) { - var comp = skinObject.components[compKeys[i]]; + const compKeys = Object.keys(skinObject.components); + for (let i = 0; i < compKeys.length; ++i) { + const comp = skinObject.components[compKeys[i]]; this.addComponent(compKeys[i], comp); } } addComponent(name, comp) { - var slot = name; + let slot = name; if (comp.replaces !== undefined) { if (comp.replaces.indexOf('.') > -1) { slot = comp.replaces; @@ -79,6 +84,9 @@ class Skinner { // behaviour with multiple copies of files etc. is erratic at best. // XXX: We can still end up with the same file twice in the resulting // JS bundle which is nonideal. +// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ +// or https://nodejs.org/api/modules.html#modules_module_caching_caveats +// ("Modules are cached based on their resolved filename") if (global.mxSkinner === undefined) { global.mxSkinner = new Skinner(); } diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 523d1d8f3c..d45e45e84c 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); -var dis = require("./dispatcher"); -var Tinter = require("./Tinter"); +import MatrixClientPeg from "./MatrixClientPeg"; +import dis from "./dispatcher"; +import Tinter from "./Tinter"; import sdk from './index'; +import { _t } from './languageHandler'; import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; class Command { @@ -41,58 +43,64 @@ class Command { } getUsage() { - return "Usage: " + this.getCommandWithArgs() + return _t('Usage') + ': ' + this.getCommandWithArgs(); } } -var reject = function(msg) { +function reject(msg) { return { - error: msg + error: msg, }; -}; +} -var success = function(promise) { +function success(promise) { return { - promise: promise + promise: promise, }; -}; +} -var commands = { +/* Disable the "unexpected this" error for these commands - all of the run + * functions are called with `this` bound to the Command instance. + */ + +/* eslint-disable babel/no-invalid-this */ + +const commands = { ddg: new Command("ddg", "", function(roomId, args) { const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. - Modal.createDialog(ErrorDialog, { - title: "/ddg is not a command", - description: "To use it, just wait for autocomplete results to load and tab through them.", + Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { + title: _t('/ddg is not a command'), + description: _t('To use it, just wait for autocomplete results to load and tab through them.'), }); return success(); }), // Change your nickname - nick: new Command("nick", "", function(room_id, args) { + nick: new Command("nick", "", function(roomId, args) { if (args) { return success( - MatrixClientPeg.get().setDisplayName(args) + MatrixClientPeg.get().setDisplayName(args), ); } return reject(this.getUsage()); }), // Changes the colorscheme of your current room - tint: new Command("tint", " []", function(room_id, args) { + tint: new Command("tint", " []", function(roomId, args) { if (args) { - var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); + const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); if (matches) { Tinter.tint(matches[1], matches[4]); - var colorScheme = {} + const colorScheme = {}; colorScheme.primary_color = matches[1]; if (matches[4]) { colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } return success( - MatrixClientPeg.get().setRoomAccountData( - room_id, "org.matrix.room.color_scheme", colorScheme - ) + SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), ); } } @@ -100,22 +108,22 @@ var commands = { }), // Change the room topic - topic: new Command("topic", "", function(room_id, args) { + topic: new Command("topic", "", function(roomId, args) { if (args) { return success( - MatrixClientPeg.get().setRoomTopic(room_id, args) + MatrixClientPeg.get().setRoomTopic(roomId, args), ); } return reject(this.getUsage()); }), // Invite a user - invite: new Command("invite", "", function(room_id, args) { + invite: new Command("invite", "", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { return success( - MatrixClientPeg.get().invite(room_id, matches[1]) + MatrixClientPeg.get().invite(roomId, matches[1]), ); } } @@ -123,21 +131,21 @@ var commands = { }), // Join a room - join: new Command("join", "#alias:domain", function(room_id, args) { + join: new Command("join", "#alias:domain", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room_alias = matches[1]; - if (room_alias[0] !== '#') { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') { return reject(this.getUsage()); } - if (!room_alias.match(/:/)) { - room_alias += ':' + MatrixClientPeg.get().getDomain(); + if (!roomAlias.match(/:/)) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); } dis.dispatch({ action: 'view_room', - room_alias: room_alias, + room_alias: roomAlias, auto_join: true, }); @@ -147,29 +155,29 @@ var commands = { return reject(this.getUsage()); }), - part: new Command("part", "[#alias:domain]", function(room_id, args) { - var targetRoomId; + part: new Command("part", "[#alias:domain]", function(roomId, args) { + let targetRoomId; if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room_alias = matches[1]; - if (room_alias[0] !== '#') { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') { return reject(this.getUsage()); } - if (!room_alias.match(/:/)) { - room_alias += ':' + MatrixClientPeg.get().getDomain(); + if (!roomAlias.match(/:/)) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias - var rooms = MatrixClientPeg.get().getRooms(); - for (var i = 0; i < rooms.length; i++) { - var aliasEvents = rooms[i].currentState.getStateEvents( - "m.room.aliases" + const rooms = MatrixClientPeg.get().getRooms(); + for (let i = 0; i < rooms.length; i++) { + const aliasEvents = rooms[i].currentState.getStateEvents( + "m.room.aliases", ); - for (var j = 0; j < aliasEvents.length; j++) { - var aliases = aliasEvents[j].getContent().aliases || []; - for (var k = 0; k < aliases.length; k++) { - if (aliases[k] === room_alias) { + for (let j = 0; j < aliasEvents.length; j++) { + const aliases = aliasEvents[j].getContent().aliases || []; + for (let k = 0; k < aliases.length; k++) { + if (aliases[k] === roomAlias) { targetRoomId = rooms[i].roomId; break; } @@ -178,27 +186,28 @@ var commands = { } if (targetRoomId) { break; } } - } - if (!targetRoomId) { - return reject("Unrecognised room alias: " + room_alias); + if (!targetRoomId) { + return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); + } } } - if (!targetRoomId) targetRoomId = room_id; + if (!targetRoomId) targetRoomId = roomId; return success( MatrixClientPeg.get().leave(targetRoomId).then( - function() { - dis.dispatch({action: 'view_next_room'}); - }) + function() { + dis.dispatch({action: 'view_next_room'}); + }, + ), ); }), // Kick a user from the room with an optional reason - kick: new Command("kick", " []", function(room_id, args) { + kick: new Command("kick", " []", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(.*))?$/); + const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success( - MatrixClientPeg.get().kick(room_id, matches[1], matches[3]) + MatrixClientPeg.get().kick(roomId, matches[1], matches[3]), ); } } @@ -206,12 +215,12 @@ var commands = { }), // Ban a user from the room with an optional reason - ban: new Command("ban", " []", function(room_id, args) { + ban: new Command("ban", " []", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(.*))?$/); + const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success( - MatrixClientPeg.get().ban(room_id, matches[1], matches[3]) + MatrixClientPeg.get().ban(roomId, matches[1], matches[3]), ); } } @@ -219,13 +228,66 @@ var commands = { }), // Unban a user from the room - unban: new Command("unban", "", function(room_id, args) { + unban: new Command("unban", "", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { // Reset the user membership to "leave" to unban him return success( - MatrixClientPeg.get().unban(room_id, matches[1]) + MatrixClientPeg.get().unban(roomId, matches[1]), + ); + } + } + return reject(this.getUsage()); + }), + + ignore: new Command("ignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t("Ignored user"), + description: ( +
    +

    { _t("You are now ignoring %(userId)s", {userId: userId}) }

    +
    + ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + + unignore: new Command("unignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t("Unignored user"), + description: ( +
    +

    { _t("You are no longer ignoring %(userId)s", {userId: userId}) }

    +
    + ), + hasCancelButton: false, + }); + }), ); } } @@ -233,27 +295,27 @@ var commands = { }), // Define the power level of a user - op: new Command("op", " []", function(room_id, args) { + op: new Command("op", " []", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(\d+))?$/); - var powerLevel = 50; // default power level for op + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); + let powerLevel = 50; // default power level for op if (matches) { - var user_id = matches[1]; + const userId = matches[1]; if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3]); } - if (powerLevel !== NaN) { - var room = MatrixClientPeg.get().getRoom(room_id); + if (!isNaN(powerLevel)) { + const room = MatrixClientPeg.get().getRoom(roomId); if (!room) { - return reject("Bad room ID: " + room_id); + return reject("Bad room ID: " + roomId); } - var powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "" + const powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "", ); return success( MatrixClientPeg.get().setPowerLevel( - room_id, user_id, powerLevel, powerLevelEvent - ) + roomId, userId, powerLevel, powerLevelEvent, + ), ); } } @@ -262,33 +324,102 @@ var commands = { }), // Reset the power level of a user - deop: new Command("deop", "", function(room_id, args) { + deop: new Command("deop", "", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room = MatrixClientPeg.get().getRoom(room_id); + const room = MatrixClientPeg.get().getRoom(roomId); if (!room) { - return reject("Bad room ID: " + room_id); + return reject("Bad room ID: " + roomId); } - var powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "" + const powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "", ); return success( MatrixClientPeg.get().setPowerLevel( - room_id, args, undefined, powerLevelEvent - ) + roomId, args, undefined, powerLevelEvent, + ), ); } } return reject(this.getUsage()); - }) + }), + + // Open developer tools + devtools: new Command("devtools", "", function(roomId) { + const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog"); + Modal.createDialog(DevtoolsDialog, { roomId }); + return success(); + }), + + // Verify a user, device, and pubkey tuple + verify: new Command("verify", " ", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); + if (matches) { + const userId = matches[1]; + const deviceId = matches[2]; + const fingerprint = matches[3]; + + return success( + // Promise.resolve to handle transition from static result to promise; can be removed + // in future + Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => { + if (!device) { + throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`); + } + + if (device.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new Error(_t(`Device already verified!`)); + } else { + throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`)); + } + } + + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new Error( + _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + + ' %(deviceId)s is "%(fprint)s" which does not match the provided key' + + ' "%(fingerprint)s". This could mean your communications are being intercepted!', + {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); + } + + return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true); + }).then(() => { + // Tell the user we verified everything + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { + title: _t("Verified key"), + description: ( +
    +

    + { + _t("The signing key you provided matches the signing key you received " + + "from %(userId)s's device %(deviceId)s. Device marked as verified.", + {userId: userId, deviceId: deviceId}) + } +

    +
    + ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), }; +/* eslint-enable babel/no-invalid-this */ + // helpful aliases -var aliases = { - j: "join" -} +const aliases = { + j: "join", +}; module.exports = { /** @@ -304,13 +435,13 @@ module.exports = { // IRC-style commands input = input.replace(/\s+$/, ""); if (input[0] === "/" && input[1] !== "/") { - var bits = input.match(/^(\S+?)( +((.|\n)*))?$/); - var cmd, args; + const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + let cmd; + let args; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[3]; - } - else { + } else { cmd = input; } if (cmd === "me") return null; @@ -319,9 +450,8 @@ module.exports = { } if (commands[cmd]) { return commands[cmd].run(roomId, args); - } - else { - return reject("Unrecognised command: " + input); + } else { + return reject(_t("Unrecognised command:") + ' ' + input); } } return null; // not a command @@ -329,12 +459,12 @@ module.exports = { getCommandList: function() { // Return all the commands plus /me and /markdown which aren't handled like normal commands - var cmds = Object.keys(commands).sort().map(function(cmdKey) { + const cmds = Object.keys(commands).sort().map(function(cmdKey) { return commands[cmdKey]; - }) - cmds.push(new Command("me", "", function(){})); - cmds.push(new Command("markdown", "", function(){})); + }); + cmds.push(new Command("me", "", function() {})); + cmds.push(new Command("markdown", "", function() {})); return cmds; - } + }, }; diff --git a/src/TabComplete.js b/src/TabComplete.js deleted file mode 100644 index a0380f36c4..0000000000 --- a/src/TabComplete.js +++ /dev/null @@ -1,391 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries'; -import SlashCommands from './SlashCommands'; -import MatrixClientPeg from './MatrixClientPeg'; - -const DELAY_TIME_MS = 1000; -const KEY_TAB = 9; -const KEY_SHIFT = 16; -const KEY_WINDOWS = 91; - -// NB: DO NOT USE \b its "words" are roman alphabet only! -// -// Capturing group containing the start -// of line or a whitespace char -// \_______________ __________Capturing group of 0 or more non-whitespace chars -// _|__ _|_ followed by the end of line -// / \/ \ -const MATCH_REGEX = /(^|\s)(\S*)$/; - -class TabComplete { - - constructor(opts) { - opts.allowLooping = opts.allowLooping || false; - opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; - opts.onClickCompletes = opts.onClickCompletes || false; - this.opts = opts; - this.completing = false; - this.list = []; // full set of tab-completable things - this.matchedList = []; // subset of completable things to loop over - this.currentIndex = 0; // index in matchedList currently - this.originalText = null; // original input text when tab was first hit - this.textArea = opts.textArea; // DOMElement - this.isFirstWord = false; // true if you tab-complete on the first word - this.enterTabCompleteTimerId = null; - this.inPassiveMode = false; - - // Map tracking ordering of the room members. - // userId: integer, highest comes first. - this.memberTabOrder = {}; - - // monotonically increasing counter used for tracking ordering of members - this.memberOrderSeq = 0; - } - - /** - * Call this when a a UI element representing a tab complete entry has been clicked - * @param {entry} The entry that was clicked - */ - onEntryClick(entry) { - if (this.opts.onClickCompletes) { - this.completeTo(entry); - } - } - - loadEntries(room) { - this._makeEntries(room); - this._initSorting(room); - this._sortEntries(); - } - - onMemberSpoke(member) { - if (this.memberTabOrder[member.userId] === undefined) { - this.list.push(new MemberEntry(member)); - } - this.memberTabOrder[member.userId] = this.memberOrderSeq++; - this._sortEntries(); - } - - /** - * @param {DOMElement} - */ - setTextArea(textArea) { - this.textArea = textArea; - } - - /** - * @return {Boolean} - */ - isTabCompleting() { - // actually have things to tab over - return this.completing && this.matchedList.length > 1; - } - - stopTabCompleting() { - this.completing = false; - this.currentIndex = 0; - this._notifyStateChange(); - } - - startTabCompleting(passive) { - this.originalText = this.textArea.value; // cache starting text - - // grab the partial word from the text which we'll be tab-completing - var res = MATCH_REGEX.exec(this.originalText); - if (!res) { - this.matchedList = []; - return; - } - // ES6 destructuring; ignore first element (the complete match) - var [ , boundaryGroup, partialGroup] = res; - - if (partialGroup.length === 0 && passive) { - return; - } - - this.isFirstWord = partialGroup.length === this.originalText.length; - - this.completing = true; - this.currentIndex = 0; - - this.matchedList = [ - new Entry(partialGroup) // first entry is always the original partial - ]; - - // find matching entries in the set of entries given to us - this.list.forEach((entry) => { - if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) { - this.matchedList.push(entry); - } - }); - - // console.log("calculated completions => %s", JSON.stringify(this.matchedList)); - } - - /** - * Do an auto-complete with the given word. This terminates the tab-complete. - * @param {Entry} entry The tab-complete entry to complete to. - */ - completeTo(entry) { - this.textArea.value = this._replaceWith( - entry.getFillText(), true, entry.getSuffix(this.isFirstWord) - ); - this.stopTabCompleting(); - // keep focus on the text area - this.textArea.focus(); - } - - /** - * @param {Number} numAheadToPeek Return *up to* this many elements. - * @return {Entry[]} - */ - peek(numAheadToPeek) { - if (this.matchedList.length === 0) { - return []; - } - var peekList = []; - - // return the current match item and then one with an index higher, and - // so on until we've reached the requested limit. If we hit the end of - // the list of options we're done. - for (var i = 0; i < numAheadToPeek; i++) { - var nextIndex; - if (this.opts.allowLooping) { - nextIndex = (this.currentIndex + i) % this.matchedList.length; - } - else { - nextIndex = this.currentIndex + i; - if (nextIndex === this.matchedList.length) { - break; - } - } - peekList.push(this.matchedList[nextIndex]); - } - // console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList)); - return peekList; - } - - handleTabPress(passive, shiftKey) { - var wasInPassiveMode = this.inPassiveMode && !passive; - this.inPassiveMode = passive; - - if (!this.completing) { - this.startTabCompleting(passive); - } - - if (shiftKey) { - this.nextMatchedEntry(-1); - } - else { - // if we were in passive mode we got out of sync by incrementing the - // index to show the peek view but not set the text area. Therefore, - // we want to set the *current* index rather than the *next* index. - this.nextMatchedEntry(wasInPassiveMode ? 0 : 1); - } - this._notifyStateChange(); - } - - /** - * @param {DOMEvent} e - */ - onKeyDown(ev) { - if (!this.textArea) { - console.error("onKeyDown called before a , + ); + + let error; + let addressSelector; + if (this.state.error) { + let tryUsing = ''; + const validTypeDescriptions = this.props.validAddressTypes.map((t) => { + return { + 'mx-user-id': _t("Matrix ID"), + 'mx-room-id': _t("Matrix Room ID"), + 'email': _t("email address"), + }[t]; + }); + tryUsing = _t("Try using one of the following valid address types: %(validTypesList)s.", { + validTypesList: validTypeDescriptions.join(", "), + }); + error =
    + { _t("You have entered an invalid address.") } +
    + { tryUsing } +
    ; + } else if (this.state.searchError) { + error =
    { this.state.searchError }
    ; + } else if ( + this.state.query.length > 0 && + this.state.queryList.length === 0 && + !this.state.busy + ) { + error =
    { _t("No results") }
    ; + } else { + addressSelector = ( + {this.addressSelector = ref;}} + addressList={this.state.queryList} + showAddress={this.props.pickerType === 'user'} + onSelected={this.onSelected} + truncateAt={TRUNCATE_QUERY_LIST} + /> + ); + } + + return ( + +
    + +
    +
    +
    { query }
    + { error } + { addressSelector } + { this.props.extraNode } +
    + +
    + ); + }, +}); diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js new file mode 100644 index 0000000000..e879808dc2 --- /dev/null +++ b/src/components/views/dialogs/BaseDialog.js @@ -0,0 +1,92 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { KeyCode } from '../../../Keyboard'; +import AccessibleButton from '../elements/AccessibleButton'; +import sdk from '../../../index'; + +/** + * Basic container for modal dialogs. + * + * Includes a div for the title, and a keypress handler which cancels the + * dialog on escape. + */ +export default React.createClass({ + displayName: 'BaseDialog', + + propTypes: { + // onFinished callback to call when Escape is pressed + onFinished: PropTypes.func.isRequired, + + // callback to call when Enter is pressed + onEnterPressed: PropTypes.func, + + // called when a key is pressed + onKeyDown: PropTypes.func, + + // CSS class to apply to dialog div + className: PropTypes.string, + + // Title for the dialog. + // (could probably actually be something more complicated than a string if desired) + title: PropTypes.string.isRequired, + + // children should be the content of the dialog + children: PropTypes.node, + }, + + _onKeyDown: function(e) { + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + if (e.keyCode === KeyCode.ESCAPE) { + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(); + } else if (e.keyCode === KeyCode.ENTER) { + if (this.props.onEnterPressed) { + e.stopPropagation(); + e.preventDefault(); + this.props.onEnterPressed(e); + } + } + }, + + _onCancelClick: function(e) { + this.props.onFinished(); + }, + + render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + + return ( +
    + + + +
    + { this.props.title } +
    + { this.props.children } +
    + ); + }, +}); diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js new file mode 100644 index 0000000000..dc4f3f77db --- /dev/null +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -0,0 +1,194 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import DMRoomMap from '../../../utils/DMRoomMap'; +import AccessibleButton from '../elements/AccessibleButton'; +import Unread from '../../../Unread'; +import classNames from 'classnames'; + +export default class ChatCreateOrReuseDialog extends React.Component { + + constructor(props) { + super(props); + this.onRoomTileClick = this.onRoomTileClick.bind(this); + + this.state = { + tiles: [], + profile: { + displayName: null, + avatarUrl: null, + }, + profileError: null, + }; + } + + componentWillMount() { + const client = MatrixClientPeg.get(); + + const dmRoomMap = new DMRoomMap(client); + const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.userId); + + const RoomTile = sdk.getComponent("rooms.RoomTile"); + + const tiles = []; + for (const roomId of dmRooms) { + const room = client.getRoom(roomId); + if (room) { + const me = room.getMember(client.credentials.userId); + const highlight = ( + room.getUnreadNotificationCount('highlight') > 0 || + me.membership == "invite" + ); + tiles.push( + , + ); + } + } + + this.setState({ + tiles: tiles, + }); + + if (tiles.length === 0) { + this.setState({ + busyProfile: true, + }); + MatrixClientPeg.get().getProfileInfo(this.props.userId).done((resp) => { + const profile = { + displayName: resp.displayname, + avatarUrl: null, + }; + if (resp.avatar_url) { + profile.avatarUrl = MatrixClientPeg.get().mxcUrlToHttp( + resp.avatar_url, 48, 48, "crop", + ); + } + this.setState({ + busyProfile: false, + profile: profile, + }); + }, (err) => { + console.error( + 'Unable to get profile for user ' + this.props.userId + ':', + err, + ); + this.setState({ + busyProfile: false, + profileError: err, + }); + }); + } + } + + onRoomTileClick(roomId) { + this.props.onExistingRoomSelected(roomId); + } + + render() { + let title = ''; + let content = null; + if (this.state.tiles.length > 0) { + // Show the existing rooms with a "+" to add a new dm + title = _t('Create a new chat or reuse an existing one'); + const labelClasses = classNames({ + mx_MemberInfo_createRoom_label: true, + mx_RoomTile_name: true, + }); + const startNewChat = +
    + +
    +
    { _t("Start new chat") }
    +
    ; + content =
    + { _t('You already have existing direct chats with this user:') } +
    + { this.state.tiles } + { startNewChat } +
    +
    ; + } else { + // Show the avatar, name and a button to confirm that a new chat is requested + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const Spinner = sdk.getComponent('elements.Spinner'); + title = _t('Start chatting'); + + let profile = null; + if (this.state.busyProfile) { + profile = ; + } else if (this.state.profileError) { + profile =
    + Unable to load profile information for { this.props.userId } +
    ; + } else { + profile =
    + +
    + { this.state.profile.displayName || this.props.userId } +
    +
    ; + } + content =
    +
    +

    + { _t('Click on the button below to start chatting!') } +

    + { profile } +
    + +
    ; + } + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return ( + + { content } + + ); + } +} + +ChatCreateOrReuseDialog.propTyps = { + userId: PropTypes.string.isRequired, + // Called when clicking outside of the dialog + onFinished: PropTypes.func.isRequired, + onNewDMClick: PropTypes.func.isRequired, + onExistingRoomSelected: PropTypes.func.isRequired, +}; diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js deleted file mode 100644 index aa694f6838..0000000000 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ /dev/null @@ -1,431 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -var React = require("react"); -var classNames = require('classnames'); -var sdk = require("../../../index"); -var Invite = require("../../../Invite"); -var createRoom = require("../../../createRoom"); -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var DMRoomMap = require('../../../utils/DMRoomMap'); -var rate_limited_func = require("../../../ratelimitedfunc"); -var dis = require("../../../dispatcher"); -var Modal = require('../../../Modal'); - -const TRUNCATE_QUERY_LIST = 40; - -module.exports = React.createClass({ - displayName: "ChatInviteDialog", - propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, - ]), - value: React.PropTypes.string, - placeholder: React.PropTypes.string, - roomId: React.PropTypes.string, - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired - }, - - getDefaultProps: function() { - return { - title: "Start a chat", - description: "Who would you like to communicate with?", - value: "", - placeholder: "User ID, Name or email", - button: "Start Chat", - focus: true - }; - }, - - getInitialState: function() { - return { - error: false, - inviteList: [], - queryList: [], - }; - }, - - componentDidMount: function() { - if (this.props.focus) { - // Set the cursor at the end of the text input - this.refs.textinput.value = this.props.value; - } - this._updateUserList(); - }, - - onButtonClick: function() { - var inviteList = this.state.inviteList.slice(); - // Check the text input field to see if user has an unconverted address - // If there is and it's valid add it to the local inviteList - var check = Invite.isValidAddress(this.refs.textinput.value); - if (check === true || check === null) { - inviteList.push(this.refs.textinput.value); - } else if (this.refs.textinput.value.length > 0) { - this.setState({ error: true }); - return; - } - - if (inviteList.length > 0) { - if (this._isDmChat(inviteList)) { - // Direct Message chat - var room = this._getDirectMessageRoom(inviteList[0]); - if (room) { - // A Direct Message room already exists for this user and you - // so go straight to that room - dis.dispatch({ - action: 'view_room', - room_id: room.roomId, - }); - this.props.onFinished(true, inviteList[0]); - } else { - this._startChat(inviteList); - } - } else { - // Multi invite chat - this._startChat(inviteList); - } - } else { - // No addresses supplied - this.setState({ error: true }); - } - }, - - onCancel: function() { - this.props.onFinished(false); - }, - - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } else if (e.keyCode === 38) { // up arrow - e.stopPropagation(); - e.preventDefault(); - this.addressSelector.onKeyUp(); - } else if (e.keyCode === 40) { // down arrow - e.stopPropagation(); - e.preventDefault(); - this.addressSelector.onKeyDown(); - } else if (this.state.queryList.length > 0 && (e.keyCode === 188, e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab - e.stopPropagation(); - e.preventDefault(); - this.addressSelector.onKeySelect(); - } else if (this.refs.textinput.value.length === 0 && this.state.inviteList.length && e.keyCode === 8) { // backspace - e.stopPropagation(); - e.preventDefault(); - this.onDismissed(this.state.inviteList.length - 1)(); - } else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - this.onButtonClick(); - } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab - e.stopPropagation(); - e.preventDefault(); - var check = Invite.isValidAddress(this.refs.textinput.value); - if (check === true || check === null) { - var inviteList = this.state.inviteList.slice(); - inviteList.push(this.refs.textinput.value.trim()); - this.setState({ - inviteList: inviteList, - queryList: [], - }); - } else { - this.setState({ error: true }); - } - } - }, - - onQueryChanged: function(ev) { - var query = ev.target.value; - var queryList = []; - - // Only do search if there is something to search - if (query.length > 0) { - queryList = this._userList.filter((user) => { - return this._matches(query, user); - }); - } - - this.setState({ - queryList: queryList, - error: false, - }); - }, - - onDismissed: function(index) { - var self = this; - return function() { - var inviteList = self.state.inviteList.slice(); - inviteList.splice(index, 1); - self.setState({ - inviteList: inviteList, - queryList: [], - }); - } - }, - - onClick: function(index) { - var self = this; - return function() { - self.onSelected(index); - }; - }, - - onSelected: function(index) { - var inviteList = this.state.inviteList.slice(); - inviteList.push(this.state.queryList[index].userId); - this.setState({ - inviteList: inviteList, - queryList: [], - }); - }, - - _getDirectMessageRoom: function(addr) { - const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); - var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); - if (dmRooms.length > 0) { - // Cycle through all the DM rooms and find the first non forgotten or parted room - for (let i = 0; i < dmRooms.length; i++) { - let room = MatrixClientPeg.get().getRoom(dmRooms[i]); - if (room) { - const me = room.getMember(MatrixClientPeg.get().credentials.userId); - if (me.membership == 'join') { - return room; - } - } - } - } - return null; - }, - - _startChat: function(addrs) { - if (MatrixClientPeg.get().isGuest()) { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't invite users. Please register." - }); - return; - } - - if (this.props.roomId) { - // Invite new user to a room - var self = this; - Invite.inviteMultipleToRoom(this.props.roomId, addrs) - .then(function(addrs) { - var room = MatrixClientPeg.get().getRoom(self.props.roomId); - return self._showAnyInviteErrors(addrs, room); - }) - .catch(function(err) { - console.error(err.stack); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failure to invite", - description: err.toString() - }); - return null; - }) - .done(); - } else if (this._isDmChat(addrs)) { - // Start the DM chat - createRoom({dmUserId: addrs[0]}) - .catch(function(err) { - console.error(err.stack); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failure to invite user", - description: err.toString() - }); - return null; - }) - .done(); - } else { - // Start multi user chat - var self = this; - var room; - createRoom().then(function(roomId) { - room = MatrixClientPeg.get().getRoom(roomId); - return Invite.inviteMultipleToRoom(roomId, addrs); - }) - .then(function(addrs) { - return self._showAnyInviteErrors(addrs, room); - }) - .catch(function(err) { - console.error(err.stack); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failure to invite", - description: err.toString() - }); - return null; - }) - .done(); - } - - // Close - this will happen before the above, as that is async - this.props.onFinished(true, addrs); - }, - - _updateUserList: new rate_limited_func(function() { - // Get all the users - this._userList = MatrixClientPeg.get().getUsers(); - }, 500), - - // This is the search algorithm for matching users - _matches: function(query, user) { - var name = user.displayName.toLowerCase(); - var uid = user.userId.toLowerCase(); - query = query.toLowerCase(); - - // don't match any that are already on the invite list - if (this._isOnInviteList(uid)) { - return false; - } - - // ignore current user - if (uid === MatrixClientPeg.get().credentials.userId) { - return false; - } - - // direct prefix matches - if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { - return true; - } - - // strip @ on uid and try matching again - if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { - return true; - } - - // split spaces in name and try matching constituent parts - var parts = name.split(" "); - for (var i = 0; i < parts.length; i++) { - if (parts[i].indexOf(query) === 0) { - return true; - } - } - return false; - }, - - _isOnInviteList: function(uid) { - for (let i = 0; i < this.state.inviteList.length; i++) { - if (this.state.inviteList[i].toLowerCase() === uid) { - return true; - } - } - return false; - }, - - _isDmChat: function(addrs) { - if (addrs.length === 1 && Invite.getAddressType(addrs[0]) === "mx" && !this.props.roomId) { - return true; - } else { - return false; - } - }, - - _showAnyInviteErrors: function(addrs, room) { - // Show user any errors - var errorList = []; - for (var addr in addrs) { - if (addrs.hasOwnProperty(addr) && addrs[addr] === "error") { - errorList.push(addr); - } - } - - if (errorList.length > 0) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failed to invite the following users to the " + room.name + " room:", - description: errorList.join(", "), - }); - } - return addrs; - }, - - render: function() { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var AddressSelector = sdk.getComponent("elements.AddressSelector"); - this.scrollElement = null; - - var query = []; - // create the invite list - if (this.state.inviteList.length > 0) { - var AddressTile = sdk.getComponent("elements.AddressTile"); - for (let i = 0; i < this.state.inviteList.length; i++) { - query.push( - - ); - } - } - - // Add the query at the end - query.push( - - ); - - var error; - var addressSelector; - if (this.state.error) { - error =
    You have entered an invalid contact. Try using their Matrix ID or email address.
    - } else { - addressSelector = ( - {this.addressSelector = ref}} - addressList={ this.state.queryList } - onSelected={ this.onSelected } - truncateAt={ TRUNCATE_QUERY_LIST } /> - ); - } - - return ( -
    -
    - {this.props.title} -
    -
    - -
    -
    - -
    -
    -
    { query }
    - { error } - { addressSelector } -
    -
    - -
    -
    - ); - } -}); diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js new file mode 100644 index 0000000000..a967b5df9a --- /dev/null +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -0,0 +1,39 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; + +/* + * A dialog for confirming a redaction. + */ +export default React.createClass({ + displayName: 'ConfirmRedactDialog', + + render: function() { + const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); + return ( + + + ); + }, +}); diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js new file mode 100644 index 0000000000..f347261470 --- /dev/null +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -0,0 +1,136 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import { GroupMemberType } from '../../../groups'; + +/* + * A dialog for confirming an operation on another user. + * Takes a user ID and a verb, displays the target user prominently + * such that it should be easy to confirm that the operation is being + * performed on the right person, and displays the operation prominently + * to make it obvious what is going to happen. + * Also tweaks the style for 'dangerous' actions (albeit only with colour) + */ +export default React.createClass({ + displayName: 'ConfirmUserActionDialog', + propTypes: { + // matrix-js-sdk (room) member object. Supply either this or 'groupMember' + member: PropTypes.object, + // group member object. Supply either this or 'member' + groupMember: GroupMemberType, + // needed if a group member is specified + matrixClient: PropTypes.instanceOf(MatrixClient), + action: PropTypes.string.isRequired, // eg. 'Ban' + title: PropTypes.string.isRequired, // eg. 'Ban this user?' + + // Whether to display a text field for a reason + // If true, the second argument to onFinished will + // be the string entered. + askReason: PropTypes.bool, + danger: PropTypes.bool, + onFinished: PropTypes.func.isRequired, + }, + + defaultProps: { + danger: false, + askReason: false, + }, + + componentWillMount: function() { + this._reasonField = null; + }, + + onOk: function() { + let reason; + if (this._reasonField) { + reason = this._reasonField.value; + } + this.props.onFinished(true, reason); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + _collectReasonField: function(e) { + this._reasonField = e; + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); + + const confirmButtonClass = this.props.danger ? 'danger' : ''; + + let reasonBox; + if (this.props.askReason) { + reasonBox = ( +
    +
    + +
    +
    + ); + } + + let avatar; + let name; + let userId; + if (this.props.member) { + avatar = ; + name = this.props.member.name; + userId = this.props.member.userId; + } else { + const httpAvatarUrl = this.props.groupMember.avatarUrl ? + this.props.matrixClient.mxcUrlToHttp(this.props.groupMember.avatarUrl, 48, 48) : null; + name = this.props.groupMember.displayname || this.props.groupMember.userId; + userId = this.props.groupMember.userId; + avatar = ; + } + + return ( + +
    +
    + { avatar } +
    +
    { name }
    +
    { userId }
    +
    + { reasonBox } + +
    + ); + }, +}); diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js new file mode 100644 index 0000000000..86a2b2498c --- /dev/null +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -0,0 +1,175 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import { _t } from '../../../languageHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; + +export default React.createClass({ + displayName: 'CreateGroupDialog', + propTypes: { + onFinished: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + groupName: '', + groupId: '', + groupError: null, + creating: false, + createError: null, + }; + }, + + _onGroupNameChange: function(e) { + this.setState({ + groupName: e.target.value, + }); + }, + + _onGroupIdChange: function(e) { + this.setState({ + groupId: e.target.value, + }); + }, + + _onGroupIdBlur: function(e) { + this._checkGroupId(); + }, + + _checkGroupId: function(e) { + let error = null; + if (!this.state.groupId) { + error = _t("Community IDs cannot not be empty."); + } else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) { + error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'"); + } + this.setState({ + groupIdError: error, + // Reset createError to get rid of now stale error message + createError: null, + }); + return error; + }, + + _onFormSubmit: function(e) { + e.preventDefault(); + + if (this._checkGroupId()) return; + + const profile = {}; + if (this.state.groupName !== '') { + profile.name = this.state.groupName; + } + this.setState({creating: true}); + MatrixClientPeg.get().createGroup({ + localpart: this.state.groupId, + profile: profile, + }).then((result) => { + dis.dispatch({ + action: 'view_group', + group_id: result.group_id, + group_is_new: true, + }); + this.props.onFinished(true); + }).catch((e) => { + this.setState({createError: e}); + }).finally(() => { + this.setState({creating: false}); + }).done(); + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('elements.Spinner'); + + if (this.state.creating) { + return ; + } + + let createErrorNode; + if (this.state.createError) { + // XXX: We should catch errcodes and give sensible i18ned messages for them, + // rather than displaying what the server gives us, but synapse doesn't give + // any yet. + createErrorNode =
    +
    { _t('Something went wrong whilst creating your community') }
    +
    { this.state.createError.message }
    +
    ; + } + + return ( + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + + + + + :{ MatrixClientPeg.get().getDomain() } + +
    +
    +
    + { this.state.groupIdError } +
    + { createErrorNode } +
    +
    + + +
    +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js new file mode 100644 index 0000000000..d9287d23da --- /dev/null +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -0,0 +1,78 @@ +/* +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import { _t } from '../../../languageHandler'; + +export default React.createClass({ + displayName: 'CreateRoomDialog', + propTypes: { + onFinished: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + const config = SdkConfig.get(); + // Dialog shows inverse of m.federate (noFederate) strict false check to skip undefined check (default = true) + this.defaultNoFederate = config.default_federate === false; + }, + + onOk: function() { + this.props.onFinished(true, this.refs.textinput.value, this.refs.checkbox.checked); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return ( + +
    +
    + +
    +
    + +
    +
    + +
    + { _t('Advanced options') } +
    + + +
    +
    +
    + +
    + ); + }, +}); diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index 926e4059d2..87228b4733 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -15,11 +15,14 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; +import Analytics from '../../../Analytics'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Lifecycle from '../../../Lifecycle'; +import * as Lifecycle from '../../../Lifecycle'; import Velocity from 'velocity-vector'; +import { _t } from '../../../languageHandler'; export default class DeactivateAccountDialog extends React.Component { constructor(props, context) { @@ -53,13 +56,14 @@ export default class DeactivateAccountDialog extends React.Component { user: MatrixClientPeg.get().credentials.userId, password: this._passwordField.value, }).done(() => { + Analytics.trackEvent('Account', 'Deactivate Account'); Lifecycle.onLoggedOut(); this.props.onFinished(false); }, (err) => { - let errStr = 'Unknown error'; + let errStr = _t('Unknown error'); // https://matrix.org/jira/browse/SYN-744 if (err.httpStatus == 401 || err.httpStatus == 403) { - errStr = 'Incorrect password'; + errStr = _t('Incorrect password'); Velocity(this._passwordField, "callout.shake", 300); } this.setState({ @@ -74,47 +78,49 @@ export default class DeactivateAccountDialog extends React.Component { } render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Loader = sdk.getComponent("elements.Spinner"); let passwordBoxClass = ''; let error = null; if (this.state.errStr) { error =
    - {this.state.err_str} -
    + { this.state.errStr } +
    ; passwordBoxClass = 'error'; } - const okLabel = this.state.busy ? : 'Deactivate Account'; + const okLabel = this.state.busy ? : _t('Deactivate Account'); const okEnabled = this.state.confirmButtonEnabled && !this.state.busy; let cancelButton = null; if (!this.state.busy) { cancelButton = + { _t("Cancel") } + ; } return ( -
    -
    - Deactivate Account -
    +
    -

    This will make your account permanently unusable. You will not be able to re-register the same user ID.

    +

    { _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }

    -

    This action is irreversible.

    +

    { _t("This action is irreversible.") }

    -

    To continue, please enter your password.

    +

    { _t("To continue, please enter your password.") }

    -

    Password:

    +

    { _t("Password") }:

    {this._passwordField = e;}} className={passwordBoxClass} /> - {error} + { error }
    - {cancelButton} + { cancelButton }
    -
    + ); } } DeactivateAccountDialog.propTypes = { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js new file mode 100644 index 0000000000..6bec933389 --- /dev/null +++ b/src/components/views/dialogs/DeviceVerifyDialog.js @@ -0,0 +1,78 @@ +/* +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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import sdk from '../../../index'; +import * as FormattingUtils from '../../../utils/FormattingUtils'; +import { _t } from '../../../languageHandler'; + +export default function DeviceVerifyDialog(props) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + const key = FormattingUtils.formatCryptoKey(props.device.getFingerprint()); + const body = ( +
    +

    + { _t("To verify that this device can be trusted, please contact its " + + "owner using some other means (e.g. in person or a phone call) " + + "and ask them whether the key they see in their User Settings " + + "for this device matches the key below:") } +

    +
    +
      +
    • { props.device.getDisplayName() }
    • +
    • { props.device.deviceId }
    • +
    • { key }
    • +
    +
    +

    + { _t("If it matches, press the verify button below. " + + "If it doesn't, then someone else is intercepting this device " + + "and you probably want to press the blacklist button instead.") } +

    +

    + { _t("In future this verification process will be more sophisticated.") } +

    +
    + ); + + function onFinished(confirm) { + if (confirm) { + MatrixClientPeg.get().setDeviceVerified( + props.userId, props.device.deviceId, true, + ); + } + props.onFinished(confirm); + } + + return ( + + ); +} + +DeviceVerifyDialog.propTypes = { + userId: PropTypes.string.isRequired, + device: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, +}; diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index ed48f10fd7..2af2d6214f 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -16,7 +16,7 @@ limitations under the License. /* * Usage: - * Modal.createDialog(ErrorDialog, { + * Modal.createTrackedDialog('An Identifier', 'some detail', ErrorDialog, { * title: "some text", (default: "Error") * description: "some more text", * button: "Button Text", @@ -25,53 +25,53 @@ limitations under the License. * }); */ -var React = require("react"); +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'ErrorDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, + title: PropTypes.string, + description: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, ]), - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + button: PropTypes.string, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { return { - title: "Error", - description: "An error has occurred.", - button: "OK", focus: true, + title: null, + description: null, + button: null, }; }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); + componentDidMount: function() { + if (this.props.focus) { + this.refs.button.focus(); } }, render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    -
    - {this.props.title} -
    +
    - {this.props.description} + { this.props.description || _t('An error has occurred.') }
    -
    -
    + ); - } + }, }); diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 301bba0486..a47702305c 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,206 +15,94 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; -const InteractiveAuth = Matrix.InteractiveAuth; - import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; -import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents'; +import AccessibleButton from '../elements/AccessibleButton'; export default React.createClass({ displayName: 'InteractiveAuthDialog', propTypes: { + // matrix client to use for UI auth requests + matrixClient: PropTypes.object.isRequired, + // response from initial request. If not supplied, will do a request on // mount. - authData: React.PropTypes.shape({ - flows: React.PropTypes.array, - params: React.PropTypes.object, - session: React.PropTypes.string, + authData: PropTypes.shape({ + flows: PropTypes.array, + params: PropTypes.object, + session: PropTypes.string, }), // callback - makeRequest: React.PropTypes.func.isRequired, + makeRequest: PropTypes.func.isRequired, - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, - title: React.PropTypes.string, - submitButtonLabel: React.PropTypes.string, - }, - - getDefaultProps: function() { - return { - title: "Authentication", - submitButtonLabel: "Submit", - }; + title: PropTypes.string, }, getInitialState: function() { return { - authStage: null, - busy: false, - errorText: null, - stageErrorText: null, - submitButtonEnabled: false, + authError: null, }; }, - componentWillMount: function() { - this._unmounted = false; - this._authLogic = new InteractiveAuth({ - authData: this.props.authData, - doRequest: this._requestCallback, - startAuthStage: this._startAuthStage, - }); - - this._authLogic.attemptAuth().then((result) => { + _onAuthFinished: function(success, result) { + if (success) { this.props.onFinished(true, result); - }).catch((error) => { - console.error("Error during user-interactive auth:", error); - if (this._unmounted) { - return; - } - - const msg = error.message || error.toString(); + } else { this.setState({ - errorText: msg + authError: result, }); - }).done(); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, - - _startAuthStage: function(stageType, error) { - this.setState({ - authStage: stageType, - errorText: error ? error.error : null, - }, this._setFocus); - }, - - _requestCallback: function(auth) { - this.setState({ - busy: true, - errorText: null, - stageErrorText: null, - }); - return this.props.makeRequest(auth).finally(() => { - if (this._unmounted) { - return; - } - this.setState({ - busy: false, - }); - }); - }, - - _onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - if (!this.state.busy) { - this._onCancel(); - } - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - if (this.state.submitButtonEnabled && !this.state.busy) { - this._onSubmit(); - } } }, - _onSubmit: function() { - if (this.refs.stageComponent && this.refs.stageComponent.onSubmitClick) { - this.refs.stageComponent.onSubmitClick(); - } - }, - - _setFocus: function() { - if (this.refs.stageComponent && this.refs.stageComponent.focus) { - this.refs.stageComponent.focus(); - } - }, - - _onCancel: function() { + _onDismissClick: function() { this.props.onFinished(false); }, - _setSubmitButtonEnabled: function(enabled) { - this.setState({ - submitButtonEnabled: enabled, - }); - }, - - _submitAuthDict: function(authData) { - this._authLogic.submitAuthDict(authData); - }, - - _renderCurrentStage: function() { - const stage = this.state.authStage; - var StageComponent = getEntryComponentForLoginType(stage); - return ( - - ); - }, - render: function() { - const Loader = sdk.getComponent("elements.Spinner"); + const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - let error = null; - if (this.state.errorText) { - error = ( -
    - {this.state.errorText} + let content; + if (this.state.authError) { + content = ( +
    +
    { this.state.authError.message || this.state.authError.toString() }
    +
    + + { _t("Dismiss") } + +
    + ); + } else { + content = ( +
    +
    ); } - const submitLabel = this.state.busy ? : this.props.submitButtonLabel; - const submitEnabled = this.state.submitButtonEnabled && !this.state.busy; - - const submitButton = ( - - ); - - const cancelButton = ( - - ); - return ( -
    -
    - {this.props.title} -
    -
    -

    This operation requires additional authentication.

    - {this._renderCurrentStage()} - {error} -
    -
    - {submitButton} - {cancelButton} -
    -
    + + { content } + ); }, }); diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js new file mode 100644 index 0000000000..00bcc942a1 --- /dev/null +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -0,0 +1,173 @@ +/* +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 Modal from '../../../Modal'; +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; + +import { _t, _td } from '../../../languageHandler'; + +/** + * Dialog which asks the user whether they want to share their keys with + * an unverified device. + * + * onFinished is called with `true` if the key should be shared, `false` if it + * should not, and `undefined` if the dialog is cancelled. (In other words: + * truthy: do the key share. falsy: don't share the keys). + */ +export default React.createClass({ + propTypes: { + matrixClient: PropTypes.object.isRequired, + userId: PropTypes.string.isRequired, + deviceId: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + deviceInfo: null, + wasNewDevice: false, + }; + }, + + componentDidMount: function() { + this._unmounted = false; + const userId = this.props.userId; + const deviceId = this.props.deviceId; + + // give the client a chance to refresh the device list + this.props.matrixClient.downloadKeys([userId], false).then((r) => { + if (this._unmounted) { return; } + + const deviceInfo = r[userId][deviceId]; + + if (!deviceInfo) { + console.warn(`No details found for device ${userId}:${deviceId}`); + + this.props.onFinished(false); + return; + } + + const wasNewDevice = !deviceInfo.isKnown(); + + this.setState({ + deviceInfo: deviceInfo, + wasNewDevice: wasNewDevice, + }); + + // if the device was new before, it's not any more. + if (wasNewDevice) { + this.props.matrixClient.setDeviceKnown( + userId, + deviceId, + true, + ); + } + }).done(); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + + _onVerifyClicked: function() { + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + + console.log("KeyShareDialog: Starting verify dialog"); + Modal.createTrackedDialog('Key Share', 'Starting dialog', DeviceVerifyDialog, { + userId: this.props.userId, + device: this.state.deviceInfo, + onFinished: (verified) => { + if (verified) { + // can automatically share the keys now. + this.props.onFinished(true); + } + }, + }); + }, + + _onShareClicked: function() { + console.log("KeyShareDialog: User clicked 'share'"); + this.props.onFinished(true); + }, + + _onIgnoreClicked: function() { + console.log("KeyShareDialog: User clicked 'ignore'"); + this.props.onFinished(false); + }, + + _renderContent: function() { + const displayName = this.state.deviceInfo.getDisplayName() || + this.state.deviceInfo.deviceId; + + let text; + if (this.state.wasNewDevice) { + text = _td("You added a new device '%(displayName)s', which is" + + " requesting encryption keys."); + } else { + text = _td("Your unverified device '%(displayName)s' is requesting" + + " encryption keys."); + } + text = _t(text, {displayName: displayName}); + + return ( +
    +

    { text }

    + +
    + + + +
    +
    + ); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('views.elements.Spinner'); + + let content; + + if (this.state.deviceInfo) { + content = this._renderContent(); + } else { + content = ( +
    +

    { _t('Loading device info...') }

    + +
    + ); + } + + return ( + + { content } + + ); + }, +}); diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js deleted file mode 100644 index c4bd7a0474..0000000000 --- a/src/components/views/dialogs/LogoutPrompt.js +++ /dev/null @@ -1,61 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -var React = require('react'); -var dis = require("../../../dispatcher"); - -module.exports = React.createClass({ - displayName: 'LogoutPrompt', - - propTypes: { - onFinished: React.PropTypes.func, - }, - - logOut: function() { - dis.dispatch({action: 'logout'}); - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - cancelPrompt: function() { - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.cancelPrompt(); - } - }, - - render: function() { - return ( -
    -
    - Sign out? -
    -
    - - -
    -
    - ); - } -}); - diff --git a/src/components/views/dialogs/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js deleted file mode 100644 index 0080e0c643..0000000000 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* - * Usage: - * Modal.createDialog(NeedToRegisterDialog, { - * title: "some text", (default: "Registration required") - * description: "some more text", - * onFinished: someFunction, - * }); - */ - -var React = require("react"); -var dis = require("../../../dispatcher"); - -module.exports = React.createClass({ - displayName: 'NeedToRegisterDialog', - propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, - ]), - onFinished: React.PropTypes.func.isRequired, - }, - - getDefaultProps: function() { - return { - title: "Registration required", - description: "A registered account is required for this action", - }; - }, - - onRegisterClicked: function() { - dis.dispatch({ - action: "start_upgrade_registration", - }); - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - render: function() { - return ( -
    -
    - {this.props.title} -
    -
    - {this.props.description} -
    -
    - - -
    -
    - ); - } -}); diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 1cd4d047fd..6cfe0babcb 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,27 +15,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'QuestionDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, - ]), - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + title: PropTypes.string, + description: PropTypes.node, + extraButtons: PropTypes.node, + button: PropTypes.string, + danger: PropTypes.bool, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { return { title: "", description: "", - button: "OK", + extraButtons: null, focus: true, + hasCancelButton: true, + danger: false, }; }, @@ -46,38 +51,30 @@ module.exports = React.createClass({ this.props.onFinished(false); }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(true); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + let primaryButtonClass = ""; + if (this.props.danger) { + primaryButtonClass = "danger"; + } return ( -
    -
    - {this.props.title} -
    +
    - {this.props.description} + { this.props.description }
    -
    - - - -
    -
    + + { this.props.extraButtons } + + ); - } + }, }); diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js new file mode 100644 index 0000000000..77f31a8d80 --- /dev/null +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -0,0 +1,79 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import Modal from '../../../Modal'; +import { _t } from '../../../languageHandler'; + + +export default React.createClass({ + displayName: 'SessionRestoreErrorDialog', + + propTypes: { + error: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, + }, + + _sendBugReport: function() { + const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); + Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {}); + }, + + _continueClicked: function() { + this.props.onFinished(true); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + let bugreport; + + if (SdkConfig.get().bug_report_endpoint_url) { + bugreport = ( +

    + { _t( + "Otherwise, click here to send a bug report.", + {}, + { 'a': (sub) => { sub } }, + ) } +

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

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

    + +

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

    + + { bugreport } +
    + +
    + ); + }, +}); diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js deleted file mode 100644 index c1041cc218..0000000000 --- a/src/components/views/dialogs/SetDisplayNameDialog.js +++ /dev/null @@ -1,85 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -var React = require("react"); -var sdk = require("../../../index.js"); -var MatrixClientPeg = require("../../../MatrixClientPeg"); - -module.exports = React.createClass({ - displayName: 'SetDisplayNameDialog', - propTypes: { - onFinished: React.PropTypes.func.isRequired, - currentDisplayName: React.PropTypes.string, - }, - - getInitialState: function() { - if (this.props.currentDisplayName) { - return { value: this.props.currentDisplayName }; - } - - if (MatrixClientPeg.get().isGuest()) { - return { value : "Guest " + MatrixClientPeg.get().getUserIdLocalpart() }; - } - else { - return { value : MatrixClientPeg.get().getUserIdLocalpart() }; - } - }, - - componentDidMount: function() { - this.refs.input_value.select(); - }, - - getValue: function() { - return this.state.value; - }, - - onValueChange: function(ev) { - this.setState({ - value: ev.target.value - }); - }, - - onFormSubmit: function(ev) { - ev.preventDefault(); - this.props.onFinished(true); - return false; - }, - - render: function() { - return ( -
    -
    - Set a Display Name -
    -
    - Your display name is how you'll appear to others when you speak in rooms.
    - What would you like it to be? -
    -
    -
    - -
    -
    - -
    -
    -
    - ); - } -}); diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js new file mode 100644 index 0000000000..c00cc1122b --- /dev/null +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -0,0 +1,165 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import Email from '../../../email'; +import AddThreepid from '../../../AddThreepid'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; + + +/** + * Prompt the user to set an email address. + * + * On success, `onFinished(true)` is called. + */ +export default React.createClass({ + displayName: 'SetEmailDialog', + propTypes: { + onFinished: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + emailAddress: null, + emailBusy: false, + }; + }, + + componentDidMount: function() { + }, + + onEmailAddressChanged: function(value) { + this.setState({ + emailAddress: value, + }); + }, + + onSubmit: function() { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + const emailAddress = this.state.emailAddress; + if (!Email.looksValid(emailAddress)) { + Modal.createTrackedDialog('Invalid Email Address', '', ErrorDialog, { + title: _t("Invalid Email Address"), + description: _t("This doesn't appear to be a valid email address"), + }); + return; + } + this._addThreepid = new AddThreepid(); + // we always bind emails when registering, so let's do the + // same here. + this._addThreepid.addEmailAddress(emailAddress, true).done(() => { + Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, { + title: _t("Verification Pending"), + description: _t( + "Please check your email and click on the link it contains. Once this " + + "is done, click continue.", + ), + button: _t('Continue'), + onFinished: this.onEmailDialogFinished, + }); + }, (err) => { + this.setState({emailBusy: false}); + console.error("Unable to add email address " + emailAddress + " " + err); + Modal.createTrackedDialog('Unable to add email address', '', ErrorDialog, { + title: _t("Unable to add email address"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + this.setState({emailBusy: true}); + }, + + onCancelled: function() { + this.props.onFinished(false); + }, + + onEmailDialogFinished: function(ok) { + if (ok) { + this.verifyEmailAddress(); + } else { + this.setState({emailBusy: false}); + } + }, + + verifyEmailAddress: function() { + this._addThreepid.checkEmailLinkClicked().done(() => { + this.props.onFinished(true); + }, (err) => { + this.setState({emailBusy: false}); + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const message = _t("Unable to verify email address.") + " " + + _t("Please check your email and click on the link it contains. Once this is done, click continue."); + Modal.createTrackedDialog('Verification Pending', '3pid Auth Failed', QuestionDialog, { + title: _t("Verification Pending"), + description: message, + button: _t('Continue'), + onFinished: this.onEmailDialogFinished, + }); + } else { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Unable to verify email address: " + err); + Modal.createTrackedDialog('Unable to verify email address', '', ErrorDialog, { + title: _t("Unable to verify email address."), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + } + }); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('elements.Spinner'); + const EditableText = sdk.getComponent('elements.EditableText'); + + const emailInput = this.state.emailBusy ? : ; + + return ( + +
    +

    + { _t('This will allow you to reset your password and receive notifications.') } +

    + { emailInput } +
    +
    + + +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js new file mode 100644 index 0000000000..6ebc2eb87f --- /dev/null +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -0,0 +1,302 @@ +/* +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. +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 Promise from 'bluebird'; +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import classnames from 'classnames'; +import { KeyCode } from '../../../Keyboard'; +import { _t } from '../../../languageHandler'; + +// The amount of time to wait for further changes to the input username before +// sending a request to the server +const USERNAME_CHECK_DEBOUNCE_MS = 250; + +/** + * Prompt the user to set a display name. + * + * On success, `onFinished(true, newDisplayName)` is called. + */ +export default React.createClass({ + displayName: 'SetMxIdDialog', + propTypes: { + onFinished: PropTypes.func.isRequired, + // Called when the user requests to register with a different homeserver + onDifferentServerClicked: PropTypes.func.isRequired, + // Called if the user wants to switch to login instead + onLoginClick: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + // The entered username + username: '', + // Indicate ongoing work on the username + usernameBusy: false, + // Indicate error with username + usernameError: '', + // Assume the homeserver supports username checking until "M_UNRECOGNIZED" + usernameCheckSupport: true, + + // Whether the auth UI is currently being used + doingUIAuth: false, + // Indicate error with auth + authError: '', + }; + }, + + componentDidMount: function() { + this.refs.input_value.select(); + + this._matrixClient = MatrixClientPeg.get(); + }, + + onValueChange: function(ev) { + this.setState({ + username: ev.target.value, + usernameBusy: true, + usernameError: '', + }, () => { + if (!this.state.username || !this.state.usernameCheckSupport) { + this.setState({ + usernameBusy: false, + }); + return; + } + + // Debounce the username check to limit number of requests sent + if (this._usernameCheckTimeout) { + clearTimeout(this._usernameCheckTimeout); + } + this._usernameCheckTimeout = setTimeout(() => { + this._doUsernameCheck().finally(() => { + this.setState({ + usernameBusy: false, + }); + }); + }, USERNAME_CHECK_DEBOUNCE_MS); + }); + }, + + onKeyUp: function(ev) { + if (ev.keyCode === KeyCode.ENTER) { + this.onSubmit(); + } + }, + + onSubmit: function(ev) { + this.setState({ + doingUIAuth: true, + }); + }, + + _doUsernameCheck: function() { + // XXX: SPEC-1 + // Check if username is valid + // Naive impl copied from https://github.com/matrix-org/matrix-react-sdk/blob/66c3a6d9ca695780eb6b662e242e88323053ff33/src/components/views/login/RegistrationForm.js#L190 + if (encodeURIComponent(this.state.username) !== this.state.username) { + this.setState({ + usernameError: _t('User names may only contain letters, numbers, dots, hyphens and underscores.'), + }); + return Promise.reject(); + } + + // Check if username is available + return this._matrixClient.isUsernameAvailable(this.state.username).then( + (isAvailable) => { + if (isAvailable) { + this.setState({usernameError: ''}); + } + }, + (err) => { + // Indicate whether the homeserver supports username checking + const newState = { + usernameCheckSupport: err.errcode !== "M_UNRECOGNIZED", + }; + console.error('Error whilst checking username availability: ', err); + switch (err.errcode) { + case "M_USER_IN_USE": + newState.usernameError = _t('Username not available'); + break; + case "M_INVALID_USERNAME": + newState.usernameError = _t( + 'Username invalid: %(errMessage)s', + { errMessage: err.message}, + ); + break; + case "M_UNRECOGNIZED": + // This homeserver doesn't support username checking, assume it's + // fine and rely on the error appearing in registration step. + newState.usernameError = ''; + break; + case undefined: + newState.usernameError = _t('Something went wrong!'); + break; + default: + newState.usernameError = _t( + 'An error occurred: %(error_string)s', + { error_string: err.message }, + ); + break; + } + this.setState(newState); + }, + ); + }, + + _generatePassword: function() { + return Math.random().toString(36).slice(2); + }, + + _makeRegisterRequest: function(auth) { + // Not upgrading - changing mxids + const guestAccessToken = null; + if (!this._generatedPassword) { + this._generatedPassword = this._generatePassword(); + } + return this._matrixClient.register( + this.state.username, + this._generatedPassword, + undefined, // session id: included in the auth dict already + auth, + {}, + guestAccessToken, + ); + }, + + _onUIAuthFinished: function(success, response) { + this.setState({ + doingUIAuth: false, + }); + + if (!success) { + this.setState({ authError: response.message }); + return; + } + + // XXX Implement RTS /register here + const teamToken = null; + + this.props.onFinished(true, { + userId: response.user_id, + deviceId: response.device_id, + homeserverUrl: this._matrixClient.getHomeserverUrl(), + identityServerUrl: this._matrixClient.getIdentityServerUrl(), + accessToken: response.access_token, + password: this._generatedPassword, + teamToken: teamToken, + }); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); + const Spinner = sdk.getComponent('elements.Spinner'); + + let auth; + if (this.state.doingUIAuth) { + auth = ; + } + const inputClasses = classnames({ + "mx_SetMxIdDialog_input": true, + "error": Boolean(this.state.usernameError), + }); + + let usernameIndicator = null; + let usernameBusyIndicator = null; + if (this.state.usernameBusy) { + usernameBusyIndicator = ; + } else { + const usernameAvailable = this.state.username && + this.state.usernameCheckSupport && !this.state.usernameError; + const usernameIndicatorClasses = classnames({ + "error": Boolean(this.state.usernameError), + "success": usernameAvailable, + }); + usernameIndicator =
    + { usernameAvailable ? _t('Username available') : this.state.usernameError } +
    ; + } + + let authErrorIndicator = null; + if (this.state.authError) { + authErrorIndicator =
    + { this.state.authError } +
    ; + } + const canContinue = this.state.username && + !this.state.usernameError && + !this.state.usernameBusy; + + return ( + +
    +
    + + { usernameBusyIndicator } +
    + { usernameIndicator } +

    + { _t( + 'This will be your account name on the ' + + 'homeserver, or you can pick a different server.', + {}, + { + 'span': { this.props.homeserverUrl }, + 'a': (sub) => { sub }, + }, + ) } +

    +

    + { _t( + 'If you already have a Matrix account you can log in instead.', + {}, + { 'a': (sub) => { sub } }, + ) } +

    + { auth } + { authErrorIndicator } +
    +
    + +
    +
    + ); + }, +}); diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 6245b5786f..dadf7b7beb 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -14,20 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'TextInputDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, + title: PropTypes.string, + description: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, ]), - value: React.PropTypes.string, - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired + value: PropTypes.string, + button: PropTypes.string, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -35,8 +37,7 @@ module.exports = React.createClass({ title: "", value: "", description: "", - button: "OK", - focus: true + focus: true, }; }, @@ -55,42 +56,26 @@ module.exports = React.createClass({ this.props.onFinished(false); }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(true, this.refs.textinput.value); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( -
    -
    - {this.props.title} -
    +
    - +
    - +
    -
    - - -
    -
    + + ); - } + }, }); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js new file mode 100644 index 0000000000..eaf2537355 --- /dev/null +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -0,0 +1,210 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import GeminiScrollbar from 'react-gemini-scrollbar'; +import Resend from '../../../Resend'; +import { _t } from '../../../languageHandler'; +import SettingsStore from "../../../settings/SettingsStore"; +import { markAllDevicesKnown } from '../../../cryptodevices'; + +function DeviceListEntry(props) { + const {userId, device} = props; + + const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons'); + + return ( +
  1. + + { device.deviceId } +
    + { device.getDisplayName() } +
  2. + ); +} + +DeviceListEntry.propTypes = { + userId: PropTypes.string.isRequired, + + // deviceinfo + device: PropTypes.object.isRequired, +}; + + +function UserUnknownDeviceList(props) { + const {userId, userDevices} = props; + + const deviceListEntries = Object.keys(userDevices).map((deviceId) => + , + ); + + return ( +
      + { deviceListEntries } +
    + ); +} + +UserUnknownDeviceList.propTypes = { + userId: PropTypes.string.isRequired, + + // map from deviceid -> deviceinfo + userDevices: PropTypes.object.isRequired, +}; + + +function UnknownDeviceList(props) { + const {devices} = props; + + const userListEntries = Object.keys(devices).map((userId) => +
  3. +

    { userId }:

    + +
  4. , + ); + + return
      { userListEntries }
    ; +} + +UnknownDeviceList.propTypes = { + // map from userid -> deviceid -> deviceinfo + devices: PropTypes.object.isRequired, +}; + + +export default React.createClass({ + displayName: 'UnknownDeviceDialog', + + propTypes: { + room: PropTypes.object.isRequired, + + // map from userid -> deviceid -> deviceinfo or null if devices are not yet loaded + devices: PropTypes.object, + + onFinished: PropTypes.func.isRequired, + + // Label for the button that marks all devices known and tries the send again + sendAnywayLabel: PropTypes.string.isRequired, + + // Label for the button that to send the event if you've verified all devices + sendLabel: PropTypes.string.isRequired, + + // function to retry the request once all devices are verified / known + onSend: PropTypes.func.isRequired, + }, + + componentWillMount: function() { + MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged); + }, + + componentWillUnmount: function() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("deviceVerificationChanged", this._onDeviceVerificationChanged); + } + }, + + _onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) { + if (this.props.devices[userId] && this.props.devices[userId][deviceId]) { + // XXX: Mutating props :/ + this.props.devices[userId][deviceId] = deviceInfo; + this.forceUpdate(); + } + }, + + _onDismissClicked: function() { + this.props.onFinished(); + }, + + _onSendAnywayClicked: function() { + markAllDevicesKnown(MatrixClientPeg.get(), this.props.devices); + + this.props.onFinished(); + this.props.onSend(); + }, + + _onSendClicked: function() { + this.props.onFinished(); + this.props.onSend(); + }, + + render: function() { + if (this.props.devices === null) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + + let warning; + if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) { + warning = ( +

    + { _t("You are currently blacklisting unverified devices; to send " + + "messages to these devices you must verify them.") } +

    + ); + } else { + warning = ( +
    +

    + { _t("We recommend you go through the verification process " + + "for each device to confirm they belong to their legitimate owner, " + + "but you can resend the message without verifying if you prefer.") } +

    +
    + ); + } + + let haveUnknownDevices = false; + Object.keys(this.props.devices).forEach((userId) => { + Object.keys(this.props.devices[userId]).map((deviceId) => { + const device = this.props.devices[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + haveUnknownDevices = true; + } + }); + }); + const sendButtonOnClick = haveUnknownDevices ? this._onSendAnywayClicked : this._onSendClicked; + const sendButtonLabel = haveUnknownDevices ? this.props.sendAnywayLabel : this.props.sendAnywayLabel; + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return ( + + +

    + { _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) } +

    + { warning } + { _t("Unknown devices") }: + + +
    + +
    + ); + // XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point? + // It feels like confused users will likely turn it on and then disappear in a cloud of UISIs... + }, +}); diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js new file mode 100644 index 0000000000..c6a973270a --- /dev/null +++ b/src/components/views/elements/AccessibleButton.js @@ -0,0 +1,57 @@ +/* + Copyright 2016 Jani Mustonen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * AccessibleButton is a generic wrapper for any element that should be treated + * as a button. Identifies the element as a button, setting proper tab + * indexing and keyboard activation behavior. + * + * @param {Object} props react element properties + * @returns {Object} rendered react + */ +export default function AccessibleButton(props) { + const {element, onClick, children, ...restProps} = props; + restProps.onClick = onClick; + restProps.onKeyUp = function(e) { + if (e.keyCode == 13 || e.keyCode == 32) return onClick(e); + }; + restProps.tabIndex = restProps.tabIndex || "0"; + restProps.role = "button"; + restProps.className = (restProps.className ? restProps.className + " " : "") + + "mx_AccessibleButton"; + return React.createElement(element, restProps, children); +} + +/** + * children: React's magic prop. Represents all children given to the element. + * element: (optional) The base element type. "div" by default. + * onClick: (required) Event handler for button activation. Should be + * implemented exactly like a normal onClick handler. + */ +AccessibleButton.propTypes = { + children: PropTypes.node, + element: PropTypes.string, + onClick: PropTypes.func.isRequired, +}; + +AccessibleButton.defaultProps = { + element: 'div', +}; + +AccessibleButton.displayName = "AccessibleButton"; diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.js new file mode 100644 index 0000000000..e494c216de --- /dev/null +++ b/src/components/views/elements/ActionButton.js @@ -0,0 +1,87 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import AccessibleButton from './AccessibleButton'; +import dis from '../../../dispatcher'; +import sdk from '../../../index'; +import Analytics from '../../../Analytics'; + +export default React.createClass({ + displayName: 'RoleButton', + + propTypes: { + size: PropTypes.string, + tooltip: PropTypes.bool, + action: PropTypes.string.isRequired, + mouseOverAction: PropTypes.string, + label: PropTypes.string.isRequired, + iconPath: PropTypes.string.isRequired, + }, + + getDefaultProps: function() { + return { + size: "25", + tooltip: false, + }; + }, + + getInitialState: function() { + return { + showTooltip: false, + }; + }, + + _onClick: function(ev) { + ev.stopPropagation(); + Analytics.trackEvent('Action Button', 'click', this.props.action); + dis.dispatch({action: this.props.action}); + }, + + _onMouseEnter: function() { + if (this.props.tooltip) this.setState({showTooltip: true}); + if (this.props.mouseOverAction) { + dis.dispatch({action: this.props.mouseOverAction}); + } + }, + + _onMouseLeave: function() { + this.setState({showTooltip: false}); + }, + + render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + + let tooltip; + if (this.state.showTooltip) { + const RoomTooltip = sdk.getComponent("rooms.RoomTooltip"); + tooltip = ; + } + + return ( + + + { tooltip } + + ); + }, +}); diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 2c2d7e2d61..b4279c7f70 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.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. @@ -16,18 +17,27 @@ limitations under the License. 'use strict'; -var React = require("react"); -var sdk = require("../../../index"); -var classNames = require('classnames'); +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import classNames from 'classnames'; +import { UserAddressType } from '../../../UserAddress'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'AddressSelector', propTypes: { - onSelected: React.PropTypes.func.isRequired, - addressList: React.PropTypes.array.isRequired, - truncateAt: React.PropTypes.number.isRequired, - selected: React.PropTypes.number, + onSelected: PropTypes.func.isRequired, + + // List of the addresses to display + addressList: PropTypes.arrayOf(UserAddressType).isRequired, + // Whether to show the address on the address tiles + showAddress: PropTypes.bool, + truncateAt: PropTypes.number.isRequired, + selected: PropTypes.number, + + // Element to put as a header on top of the list + header: PropTypes.node, }, getInitialState: function() { @@ -39,8 +49,8 @@ module.exports = React.createClass({ componentWillReceiveProps: function(props) { // Make sure the selected item isn't outside the list bounds - var selected = this.state.selected; - var maxSelected = this._maxSelected(props.addressList); + const selected = this.state.selected; + const maxSelected = this._maxSelected(props.addressList); if (selected > maxSelected) { this.setState({ selected: maxSelected }); } @@ -50,52 +60,55 @@ module.exports = React.createClass({ // As the user scrolls with the arrow keys keep the selected item // at the top of the window. if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { - var elementHeight = this.addressListElement.getBoundingClientRect().height; + const elementHeight = this.addressListElement.getBoundingClientRect().height; this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; } }, - onKeyUp: function() { + moveSelectionTop: function() { + if (this.state.selected > 0) { + this.setState({ + selected: 0, + hover: false, + }); + } + }, + + moveSelectionUp: function() { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, - hover : false, + hover: false, }); } }, - onKeyDown: function() { + moveSelectionDown: function() { if (this.state.selected < this._maxSelected(this.props.addressList)) { this.setState({ selected: this.state.selected + 1, - hover : false, + hover: false, }); } }, - onKeySelect: function() { + chooseSelection: function() { this.selectAddress(this.state.selected); }, onClick: function(index) { - var self = this; - return function() { - self.selectAddress(index); - }; + this.selectAddress(index); }, onMouseEnter: function(index) { - var self = this; - return function() { - self.setState({ - selected: index, - hover: true, - }); - }; + this.setState({ + selected: index, + hover: true, + }); }, onMouseLeave: function() { - this.setState({ hover : false }); + this.setState({ hover: false }); }, selectAddress: function(index) { @@ -107,15 +120,15 @@ module.exports = React.createClass({ }, createAddressListTiles: function() { - var self = this; - var AddressTile = sdk.getComponent("elements.AddressTile"); - var maxSelected = this._maxSelected(this.props.addressList); - var addressList = []; + const self = this; + const AddressTile = sdk.getComponent("elements.AddressTile"); + const maxSelected = this._maxSelected(this.props.addressList); + const addressList = []; // Only create the address elements if there are address if (this.props.addressList.length > 0) { - for (var i = 0; i <= maxSelected; i++) { - var classes = classNames({ + for (let i = 0; i <= maxSelected; i++) { + const classes = classNames({ "mx_AddressSelector_addressListElement": true, "mx_AddressSelector_selected": this.state.selected === i, }); @@ -124,9 +137,22 @@ module.exports = React.createClass({ // Saving the addressListElement so we can use it to work out, in the componentDidUpdate // method, how far to scroll when using the arrow keys addressList.push( -
    { this.addressListElement = ref; }} > - -
    +
    { this.addressListElement = ref; }} + > + +
    , ); } } @@ -134,21 +160,22 @@ module.exports = React.createClass({ }, _maxSelected: function(list) { - var listSize = list.length === 0 ? 0 : list.length - 1; - var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize + const listSize = list.length === 0 ? 0 : list.length - 1; + const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; return maxSelected; }, render: function() { - var classes = classNames({ + const classes = classNames({ "mx_AddressSelector": true, "mx_AddressSelector_empty": this.props.addressList.length === 0, }); return ( -
    {this.scrollElement = ref}}> +
    {this.scrollElement = ref;}}> + { this.props.header } { this.createAddressListTiles() }
    ); - } + }, }); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 2799f10a41..16e340756a 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,25 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import sdk from "../../../index"; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import { _t } from '../../../languageHandler'; +import { UserAddressType } from '../../../UserAddress.js'; -var React = require('react'); -var classNames = require('classnames'); -var sdk = require("../../../index"); -var Invite = require("../../../Invite"); -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var Avatar = require('../../../Avatar'); -module.exports = React.createClass({ +export default React.createClass({ displayName: 'AddressTile', propTypes: { - address: React.PropTypes.string.isRequired, - canDismiss: React.PropTypes.bool, - onDismissed: React.PropTypes.func, - justified: React.PropTypes.bool, - networkName: React.PropTypes.string, - networkUrl: React.PropTypes.string, + address: UserAddressType.isRequired, + canDismiss: PropTypes.bool, + onDismissed: PropTypes.func, + justified: PropTypes.bool, }, getDefaultProps: function() { @@ -40,37 +39,26 @@ module.exports = React.createClass({ canDismiss: false, onDismissed: function() {}, // NOP justified: false, - networkName: "", - networkUrl: "", }; }, render: function() { - var userId, name, imgUrl, email; - var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const address = this.props.address; + const name = address.displayName || address.address; - // Check if the addr is a valid type - var addrType = Invite.getAddressType(this.props.address); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(this.props.address); - if (user) { - userId = user.userId; - name = user.rawDisplayName || userId; - imgUrl = Avatar.avatarUrlForUser(user, 25, 25, "crop"); - } else { - name=this.props.address; - imgUrl = "img/icon-mx-user.svg"; - } - } else if (addrType === "email") { - email = this.props.address; - name="email"; - imgUrl = "img/icon-email-user.svg"; - } else { - name="Unknown"; - imgUrl = "img/avatar-error.svg"; + const imgUrls = []; + const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType); + + if (isMatrixAddress && address.avatarMxc) { + imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( + address.avatarMxc, 25, 25, 'crop', + )); + } else if (address.addressType === 'email') { + imgUrls.push('img/icon-email-user.svg'); } + // Removing networks for now as they're not really supported + /* var network; if (this.props.networkUrl !== "") { network = ( @@ -79,16 +67,20 @@ module.exports = React.createClass({
    ); } + */ - var info; - var error = false; - if (addrType === "mx" && userId) { - var nameClasses = classNames({ - "mx_AddressTile_name": true, - "mx_AddressTile_justified": this.props.justified, - }); + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); - var idClasses = classNames({ + const nameClasses = classNames({ + "mx_AddressTile_name": true, + "mx_AddressTile_justified": this.props.justified, + }); + + let info; + let error = false; + if (isMatrixAddress && address.isKnown) { + const idClasses = classNames({ "mx_AddressTile_id": true, "mx_AddressTile_justified": this.props.justified, }); @@ -96,45 +88,56 @@ module.exports = React.createClass({ info = (
    { name }
    -
    { userId }
    + { this.props.showAddress ? +
    { address.address }
    : +
    + }
    ); - } else if (addrType === "mx") { - var unknownMxClasses = classNames({ + } else if (isMatrixAddress) { + const unknownMxClasses = classNames({ "mx_AddressTile_unknownMx": true, "mx_AddressTile_justified": this.props.justified, }); info = ( -
    { this.props.address }
    +
    { this.props.address.address }
    ); - } else if (email) { - var emailClasses = classNames({ + } else if (address.addressType === "email") { + const emailClasses = classNames({ "mx_AddressTile_email": true, "mx_AddressTile_justified": this.props.justified, }); + let nameNode = null; + if (address.displayName) { + nameNode =
    { address.displayName }
    ; + } + info = ( -
    { email }
    +
    +
    { address.address }
    + { nameNode } +
    ); } else { error = true; - var unknownClasses = classNames({ + const unknownClasses = classNames({ "mx_AddressTile_unknown": true, "mx_AddressTile_justified": this.props.justified, }); info = ( -
    Unknown Address
    +
    { _t("Unknown Address") }
    ); } - var classes = classNames({ + const classes = classNames({ "mx_AddressTile": true, "mx_AddressTile_error": error, }); - var dismiss; + let dismiss; if (this.props.canDismiss) { dismiss = (
    @@ -145,13 +148,12 @@ module.exports = React.createClass({ return (
    - { network }
    - +
    { info } { dismiss }
    ); - } + }, }); diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js new file mode 100644 index 0000000000..ef08c8355b --- /dev/null +++ b/src/components/views/elements/AppPermission.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import url from 'url'; +import { _t } from '../../../languageHandler'; + +export default class AppPermission extends React.Component { + constructor(props) { + super(props); + + const curlBase = this.getCurlBase(); + this.state = { curlBase: curlBase}; + } + + // Return string representation of content URL without query parameters + getCurlBase() { + const wurl = url.parse(this.props.url); + let curl; + let curlString; + + const searchParams = new URLSearchParams(wurl.search); + + if (this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) { + curl = url.parse(searchParams.get('url')); + if (curl) { + curl.search = curl.query = ""; + curlString = curl.format(); + } + } + if (!curl && wurl) { + wurl.search = wurl.query = ""; + curlString = wurl.format(); + } + return curlString; + } + + isScalarWurl(wurl) { + if (wurl && wurl.hostname && ( + wurl.hostname === 'scalar.vector.im' || + wurl.hostname === 'scalar-staging.riot.im' || + wurl.hostname === 'scalar-develop.riot.im' || + wurl.hostname === 'demo.riot.im' || + wurl.hostname === 'localhost' + )) { + return true; + } + return false; + } + + render() { + let e2eWarningText; + if (this.props.isRoomEncrypted) { + e2eWarningText = + { _t('NOTE: Apps are not end-to-end encrypted') }; + } + return ( +
    +
    + {_t('Warning!')} +
    +
    + { _t('Do you want to load widget from URL:') } { this.state.curlBase } + { e2eWarningText } +
    + +
    + ); + } +} + +AppPermission.propTypes = { + isRoomEncrypted: PropTypes.bool, + url: PropTypes.string.isRequired, + onPermissionGranted: PropTypes.func.isRequired, +}; +AppPermission.defaultProps = { + isRoomEncrypted: false, + onPermissionGranted: function() {}, +}; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js new file mode 100644 index 0000000000..a63823555f --- /dev/null +++ b/src/components/views/elements/AppTile.js @@ -0,0 +1,493 @@ +/* +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. +*/ + +'use strict'; + +import url from 'url'; +import qs from 'querystring'; +import React from 'react'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import PlatformPeg from '../../../PlatformPeg'; +import ScalarAuthClient from '../../../ScalarAuthClient'; +import WidgetMessaging from '../../../WidgetMessaging'; +import TintableSvgButton from './TintableSvgButton'; +import SdkConfig from '../../../SdkConfig'; +import Modal from '../../../Modal'; +import { _t, _td } from '../../../languageHandler'; +import sdk from '../../../index'; +import AppPermission from './AppPermission'; +import AppWarning from './AppWarning'; +import MessageSpinner from './MessageSpinner'; +import WidgetUtils from '../../../WidgetUtils'; +import dis from '../../../dispatcher'; + +const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; + +export default React.createClass({ + displayName: 'AppTile', + + propTypes: { + id: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + room: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. + // This should be set to true when there is only one widget in the app drawer, otherwise it should be false. + fullWidth: PropTypes.bool, + // UserId of the current user + userId: PropTypes.string.isRequired, + // UserId of the entity that added / modified the widget + creatorUserId: PropTypes.string, + waitForIframeLoad: PropTypes.bool, + }, + + getDefaultProps() { + return { + url: "", + waitForIframeLoad: true, + }; + }, + + /** + * Set initial component state when the App wUrl (widget URL) is being updated. + * Component props *must* be passed (rather than relying on this.props). + * @param {Object} newProps The new properties of the component + * @return {Object} Updated component state to be set with setState + */ + _getNewState(newProps) { + const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_'); + const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); + return { + initialising: true, // True while we are mangling the widget URL + loading: this.props.waitForIframeLoad, // True while the iframe content is loading + widgetUrl: this._addWurlParams(newProps.url), + widgetPermissionId: widgetPermissionId, + // Assume that widget has permission to load if we are the user who + // added it to the room, or if explicitly granted by the user + hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId, + error: null, + deleting: false, + widgetPageTitle: newProps.widgetPageTitle, + }; + }, + + /** + * Add widget instance specific parameters to pass in wUrl + * Properties passed to widget instance: + * - widgetId + * - origin / parent URL + * @param {string} urlString Url string to modify + * @return {string} + * Url string with parameters appended. + * If url can not be parsed, it is returned unmodified. + */ + _addWurlParams(urlString) { + const u = url.parse(urlString); + if (!u) { + console.error("_addWurlParams", "Invalid URL", urlString); + return url; + } + + const params = qs.parse(u.query); + // Append widget ID to query parameters + params.widgetId = this.props.id; + // Append current / parent URL + params.parentUrl = window.location.href; + u.search = undefined; + u.query = params; + + return u.format(); + }, + + getInitialState() { + return this._getNewState(this.props); + }, + + /** + * Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api + * @param {[type]} url URL to check + * @return {Boolean} True if specified URL is a scalar URL + */ + isScalarUrl(url) { + if (!url) { + console.error('Scalar URL check failed. No URL specified'); + return false; + } + + let scalarUrls = SdkConfig.get().integrations_widgets_urls; + if (!scalarUrls || scalarUrls.length == 0) { + scalarUrls = [SdkConfig.get().integrations_rest_url]; + } + + for (let i = 0; i < scalarUrls.length; i++) { + if (url.startsWith(scalarUrls[i])) { + return true; + } + } + return false; + }, + + isMixedContent() { + const parentContentProtocol = window.location.protocol; + const u = url.parse(this.props.url); + const childContentProtocol = u.protocol; + if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { + console.warn("Refusing to load mixed-content app:", + parentContentProtocol, childContentProtocol, window.location, this.props.url); + return true; + } + return false; + }, + + componentWillMount() { + WidgetMessaging.startListening(); + WidgetMessaging.addEndpoint(this.props.id, this.props.url); + window.addEventListener('message', this._onMessage, false); + this.setScalarToken(); + }, + + /** + * Adds a scalar token to the widget URL, if required + * Component initialisation is only complete when this function has resolved + */ + setScalarToken() { + this.setState({initialising: true}); + + if (!this.isScalarUrl(this.props.url)) { + console.warn('Non-scalar widget, not setting scalar token!', url); + this.setState({ + error: null, + widgetUrl: this._addWurlParams(this.props.url), + initialising: false, + }); + return; + } + + // Fetch the token before loading the iframe as we need it to mangle the URL + if (!this._scalarClient) { + this._scalarClient = new ScalarAuthClient(); + } + this._scalarClient.getScalarToken().done((token) => { + // Append scalar_token as a query param if not already present + this._scalarClient.scalarToken = token; + const u = url.parse(this._addWurlParams(this.props.url)); + const params = qs.parse(u.query); + if (!params.scalar_token) { + params.scalar_token = encodeURIComponent(token); + // u.search must be set to undefined, so that u.format() uses query paramerters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options + u.search = undefined; + u.query = params; + } + + this.setState({ + error: null, + widgetUrl: u.format(), + initialising: false, + }); + + // Fetch page title from remote content if not already set + if (!this.state.widgetPageTitle && params.url) { + this._fetchWidgetTitle(params.url); + } + }, (err) => { + console.error("Failed to get scalar_token", err); + this.setState({ + error: err.message, + initialising: false, + }); + }); + }, + + componentWillUnmount() { + WidgetMessaging.stopListening(); + WidgetMessaging.removeEndpoint(this.props.id, this.props.url); + window.removeEventListener('message', this._onMessage); + }, + + componentWillReceiveProps(nextProps) { + if (nextProps.url !== this.props.url) { + this._getNewState(nextProps); + this.setScalarToken(); + } else if (nextProps.show && !this.props.show && this.props.waitForIframeLoad) { + this.setState({ + loading: true, + }); + } else if (nextProps.widgetPageTitle !== this.props.widgetPageTitle) { + this.setState({ + widgetPageTitle: nextProps.widgetPageTitle, + }); + } + }, + + _onMessage(event) { + if (this.props.type !== 'jitsi') { + return; + } + if (!event.origin) { + event.origin = event.originalEvent.origin; + } + + if (!this.state.widgetUrl.startsWith(event.origin)) { + return; + } + + if (event.data.widgetAction === 'jitsi_iframe_loaded') { + const iframe = this.refs.appFrame.contentWindow + .document.querySelector('iframe[id^="jitsiConferenceFrame"]'); + PlatformPeg.get().setupScreenSharingForIframe(iframe); + } + }, + + _canUserModify() { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + }, + + _onEditClick(e) { + console.log("Edit widget ID ", this.props.id); + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const src = this._scalarClient.getScalarInterfaceUrlForRoom( + this.props.room.roomId, 'type_' + this.props.type, this.props.id); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + src: src, + }, "mx_IntegrationsManager"); + }, + + /* If user has permission to modify widgets, delete the widget, + * otherwise revoke access for the widget to load in the user's browser + */ + _onDeleteClick() { + if (this._canUserModify()) { + // Show delete confirmation dialog + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) { + return; + } + this.setState({deleting: true}); + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, + 'im.vector.modular.widgets', + {}, // empty content + this.props.id, + ).catch((e) => { + console.error('Failed to delete widget', e); + this.setState({deleting: false}); + }); + }, + }); + } else { + console.log("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); + } + }, + + /** + * Called when widget iframe has finished loading + */ + _onLoaded() { + this.setState({loading: false}); + }, + + /** + * Set remote content title on AppTile + * @param {string} url Url to check for title + */ + _fetchWidgetTitle(url) { + this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { + if (widgetPageTitle) { + this.setState({widgetPageTitle: widgetPageTitle}); + } + }, (err) =>{ + console.error("Failed to get page title", err); + }); + }, + + // Widget labels to render, depending upon user permissions + // These strings are translated at the point that they are inserted in to the DOM, in the render method + _deleteWidgetLabel() { + if (this._canUserModify()) { + return _td('Delete widget'); + } + return _td('Revoke widget access'); + }, + + /* TODO -- Store permission in account data so that it is persisted across multiple devices */ + _grantWidgetPermission() { + console.warn('Granting permission to load widget - ', this.state.widgetUrl); + localStorage.setItem(this.state.widgetPermissionId, true); + this.setState({hasPermissionToLoad: true}); + }, + + _revokeWidgetPermission() { + console.warn('Revoking permission to load widget - ', this.state.widgetUrl); + localStorage.removeItem(this.state.widgetPermissionId); + this.setState({hasPermissionToLoad: false}); + }, + + formatAppTileName() { + let appTileName = "No name"; + if (this.props.name && this.props.name.trim()) { + appTileName = this.props.name.trim(); + } + return appTileName; + }, + + onClickMenuBar(ev) { + ev.preventDefault(); + + // Ignore clicks on menu bar children + if (ev.target !== this.refs.menu_bar) { + return; + } + + // Toggle the view state of the apps drawer + dis.dispatch({ + action: 'appsDrawer', + show: !this.props.show, + }); + }, + + _getSafeUrl() { + const parsedWidgetUrl = url.parse(this.state.widgetUrl); + let safeWidgetUrl = ''; + if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { + safeWidgetUrl = url.format(parsedWidgetUrl); + } + return safeWidgetUrl; + }, + + render() { + let appTileBody; + + // Don't render widget if it is in the process of being deleted + if (this.state.deleting) { + return
    ; + } + + // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin + // because that would allow the iframe to prgramatically remove the sandbox attribute, but + // this would only be for content hosted on the same origin as the riot client: anything + // hosted on the same origin as the client will get the same access as if you clicked + // a link to it. + const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ + "allow-same-origin allow-scripts allow-presentation"; + + if (this.props.show) { + const loadingElement = ( +
    + +
    + ); + if (this.state.initialising) { + appTileBody = loadingElement; + } else if (this.state.hasPermissionToLoad == true) { + if (this.isMixedContent()) { + appTileBody = ( +
    + +
    + ); + } else { + appTileBody = ( +
    + { this.state.loading && loadingElement } + +
    + ); + } + } else { + const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + appTileBody = ( +
    + +
    + ); + } + } + + // editing is done in scalar + const showEditButton = Boolean(this._scalarClient && this._canUserModify()); + const deleteWidgetLabel = this._deleteWidgetLabel(); + let deleteIcon = 'img/cancel_green.svg'; + let deleteClasses = 'mx_AppTileMenuBarWidget'; + if (this._canUserModify()) { + deleteIcon = 'img/icon-delete-pink.svg'; + deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; + } + + const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg'); + + return ( +
    +
    + + + { this.formatAppTileName() } + { this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && ( +  - { this.state.widgetPageTitle } + ) } + + + { /* Edit widget */ } + { showEditButton && } + + { /* Delete widget */ } + + +
    + { appTileBody } +
    + ); + }, +}); diff --git a/src/components/views/elements/AppWarning.js b/src/components/views/elements/AppWarning.js new file mode 100644 index 0000000000..f4015ae5b7 --- /dev/null +++ b/src/components/views/elements/AppWarning.js @@ -0,0 +1,25 @@ +import React from 'react'; // eslint-disable-line no-unused-vars +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const AppWarning = (props) => { + return ( +
    +
    + {_t('Warning!')} +
    +
    + { props.errorMsg } +
    +
    + ); +}; + +AppWarning.propTypes = { + errorMsg: PropTypes.string, +}; +AppWarning.defaultProps = { + errorMsg: 'Error', +}; + +export default AppWarning; diff --git a/src/components/views/elements/CreateRoomButton.js b/src/components/views/elements/CreateRoomButton.js new file mode 100644 index 0000000000..177d033c75 --- /dev/null +++ b/src/components/views/elements/CreateRoomButton.js @@ -0,0 +1,40 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const CreateRoomButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +CreateRoomButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default CreateRoomButton; diff --git a/src/components/views/elements/DNDTagTile.js b/src/components/views/elements/DNDTagTile.js new file mode 100644 index 0000000000..e17ea52976 --- /dev/null +++ b/src/components/views/elements/DNDTagTile.js @@ -0,0 +1,43 @@ +/* eslint new-cap: "off" */ +/* +Copyright 2017 New Vector 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 TagTile from './TagTile'; + +import { Draggable } from 'react-beautiful-dnd'; + +export default function DNDTagTile(props) { + return
    + + { (provided, snapshot) => ( +
    +
    + +
    + { provided.placeholder } +
    + ) } +
    +
    ; +} diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index aeb93e866c..c775cba610 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -15,107 +15,99 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import Modal from '../../../Modal'; +import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'DeviceVerifyButtons', propTypes: { - userId: React.PropTypes.string.isRequired, - device: React.PropTypes.object.isRequired, + userId: PropTypes.string.isRequired, + device: PropTypes.object.isRequired, + }, + + getInitialState: function() { + return { + device: this.props.device, + }; + }, + + componentWillMount: function() { + const cli = MatrixClientPeg.get(); + cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged); + }, + + componentWillUnmount: function() { + const cli = MatrixClientPeg.get(); + cli.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + }, + + onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) { + if (userId === this.props.userId && deviceId === this.props.device.deviceId) { + this.setState({ device: deviceInfo }); + } }, onVerifyClick: function() { - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { - title: "Verify device", - description: ( -
    -

    - To verify that this device can be trusted, please contact its - owner using some other means (e.g. in person or a phone call) - and ask them whether the key they see in their User Settings - for this device matches the key below: -

    -
    -
      -
    • { this.props.device.getDisplayName() }
    • -
    • { this.props.device.deviceId}
    • -
    • { this.props.device.getFingerprint() }
    • -
    -
    -

    - If it matches, press the verify button below. - If it doesnt, then someone else is intercepting this device - and you probably want to press the blacklist button instead. -

    -

    - In future this verification process will be more sophisticated. -

    -
    - ), - button: "I verify that the keys match", - onFinished: confirm=>{ - if (confirm) { - MatrixClientPeg.get().setDeviceVerified( - this.props.userId, this.props.device.deviceId, true - ); - } - }, + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { + userId: this.props.userId, + device: this.state.device, }); }, onUnverifyClick: function() { MatrixClientPeg.get().setDeviceVerified( - this.props.userId, this.props.device.deviceId, false + this.props.userId, this.state.device.deviceId, false, ); }, onBlacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( - this.props.userId, this.props.device.deviceId, true + this.props.userId, this.state.device.deviceId, true, ); }, onUnblacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( - this.props.userId, this.props.device.deviceId, false + this.props.userId, this.state.device.deviceId, false, ); }, render: function() { - var blacklistButton = null, verifyButton = null; + let blacklistButton = null, verifyButton = null; - if (this.props.device.isBlocked()) { + if (this.state.device.isBlocked()) { blacklistButton = ( ); } else { blacklistButton = ( ); } - if (this.props.device.isVerified()) { + if (this.state.device.isVerified()) { verifyButton = ( ); } else { verifyButton = ( ); } diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js new file mode 100644 index 0000000000..c159324c1d --- /dev/null +++ b/src/components/views/elements/DialogButtons.js @@ -0,0 +1,62 @@ +/* +Copyright 2017 Aidan Gauland + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +"use strict"; + +import React from "react"; +import PropTypes from "prop-types"; +import { _t } from '../../../languageHandler'; + +/** + * Basic container for buttons in modal dialogs. + */ +module.exports = React.createClass({ + displayName: "DialogButtons", + + propTypes: { + // The primary button which is styled differently and has default focus. + primaryButton: PropTypes.node.isRequired, + + // onClick handler for the primary button. + onPrimaryButtonClick: PropTypes.func.isRequired, + + // onClick handler for the cancel button. + onCancel: PropTypes.func.isRequired, + + focus: PropTypes.bool, + }, + + render: function() { + let primaryButtonClassName = "mx_Dialog_primary"; + if (this.props.primaryButtonClass) { + primaryButtonClassName += " " + this.props.primaryButtonClass; + } + return ( +
    + + { this.props.children } + +
    + ); + }, +}); diff --git a/src/components/views/elements/DirectorySearchBox.js b/src/components/views/elements/DirectorySearchBox.js index 3ea0d16336..14f79687e1 100644 --- a/src/components/views/elements/DirectorySearchBox.js +++ b/src/components/views/elements/DirectorySearchBox.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import classnames from 'classnames'; export default class DirectorySearchBox extends React.Component { @@ -59,7 +60,7 @@ export default class DirectorySearchBox extends React.Component { } _onKeyUp(ev) { - if (ev.key == 'Enter') { + if (ev.key == 'Enter' && this.props.showJoinButton) { if (this.props.onJoinClick) { this.props.onJoinClick(this.state.value); } @@ -93,9 +94,9 @@ export default class DirectorySearchBox extends React.Component { className="mx_DirectorySearchBox_input" ref={this._collectInput} onChange={this._onChange} onKeyUp={this._onKeyUp} - placeholder={this.props.placeholder} + placeholder={this.props.placeholder} autoFocus /> - {join_button} + { join_button } @@ -105,10 +106,10 @@ export default class DirectorySearchBox extends React.Component { } DirectorySearchBox.propTypes = { - className: React.PropTypes.string, - onChange: React.PropTypes.func, - onClear: React.PropTypes.func, - onJoinClick: React.PropTypes.func, - placeholder: React.PropTypes.string, - showJoinButton: React.PropTypes.bool, + className: PropTypes.string, + onChange: PropTypes.func, + onClear: PropTypes.func, + onJoinClick: PropTypes.func, + placeholder: PropTypes.string, + showJoinButton: PropTypes.bool, }; diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js new file mode 100644 index 0000000000..10111e415e --- /dev/null +++ b/src/components/views/elements/Dropdown.js @@ -0,0 +1,342 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import AccessibleButton from './AccessibleButton'; +import { _t } from '../../../languageHandler'; + +class MenuOption extends React.Component { + constructor(props) { + super(props); + this._onMouseEnter = this._onMouseEnter.bind(this); + this._onClick = this._onClick.bind(this); + } + + static defaultProps = { + disabled: false, + }; + + _onMouseEnter() { + this.props.onMouseEnter(this.props.dropdownKey); + } + + _onClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.onClick(this.props.dropdownKey); + } + + render() { + const optClasses = classnames({ + mx_Dropdown_option: true, + mx_Dropdown_option_highlight: this.props.highlighted, + }); + + return
    + { this.props.children } +
    ; + } +} + +MenuOption.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(React.PropTypes.node), + PropTypes.node, + ]), + highlighted: PropTypes.bool, + dropdownKey: PropTypes.string, + onClick: PropTypes.func.isRequired, + onMouseEnter: PropTypes.func.isRequired, +}; + +/* + * Reusable dropdown select control, akin to react-select, + * but somewhat simpler as react-select is 79KB of minified + * javascript. + * + * TODO: Port NetworkDropdown to use this. + */ +export default class Dropdown extends React.Component { + constructor(props) { + super(props); + + this.dropdownRootElement = null; + this.ignoreEvent = null; + + this._onInputClick = this._onInputClick.bind(this); + this._onRootClick = this._onRootClick.bind(this); + this._onDocumentClick = this._onDocumentClick.bind(this); + this._onMenuOptionClick = this._onMenuOptionClick.bind(this); + this._onInputKeyPress = this._onInputKeyPress.bind(this); + this._onInputKeyUp = this._onInputKeyUp.bind(this); + this._onInputChange = this._onInputChange.bind(this); + this._collectRoot = this._collectRoot.bind(this); + this._collectInputTextBox = this._collectInputTextBox.bind(this); + this._setHighlightedOption = this._setHighlightedOption.bind(this); + + this.inputTextBox = null; + + this._reindexChildren(this.props.children); + + const firstChild = React.Children.toArray(props.children)[0]; + + this.state = { + // True if the menu is dropped-down + expanded: false, + // The key of the highlighted option + // (the option that would become selected if you pressed enter) + highlightedOption: firstChild ? firstChild.key : null, + // the current search query + searchQuery: '', + }; + } + + componentWillMount() { + // Listen for all clicks on the document so we can close the + // menu when the user clicks somewhere else + document.addEventListener('click', this._onDocumentClick, false); + } + + componentWillUnmount() { + document.removeEventListener('click', this._onDocumentClick, false); + } + + componentWillReceiveProps(nextProps) { + if (!nextProps.children || nextProps.children.length === 0) { + return; + } + this._reindexChildren(nextProps.children); + const firstChild = nextProps.children[0]; + this.setState({ + highlightedOption: firstChild ? firstChild.key : null, + }); + } + + _reindexChildren(children) { + this.childrenByKey = {}; + React.Children.forEach(children, (child) => { + this.childrenByKey[child.key] = child; + }); + } + + _onDocumentClick(ev) { + // Close the dropdown if the user clicks anywhere that isn't + // within our root element + if (ev !== this.ignoreEvent) { + this.setState({ + expanded: false, + }); + } + } + + _onRootClick(ev) { + // This captures any clicks that happen within our elements, + // such that we can then ignore them when they're seen by the + // click listener on the document handler, ie. not close the + // dropdown immediately after opening it. + // NB. We can't just stopPropagation() because then the event + // doesn't reach the React onClick(). + this.ignoreEvent = ev; + } + + _onInputClick(ev) { + if (this.props.disabled) return; + + if (!this.state.expanded) { + this.setState({ + expanded: true, + }); + ev.preventDefault(); + } + } + + _onMenuOptionClick(dropdownKey) { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(dropdownKey); + } + + _onInputKeyPress(e) { + // This needs to be on the keypress event because otherwise + // it can't cancel the form submission + if (e.key == 'Enter') { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(this.state.highlightedOption); + e.preventDefault(); + } + } + + _onInputKeyUp(e) { + // These keys don't generate keypress events and so needs to + // be on keyup + if (e.key == 'Escape') { + this.setState({ + expanded: false, + }); + } else if (e.key == 'ArrowDown') { + this.setState({ + highlightedOption: this._nextOption(this.state.highlightedOption), + }); + } else if (e.key == 'ArrowUp') { + this.setState({ + highlightedOption: this._prevOption(this.state.highlightedOption), + }); + } + } + + _onInputChange(e) { + this.setState({ + searchQuery: e.target.value, + }); + if (this.props.onSearchChange) { + this.props.onSearchChange(e.target.value); + } + } + + _collectRoot(e) { + if (this.dropdownRootElement) { + this.dropdownRootElement.removeEventListener( + 'click', this._onRootClick, false, + ); + } + if (e) { + e.addEventListener('click', this._onRootClick, false); + } + this.dropdownRootElement = e; + } + + _collectInputTextBox(e) { + this.inputTextBox = e; + if (e) e.focus(); + } + + _setHighlightedOption(optionKey) { + this.setState({ + highlightedOption: optionKey, + }); + } + + _nextOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index + 1) % keys.length]; + } + + _prevOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index - 1) % keys.length]; + } + + _getMenuOptions() { + const options = React.Children.map(this.props.children, (child) => { + return ( + + { child } + + ); + }); + if (options.length === 0) { + return [
    + { _t("No results") } +
    ]; + } + return options; + } + + render() { + let currentValue; + + const menuStyle = {}; + if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; + + let menu; + if (this.state.expanded) { + if (this.props.searchEnabled) { + currentValue = ; + } + menu =
    + { this._getMenuOptions() } +
    ; + } + + if (!currentValue) { + const selectedChild = this.props.getShortOption ? + this.props.getShortOption(this.props.value) : + this.childrenByKey[this.props.value]; + currentValue =
    + { selectedChild } +
    ; + } + + const dropdownClasses = { + mx_Dropdown: true, + mx_Dropdown_disabled: this.props.disabled, + }; + if (this.props.className) { + dropdownClasses[this.props.className] = true; + } + + // Note the menu sits inside the AccessibleButton div so it's anchored + // to the input, but overflows below it. The root contains both. + return
    + + { currentValue } + + { menu } + +
    ; + } +} + +Dropdown.propTypes = { + // The width that the dropdown should be. If specified, + // the dropped-down part of the menu will be set to this + // width. + menuWidth: PropTypes.number, + // Called when the selected option changes + onOptionChange: PropTypes.func.isRequired, + // Called when the value of the search field changes + onSearchChange: PropTypes.func, + searchEnabled: PropTypes.bool, + // Function that, given the key of an option, returns + // a node representing that option to be displayed in the + // box itself as the currently-selected option (ie. as + // opposed to in the actual dropped-down part). If + // unspecified, the appropriate child element is used as + // in the dropped-down menu. + getShortOption: PropTypes.func, + value: PropTypes.string, + // negative for consistency with HTML + disabled: PropTypes.bool, +}; diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js new file mode 100644 index 0000000000..05ae625515 --- /dev/null +++ b/src/components/views/elements/EditableItemList.js @@ -0,0 +1,153 @@ +/* +Copyright 2017 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import {_t} from '../../../languageHandler.js'; + +const EditableItem = React.createClass({ + displayName: 'EditableItem', + + propTypes: { + initialValue: PropTypes.string, + index: PropTypes.number, + placeholder: PropTypes.string, + + onChange: PropTypes.func, + onRemove: PropTypes.func, + onAdd: PropTypes.func, + + addOnChange: PropTypes.bool, + }, + + onChange: function(value) { + this.setState({ value }); + if (this.props.onChange) this.props.onChange(value, this.props.index); + if (this.props.addOnChange && this.props.onAdd) this.props.onAdd(value); + }, + + onRemove: function() { + if (this.props.onRemove) this.props.onRemove(this.props.index); + }, + + onAdd: function() { + if (this.props.onAdd) this.props.onAdd(this.state.value); + }, + + render: function() { + const EditableText = sdk.getComponent('elements.EditableText'); + return
    + + { this.props.onAdd ? +
    + {_t("Add")} +
    + : +
    + {_t("Delete")} +
    + } +
    ; + }, +}); + +module.exports = React.createClass({ + displayName: 'EditableItemList', + + propTypes: { + items: PropTypes.arrayOf(PropTypes.string).isRequired, + onNewItemChanged: PropTypes.func, + onItemAdded: PropTypes.func, + onItemEdited: PropTypes.func, + onItemRemoved: PropTypes.func, + + canEdit: PropTypes.bool, + }, + + getDefaultProps: function() { + return { + onItemAdded: () => {}, + onItemEdited: () => {}, + onItemRemoved: () => {}, + onNewItemChanged: () => {}, + }; + }, + + onItemAdded: function(value) { + this.props.onItemAdded(value); + }, + + onItemEdited: function(value, index) { + if (value.length === 0) { + this.onItemRemoved(index); + } else { + this.props.onItemEdited(value, index); + } + }, + + onItemRemoved: function(index) { + this.props.onItemRemoved(index); + }, + + onNewItemChanged: function(value) { + this.props.onNewItemChanged(value); + }, + + render: function() { + const editableItems = this.props.items.map((item, index) => { + return ; + }); + + const label = this.props.items.length > 0 ? + this.props.itemsLabel : this.props.noItemsLabel; + + return (
    +
    + { label } +
    + { editableItems } + { this.props.canEdit ? + :
    + } +
    ); + }, +}); diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 15118f249e..ce1817c272 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -16,7 +16,8 @@ limitations under the License. 'use strict'; -var React = require('react'); +const React = require('react'); +import PropTypes from 'prop-types'; const KEY_TAB = 9; const KEY_SHIFT = 16; @@ -26,15 +27,18 @@ module.exports = React.createClass({ displayName: 'EditableText', propTypes: { - onValueChanged: React.PropTypes.func, - initialValue: React.PropTypes.string, - label: React.PropTypes.string, - placeholder: React.PropTypes.string, - className: React.PropTypes.string, - labelClassName: React.PropTypes.string, - placeholderClassName: React.PropTypes.string, - blurToCancel: React.PropTypes.bool, - editable: React.PropTypes.bool, + onValueChanged: PropTypes.func, + initialValue: PropTypes.string, + label: PropTypes.string, + placeholder: PropTypes.string, + className: PropTypes.string, + labelClassName: PropTypes.string, + placeholderClassName: PropTypes.string, + // Overrides blurToSubmit if true + blurToCancel: PropTypes.bool, + // Will cause onValueChanged(value, true) to fire on blur + blurToSubmit: PropTypes.bool, + editable: PropTypes.bool, }, Phases: { @@ -51,17 +55,20 @@ module.exports = React.createClass({ editable: true, className: "mx_EditableText", placeholderClassName: "mx_EditableText_placeholder", + blurToSubmit: false, }; }, getInitialState: function() { return { phase: this.Phases.Display, - } + }; }, componentWillReceiveProps: function(nextProps) { - if (nextProps.initialValue !== this.props.initialValue) { + if (nextProps.initialValue !== this.props.initialValue || + nextProps.initialValue !== this.value + ) { this.value = nextProps.initialValue; if (this.refs.editable_div) { this.showPlaceholder(!this.value); @@ -89,8 +96,7 @@ module.exports = React.createClass({ this.refs.editable_div.setAttribute("class", this.props.className + " " + this.props.placeholderClassName); this.placeholder = true; this.value = ''; - } - else { + } else { this.refs.editable_div.textContent = this.value; this.refs.editable_div.setAttribute("class", this.props.className); this.placeholder = false; @@ -119,6 +125,7 @@ module.exports = React.createClass({ this.value = this.props.initialValue; this.showPlaceholder(!this.value); this.onValueChanged(false); + this.refs.editable_div.blur(); }, onValueChanged: function(shouldSubmit) { @@ -145,8 +152,7 @@ module.exports = React.createClass({ if (!ev.target.textContent) { this.showPlaceholder(true); - } - else if (!this.placeholder) { + } else if (!this.placeholder) { this.value = ev.target.textContent; } @@ -164,48 +170,47 @@ module.exports = React.createClass({ this.setState({ phase: this.Phases.Edit, - }) + }); }, onFocus: function(ev) { //ev.target.setSelectionRange(0, ev.target.textContent.length); - var node = ev.target.childNodes[0]; + const node = ev.target.childNodes[0]; if (node) { - var range = document.createRange(); + const range = document.createRange(); range.setStart(node, 0); range.setEnd(node, node.length); - var sel = window.getSelection(); + const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } }, - onFinish: function(ev) { - var self = this; - var submit = (ev.key === "Enter"); + onFinish: function(ev, shouldSubmit) { + const self = this; + const submit = (ev.key === "Enter") || shouldSubmit; this.setState({ phase: this.Phases.Display, }, function() { - self.onValueChanged(submit); + if (this.value !== this.props.initialValue) { + self.onValueChanged(submit); + } }); }, onBlur: function(ev) { - var sel = window.getSelection(); + const sel = window.getSelection(); sel.removeAllRanges(); - if (this.props.blurToCancel) - this.cancelEdit(); - else - this.onFinish(ev); + if (this.props.blurToCancel) {this.cancelEdit();} else {this.onFinish(ev, this.props.blurToSubmit);} this.showPlaceholder(!this.value); }, render: function() { - var editable_el; + let editable_el; if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) { // show the label @@ -217,5 +222,5 @@ module.exports = React.createClass({ } return editable_el; - } + }, }); diff --git a/src/components/views/elements/EditableTextContainer.js b/src/components/views/elements/EditableTextContainer.js index b17f1b417d..064d2f1c39 100644 --- a/src/components/views/elements/EditableTextContainer.js +++ b/src/components/views/elements/EditableTextContainer.js @@ -15,8 +15,9 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; -import q from 'q'; +import Promise from 'bluebird'; /** * A component which wraps an EditableText, with a spinner while updates take @@ -64,7 +65,7 @@ export default class EditableTextContainer extends React.Component { errorString: error.toString(), busy: false, }); - } + }, ); } @@ -96,26 +97,27 @@ export default class EditableTextContainer extends React.Component { errorString: error.toString(), busy: false, }); - } + }, ); } render() { if (this.state.busy) { - var Loader = sdk.getComponent("elements.Spinner"); + const Loader = sdk.getComponent("elements.Spinner"); return ( ); } else if (this.state.errorString) { return ( -
    {this.state.errorString}
    +
    { this.state.errorString }
    ); } else { - var EditableText = sdk.getComponent('elements.EditableText'); + const EditableText = sdk.getComponent('elements.EditableText'); return ( ); } @@ -125,23 +127,27 @@ export default class EditableTextContainer extends React.Component { EditableTextContainer.propTypes = { /* callback to retrieve the initial value. */ - getInitialValue: React.PropTypes.func, + getInitialValue: PropTypes.func, /* initial value; used if getInitialValue is not given */ - initialValue: React.PropTypes.string, + initialValue: PropTypes.string, /* placeholder text to use when the value is empty (and not being * edited) */ - placeholder: React.PropTypes.string, + placeholder: PropTypes.string, /* callback to update the value. Called with a single argument: the new * value. */ - onSubmit: React.PropTypes.func, + onSubmit: PropTypes.func, + + /* should the input submit when focus is lost? */ + blurToSubmit: PropTypes.bool, }; EditableTextContainer.defaultProps = { initialValue: "", placeholder: "", - onSubmit: function(v) {return q(); }, + blurToSubmit: false, + onSubmit: function(v) {return Promise.resolve(); }, }; diff --git a/src/components/views/elements/EmojiText.js b/src/components/views/elements/EmojiText.js index cb6cd2ef5e..9fb650b2c3 100644 --- a/src/components/views/elements/EmojiText.js +++ b/src/components/views/elements/EmojiText.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta + Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,17 +16,25 @@ */ import React from 'react'; -import {emojifyText} from '../../../HtmlUtils'; +import PropTypes from 'prop-types'; +import {emojifyText, containsEmoji} from '../../../HtmlUtils'; export default function EmojiText(props) { const {element, children, ...restProps} = props; - restProps.dangerouslySetInnerHTML = emojifyText(children); - return React.createElement(element, restProps); + + // fast path: simple regex to detect strings that don't contain + // emoji and just return them + if (containsEmoji(children)) { + restProps.dangerouslySetInnerHTML = emojifyText(children); + return React.createElement(element, restProps); + } else { + return React.createElement(element, restProps, children); + } } EmojiText.propTypes = { - element: React.PropTypes.string, - children: React.PropTypes.string.isRequired, + element: PropTypes.string, + children: PropTypes.string.isRequired, }; EmojiText.defaultProps = { diff --git a/src/components/views/elements/Flair.js b/src/components/views/elements/Flair.js new file mode 100644 index 0000000000..76566e8c4d --- /dev/null +++ b/src/components/views/elements/Flair.js @@ -0,0 +1,142 @@ +/* + Copyright 2017 New Vector Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +'use strict'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import {MatrixClient} from 'matrix-js-sdk'; +import FlairStore from '../../../stores/FlairStore'; +import dis from '../../../dispatcher'; + + +class FlairAvatar extends React.Component { + constructor() { + super(); + this.onClick = this.onClick.bind(this); + } + + onClick(ev) { + ev.preventDefault(); + // Don't trigger onClick of parent element + ev.stopPropagation(); + dis.dispatch({ + action: 'view_group', + group_id: this.props.groupProfile.groupId, + }); + } + + render() { + const httpUrl = this.context.matrixClient.mxcUrlToHttp( + this.props.groupProfile.avatarUrl, 16, 16, 'scale', false); + const tooltip = this.props.groupProfile.name ? + `${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`: + this.props.groupProfile.groupId; + return ; + } +} + +FlairAvatar.propTypes = { + groupProfile: PropTypes.shape({ + groupId: PropTypes.string.isRequired, + name: PropTypes.string, + avatarUrl: PropTypes.string.isRequired, + }), +}; + +FlairAvatar.contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, +}; + +export default class Flair extends React.Component { + constructor() { + super(); + this.state = { + profiles: [], + }; + } + + componentWillUnmount() { + this._unmounted = true; + } + + componentWillMount() { + this._unmounted = false; + this._generateAvatars(this.props.groups); + } + + componentWillReceiveProps(newProps) { + this._generateAvatars(newProps.groups); + } + + async _getGroupProfiles(groups) { + const profiles = []; + for (const groupId of groups) { + let groupProfile = null; + try { + groupProfile = await FlairStore.getGroupProfileCached(this.context.matrixClient, groupId); + } catch (err) { + console.error('Could not get profile for group', groupId, err); + } + profiles.push(groupProfile); + } + return profiles.filter((p) => p !== null); + } + + async _generateAvatars(groups) { + if (!groups || groups.length === 0) { + return; + } + const profiles = await this._getGroupProfiles(groups); + if (!this.unmounted) { + this.setState({ + profiles: profiles.filter((profile) => { + return profile ? profile.avatarUrl : false; + }), + }); + } + } + + render() { + if (this.state.profiles.length === 0) { + return
    ; + } + const avatars = this.state.profiles.map((profile, index) => { + return ; + }); + return ( + + { avatars } + + ); + } +} + +Flair.propTypes = { + groups: PropTypes.arrayOf(PropTypes.string), +}; + +// TODO: We've decided that all components should follow this pattern, which means removing withMatrixClient and using +// this.context.matrixClient everywhere instead of this.props.matrixClient. +// See https://github.com/vector-im/riot-web/issues/4951. +Flair.contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, +}; diff --git a/src/components/views/elements/GroupsButton.js b/src/components/views/elements/GroupsButton.js new file mode 100644 index 0000000000..75dfe4e9ad --- /dev/null +++ b/src/components/views/elements/GroupsButton.js @@ -0,0 +1,39 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const GroupsButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +GroupsButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default GroupsButton; diff --git a/src/components/views/elements/HomeButton.js b/src/components/views/elements/HomeButton.js new file mode 100644 index 0000000000..05e21487eb --- /dev/null +++ b/src/components/views/elements/HomeButton.js @@ -0,0 +1,39 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const HomeButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +HomeButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default HomeButton; diff --git a/src/components/views/elements/LanguageDropdown.js b/src/components/views/elements/LanguageDropdown.js new file mode 100644 index 0000000000..365f9ded61 --- /dev/null +++ b/src/components/views/elements/LanguageDropdown.js @@ -0,0 +1,121 @@ +/* +Copyright 2017 Marcel Radzio (MTRNord) +Copyright 2017 Vector Creations Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import sdk from '../../../index'; +import * as languageHandler from '../../../languageHandler'; +import SettingsStore from "../../../settings/SettingsStore"; + +function languageMatchesSearchQuery(query, language) { + if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true; + if (language.value.toUpperCase() == query.toUpperCase()) return true; + return false; +} + +export default class LanguageDropdown extends React.Component { + constructor(props) { + super(props); + this._onSearchChange = this._onSearchChange.bind(this); + + this.state = { + searchQuery: '', + langs: null, + }; + } + + componentWillMount() { + languageHandler.getAllLanguagesFromJson().then((langs) => { + langs.sort(function(a, b) { + if (a.label < b.label) return -1; + if (a.label > b.label) return 1; + return 0; + }); + this.setState({langs}); + }).catch(() => { + this.setState({langs: ['en']}); + }).done(); + + if (!this.props.value) { + // If no value is given, we start with the first + // country selected, but our parent component + // doesn't know this, therefore we do this. + const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); + if (language) { + this.props.onOptionChange(language); + } else { + const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser()); + this.props.onOptionChange(language); + } + } + } + + _onSearchChange(search) { + this.setState({ + searchQuery: search, + }); + } + + render() { + if (this.state.langs === null) { + const Spinner = sdk.getComponent('elements.Spinner'); + return ; + } + + const Dropdown = sdk.getComponent('elements.Dropdown'); + + let displayedLanguages; + if (this.state.searchQuery) { + displayedLanguages = this.state.langs.filter((lang) => { + return languageMatchesSearchQuery(this.state.searchQuery, lang); + }); + } else { + displayedLanguages = this.state.langs; + } + + const options = displayedLanguages.map((language) => { + return
    + { language.label } +
    ; + }); + + // default value here too, otherwise we need to handle null / undefined + // values between mounting and the initial value propgating + let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); + let value = null; + if (language) { + value = this.props.value || language; + } else { + language = navigator.language || navigator.userLanguage; + value = this.props.value || language; + } + + return + { options } + ; + } +} + +LanguageDropdown.propTypes = { + className: PropTypes.string, + onOptionChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js new file mode 100644 index 0000000000..17dbbeee62 --- /dev/null +++ b/src/components/views/elements/ManageIntegsButton.js @@ -0,0 +1,107 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import classNames from 'classnames'; +import SdkConfig from '../../../SdkConfig'; +import ScalarAuthClient from '../../../ScalarAuthClient'; +import ScalarMessaging from '../../../ScalarMessaging'; +import Modal from "../../../Modal"; +import { _t } from '../../../languageHandler'; +import AccessibleButton from './AccessibleButton'; +import TintableSvg from './TintableSvg'; + +export default class ManageIntegsButton extends React.Component { + constructor(props) { + super(props); + + this.state = { + scalarError: null, + }; + + this.onManageIntegrations = this.onManageIntegrations.bind(this); + } + + componentWillMount() { + ScalarMessaging.startListening(); + 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({ scalarError: err}); + console.error('Error whilst initialising scalarClient for ManageIntegsButton', err); + }); + } + } + + componentWillUnmount() { + ScalarMessaging.stopListening(); + } + + onManageIntegrations(ev) { + ev.preventDefault(); + if (this.state.scalarError && !this.scalarClient.hasCredentials()) { + return; + } + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + Modal.createDialog(IntegrationsManager, { + src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? + this.scalarClient.getScalarInterfaceUrlForRoom(this.props.roomId) : + null, + }, "mx_IntegrationsManager"); + } + + render() { + let integrationsButton =
    ; + let integrationsWarningTriangle =
    ; + let integrationsErrorPopup =
    ; + if (this.scalarClient !== null) { + const integrationsButtonClasses = classNames({ + mx_RoomHeader_button: true, + mx_RoomSettings_integrationsButton_error: !!this.state.scalarError, + }); + + if (this.state.scalarError && !this.scalarClient.hasCredentials()) { + integrationsWarningTriangle = ; + // Popup shown when hovering over integrationsButton_error (via CSS) + integrationsErrorPopup = ( + + { _t('Could not connect to the integration server') } + + ); + } + + integrationsButton = ( + + + { integrationsWarningTriangle } + { integrationsErrorPopup } + + ); + } + + return integrationsButton; + } +} + +ManageIntegsButton.propTypes = { + roomId: PropTypes.string.isRequired, +}; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index bff2ce3d05..3c58f90a2b 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -14,136 +14,45 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; const MemberAvatar = require('../avatars/MemberAvatar.js'); +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'MemberEventListSummary', propTypes: { // An array of member events to summarise - events: React.PropTypes.array.isRequired, + events: PropTypes.array.isRequired, // An array of EventTiles to render when expanded - children: React.PropTypes.array.isRequired, - // The maximum number of names to show in either the join or leave summaries - summaryLength: React.PropTypes.number, + children: PropTypes.array.isRequired, + // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" + summaryLength: PropTypes.number, // The maximum number of avatars to display in the summary - avatarsMaxLength: React.PropTypes.number, + avatarsMaxLength: PropTypes.number, // The minimum number of events needed to trigger summarisation - threshold: React.PropTypes.number, + threshold: PropTypes.number, + // Called when the MELS expansion is toggled + onToggle: PropTypes.func, + // Whether or not to begin with state.expanded=true + startExpanded: PropTypes.bool, }, getInitialState: function() { return { - expanded: false, + expanded: Boolean(this.props.startExpanded), }; }, getDefaultProps: function() { return { - summaryLength: 3, + summaryLength: 1, threshold: 3, avatarsMaxLength: 5, }; }, - _toggleSummary: function() { - this.setState({ - expanded: !this.state.expanded, - }); - }, - - _getEventSenderName: function(ev) { - if (!ev) { - return 'undefined'; - } - return ev.sender.name || ev.event.content.displayname || ev.getSender(); - }, - - _renderNameList: function(events) { - if (events.length === 0) { - return null; - } - let originalNumber = events.length; - events = events.slice(0, this.props.summaryLength); - let lastEvent = events.pop(); - - let names = events.map((ev) => { - return this._getEventSenderName(ev); - }).join(', '); - - let lastName = this._getEventSenderName(lastEvent); - if (names.length === 0) { - // special-case for a single event - return lastName; - } - - let remaining = originalNumber - this.props.summaryLength; - if (remaining > 0) { - // name1, name2, name3, and 100 others - return names + ', ' + lastName + ', and ' + remaining + ' others'; - } else { - // name1, name2 and name3 - return names + ' and ' + lastName; - } - }, - - _renderSummary: function(joinEvents, leaveEvents) { - let joiners = this._renderNameList(joinEvents); - let leavers = this._renderNameList(leaveEvents); - - let joinSummary = null; - if (joiners) { - joinSummary = ( - - {joiners} joined the room - - ); - } - let leaveSummary = null; - if (leavers) { - leaveSummary = ( - - {leavers} left the room - - ); - } - - // The joinEvents and leaveEvents are representative of the net movement - // per-user, and so it is possible that the total net movement is nil, - // whilst there are some events in the expanded list. If the total net - // movement is nil, then neither joinSummary nor leaveSummary will be - // truthy, so return null. - if (!joinSummary && !leaveSummary) { - return null; - } - - return ( - - {joinSummary}{joinSummary && leaveSummary?'; ':''} - {leaveSummary}.  - - ); - }, - - _renderAvatars: function(events) { - let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => { - return ( - - ); - }); - - return ( - - {avatars} - - ); - }, - shouldComponentUpdate: function(nextProps, nextState) { // Update if // - The number of summarised events has changed @@ -157,10 +66,358 @@ module.exports = React.createClass({ ); }, + _toggleSummary: function() { + this.setState({ + expanded: !this.state.expanded, + }); + this.props.onToggle(); + }, + + /** + * Render the JSX for users aggregated by their transition sequences (`eventAggregates`) where + * the sequences are ordered by `orderedTransitionSequences`. + * @param {object[]} eventAggregates a map of transition sequence to array of user display names + * or user IDs. + * @param {string[]} orderedTransitionSequences an array which is some ordering of + * `Object.keys(eventAggregates)`. + * @returns {ReactElement} a single containing the textual summary of the aggregated + * events that occurred. + */ + _renderSummary: function(eventAggregates, orderedTransitionSequences) { + const summaries = orderedTransitionSequences.map((transitions) => { + const userNames = eventAggregates[transitions]; + const nameList = this._renderNameList(userNames); + + const splitTransitions = transitions.split(','); + + // Some neighbouring transitions are common, so canonicalise some into "pair" + // transitions + const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); + // Transform into consecutive repetitions of the same transition (like 5 + // consecutive 'joined_and_left's) + const coalescedTransitions = this._coalesceRepeatedTransitions( + canonicalTransitions, + ); + + const descs = coalescedTransitions.map((t) => { + return this._getDescriptionForTransition( + t.transitionType, userNames.length, t.repeats, + ); + }); + + const desc = this._renderCommaSeparatedList(descs); + + return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc }); + }); + + if (!summaries) { + return null; + } + + const EmojiText = sdk.getComponent('elements.EmojiText'); + + return ( + + + { summaries.join(", ") } + + + ); + }, + + /** + * @param {string[]} users an array of user display names or user IDs. + * @returns {string} a comma-separated list that ends with "and [n] others" if there are + * more items in `users` than `this.props.summaryLength`, which is the number of names + * included before "and [n] others". + */ + _renderNameList: function(users) { + return this._renderCommaSeparatedList(users, this.props.summaryLength); + }, + + /** + * Canonicalise an array of transitions such that some pairs of transitions become + * single transitions. For example an input ['joined','left'] would result in an output + * ['joined_and_left']. + * @param {string[]} transitions an array of transitions. + * @returns {string[]} an array of transitions. + */ + _getCanonicalTransitions: function(transitions) { + const modMap = { + 'joined': { + 'after': 'left', + 'newTransition': 'joined_and_left', + }, + 'left': { + 'after': 'joined', + 'newTransition': 'left_and_joined', + }, + // $currentTransition : { + // 'after' : $nextTransition, + // 'newTransition' : 'new_transition_type', + // }, + }; + const res = []; + + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i]; + const t2 = transitions[i + 1]; + + let transition = t; + + if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) { + transition = modMap[t].newTransition; + i++; + } + + res.push(transition); + } + return res; + }, + + /** + * Transform an array of transitions into an array of transitions and how many times + * they are repeated consecutively. + * + * An array of 123 "joined_and_left" transitions, would result in: + * ``` + * [{ + * transitionType: "joined_and_left" + * repeats: 123 + * }] + * ``` + * @param {string[]} transitions the array of transitions to transform. + * @returns {object[]} an array of coalesced transitions. + */ + _coalesceRepeatedTransitions: function(transitions) { + const res = []; + for (let i = 0; i < transitions.length; i++) { + if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { + res[res.length - 1].repeats += 1; + } else { + res.push({ + transitionType: transitions[i], + repeats: 1, + }); + } + } + return res; + }, + + /** + * For a certain transition, t, describe what happened to the users that + * underwent the transition. + * @param {string} t the transition type. + * @param {integer} userCount number of usernames + * @param {number} repeats the number of times the transition was repeated in a row. + * @returns {string} the written Human Readable equivalent of the transition. + */ + _getDescriptionForTransition(t, userCount, repeats) { + // The empty interpolations 'severalUsers' and 'oneUser' + // are there only to show translators to non-English languages + // that the verb is conjugated to plural or singular Subject. + let res = null; + switch (t) { + case "joined": + res = (userCount > 1) + ? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count: repeats }); + break; + case "left": + res = (userCount > 1) + ? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sleft %(count)s times", { oneUser: "", count: repeats }); + break; + case "joined_and_left": + res = (userCount > 1) + ? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count: repeats }); + break; + case "left_and_joined": + res = (userCount > 1) + ? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count: repeats }); + break; + case "invite_reject": + res = (userCount > 1) + ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats }); + break; + case "invite_withdrawal": + res = (userCount > 1) + ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats }); + break; + case "invited": + res = (userCount > 1) + ? _t("were invited %(count)s times", { count: repeats }) + : _t("was invited %(count)s times", { count: repeats }); + break; + case "banned": + res = (userCount > 1) + ? _t("were banned %(count)s times", { count: repeats }) + : _t("was banned %(count)s times", { count: repeats }); + break; + case "unbanned": + res = (userCount > 1) + ? _t("were unbanned %(count)s times", { count: repeats }) + : _t("was unbanned %(count)s times", { count: repeats }); + break; + case "kicked": + res = (userCount > 1) + ? _t("were kicked %(count)s times", { count: repeats }) + : _t("was kicked %(count)s times", { count: repeats }); + break; + case "changed_name": + res = (userCount > 1) + ? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count: repeats }); + break; + case "changed_avatar": + res = (userCount > 1) + ? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count: repeats }); + break; + } + + return res; + }, + + /** + * Constructs a written English string representing `items`, with an optional limit on + * the number of items included in the result. If specified and if the length of + *`items` is greater than the limit, the string "and n others" will be appended onto + * the result. + * If `items` is empty, returns the empty string. If there is only one item, return + * it. + * @param {string[]} items the items to construct a string from. + * @param {number?} itemLimit the number by which to limit the list. + * @returns {string} a string constructed by joining `items` with a comma between each + * item, but with the last item appended as " and [lastItem]". + */ + _renderCommaSeparatedList(items, itemLimit) { + const remaining = itemLimit === undefined ? 0 : Math.max( + items.length - itemLimit, 0, + ); + if (items.length === 0) { + return ""; + } else if (items.length === 1) { + return items[0]; + } else if (remaining > 0) { + items = items.slice(0, itemLimit); + return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } ); + } else { + const lastItem = items.pop(); + return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); + } + }, + + _renderAvatars: function(roomMembers) { + const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => { + return ( + + ); + }); + return ( + + { avatars } + + ); + }, + + _getTransitionSequence: function(events) { + return events.map(this._getTransition); + }, + + /** + * Label a given membership event, `e`, where `getContent().membership` has + * changed for each transition allowed by the Matrix protocol. This attempts to + * label the membership changes that occur in `../../../TextForEvent.js`. + * @param {MatrixEvent} e the membership change event to label. + * @returns {string?} the transition type given to this event. This defaults to `null` + * if a transition is not recognised. + */ + _getTransition: function(e) { + switch (e.mxEvent.getContent().membership) { + case 'invite': return 'invited'; + case 'ban': return 'banned'; + case 'join': + if (e.mxEvent.getPrevContent().membership === 'join') { + if (e.mxEvent.getContent().displayname !== + e.mxEvent.getPrevContent().displayname) { + return 'changed_name'; + } else if (e.mxEvent.getContent().avatar_url !== + e.mxEvent.getPrevContent().avatar_url) { + return 'changed_avatar'; + } + // console.log("MELS ignoring duplicate membership join event"); + return null; + } else { + return 'joined'; + } + case 'leave': + if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_reject'; + default: return 'left'; + } + } + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_withdrawal'; + case 'ban': return 'unbanned'; + case 'join': return 'kicked'; + default: return 'left'; + } + default: return null; + } + }, + + _getAggregate: function(userEvents) { + // A map of aggregate type to arrays of display names. Each aggregate type + // is a comma-delimited string of transitions, e.g. "joined,left,kicked". + // The array of display names is the array of users who went through that + // sequence during eventsToRender. + const aggregate = { + // $aggregateType : []:string + }; + // A map of aggregate types to the indices that order them (the index of + // the first event for a given transition sequence) + const aggregateIndices = { + // $aggregateType : int + }; + + const users = Object.keys(userEvents); + users.forEach( + (userId) => { + const firstEvent = userEvents[userId][0]; + const displayName = firstEvent.displayName; + + const seq = this._getTransitionSequence(userEvents[userId]); + if (!aggregate[seq]) { + aggregate[seq] = []; + aggregateIndices[seq] = -1; + } + + aggregate[seq].push(displayName); + + if (aggregateIndices[seq] === -1 || + firstEvent.index < aggregateIndices[seq]) { + aggregateIndices[seq] = firstEvent.index; + } + }, + ); + + return { + names: aggregate, + indices: aggregateIndices, + }; + }, + render: function() { - let eventsToRender = this.props.events; - let fewEvents = eventsToRender.length < this.props.threshold; - let expanded = this.state.expanded || fewEvents; + const eventsToRender = this.props.events; + const eventIds = eventsToRender.map((e) => e.getId()).join(','); + const fewEvents = eventsToRender.length < this.props.threshold; + const expanded = this.state.expanded || fewEvents; let expandedEvents = null; if (expanded) { @@ -169,86 +426,69 @@ module.exports = React.createClass({ if (fewEvents) { return ( -
    - {expandedEvents} +
    + { expandedEvents }
    ); } - // Map user IDs to the first and last member events in eventsToRender for each user - let userEvents = { - // $userId : {first : e0, last : e1} + // Map user IDs to an array of objects: + const userEvents = { + // $userId : [{ + // // The original event + // mxEvent: e, + // // The display name of the user (if not, then user ID) + // displayName: e.target.name || userId, + // // The original index of the event in this.props.events + // index: index, + // }] }; - eventsToRender.forEach((e) => { - const userId = e.getSender(); + const avatarMembers = []; + eventsToRender.forEach((e, index) => { + const userId = e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { - userEvents[userId] = {first: null, last: null}; + userEvents[userId] = []; + if (e.target) avatarMembers.push(e.target); } - if (!userEvents[userId].first) { - userEvents[userId].first = e; - } - userEvents[userId].last = e; + userEvents[userId].push({ + mxEvent: e, + displayName: (e.target ? e.target.name : null) || userId, + index: index, + }); }); - // Populate the join/leave event arrays with events that represent what happened - // overall to a user's membership. If no events are added to either array for a - // particular user, they will be considered a user that "joined and left". - let joinEvents = []; - let leaveEvents = []; - let joinedAndLeft = 0; - let senders = Object.keys(userEvents); - senders.forEach( - (userId) => { - let firstEvent = userEvents[userId].first; - let lastEvent = userEvents[userId].last; + const aggregate = this._getAggregate(userEvents); - // Membership BEFORE eventsToRender - let previousMembership = firstEvent.getPrevContent().membership || "leave"; - - // If the last membership event differs from previousMembership, use that. - if (previousMembership !== lastEvent.getContent().membership) { - if (lastEvent.event.content.membership === 'join') { - joinEvents.push(lastEvent); - } else if (lastEvent.event.content.membership === 'leave') { - leaveEvents.push(lastEvent); - } - } else { - // Increment the number of users whose membership change was nil overall - joinedAndLeft++; - } - } + // Sort types by order of lowest event index within sequence + const orderedTransitionSequences = Object.keys(aggregate.names).sort( + (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2], ); - let avatars = this._renderAvatars(joinEvents.concat(leaveEvents)); - let summary = this._renderSummary(joinEvents, leaveEvents); - let toggleButton = ( - - {expanded ? 'collapse' : 'expand'} - - ); - let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users'; - let noun = (joinedAndLeft === 1 ? 'user' : plural); - - let summaryContainer = ( -
    -
    - - {avatars} - - - {summary}{joinedAndLeft ? joinedAndLeft + ' ' + noun + ' joined and left' : ''} -   - {toggleButton} + let summaryContainer = null; + if (!expanded) { + summaryContainer = ( +
    +
    + { this._renderAvatars(avatarMembers) } + { this._renderSummary(aggregate.names, orderedTransitionSequences) } +
    + ); + } + const toggleButton = ( +
    + { expanded ? _t('collapse') : _t('expand') }
    ); return ( -
    - {summaryContainer} - {expandedEvents} +
    + { toggleButton } + { summaryContainer } + { expanded ?
     
    : null } + { expandedEvents }
    ); }, diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js new file mode 100644 index 0000000000..500c919d45 --- /dev/null +++ b/src/components/views/elements/MessageSpinner.js @@ -0,0 +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 React from 'react'; + +module.exports = React.createClass({ + displayName: 'MessageSpinner', + + render: function() { + const w = this.props.w || 32; + const h = this.props.h || 32; + const imgClass = this.props.imgClassName || ""; + const msg = this.props.msg || "Loading..."; + return ( +
    +
    { msg }
      + +
    + ); + }, +}); diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js new file mode 100644 index 0000000000..a85f83d78c --- /dev/null +++ b/src/components/views/elements/Pill.js @@ -0,0 +1,242 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import classNames from 'classnames'; +import { Room, RoomMember } from 'matrix-js-sdk'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix'; +import { getDisplayAliasForRoom } from '../../../Rooms'; + +const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); + +// For URLs of matrix.to links in the timeline which have been reformatted by +// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) +const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+])[^\/]*)$/; + +const Pill = React.createClass({ + statics: { + isPillUrl: (url) => { + return !!REGEX_MATRIXTO.exec(url); + }, + isMessagePillUrl: (url) => { + return !!REGEX_LOCAL_MATRIXTO.exec(url); + }, + roomNotifPos: (text) => { + return text.indexOf("@room"); + }, + roomNotifLen: () => { + return "@room".length; + }, + TYPE_USER_MENTION: 'TYPE_USER_MENTION', + TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', + TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention + }, + + props: { + // The Type of this Pill. If url is given, this is auto-detected. + type: PropTypes.string, + // The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl) + url: PropTypes.string, + // Whether the pill is in a message + inMessage: PropTypes.bool, + // The room in which this pill is being rendered + room: PropTypes.instanceOf(Room), + // Whether to include an avatar in the pill + shouldShowPillAvatar: PropTypes.bool, + }, + + getInitialState() { + return { + // ID/alias of the room/user + resourceId: null, + // Type of pill + pillType: null, + + // The member related to the user pill + member: null, + // The room related to the room pill + room: null, + }; + }, + + componentWillReceiveProps(nextProps) { + let regex = REGEX_MATRIXTO; + if (nextProps.inMessage) { + regex = REGEX_LOCAL_MATRIXTO; + } + + let matrixToMatch; + let resourceId; + let prefix; + + if (nextProps.url) { + // Default to the empty array if no match for simplicity + // resource and prefix will be undefined instead of throwing + matrixToMatch = regex.exec(nextProps.url) || []; + + resourceId = matrixToMatch[1]; // The room/user ID + prefix = matrixToMatch[2]; // The first character of prefix + } + + const pillType = this.props.type || { + '@': Pill.TYPE_USER_MENTION, + '#': Pill.TYPE_ROOM_MENTION, + '!': Pill.TYPE_ROOM_MENTION, + }[prefix]; + + let member; + let room; + switch (pillType) { + case Pill.TYPE_AT_ROOM_MENTION: { + room = nextProps.room; + } + break; + case Pill.TYPE_USER_MENTION: { + const localMember = nextProps.room.getMember(resourceId); + member = localMember; + if (!localMember) { + member = new RoomMember(null, resourceId); + this.doProfileLookup(resourceId, member); + } + } + break; + case Pill.TYPE_ROOM_MENTION: { + const localRoom = resourceId[0] === '#' ? + MatrixClientPeg.get().getRooms().find((r) => { + return r.getAliases().includes(resourceId); + }) : MatrixClientPeg.get().getRoom(resourceId); + room = localRoom; + if (!localRoom) { + // TODO: This would require a new API to resolve a room alias to + // a room avatar and name. + // this.doRoomProfileLookup(resourceId, member); + } + } + break; + } + this.setState({resourceId, pillType, member, room}); + }, + + componentWillMount() { + this._unmounted = false; + this.componentWillReceiveProps(this.props); + }, + + componentWillUnmount() { + this._unmounted = true; + }, + + doProfileLookup: function(userId, member) { + MatrixClientPeg.get().getProfileInfo(userId).then((resp) => { + if (this._unmounted) { + return; + } + member.name = resp.displayname; + member.rawDisplayName = resp.displayname; + member.events.member = { + getContent: () => { + return {avatar_url: resp.avatar_url}; + }, + }; + this.setState({member}); + }).catch((err) => { + console.error('Could not retrieve profile data for ' + userId + ':', err); + }); + }, + + onUserPillClicked: function() { + dis.dispatch({ + action: 'view_user', + member: this.state.member, + }); + }, + render: function() { + const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + const RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); + + const resource = this.state.resourceId; + + let avatar = null; + let linkText = resource; + let pillClass; + let userId; + let href = this.props.url; + let onClick; + switch (this.state.pillType) { + case Pill.TYPE_AT_ROOM_MENTION: { + const room = this.props.room; + if (room) { + linkText = "@room"; + if (this.props.shouldShowPillAvatar) { + avatar = ; + } + pillClass = 'mx_AtRoomPill'; + } + } + break; + case Pill.TYPE_USER_MENTION: { + // If this user is not a member of this room, default to the empty member + const member = this.state.member; + if (member) { + userId = member.userId; + linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done + if (this.props.shouldShowPillAvatar) { + avatar = ; + } + pillClass = 'mx_UserPill'; + href = null; + onClick = this.onUserPillClicked; + } + } + break; + case Pill.TYPE_ROOM_MENTION: { + const room = this.state.room; + if (room) { + linkText = (room ? getDisplayAliasForRoom(room) : null) || resource; + if (this.props.shouldShowPillAvatar) { + avatar = ; + } + pillClass = 'mx_RoomPill'; + } + } + break; + } + + const classes = classNames(pillClass, { + "mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId, + }); + + if (this.state.pillType) { + return this.props.inMessage ? + + { avatar } + { linkText } + : + + { avatar } + { linkText } + ; + } else { + // Deliberately render nothing if the URL isn't recognised + return null; + } + }, +}); + +export default Pill; diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 993f2b965a..f8443c6ecd 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -16,106 +16,139 @@ limitations under the License. 'use strict'; -var React = require('react'); - -var roles = { - 0: 'User', - 50: 'Moderator', - 100: 'Admin', -}; - -var reverseRoles = {}; -Object.keys(roles).forEach(function(key) { - reverseRoles[roles[key]] = key; -}); +import React from 'react'; +import PropTypes from 'prop-types'; +import * as Roles from '../../../Roles'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'PowerSelector', propTypes: { - value: React.PropTypes.number.isRequired, + value: PropTypes.number.isRequired, + // The maximum value that can be set with the power selector + maxValue: PropTypes.number.isRequired, + + // Default user power level for the room + usersDefault: PropTypes.number.isRequired, // if true, the + ; } - else { - input = - } - customPicker = of { input }; } - var selectValue; + let selectValue; if (this.state.custom) { - selectValue = "Custom"; + selectValue = "SELECT_VALUE_CUSTOM"; + } else { + selectValue = this.state.levelRoleMap[this.props.value] ? + this.props.value : "SELECT_VALUE_CUSTOM"; } - else { - selectValue = roles[this.props.value] || "Custom"; - } - var select; + let select; if (this.props.disabled) { - select = { selectValue }; - } - else { + select = { this.state.levelRoleMap[selectValue] }; + } else { + // Each level must have a definition in this.state.levelRoleMap + let options = this.state.options.map((level) => { + return { + value: level, + text: Roles.textualPowerLevel(level, this.props.usersDefault), + }; + }); + options.push({ value: "SELECT_VALUE_CUSTOM", text: _t("Custom level") }); + options = options.map((op) => { + return ; + }); + select = + value={this.props.controlled ? selectValue : undefined} + defaultValue={!this.props.controlled ? selectValue : undefined} + onChange={this.onSelectChange}> + { options } + ; } return ( @@ -124,5 +157,5 @@ module.exports = React.createClass({ { customPicker } ); - } + }, }); diff --git a/src/components/views/elements/ProgressBar.js b/src/components/views/elements/ProgressBar.js index 12b34480f1..15da5d44f6 100644 --- a/src/components/views/elements/ProgressBar.js +++ b/src/components/views/elements/ProgressBar.js @@ -16,23 +16,24 @@ limitations under the License. 'use strict'; -var React = require('react'); +const React = require('react'); +import PropTypes from 'prop-types'; module.exports = React.createClass({ displayName: 'ProgressBar', propTypes: { - value: React.PropTypes.number, - max: React.PropTypes.number + value: PropTypes.number, + max: PropTypes.number, }, render: function() { // Would use an HTML5 progress tag but if that doesn't animate if you // use the HTML attributes rather than styles - var progressStyle = { - width: ((this.props.value / this.props.max) * 100)+"%" + const progressStyle = { + width: ((this.props.value / this.props.max) * 100)+"%", }; return (
    ); - } -}); \ No newline at end of file + }, +}); diff --git a/src/components/views/elements/Quote.js b/src/components/views/elements/Quote.js new file mode 100644 index 0000000000..761f7aa151 --- /dev/null +++ b/src/components/views/elements/Quote.js @@ -0,0 +1,188 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import sdk from '../../../index'; +import {_t} from '../../../languageHandler'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import {wantsDateSeparator} from '../../../DateUtils'; +import {MatrixEvent} from 'matrix-js-sdk'; +import {makeUserPermalink} from "../../../matrix-to"; + +// For URLs of matrix.to links in the timeline which have been reformatted by +// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) +const REGEX_LOCAL_MATRIXTO = /^#\/room\/([\#\!][^\/]*)\/(\$[^\/]*)$/; + +export default class Quote extends React.Component { + static isMessageUrl(url) { + return !!REGEX_LOCAL_MATRIXTO.exec(url); + } + + static childContextTypes = { + matrixClient: PropTypes.object, + addRichQuote: PropTypes.func, + }; + + static propTypes = { + // The matrix.to url of the event + url: PropTypes.string, + // The original node that was rendered + node: PropTypes.instanceOf(Element), + // The parent event + parentEv: PropTypes.instanceOf(MatrixEvent), + }; + + constructor(props, context) { + super(props, context); + + this.state = { + // The event related to this quote and their nested rich quotes + events: [], + // Whether the top (oldest) event should be shown or spoilered + show: true, + // Whether an error was encountered fetching nested older event, show node if it does + err: false, + }; + + this.onQuoteClick = this.onQuoteClick.bind(this); + this.addRichQuote = this.addRichQuote.bind(this); + } + + getChildContext() { + return { + matrixClient: MatrixClientPeg.get(), + addRichQuote: this.addRichQuote, + }; + } + + parseUrl(url) { + if (!url) return; + + // Default to the empty array if no match for simplicity + // resource and prefix will be undefined instead of throwing + const matrixToMatch = REGEX_LOCAL_MATRIXTO.exec(url) || []; + + const [, roomIdentifier, eventId] = matrixToMatch; + return {roomIdentifier, eventId}; + } + + componentWillReceiveProps(nextProps) { + const {roomIdentifier, eventId} = this.parseUrl(nextProps.url); + if (!roomIdentifier || !eventId) return; + + const room = this.getRoom(roomIdentifier); + if (!room) return; + + // Only try and load the event if we know about the room + // otherwise we just leave a `Quote` anchor which can be used to navigate/join the room manually. + this.setState({ events: [] }); + if (room) this.getEvent(room, eventId, true); + } + + componentWillMount() { + this.componentWillReceiveProps(this.props); + } + + getRoom(id) { + const cli = MatrixClientPeg.get(); + if (id[0] === '!') return cli.getRoom(id); + + return cli.getRooms().find((r) => { + return r.getAliases().includes(id); + }); + } + + async getEvent(room, eventId, show) { + const event = room.findEventById(eventId); + if (event) { + this.addEvent(event, show); + return; + } + + await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId); + this.addEvent(room.findEventById(eventId), show); + } + + addEvent(event, show) { + const events = [event].concat(this.state.events); + this.setState({events, show}); + } + + // addRichQuote(roomId, eventId) { + addRichQuote(href) { + const {roomIdentifier, eventId} = this.parseUrl(href); + if (!roomIdentifier || !eventId) { + this.setState({ err: true }); + return; + } + + const room = this.getRoom(roomIdentifier); + if (!room) { + this.setState({ err: true }); + return; + } + + this.getEvent(room, eventId, false); + } + + onQuoteClick() { + this.setState({ show: true }); + } + + render() { + const events = this.state.events.slice(); + if (events.length) { + const evTiles = []; + + if (!this.state.show) { + const oldestEv = events.shift(); + const Pill = sdk.getComponent('elements.Pill'); + const room = MatrixClientPeg.get().getRoom(oldestEv.getRoomId()); + + evTiles.push(
    + { + _t('In reply to ', {}, { + 'a': (sub) => { sub }, + 'pill': , + }) + } +
    ); + } + + const EventTile = sdk.getComponent('views.rooms.EventTile'); + const DateSeparator = sdk.getComponent('messages.DateSeparator'); + events.forEach((ev) => { + let dateSep = null; + + if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) { + dateSep = ; + } + + evTiles.push(
    + { dateSep } + +
    ); + }); + + return
    { evTiles }
    ; + } + + // Deliberately render nothing if the URL isn't recognised + // in case we get an undefined/falsey node, replace it with null to make React happy + return this.props.node || null; + } +} diff --git a/src/components/views/elements/RoomDirectoryButton.js b/src/components/views/elements/RoomDirectoryButton.js new file mode 100644 index 0000000000..d8f88034e3 --- /dev/null +++ b/src/components/views/elements/RoomDirectoryButton.js @@ -0,0 +1,40 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const RoomDirectoryButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +RoomDirectoryButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default RoomDirectoryButton; diff --git a/src/components/views/elements/SettingsButton.js b/src/components/views/elements/SettingsButton.js new file mode 100644 index 0000000000..215d757e6c --- /dev/null +++ b/src/components/views/elements/SettingsButton.js @@ -0,0 +1,39 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const SettingsButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +SettingsButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default SettingsButton; diff --git a/src/components/views/elements/SettingsFlag.js b/src/components/views/elements/SettingsFlag.js new file mode 100644 index 0000000000..7f6c74538a --- /dev/null +++ b/src/components/views/elements/SettingsFlag.js @@ -0,0 +1,111 @@ +/* +Copyright 2017 Travis Ralston + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import PropTypes from 'prop-types'; +import SettingsStore from "../../../settings/SettingsStore"; +import { _t } from '../../../languageHandler'; + +module.exports = React.createClass({ + displayName: 'SettingsFlag', + propTypes: { + name: PropTypes.string.isRequired, + level: PropTypes.string.isRequired, + roomId: PropTypes.string, // for per-room settings + label: PropTypes.string, // untranslated + onChange: PropTypes.func, + isExplicit: PropTypes.bool, + manualSave: PropTypes.bool, + + // If group is supplied, then this will create a radio button instead. + group: PropTypes.string, + value: PropTypes.any, // the value for the radio button + }, + + getInitialState: function() { + return { + value: SettingsStore.getValueAt( + this.props.level, + this.props.name, + this.props.roomId, + this.props.isExplicit, + ), + }; + }, + + onChange: function(e) { + if (this.props.group && !e.target.checked) return; + + const newState = this.props.group ? this.props.value : e.target.checked; + if (!this.props.manualSave) this.save(newState); + else this.setState({ value: newState }); + if (this.props.onChange) this.props.onChange(newState); + }, + + save: function(val = undefined) { + return SettingsStore.setValue( + this.props.name, + this.props.roomId, + this.props.level, + val !== undefined ? val : this.state.value, + ); + }, + + render: function() { + const value = this.props.manualSave ? this.state.value : SettingsStore.getValueAt( + this.props.level, + this.props.name, + this.props.roomId, + this.props.isExplicit, + ); + + const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level); + + let label = this.props.label; + if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level); + else label = _t(label); + + // We generate a relatively complex ID to avoid conflicts + const id = this.props.name + "_" + this.props.group + "_" + this.props.value + "_" + this.props.level; + let checkbox = ( + + ); + if (this.props.group) { + checkbox = ( + + ); + } + + return ( + + ); + }, +}); diff --git a/src/components/views/elements/StartChatButton.js b/src/components/views/elements/StartChatButton.js new file mode 100644 index 0000000000..199c5e44a6 --- /dev/null +++ b/src/components/views/elements/StartChatButton.js @@ -0,0 +1,40 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const StartChatButton = function(props) { + const ActionButton = sdk.getComponent('elements.ActionButton'); + return ( + + ); +}; + +StartChatButton.propTypes = { + size: PropTypes.string, + tooltip: PropTypes.bool, +}; + +export default StartChatButton; diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js new file mode 100644 index 0000000000..f52f758cc0 --- /dev/null +++ b/src/components/views/elements/TagTile.js @@ -0,0 +1,119 @@ +/* +Copyright 2017 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { MatrixClient } from 'matrix-js-sdk'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; + +import FlairStore from '../../../stores/FlairStore'; + +// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents +// a thing to click on for the user to filter the visible rooms in the RoomList to: +// - Rooms that are part of the group +// - Direct messages with members of the group +// with the intention that this could be expanded to arbitrary tags in future. +export default React.createClass({ + displayName: 'TagTile', + + propTypes: { + // A string tag such as "m.favourite" or a group ID such as "+groupid:domain.bla" + // For now, only group IDs are handled. + tag: PropTypes.string, + }, + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }, + + getInitialState() { + return { + // Whether the mouse is over the tile + hover: false, + // The profile data of the group if this.props.tag is a group ID + profile: null, + }; + }, + + componentWillMount() { + this.unmounted = false; + if (this.props.tag[0] === '+') { + FlairStore.getGroupProfileCached( + this.context.matrixClient, + this.props.tag, + ).then((profile) => { + if (this.unmounted) return; + this.setState({profile}); + }).catch((err) => { + console.warn('Could not fetch group profile for ' + this.props.tag, err); + }); + } + }, + + componentWillUnmount() { + this.unmounted = true; + }, + + onClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + dis.dispatch({ + action: 'select_tag', + tag: this.props.tag, + ctrlOrCmdKey: isOnlyCtrlOrCmdIgnoreShiftKeyEvent(e), + shiftKey: e.shiftKey, + }); + }, + + onMouseOver: function() { + this.setState({hover: true}); + }, + + onMouseOut: function() { + this.setState({hover: false}); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const RoomTooltip = sdk.getComponent('rooms.RoomTooltip'); + const profile = this.state.profile || {}; + const name = profile.name || this.props.tag; + const avatarHeight = 35; + + const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp( + profile.avatarUrl, avatarHeight, avatarHeight, "crop", + ) : null; + + const className = classNames({ + mx_TagTile: true, + mx_TagTile_selected: this.props.selected, + }); + + const tip = this.state.hover ? + : +
    ; + return +
    + + { tip } +
    +
    ; + }, +}); diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js index 0157131506..e04bf87793 100644 --- a/src/components/views/elements/TintableSvg.js +++ b/src/components/views/elements/TintableSvg.js @@ -16,18 +16,19 @@ limitations under the License. 'use strict'; -var React = require('react'); -var ReactDOM = require("react-dom"); -var Tinter = require("../../../Tinter"); +const React = require('react'); +const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; +const Tinter = require("../../../Tinter"); var TintableSvg = React.createClass({ displayName: 'TintableSvg', propTypes: { - src: React.PropTypes.string.isRequired, - width: React.PropTypes.string.isRequired, - height: React.PropTypes.string.isRequired, - className: React.PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.string.isRequired, + height: PropTypes.string.isRequired, + className: PropTypes.string, }, statics: { @@ -63,15 +64,16 @@ var TintableSvg = React.createClass({ render: function() { return ( - ); - } + }, }); // Register with the Tinter so that we will be told if the tint changes diff --git a/src/components/views/elements/TintableSvgButton.js b/src/components/views/elements/TintableSvgButton.js new file mode 100644 index 0000000000..9ca2cdcbb4 --- /dev/null +++ b/src/components/views/elements/TintableSvgButton.js @@ -0,0 +1,61 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import TintableSvg from './TintableSvg'; + +export default class TintableSvgButton extends React.Component { + + constructor(props) { + super(props); + } + + render() { + let classes = "mx_TintableSvgButton"; + if (this.props.className) { + classes += " " + this.props.className; + } + return ( + + + + + ); + } +} + +TintableSvgButton.propTypes = { + src: PropTypes.string, + title: PropTypes.string, + className: PropTypes.string, + width: PropTypes.string.isRequired, + height: PropTypes.string.isRequired, + onClick: PropTypes.func, +}; + +TintableSvgButton.defaultProps = { + onClick: function() {}, +}; diff --git a/src/components/views/elements/ToolTipButton.js b/src/components/views/elements/ToolTipButton.js new file mode 100644 index 0000000000..b5b2d735ee --- /dev/null +++ b/src/components/views/elements/ToolTipButton.js @@ -0,0 +1,55 @@ +/* +Copyright 2017 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../index'; + +module.exports = React.createClass({ + displayName: 'ToolTipButton', + + getInitialState: function() { + return { + hover: false, + }; + }, + + onMouseOver: function() { + this.setState({ + hover: true, + }); + }, + + onMouseOut: function() { + this.setState({ + hover: false, + }); + }, + + render: function() { + const RoomTooltip = sdk.getComponent("rooms.RoomTooltip"); + const tip = this.state.hover ? :
    ; + return ( +
    + ? + { tip } +
    + ); + }, +}); diff --git a/src/components/views/elements/TruncatedList.js b/src/components/views/elements/TruncatedList.js index 3e174848d3..1a674eef65 100644 --- a/src/components/views/elements/TruncatedList.js +++ b/src/components/views/elements/TruncatedList.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,19 +14,31 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); + +import React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'TruncatedList', propTypes: { // The number of elements to show before truncating. If negative, no truncation is done. - truncateAt: React.PropTypes.number, + truncateAt: PropTypes.number, // The className to apply to the wrapping div - className: React.PropTypes.string, + className: PropTypes.string, + // A function that returns the children to be rendered into the element. + // function getChildren(start: number, end: number): Array + // The start element is included, the end is not (as in `slice`). + // If omitted, the React child elements will be used. This parameter can be used + // to avoid creating unnecessary React elements. + getChildren: PropTypes.func, + // A function that should return the total number of child element available. + // Required if getChildren is supplied. + getChildCount: PropTypes.func, // A function which will be invoked when an overflow element is required. // This will be inserted after the children. - createOverflowElement: React.PropTypes.func + createOverflowElement: PropTypes.func, }, getDefaultProps: function() { @@ -33,40 +46,56 @@ module.exports = React.createClass({ truncateAt: 2, createOverflowElement: function(overflowCount, totalCount) { return ( -
    And {overflowCount} more...
    +
    { _t("And %(count)s more...", {count: overflowCount}) }
    ); - } + }, }; }, + _getChildren: function(start, end) { + if (this.props.getChildren && this.props.getChildCount) { + return this.props.getChildren(start, end); + } else { + // XXX: I'm not sure why anything would pass null into this, it seems + // like a bizzare case to handle, but I'm preserving the behaviour. + // (see commit 38d5c7d5c5d5a34dc16ef5d46278315f5c57f542) + return React.Children.toArray(this.props.children).filter((c) => { + return c != null; + }).slice(start, end); + } + }, + + _getChildCount: function() { + if (this.props.getChildren && this.props.getChildCount) { + return this.props.getChildCount(); + } else { + return React.Children.toArray(this.props.children).filter((c) => { + return c != null; + }).length; + } + }, + render: function() { - var childsJsx = this.props.children; - var overflowJsx; - var childArray = React.Children.toArray(this.props.children).filter((c) => { - return c != null; - }); - - var childCount = childArray.length; + let overflowNode = null; + const totalChildren = this._getChildCount(); + let upperBound = totalChildren; if (this.props.truncateAt >= 0) { - var overflowCount = childCount - this.props.truncateAt; - + const overflowCount = totalChildren - this.props.truncateAt; if (overflowCount > 1) { - overflowJsx = this.props.createOverflowElement( - overflowCount, childCount + overflowNode = this.props.createOverflowElement( + overflowCount, totalChildren, ); - - // cut out the overflow elements - childArray.splice(childCount - overflowCount, overflowCount); - childsJsx = childArray; // use what is left + upperBound = this.props.truncateAt; } } + const childNodes = this._getChildren(0, upperBound); return (
    - {childsJsx} - {overflowJsx} + { childNodes } + { overflowNode }
    ); - } + }, }); diff --git a/src/components/views/elements/UserSelector.js b/src/components/views/elements/UserSelector.js index 5f176a3e54..a05f615e34 100644 --- a/src/components/views/elements/UserSelector.js +++ b/src/components/views/elements/UserSelector.js @@ -16,14 +16,16 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'UserSelector', propTypes: { - onChange: React.PropTypes.func, - selected_users: React.PropTypes.arrayOf(React.PropTypes.string), + onChange: PropTypes.func, + selected_users: PropTypes.arrayOf(React.PropTypes.string), }, getDefaultProps: function() { @@ -51,19 +53,19 @@ module.exports = React.createClass({ }, render: function() { - var self = this; + const self = this; return (
      - {this.props.selected_users.map(function(user_id, i) { - return
    • {user_id} - X
    • - })} + { this.props.selected_users.map(function(user_id, i) { + return
    • { user_id } - X
    • ; + }) }
    - +
    ); - } + }, }); diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js new file mode 100644 index 0000000000..d97464e8ca --- /dev/null +++ b/src/components/views/groups/GroupInviteTile.js @@ -0,0 +1,75 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; + +export default React.createClass({ + displayName: 'GroupInviteTile', + + propTypes: { + group: PropTypes.object.isRequired, + }, + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + onClick: function(e) { + dis.dispatch({ + action: 'view_group', + group_id: this.props.group.groupId, + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const EmojiText = sdk.getComponent('elements.EmojiText'); + + const groupName = this.props.group.name || this.props.group.groupId; + const httpAvatarUrl = this.props.group.avatarUrl ? + this.context.matrixClient.mxcUrlToHttp(this.props.group.avatarUrl, 24, 24) : null; + + const av = ; + + const label = + { groupName } + ; + + const badge =
    !
    ; + + return ( + +
    + { av } +
    +
    + { label } + { badge } +
    +
    + ); + }, +}); diff --git a/src/components/views/groups/GroupMemberInfo.js b/src/components/views/groups/GroupMemberInfo.js new file mode 100644 index 0000000000..305aec8cdd --- /dev/null +++ b/src/components/views/groups/GroupMemberInfo.js @@ -0,0 +1,204 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector 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 PropTypes from 'prop-types'; +import React from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import dis from '../../../dispatcher'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import { GroupMemberType } from '../../../groups'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import AccessibleButton from '../elements/AccessibleButton'; +import GeminiScrollbar from 'react-gemini-scrollbar'; + +module.exports = React.createClass({ + displayName: 'GroupMemberInfo', + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + propTypes: { + groupId: PropTypes.string, + groupMember: GroupMemberType, + isInvited: PropTypes.bool, + }, + + getInitialState: function() { + return { + removingUser: false, + isUserPrivilegedInGroup: null, + }; + }, + + componentWillMount: function() { + this._initGroupStore(this.props.groupId); + }, + + componentWillReceiveProps(newProps) { + if (newProps.groupId !== this.props.groupId) { + this._unregisterGroupStore(); + this._initGroupStore(newProps.groupId); + } + }, + + _initGroupStore(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId); + this._groupStore.registerListener(this.onGroupStoreUpdated); + }, + + _unregisterGroupStore() { + if (this._groupStore) { + this._groupStore.unregisterListener(this.onGroupStoreUpdated); + } + }, + + onGroupStoreUpdated: function() { + this.setState({ + isUserInvited: this._groupStore.getGroupInvitedMembers().some( + (m) => m.userId === this.props.groupMember.userId, + ), + isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(), + }); + }, + + _onKick: function() { + const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + Modal.createDialog(ConfirmUserActionDialog, { + matrixClient: this.context.matrixClient, + groupMember: this.props.groupMember, + action: this.state.isUserInvited ? _t('Disinvite') : _t('Remove from community'), + title: this.state.isUserInvited ? _t('Disinvite this user from community?') + : _t('Remove this user from community?'), + danger: true, + onFinished: (proceed) => { + if (!proceed) return; + + this.setState({removingUser: true}); + this.context.matrixClient.removeUserFromGroup( + this.props.groupId, this.props.groupMember.userId, + ).then(() => { + // return to the user list + dis.dispatch({ + action: "view_user", + member: null, + }); + }).catch((e) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, { + title: _t('Error'), + description: this.state.isUserInvited ? + _t('Failed to withdraw invitation') : + _t('Failed to remove user from community'), + }); + }).finally(() => { + this.setState({removingUser: false}); + }); + }, + }); + }, + + _onCancel: function(e) { + // Go back to the user list + dis.dispatch({ + action: "view_user", + member: null, + }); + }, + + onRoomTileClick(roomId) { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + }, + + render: function() { + if (this.state.removingUser) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + + let adminTools; + if (this.state.isUserPrivilegedInGroup) { + const kickButton = ( + + { this.state.isUserInvited ? _t('Disinvite') : _t('Remove from community') } + + ); + + // No make/revoke admin API yet + /*const opLabel = this.state.isTargetMod ? _t("Revoke Moderator") : _t("Make Moderator"); + giveModButton = + {giveOpLabel} + ;*/ + + if (kickButton) { + adminTools = +
    +

    { _t("Admin Tools") }

    +
    + { kickButton } +
    +
    ; + } + } + + const avatarUrl = this.context.matrixClient.mxcUrlToHttp( + this.props.groupMember.avatarUrl, + 36, 36, 'crop', + ); + + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const avatar = ( + + ); + + const groupMemberName = ( + this.props.groupMember.displayname || this.props.groupMember.userId + ); + + const EmojiText = sdk.getComponent('elements.EmojiText'); + return ( +
    + + + + +
    + { avatar } +
    + + { groupMemberName } + +
    +
    + { this.props.groupMember.userId } +
    +
    + + { adminTools } +
    +
    + ); + }, +}); diff --git a/src/components/views/groups/GroupMemberList.js b/src/components/views/groups/GroupMemberList.js new file mode 100644 index 0000000000..71c168f5e6 --- /dev/null +++ b/src/components/views/groups/GroupMemberList.js @@ -0,0 +1,172 @@ +/* +Copyright 2017 Vector Creations Ltd. +Copyright 2017 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import { _t } from '../../../languageHandler'; +import sdk from '../../../index'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import GeminiScrollbar from 'react-gemini-scrollbar'; +import PropTypes from 'prop-types'; + +const INITIAL_LOAD_NUM_MEMBERS = 30; + +export default React.createClass({ + displayName: 'GroupMemberList', + + propTypes: { + groupId: PropTypes.string.isRequired, + }, + + getInitialState: function() { + return { + members: null, + invitedMembers: null, + truncateAt: INITIAL_LOAD_NUM_MEMBERS, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this._initGroupStore(this.props.groupId); + }, + + _initGroupStore: function(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(groupId); + this._groupStore.registerListener(() => { + this._fetchMembers(); + }); + }, + + _fetchMembers: function() { + if (this._unmounted) return; + this.setState({ + members: this._groupStore.getGroupMembers(), + invitedMembers: this._groupStore.getGroupInvitedMembers(), + }); + }, + + _createOverflowTile: function(overflowCount, totalCount) { + // For now we'll pretend this is any entity. It should probably be a separate tile. + const EntityTile = sdk.getComponent("rooms.EntityTile"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } name={text} presenceState="online" suppressOnHover={true} + onClick={this._showFullMemberList} /> + ); + }, + + _showFullMemberList: function() { + this.setState({ + truncateAt: -1, + }); + }, + + onSearchQueryChanged: function(ev) { + this.setState({ searchQuery: ev.target.value }); + }, + + makeGroupMemberTiles: function(query, memberList) { + const GroupMemberTile = sdk.getComponent("groups.GroupMemberTile"); + const TruncatedList = sdk.getComponent("elements.TruncatedList"); + query = (query || "").toLowerCase(); + if (query) { + memberList = memberList.filter((m) => { + const matchesName = (m.displayname || "").toLowerCase().includes(query); + const matchesId = m.userId.toLowerCase().includes(query); + + if (!matchesName && !matchesId) { + return false; + } + + return true; + }); + } + + const uniqueMembers = {}; + memberList.forEach((m) => { + if (!uniqueMembers[m.userId]) uniqueMembers[m.userId] = m; + }); + memberList = Object.keys(uniqueMembers).map((userId) => uniqueMembers[userId]); + // Descending sort on isPrivileged = true = 1 to isPrivileged = false = 0 + memberList.sort((a, b) => { + if (a.isPrivileged === b.isPrivileged) { + const aName = a.displayname || a.userId; + const bName = b.displayname || b.userId; + if (aName < bName) { + return -1; + } else if (aName > bName) { + return 1; + } else { + return 0; + } + } else { + return a.isPrivileged ? -1 : 1; + } + }); + + const memberTiles = memberList.map((m) => { + return ( + + ); + }); + + return + { memberTiles } + ; + }, + + render: function() { + if (this.state.fetching || this.state.fetchingInvitedMembers) { + const Spinner = sdk.getComponent("elements.Spinner"); + return (
    + +
    ); + } + + const inputBox = ( +
    + +
    + ); + + const joined = this.state.members ?
    + { this.makeGroupMemberTiles(this.state.searchQuery, this.state.members) } +
    :
    ; + + const invited = (this.state.invitedMembers && this.state.invitedMembers.length > 0) ? +
    +

    { _t("Invited") }

    + { this.makeGroupMemberTiles(this.state.searchQuery, this.state.invitedMembers) } +
    :
    ; + + return ( +
    + { inputBox } + + { joined } + { invited } + +
    + ); + }, +}); diff --git a/src/components/views/groups/GroupMemberTile.js b/src/components/views/groups/GroupMemberTile.js new file mode 100644 index 0000000000..f967a33f46 --- /dev/null +++ b/src/components/views/groups/GroupMemberTile.js @@ -0,0 +1,70 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import { GroupMemberType } from '../../../groups'; +import withMatrixClient from '../../../wrappers/withMatrixClient'; + +export default withMatrixClient(React.createClass({ + displayName: 'GroupMemberTile', + + propTypes: { + matrixClient: PropTypes.object, + groupId: PropTypes.string.isRequired, + member: GroupMemberType.isRequired, + }, + + getInitialState: function() { + return {}; + }, + + onClick: function(e) { + dis.dispatch({ + action: 'view_group_user', + member: this.props.member, + groupId: this.props.groupId, + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const EntityTile = sdk.getComponent('rooms.EntityTile'); + + const name = this.props.member.displayname || this.props.member.userId; + const avatarUrl = this.props.matrixClient.mxcUrlToHttp( + this.props.member.avatarUrl, + 36, 36, 'crop', + ); + + const av = ( + + ); + + return ( + + ); + }, +})); diff --git a/src/components/views/groups/GroupPublicityToggle.js b/src/components/views/groups/GroupPublicityToggle.js new file mode 100644 index 0000000000..0fcabb4ef8 --- /dev/null +++ b/src/components/views/groups/GroupPublicityToggle.js @@ -0,0 +1,86 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import GroupStore from '../../../stores/GroupStore'; +import { _t } from '../../../languageHandler.js'; + +export default React.createClass({ + displayName: 'GroupPublicityToggle', + + propTypes: { + groupId: PropTypes.string.isRequired, + }, + + getInitialState() { + return { + busy: false, + ready: false, + isGroupPublicised: null, + }; + }, + + componentWillMount: function() { + this._initGroupStore(this.props.groupId); + }, + + _initGroupStore: function(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(groupId); + this._groupStore.registerListener(() => { + this.setState({ + isGroupPublicised: this._groupStore.getGroupPublicity(), + ready: this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary), + }); + }); + }, + + _onPublicityToggle: function(e) { + e.stopPropagation(); + this.setState({ + busy: true, + // Optimistic early update + isGroupPublicised: !this.state.isGroupPublicised, + }); + this._groupStore.setGroupPublicity(!this.state.isGroupPublicised).then(() => { + this.setState({ + busy: false, + }); + }); + }, + + render() { + const GroupTile = sdk.getComponent('groups.GroupTile'); + const input = ; + const labelText = !this.state.ready ? _t("Loading...") : + (this.state.isGroupPublicised ? + _t("Flair will appear if enabled in room settings") : + _t("Flair will not appear") + ); + return
    + + +
    ; + }, +}); diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js new file mode 100644 index 0000000000..fa4ed89ae0 --- /dev/null +++ b/src/components/views/groups/GroupRoomInfo.js @@ -0,0 +1,240 @@ +/* +Copyright 2017 New Vector 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 PropTypes from 'prop-types'; +import React from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import dis from '../../../dispatcher'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import GeminiScrollbar from 'react-gemini-scrollbar'; + +module.exports = React.createClass({ + displayName: 'GroupRoomInfo', + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + propTypes: { + groupId: PropTypes.string, + groupRoomId: PropTypes.string, + }, + + getInitialState: function() { + return { + isUserPrivilegedInGroup: null, + groupRoom: null, + groupRoomPublicityLoading: false, + groupRoomRemoveLoading: false, + }; + }, + + componentWillMount: function() { + this._initGroupStore(this.props.groupId); + }, + + componentWillReceiveProps(newProps) { + if (newProps.groupId !== this.props.groupId) { + this._unregisterGroupStore(); + this._initGroupStore(newProps.groupId); + } + }, + + componentWillUnmount() { + this._unregisterGroupStore(); + }, + + _initGroupStore(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId); + this._groupStore.registerListener(this.onGroupStoreUpdated); + }, + + _unregisterGroupStore() { + if (this._groupStore) { + this._groupStore.unregisterListener(this.onGroupStoreUpdated); + } + }, + + _updateGroupRoom() { + this.setState({ + groupRoom: this._groupStore.getGroupRooms().find( + (r) => r.roomId === this.props.groupRoomId, + ), + }); + }, + + onGroupStoreUpdated: function() { + this.setState({ + isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(), + }); + this._updateGroupRoom(); + }, + + _onRemove: function(e) { + const groupId = this.props.groupId; + const roomName = this.state.groupRoom.displayname; + e.preventDefault(); + e.stopPropagation(); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Confirm removal of group from room', '', QuestionDialog, { + title: _t("Are you sure you want to remove '%(roomName)s' from %(groupId)s?", {roomName, groupId}), + description: _t("Removing a room from the community will also remove it from the community page."), + button: _t("Remove"), + onFinished: (proceed) => { + if (!proceed) return; + this.setState({groupRoomRemoveLoading: true}); + const groupId = this.props.groupId; + const roomId = this.props.groupRoomId; + this._groupStore.removeRoomFromGroup(roomId).then(() => { + dis.dispatch({ + action: "view_group_room_list", + }); + }).catch((err) => { + console.error(`Error whilst removing ${roomId} from ${groupId}`, err); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { + title: _t("Failed to remove room from community"), + description: _t( + "Failed to remove '%(roomName)s' from %(groupId)s", {groupId, roomName}, + ), + }); + }).finally(() => { + this.setState({groupRoomRemoveLoading: false}); + }); + }, + }); + }, + + _onCancel: function(e) { + dis.dispatch({ + action: "view_group_room_list", + }); + }, + + _changeGroupRoomPublicity(e) { + const isPublic = e.target.value === "public"; + this.setState({ + groupRoomPublicityLoading: true, + }); + const groupId = this.props.groupId; + const roomId = this.props.groupRoomId; + const roomName = this.state.groupRoom.displayname; + this._groupStore.updateGroupRoomVisibility(roomId, isPublic).catch((err) => { + console.error(`Error whilst changing visibility of ${roomId} in ${groupId} to ${isPublic}`, err); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { + title: _t("Something went wrong!"), + description: _t( + "The visibility of '%(roomName)s' in %(groupId)s could not be updated.", + {roomName, groupId}, + ), + }); + }).finally(() => { + this.setState({ + groupRoomPublicityLoading: false, + }); + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const EmojiText = sdk.getComponent('elements.EmojiText'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); + if (this.state.groupRoomRemoveLoading || !this.state.groupRoom) { + const Spinner = sdk.getComponent("elements.Spinner"); + return
    + +
    ; + } + + let adminTools; + if (this.state.isUserPrivilegedInGroup) { + adminTools = +
    +

    { _t("Admin Tools") }

    +
    + + { _t('Remove from community') } + +
    +

    + { _t('Visibility in Room List') } + { this.state.groupRoomPublicityLoading ? + :
    + } +

    +
    + +
    +
    + +
    +
    ; + } + + const avatarUrl = this.context.matrixClient.mxcUrlToHttp( + this.state.groupRoom.avatarUrl, + 36, 36, 'crop', + ); + + const groupRoomName = this.state.groupRoom.displayname; + const avatar = ; + return ( +
    + + + + +
    + { avatar } +
    + + { groupRoomName } + +
    +
    + { this.state.groupRoom.canonical_alias } +
    +
    + + { adminTools } +
    +
    + ); + }, +}); diff --git a/src/components/views/groups/GroupRoomList.js b/src/components/views/groups/GroupRoomList.js new file mode 100644 index 0000000000..189fa944e2 --- /dev/null +++ b/src/components/views/groups/GroupRoomList.js @@ -0,0 +1,136 @@ +/* +Copyright 2017 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import { _t } from '../../../languageHandler'; +import sdk from '../../../index'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import GeminiScrollbar from 'react-gemini-scrollbar'; +import PropTypes from 'prop-types'; + +const INITIAL_LOAD_NUM_ROOMS = 30; + +export default React.createClass({ + propTypes: { + groupId: PropTypes.string.isRequired, + }, + + getInitialState: function() { + return { + rooms: null, + truncateAt: INITIAL_LOAD_NUM_ROOMS, + searchQuery: "", + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this._initGroupStore(this.props.groupId); + }, + + _initGroupStore: function(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(groupId); + this._groupStore.registerListener(() => { + this._fetchRooms(); + }); + this._groupStore.on('error', (err) => { + this.setState({ + rooms: null, + }); + }); + }, + + _fetchRooms: function() { + if (this._unmounted) return; + this.setState({ + rooms: this._groupStore.getGroupRooms(), + }); + }, + + _createOverflowTile: function(overflowCount, totalCount) { + // For now we'll pretend this is any entity. It should probably be a separate tile. + const EntityTile = sdk.getComponent("rooms.EntityTile"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } name={text} presenceState="online" suppressOnHover={true} + onClick={this._showFullRoomList} /> + ); + }, + + _showFullRoomList: function() { + this.setState({ + truncateAt: -1, + }); + }, + + onSearchQueryChanged: function(ev) { + this.setState({ searchQuery: ev.target.value }); + }, + + makeGroupRoomTiles: function(query) { + const GroupRoomTile = sdk.getComponent("groups.GroupRoomTile"); + query = (query || "").toLowerCase(); + + let roomList = this.state.rooms; + if (query) { + roomList = roomList.filter((room) => { + const matchesName = (room.name || "").toLowerCase().includes(query); + const matchesAlias = (room.canonicalAlias || "").toLowerCase().includes(query); + return matchesName || matchesAlias; + }); + } + + roomList = roomList.map((groupRoom, index) => { + return ( + + ); + }); + + return roomList; + }, + + render: function() { + if (this.state.rooms === null) { + return null; + } + + const inputBox = ( +
    + +
    + ); + + const TruncatedList = sdk.getComponent("elements.TruncatedList"); + return ( +
    + { inputBox } + + + { this.makeGroupRoomTiles(this.state.searchQuery) } + + +
    + ); + }, +}); diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js new file mode 100644 index 0000000000..a4961fefa9 --- /dev/null +++ b/src/components/views/groups/GroupRoomTile.js @@ -0,0 +1,73 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import {MatrixClient} from 'matrix-js-sdk'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import { GroupRoomType } from '../../../groups'; + +const GroupRoomTile = React.createClass({ + displayName: 'GroupRoomTile', + + propTypes: { + groupId: PropTypes.string.isRequired, + groupRoom: GroupRoomType.isRequired, + }, + + onClick: function(e) { + dis.dispatch({ + action: 'view_group_room', + groupId: this.props.groupId, + groupRoomId: this.props.groupRoom.roomId, + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const avatarUrl = this.context.matrixClient.mxcUrlToHttp( + this.props.groupRoom.avatarUrl, + 36, 36, 'crop', + ); + + const av = ( + + ); + + return ( + +
    + { av } +
    +
    + { this.props.groupRoom.displayname } +
    +
    + ); + }, +}); + +GroupRoomTile.contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, +}; + + +export default GroupRoomTile; diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js new file mode 100644 index 0000000000..ce426a9b78 --- /dev/null +++ b/src/components/views/groups/GroupTile.js @@ -0,0 +1,93 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {MatrixClient} from 'matrix-js-sdk'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import FlairStore from '../../../stores/FlairStore'; + +const GroupTile = React.createClass({ + displayName: 'GroupTile', + + propTypes: { + groupId: PropTypes.string.isRequired, + // Whether to show the short description of the group on the tile + showDescription: PropTypes.bool, + // Height of the group avatar in pixels + avatarHeight: PropTypes.number, + }, + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }, + + getInitialState() { + return { + profile: null, + }; + }, + + getDefaultProps() { + return { + showDescription: true, + avatarHeight: 50, + }; + }, + + componentWillMount: function() { + FlairStore.getGroupProfileCached(this.context.matrixClient, this.props.groupId).then((profile) => { + this.setState({profile}); + }).catch((err) => { + console.error('Error whilst getting cached profile for GroupTile', err); + }); + }, + + onClick: function(e) { + e.preventDefault(); + dis.dispatch({ + action: 'view_group', + group_id: this.props.groupId, + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const profile = this.state.profile || {}; + const name = profile.name || this.props.groupId; + const avatarHeight = this.props.avatarHeight; + const descElement = this.props.showDescription ? +
    { profile.shortDescription }
    : +
    ; + const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp( + profile.avatarUrl, avatarHeight, avatarHeight, "crop", + ) : null; + return +
    + +
    +
    +
    { name }
    + { descElement } +
    { this.props.groupId }
    +
    +
    ; + }, +}); + +export default GroupTile; diff --git a/src/components/views/groups/GroupUserSettings.js b/src/components/views/groups/GroupUserSettings.js new file mode 100644 index 0000000000..755d6aae8f --- /dev/null +++ b/src/components/views/groups/GroupUserSettings.js @@ -0,0 +1,89 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import GeminiScrollbar from 'react-gemini-scrollbar'; +import sdk from '../../../index'; +import { MatrixClient } from 'matrix-js-sdk'; +import { _t } from '../../../languageHandler'; + +export default React.createClass({ + displayName: 'GroupUserSettings', + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + getInitialState() { + return { + error: null, + groups: null, + }; + }, + + componentWillMount: function() { + this.context.matrixClient.getJoinedGroups().done((result) => { + this.setState({groups: result.groups || [], error: null}); + }, (err) => { + console.error(err); + this.setState({groups: null, error: err}); + }); + }, + + _renderGroupPublicity() { + let text = ""; + let scrollbox =
    ; + const groups = this.state.groups; + + if (this.state.error) { + text = _t('Something went wrong when trying to get your communities.'); + } else if (groups === null) { + text = _t('Loading...'); + } else if (groups.length > 0) { + const GroupPublicityToggle = sdk.getComponent('groups.GroupPublicityToggle'); + const groupPublicityToggles = groups.map((groupId, index) => { + return ; + }); + text = _t('Display your community flair in rooms configured to show it.'); + scrollbox =
    + + { groupPublicityToggles } + +
    ; + } else { + text = _t("You're not currently a member of any communities."); + } + + return
    +

    { _t('Flair') }

    +
    +

    + { text } +

    + { scrollbox } +
    +
    ; + }, + + render() { + const groupPublicity = this._renderGroupPublicity(); + + return
    + { groupPublicity } +
    ; + }, +}); diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index 0e5922f464..ab1bfbd393 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -16,8 +16,12 @@ limitations under the License. 'use strict'; -var React = require('react'); -var DIV_ID = 'mx_recaptcha'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const DIV_ID = 'mx_recaptcha'; /** * A pure UI component which displays a captcha form. @@ -26,10 +30,10 @@ module.exports = React.createClass({ displayName: 'CaptchaForm', propTypes: { - sitePublicKey: React.PropTypes.string, + sitePublicKey: PropTypes.string, // called with the captcha response - onCaptchaResponse: React.PropTypes.func, + onCaptchaResponse: PropTypes.func, }, getDefaultProps: function() { @@ -44,6 +48,10 @@ module.exports = React.createClass({ }; }, + componentWillMount: function() { + this._captchaWidgetId = null; + }, + componentDidMount: function() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. @@ -52,22 +60,40 @@ module.exports = React.createClass({ this._onCaptchaLoaded(); } else { console.log("Loading recaptcha script..."); - var scriptTag = document.createElement('script'); - window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; - scriptTag.setAttribute( - 'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" - ); - this.refs.recaptchaContainer.appendChild(scriptTag); + window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; + const protocol = global.location.protocol; + if (protocol === "file:") { + const warning = document.createElement('div'); + // XXX: fix hardcoded app URL. Better solutions include: + // * jumping straight to a hosted captcha page (but we don't support that yet) + // * embedding the captcha in an iframe (if that works) + // * using a better captcha lib + ReactDOM.render(_t( + "Robot check is currently unavailable on desktop - please use a web browser", + {}, + { 'a': (sub) => { return { sub }; }}), warning); + this.refs.recaptchaContainer.appendChild(warning); + } else { + const scriptTag = document.createElement('script'); + scriptTag.setAttribute( + 'src', protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit", + ); + this.refs.recaptchaContainer.appendChild(scriptTag); + } } }, + componentWillUnmount: function() { + this._resetRecaptcha(); + }, + _renderRecaptcha: function(divId) { if (!global.grecaptcha) { console.error("grecaptcha not loaded!"); throw new Error("Recaptcha did not load successfully"); } - var publicKey = this.props.sitePublicKey; + const publicKey = this.props.sitePublicKey; if (!publicKey) { console.error("No public key for recaptcha!"); throw new Error( @@ -76,12 +102,18 @@ module.exports = React.createClass({ } console.log("Rendering to %s", divId); - global.grecaptcha.render(divId, { + this._captchaWidgetId = global.grecaptcha.render(divId, { sitekey: publicKey, callback: this.props.onCaptchaResponse, }); }, + _resetRecaptcha: function() { + if (this._captchaWidgetId !== null) { + global.grecaptcha.reset(this._captchaWidgetId); + } + }, + _onCaptchaLoaded: function() { console.log("Loaded recaptcha script."); try { @@ -89,7 +121,7 @@ module.exports = React.createClass({ } catch (e) { this.setState({ errorText: e.toString(), - }) + }); } }, @@ -98,17 +130,18 @@ module.exports = React.createClass({ if (this.state.errorText) { error = (
    - {this.state.errorText} + { this.state.errorText }
    ); } return (
    - This Home Server would like to make sure you are not a robot + { _t("This Home Server would like to make sure you are not a robot") } +
    - {error} + { error }
    ); - } + }, }); diff --git a/src/components/views/login/CasLogin.js b/src/components/views/login/CasLogin.js index c818586d52..9219c79733 100644 --- a/src/components/views/login/CasLogin.js +++ b/src/components/views/login/CasLogin.js @@ -16,21 +16,23 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'CasLogin', propTypes: { - onSubmit: React.PropTypes.func, // fn() + onSubmit: PropTypes.func, // fn() }, render: function() { return (
    - +
    ); - } + }, }); diff --git a/src/components/views/login/CountryDropdown.js b/src/components/views/login/CountryDropdown.js new file mode 100644 index 0000000000..3fd28e32d2 --- /dev/null +++ b/src/components/views/login/CountryDropdown.js @@ -0,0 +1,142 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import sdk from '../../../index'; + +import { COUNTRIES } from '../../../phonenumber'; + +const COUNTRIES_BY_ISO2 = new Object(null); +for (const c of COUNTRIES) { + COUNTRIES_BY_ISO2[c.iso2] = c; +} + +function countryMatchesSearchQuery(query, country) { + // Remove '+' if present (when searching for a prefix) + if (query[0] === '+') { + query = query.slice(1); + } + + if (country.name.toUpperCase().indexOf(query.toUpperCase()) == 0) return true; + if (country.iso2 == query.toUpperCase()) return true; + if (country.prefix.indexOf(query) !== -1) return true; + return false; +} + +export default class CountryDropdown extends React.Component { + constructor(props) { + super(props); + this._onSearchChange = this._onSearchChange.bind(this); + this._onOptionChange = this._onOptionChange.bind(this); + this._getShortOption = this._getShortOption.bind(this); + + this.state = { + searchQuery: '', + }; + } + + componentWillMount() { + if (!this.props.value) { + // If no value is given, we start with the first + // country selected, but our parent component + // doesn't know this, therefore we do this. + this.props.onOptionChange(COUNTRIES[0]); + } + } + + _onSearchChange(search) { + this.setState({ + searchQuery: search, + }); + } + + _onOptionChange(iso2) { + this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]); + } + + _flagImgForIso2(iso2) { + return ; + } + + _getShortOption(iso2) { + if (!this.props.isSmall) { + return undefined; + } + let countryPrefix; + if (this.props.showPrefix) { + countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix; + } + return + { this._flagImgForIso2(iso2) } + { countryPrefix } + ; + } + + render() { + const Dropdown = sdk.getComponent('elements.Dropdown'); + + let displayedCountries; + if (this.state.searchQuery) { + displayedCountries = COUNTRIES.filter( + countryMatchesSearchQuery.bind(this, this.state.searchQuery), + ); + if ( + this.state.searchQuery.length == 2 && + COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()] + ) { + // exact ISO2 country name match: make the first result the matches ISO2 + const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]; + displayedCountries = displayedCountries.filter((c) => { + return c.iso2 != matched.iso2; + }); + displayedCountries.unshift(matched); + } + } else { + displayedCountries = COUNTRIES; + } + + const options = displayedCountries.map((country) => { + return
    + { this._flagImgForIso2(country.iso2) } + { country.name } (+{ country.prefix }) +
    ; + }); + + // default value here too, otherwise we need to handle null / undefined + // values between mounting and the initial value propgating + const value = this.props.value || COUNTRIES[0].iso2; + + return + { options } + ; + } +} + +CountryDropdown.propTypes = { + className: PropTypes.string, + isSmall: PropTypes.bool, + // if isSmall, show +44 in the selected value + showPrefix: PropTypes.bool, + onOptionChange: PropTypes.func.isRequired, + value: PropTypes.string, + disabled: PropTypes.bool, +}; diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js index e6450adef1..474a4097d1 100644 --- a/src/components/views/login/CustomServerDialog.js +++ b/src/components/views/login/CustomServerDialog.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'CustomServerDialog', @@ -23,27 +24,27 @@ module.exports = React.createClass({ return (
    - Custom Server Options + { _t("Custom Server Options") }
    - You can use the custom server options to sign into other Matrix - servers by specifying a different Home server URL. -
    - This allows you to use this app with an existing Matrix account on - a different home server. -
    -
    - You can also set a custom identity server but this will typically prevent - interaction with users based on email address. + { _t("You can use the custom server options to sign into other Matrix " + + "servers by specifying a different Home server URL.") } +
    + { _t("This allows you to use this app with an existing Matrix account on " + + "a different home server.") } +
    +
    + { _t("You can also set a custom identity server but this will typically prevent " + + "interaction with users based on email address.") }
    ); - } + }, }); diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 23e2b442ef..eeda5cd9f3 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,27 +16,49 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import url from 'url'; +import classnames from 'classnames'; import sdk from '../../../index'; -import MatrixClientPeg from '../../../MatrixClientPeg'; +import { _t } from '../../../languageHandler'; /* This file contains a collection of components which are used by the - * InteractiveAuthDialog to prompt the user to enter the information needed + * InteractiveAuth to prompt the user to enter the information needed * for an auth stage. (The intention is that they could also be used for other * components, such as the registration flow). * * Call getEntryComponentForLoginType() to get a component suitable for a * particular login type. Each component requires the same properties: * + * matrixClient: A matrix client. May be a different one to the one + * currently being used generally (eg. to register with + * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server + * clientSecret: The client secret in use for ID server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict - * setSubmitButtonEnabled: a function which will enable/disable the 'submit' button + * busy: a boolean indicating whether the auth logic is doing something + * the user needs to wait for. + * inputs: Object of inputs provided by the user, as in js-sdk + * interactive-auth + * stageState: Stage-specific object used for communicating state information + * to the UI from the state-specific auth logic. + * Defined keys for stages are: + * m.login.email.identity: + * * emailSid: string representing the sid of the active + * verification session from the ID server, or + * null if no session is active. + * fail: a function which should be called with an error object if an + * error occurred during the auth stage. This will cause the auth + * session to be failed and the process to go back to the start. + * setEmailSid: m.login.email.identity only: a function to be called with the + * email sid after a token is requested. + * makeRegistrationUrl A function that makes a registration URL * * Each component may also provide the following functions (beyond the standard React ones): - * onSubmitClick: handle a 'submit' button click * focus: set the input focus appropriately in the form. */ @@ -47,13 +70,18 @@ export const PasswordAuthEntry = React.createClass({ }, propTypes: { - submitAuthDict: React.PropTypes.func.isRequired, - setSubmitButtonEnabled: React.PropTypes.func.isRequired, - errorText: React.PropTypes.string, + matrixClient: PropTypes.object.isRequired, + submitAuthDict: PropTypes.func.isRequired, + errorText: PropTypes.string, + // is the auth logic currently waiting for something to + // happen? + busy: PropTypes.bool, }, - componentWillMount: function() { - this.props.setSubmitButtonEnabled(false); + getInitialState: function() { + return { + passwordValid: false, + }; }, focus: function() { @@ -62,17 +90,22 @@ export const PasswordAuthEntry = React.createClass({ } }, - onSubmitClick: function() { + _onSubmit: function(e) { + e.preventDefault(); + if (this.props.busy) return; + this.props.submitAuthDict({ type: PasswordAuthEntry.LOGIN_TYPE, - user: MatrixClientPeg.get().credentials.userId, + user: this.props.matrixClient.credentials.userId, password: this.refs.passwordField.value, }); }, - _onPasswordFieldChange: function (ev) { + _onPasswordFieldChange: function(ev) { // enable the submit button iff the password is non-empty - this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); + this.setState({ + passwordValid: Boolean(this.refs.passwordField.value), + }); }, render: function() { @@ -82,18 +115,36 @@ export const PasswordAuthEntry = React.createClass({ passwordBoxClass = 'error'; } + let submitButtonOrSpinner; + if (this.props.busy) { + const Loader = sdk.getComponent("elements.Spinner"); + submitButtonOrSpinner = ; + } else { + submitButtonOrSpinner = ( + + ); + } + return (
    -

    To continue, please enter your password.

    -

    Password:

    - +

    { _t("To continue, please enter your password.") }

    +

    { _t("Password:") }

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

    { _t("An email has been sent to %(emailAddress)s", + { emailAddress: (sub) => { this.props.inputs.emailAddress } }, + ) } +

    +

    { _t("Please check your email to continue registration.") }

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

    { _t("A text message has been sent to %(msisdn)s", + { msisdn: { this._msisdn } }, + ) } +

    +

    { _t("Please enter the code it contains:") }

    +
    +
    + +
    + +
    +
    + { this.state.errorText } +
    +
    +
    + ); + } + }, +}); + export const FallbackAuthEntry = React.createClass({ displayName: 'FallbackAuthEntry', propTypes: { - authSessionId: React.PropTypes.string.isRequired, - loginType: React.PropTypes.string.isRequired, - submitAuthDict: React.PropTypes.func.isRequired, - setSubmitButtonEnabled: React.PropTypes.func.isRequired, - errorText: React.PropTypes.string, + matrixClient: PropTypes.object.isRequired, + authSessionId: PropTypes.string.isRequired, + loginType: PropTypes.string.isRequired, + submitAuthDict: PropTypes.func.isRequired, + errorText: PropTypes.string, }, componentWillMount: function() { // we have to make the user click a button, as browsers will block // the popup if we open it immediately. this._popupWindow = null; - this.props.setSubmitButtonEnabled(true); window.addEventListener("message", this._onReceiveMessage); }, @@ -167,19 +427,18 @@ export const FallbackAuthEntry = React.createClass({ } }, - onSubmitClick: function() { - var url = MatrixClientPeg.get().getFallbackAuthUrl( + _onShowFallbackClick: function() { + const url = this.props.matrixClient.getFallbackAuthUrl( this.props.loginType, - this.props.authSessionId + this.props.authSessionId, ); this._popupWindow = window.open(url); - this.props.setSubmitButtonEnabled(false); }, _onReceiveMessage: function(event) { if ( event.data === "authDone" && - event.origin === MatrixClientPeg.get().getHomeserverUrl() + event.origin === this.props.matrixClient.getHomeserverUrl() ) { this.props.submitAuthDict({}); } @@ -188,9 +447,9 @@ export const FallbackAuthEntry = React.createClass({ render: function() { return (
    - Click "Submit" to authenticate + { _t("Start authentication") }
    - {this.props.errorText} + { this.props.errorText }
    ); @@ -200,13 +459,15 @@ export const FallbackAuthEntry = React.createClass({ const AuthEntryComponents = [ PasswordAuthEntry, RecaptchaAuthEntry, + EmailIdentityAuthEntry, + MsisdnAuthEntry, ]; export function getEntryComponentForLoginType(loginType) { - for (var c of AuthEntryComponents) { + for (const c of AuthEntryComponents) { if (c.LOGIN_TYPE == loginType) { return c; } } return FallbackAuthEntry; -}; +} diff --git a/src/components/views/login/LoginFooter.js b/src/components/views/login/LoginFooter.js index 5ec57194e0..392d36e288 100644 --- a/src/components/views/login/LoginFooter.js +++ b/src/components/views/login/LoginFooter.js @@ -16,7 +16,8 @@ limitations under the License. 'use strict'; -var React = require('react'); +import { _t } from '../../../languageHandler'; +import React from 'react'; module.exports = React.createClass({ displayName: 'LoginFooter', @@ -24,8 +25,8 @@ module.exports = React.createClass({ render: function() { return ( ); - } + }, }); diff --git a/src/components/views/login/LoginHeader.js b/src/components/views/login/LoginHeader.js index 3ee3cbea2e..cd1f9c6a28 100644 --- a/src/components/views/login/LoginHeader.js +++ b/src/components/views/login/LoginHeader.js @@ -16,7 +16,7 @@ limitations under the License. 'use strict'; -var React = require('react'); +const React = require('react'); module.exports = React.createClass({ displayName: 'LoginHeader', @@ -27,5 +27,5 @@ module.exports = React.createClass({ Matrix
    ); - } + }, }); diff --git a/src/components/views/login/LoginPage.js b/src/components/views/login/LoginPage.js new file mode 100644 index 0000000000..9eba53188e --- /dev/null +++ b/src/components/views/login/LoginPage.js @@ -0,0 +1,59 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +import SettingsStore from "../../../settings/SettingsStore"; + +const React = require('react'); + +module.exports = React.createClass({ + displayName: 'LoginPage', + + render: function() { + // FIXME: this should be turned into a proper skin with a StatusLoginPage component + if (SettingsStore.getValue("theme") === 'status') { + return ( +
    +
    + Status +
    +
    +
    +

    Status Community Chat

    +
    + A safer, decentralised communication + platform powered by Riot +
    +
    + { this.props.children } +
    +

    This channel is for our development community.

    +

    Interested in SNT and discussions on the cryptocurrency market?

    +

    Join Telegram Chat

    +
    +
    +
    + ); + } else { + return ( +
    + { this.props.children } +
    + ); + } + }, +}); diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 6f6081858b..71dfbe2c36 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,100 +16,258 @@ limitations under the License. */ import React from 'react'; -import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import {field_input_incorrect} from '../../../UiEffects'; - +import SdkConfig from '../../../SdkConfig'; /** * A pure UI component which displays a username/password form. */ -module.exports = React.createClass({displayName: 'PasswordLogin', - propTypes: { - onSubmit: React.PropTypes.func.isRequired, // fn(username, password) - onForgotPasswordClick: React.PropTypes.func, // fn() - initialUsername: React.PropTypes.string, - initialPassword: React.PropTypes.string, - onUsernameChanged: React.PropTypes.func, - onPasswordChanged: React.PropTypes.func, - loginIncorrect: React.PropTypes.bool, - }, +class PasswordLogin extends React.Component { + static defaultProps = { + onUsernameChanged: function() {}, + onPasswordChanged: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + initialUsername: "", + initialPhoneCountry: "", + initialPhoneNumber: "", + initialPassword: "", + loginIncorrect: false, + hsDomain: "", + } - getDefaultProps: function() { - return { - onUsernameChanged: function() {}, - onPasswordChanged: function() {}, - initialUsername: "", - initialPassword: "", - loginIncorrect: false, - }; - }, - - getInitialState: function() { - return { + constructor(props) { + super(props); + this.state = { username: this.props.initialUsername, password: this.props.initialPassword, + phoneCountry: this.props.initialPhoneCountry, + phoneNumber: this.props.initialPhoneNumber, + loginType: PasswordLogin.LOGIN_FIELD_MXID, }; - }, - componentWillMount: function() { + this.onSubmitForm = this.onSubmitForm.bind(this); + this.onUsernameChanged = this.onUsernameChanged.bind(this); + this.onLoginTypeChange = this.onLoginTypeChange.bind(this); + this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); + this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); + this.onPasswordChanged = this.onPasswordChanged.bind(this); + } + + componentWillMount() { this._passwordField = null; - }, + } - componentWillReceiveProps: function(nextProps) { + componentWillReceiveProps(nextProps) { if (!this.props.loginIncorrect && nextProps.loginIncorrect) { field_input_incorrect(this._passwordField); } - }, + } - onSubmitForm: function(ev) { + onSubmitForm(ev) { ev.preventDefault(); - this.props.onSubmit(this.state.username, this.state.password); - }, + if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) { + this.props.onSubmit( + '', // XXX: Synapse breaks if you send null here: + this.state.phoneCountry, + this.state.phoneNumber, + this.state.password, + ); + return; + } + this.props.onSubmit( + this.state.username, + null, + null, + this.state.password, + ); + } - onUsernameChanged: function(ev) { + onUsernameChanged(ev) { this.setState({username: ev.target.value}); this.props.onUsernameChanged(ev.target.value); - }, + } - onPasswordChanged: function(ev) { + onLoginTypeChange(loginType) { + this.setState({ + loginType: loginType, + username: "", // Reset because email and username use the same state + }); + } + + onPhoneCountryChanged(country) { + this.setState({ + phoneCountry: country.iso2, + phonePrefix: country.prefix, + }); + this.props.onPhoneCountryChanged(country.iso2); + } + + onPhoneNumberChanged(ev) { + this.setState({phoneNumber: ev.target.value}); + this.props.onPhoneNumberChanged(ev.target.value); + } + + onPasswordChanged(ev) { this.setState({password: ev.target.value}); this.props.onPasswordChanged(ev.target.value); - }, + } - render: function() { - var forgotPasswordJsx; + renderLoginField(loginType, disabled) { + const classes = { + mx_Login_field: true, + mx_Login_field_disabled: disabled, + }; + + switch (loginType) { + case PasswordLogin.LOGIN_FIELD_EMAIL: + classes.mx_Login_email = true; + return ; + case PasswordLogin.LOGIN_FIELD_MXID: + classes.mx_Login_username = true; + return ; + case PasswordLogin.LOGIN_FIELD_PHONE: + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + classes.mx_Login_phoneNumberField = true; + classes.mx_Login_field_has_prefix = true; + return
    + + +
    ; + } + } + + render() { + let forgotPasswordJsx; if (this.props.onForgotPasswordClick) { forgotPasswordJsx = ( - Forgot your password? + { _t('Forgot your password?') } ); } + let matrixIdText = ''; + if (this.props.hsUrl) { + try { + const parsedHsUrl = new URL(this.props.hsUrl); + matrixIdText = _t('%(serverName)s Matrix ID', {serverName: parsedHsUrl.hostname}); + } catch (e) { + // pass + } + } + const pwFieldClass = classNames({ mx_Login_field: true, + mx_Login_field_disabled: matrixIdText === '', error: this.props.loginIncorrect, }); + const Dropdown = sdk.getComponent('elements.Dropdown'); + + const loginField = this.renderLoginField(this.state.loginType, matrixIdText === ''); + + let loginType; + if (!SdkConfig.get().disable_3pid_login) { + loginType = ( +
    + + + { matrixIdText } + { _t('Email address') } + { _t('Phone') } + +
    + ); + } + return (
    - -
    + { loginType } + { loginField } {this._passwordField = e;}} type="password" name="password" value={this.state.password} onChange={this.onPasswordChanged} - placeholder="Password" /> + placeholder={_t('Password')} + disabled={matrixIdText === ''} + />
    - {forgotPasswordJsx} - + { forgotPasswordJsx } +
    ); } -}); +} + +PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email"; +PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid"; +PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone"; + +PasswordLogin.propTypes = { + onSubmit: PropTypes.func.isRequired, // fn(username, password) + onForgotPasswordClick: PropTypes.func, // fn() + initialUsername: PropTypes.string, + initialPhoneCountry: PropTypes.string, + initialPhoneNumber: PropTypes.string, + initialPassword: PropTypes.string, + onUsernameChanged: PropTypes.func, + onPhoneCountryChanged: PropTypes.func, + onPhoneNumberChanged: PropTypes.func, + onPasswordChanged: PropTypes.func, + loginIncorrect: PropTypes.bool, +}; + +module.exports = PasswordLogin; diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 33809fbfd6..fff808cf22 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,18 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { field_input_incorrect } from '../../../UiEffects'; +import sdk from '../../../index'; +import Email from '../../../email'; +import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; +import Modal from '../../../Modal'; +import { _t } from '../../../languageHandler'; +import SdkConfig from '../../../SdkConfig'; +import SettingsStore from "../../../settings/SettingsStore"; -var React = require('react'); -var UiEffects = require('../../../UiEffects'); -var sdk = require('../../../index'); -var Email = require('../../../email'); -var Modal = require("../../../Modal"); - -var FIELD_EMAIL = 'field_email'; -var FIELD_USERNAME = 'field_username'; -var FIELD_PASSWORD = 'field_password'; -var FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +const FIELD_EMAIL = 'field_email'; +const FIELD_PHONE_COUNTRY = 'field_phone_country'; +const FIELD_PHONE_NUMBER = 'field_phone_number'; +const FIELD_USERNAME = 'field_username'; +const FIELD_PASSWORD = 'field_password'; +const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; /** * A pure UI component which displays a registration form. @@ -35,34 +41,42 @@ module.exports = React.createClass({ propTypes: { // Values pre-filled in the input boxes when the component loads - defaultEmail: React.PropTypes.string, - defaultUsername: React.PropTypes.string, - defaultPassword: React.PropTypes.string, + defaultEmail: PropTypes.string, + defaultPhoneCountry: PropTypes.string, + defaultPhoneNumber: PropTypes.string, + defaultUsername: PropTypes.string, + defaultPassword: PropTypes.string, + teamsConfig: PropTypes.shape({ + // Email address to request new teams + supportEmail: PropTypes.string, + teams: PropTypes.arrayOf(React.PropTypes.shape({ + // The displayed name of the team + "name": PropTypes.string, + // The domain of team email addresses + "domain": PropTypes.string, + })).required, + }), - // A username that will be used if no username is entered. - // Specifying this param will also warn the user that entering - // a different username will cause a fresh account to be generated. - guestUsername: React.PropTypes.string, - - showEmail: React.PropTypes.bool, - minPasswordLength: React.PropTypes.number, - onError: React.PropTypes.func, - onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise + minPasswordLength: PropTypes.number, + onError: PropTypes.func, + onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise }, getDefaultProps: function() { return { - showEmail: false, minPasswordLength: 6, onError: function(e) { console.error(e); - } + }, }; }, getInitialState: function() { return { - fieldValid: {} + fieldValid: {}, + selectedTeam: null, + // The ISO2 country code selected in the phone number entry + phoneCountry: this.props.defaultPhoneCountry, }; }, @@ -77,38 +91,41 @@ module.exports = React.createClass({ this.validateField(FIELD_PASSWORD_CONFIRM); this.validateField(FIELD_PASSWORD); this.validateField(FIELD_USERNAME); + this.validateField(FIELD_PHONE_NUMBER); this.validateField(FIELD_EMAIL); - var self = this; + const self = this; if (this.allFieldsValid()) { if (this.refs.email.value == '') { - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { - title: "Warning", + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { + title: _t("Warning!"), description:
    - If you don't specify an email address, you won't be able to reset your password.
    - Are you sure? + { _t("If you don't specify an email address, you won't be able to reset your password. " + + "Are you sure?") }
    , - button: "Continue", + button: _t("Continue"), onFinished: function(confirmed) { if (confirmed) { - self._doSubmit(); + self._doSubmit(ev); } }, }); - } - else { - self._doSubmit(); + } else { + self._doSubmit(ev); } } }, - _doSubmit: function() { - var promise = this.props.onRegisterClick({ - username: this.refs.username.value.trim() || this.props.guestUsername, + _doSubmit: function(ev) { + const email = this.refs.email.value.trim(); + const promise = this.props.onRegisterClick({ + username: this.refs.username.value.trim(), password: this.refs.password.value.trim(), - email: this.refs.email.value.trim() + email: email, + phoneCountry: this.state.phoneCountry, + phoneNumber: this.refs.phoneNumber ? this.refs.phoneNumber.value.trim() : '', }); if (promise) { @@ -124,8 +141,8 @@ module.exports = React.createClass({ * they were validated. */ allFieldsValid: function() { - var keys = Object.keys(this.state.fieldValid); - for (var i = 0; i < keys.length; ++i) { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { if (this.state.fieldValid[keys[i]] == false) { return false; } @@ -133,32 +150,57 @@ module.exports = React.createClass({ return true; }, + _isUniEmail: function(email) { + return email.endsWith('.ac.uk') || email.endsWith('.edu') || email.endsWith('matrix.org'); + }, + validateField: function(field_id) { - var pwd1 = this.refs.password.value.trim(); - var pwd2 = this.refs.passwordConfirm.value.trim() + const pwd1 = this.refs.password.value.trim(); + const pwd2 = this.refs.passwordConfirm.value.trim(); switch (field_id) { case FIELD_EMAIL: - this.markFieldValid( - field_id, - this.refs.email.value == '' || Email.looksValid(this.refs.email.value), - "RegistrationForm.ERR_EMAIL_INVALID" - ); + const email = this.refs.email.value; + if (this.props.teamsConfig && this._isUniEmail(email)) { + const matchingTeam = this.props.teamsConfig.teams.find( + (team) => { + return email.split('@').pop() === team.domain; + }, + ) || null; + this.setState({ + selectedTeam: matchingTeam, + showSupportEmail: !matchingTeam, + }); + this.props.onTeamSelected(matchingTeam); + } else { + this.props.onTeamSelected(null); + this.setState({ + selectedTeam: null, + showSupportEmail: false, + }); + } + const emailValid = email === '' || Email.looksValid(email); + this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); + break; + case FIELD_PHONE_NUMBER: + const phoneNumber = this.refs.phoneNumber ? this.refs.phoneNumber.value : ''; + const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber); + this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID"); break; case FIELD_USERNAME: // XXX: SPEC-1 - var username = this.refs.username.value.trim() || this.props.guestUsername; + var username = this.refs.username.value.trim(); if (encodeURIComponent(username) != username) { this.markFieldValid( field_id, false, - "RegistrationForm.ERR_USERNAME_INVALID" + "RegistrationForm.ERR_USERNAME_INVALID", ); } else if (username == '') { this.markFieldValid( field_id, false, - "RegistrationForm.ERR_USERNAME_BLANK" + "RegistrationForm.ERR_USERNAME_BLANK", ); } else { this.markFieldValid(field_id, true); @@ -169,13 +211,13 @@ module.exports = React.createClass({ this.markFieldValid( field_id, false, - "RegistrationForm.ERR_PASSWORD_MISSING" + "RegistrationForm.ERR_PASSWORD_MISSING", ); } else if (pwd1.length < this.props.minPasswordLength) { this.markFieldValid( field_id, false, - "RegistrationForm.ERR_PASSWORD_LENGTH" + "RegistrationForm.ERR_PASSWORD_LENGTH", ); } else { this.markFieldValid(field_id, true); @@ -184,18 +226,18 @@ module.exports = React.createClass({ case FIELD_PASSWORD_CONFIRM: this.markFieldValid( field_id, pwd1 == pwd2, - "RegistrationForm.ERR_PASSWORD_MISMATCH" + "RegistrationForm.ERR_PASSWORD_MISMATCH", ); break; } }, markFieldValid: function(field_id, val, error_code) { - var fieldValid = this.state.fieldValid; + const fieldValid = this.state.fieldValid; fieldValid[field_id] = val; this.setState({fieldValid: fieldValid}); if (!val) { - UiEffects.field_input_incorrect(this.fieldElementById(field_id)); + field_input_incorrect(this.fieldElementById(field_id)); this.props.onError(error_code); } }, @@ -204,6 +246,8 @@ module.exports = React.createClass({ switch (field_id) { case FIELD_EMAIL: return this.refs.email; + case FIELD_PHONE_NUMBER: + return this.refs.phoneNumber; case FIELD_USERNAME: return this.refs.username; case FIELD_PASSWORD: @@ -213,8 +257,8 @@ module.exports = React.createClass({ } }, - _classForField: function(field_id, baseClass) { - let cls = baseClass || ''; + _classForField: function(field_id, ...baseClasses) { + let cls = baseClasses.join(' '); if (this.state.fieldValid[field_id] === false) { if (cls) cls += ' '; cls += 'error'; @@ -222,56 +266,110 @@ module.exports = React.createClass({ return cls; }, + _onPhoneCountryChange(newVal) { + this.setState({ + phoneCountry: newVal.iso2, + phonePrefix: newVal.prefix, + }); + }, + render: function() { - var self = this; - var emailSection, registerButton; - if (this.props.showEmail) { - emailSection = ( + const self = this; + + const theme = SettingsStore.getValue("theme"); + // FIXME: remove hardcoded Status team tweaks at some point + const emailPlaceholder = theme === 'status' ? _t("Email address") : _t("Email address (optional)"); + + const emailSection = ( +
    - ); + onBlur={function() {self.validateField(FIELD_EMAIL);}} + value={self.state.email} /> +
    + ); + let belowEmailSection; + if (this.props.teamsConfig) { + if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { + belowEmailSection = ( +

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

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

    + { _t("You are registering with %(SelectedTeamName)s", {SelectedTeamName: this.state.selectedTeam.name}) } +

    + ); + } } - if (this.props.onRegisterClick) { - registerButton = ( - + + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + let phoneSection; + if (!SdkConfig.get().disable_3pid_login) { + phoneSection = ( +
    + + +
    ); } - var placeholderUserName = "User name"; - if (this.props.guestUsername) { - placeholderUserName += " (default: " + this.props.guestUsername + ")" - } + const registerButton = ( + + ); + + const placeholderUserName = _t("User name"); return (
    - {emailSection} -
    + { emailSection } + { belowEmailSection } + { phoneSection } + onBlur={function() {self.validateField(FIELD_USERNAME);}} />
    - { this.props.guestUsername ? -
    Setting a user name will create a fresh account
    : null - } + onBlur={function() {self.validateField(FIELD_PASSWORD);}} + placeholder={_t("Password")} defaultValue={this.props.defaultPassword} />

    - {registerButton} + { registerButton }
    ); - } + }, }); diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index a18cfbbcef..a6944ec20a 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -16,9 +16,11 @@ limitations under the License. 'use strict'; -var React = require('react'); -var Modal = require('../../../Modal'); -var sdk = require('../../../index'); +const React = require('react'); +import PropTypes from 'prop-types'; +const Modal = require('../../../Modal'); +const sdk = require('../../../index'); +import { _t } from '../../../languageHandler'; /** * A pure UI component which displays the HS and IS to use. @@ -27,35 +29,33 @@ module.exports = React.createClass({ displayName: 'ServerConfig', propTypes: { - onHsUrlChanged: React.PropTypes.func, - onIsUrlChanged: React.PropTypes.func, + onServerConfigChange: PropTypes.func, // default URLs are defined in config.json (or the hardcoded defaults) // they are used if the user has not overridden them with a custom URL. // In other words, if the custom URL is blank, the default is used. - defaultHsUrl: React.PropTypes.string, // e.g. https://matrix.org - defaultIsUrl: React.PropTypes.string, // e.g. https://vector.im + defaultHsUrl: PropTypes.string, // e.g. https://matrix.org + defaultIsUrl: PropTypes.string, // e.g. https://vector.im // custom URLs are explicitly provided by the user and override the // default URLs. The user enters them via the component's input fields, // which is reflected on these properties whenever on..UrlChanged fires. // They are persisted in localStorage by MatrixClientPeg, and so can // override the default URLs when the component initially loads. - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, - withToggleButton: React.PropTypes.bool, - delayTimeMs: React.PropTypes.number // time to wait before invoking onChanged + withToggleButton: PropTypes.bool, + delayTimeMs: PropTypes.number, // time to wait before invoking onChanged }, getDefaultProps: function() { return { - onHsUrlChanged: function() {}, - onIsUrlChanged: function() {}, + onServerConfigChange: function() {}, customHsUrl: "", customIsUrl: "", withToggleButton: false, - delayTimeMs: 0 + delayTimeMs: 0, }; }, @@ -64,18 +64,21 @@ module.exports = React.createClass({ hs_url: this.props.customHsUrl, is_url: this.props.customIsUrl, // if withToggleButton is false, then show the config all the time given we have no way otherwise of making it visible - configVisible: !this.props.withToggleButton || + configVisible: !this.props.withToggleButton || (this.props.customHsUrl !== this.props.defaultHsUrl) || - (this.props.customIsUrl !== this.props.defaultIsUrl) - } + (this.props.customIsUrl !== this.props.defaultIsUrl), + }; }, onHomeserverChanged: function(ev) { this.setState({hs_url: ev.target.value}, function() { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { - var hsUrl = this.state.hs_url.trim().replace(/\/$/, ""); + let hsUrl = this.state.hs_url.trim().replace(/\/$/, ""); if (hsUrl === "") hsUrl = this.props.defaultHsUrl; - this.props.onHsUrlChanged(hsUrl); + this.props.onServerConfigChange({ + hsUrl: this.state.hs_url, + isUrl: this.state.is_url, + }); }); }); }, @@ -83,9 +86,12 @@ module.exports = React.createClass({ onIdentityServerChanged: function(ev) { this.setState({is_url: ev.target.value}, function() { this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { - var isUrl = this.state.is_url.trim().replace(/\/$/, ""); + let isUrl = this.state.is_url.trim().replace(/\/$/, ""); if (isUrl === "") isUrl = this.props.defaultIsUrl; - this.props.onIsUrlChanged(isUrl); + this.props.onServerConfigChange({ + hsUrl: this.state.hs_url, + isUrl: this.state.is_url, + }); }); }); }, @@ -99,43 +105,46 @@ module.exports = React.createClass({ onServerConfigVisibleChange: function(visible, ev) { this.setState({ - configVisible: visible + configVisible: visible, }); if (!visible) { - this.props.onHsUrlChanged(this.props.defaultHsUrl); - this.props.onIsUrlChanged(this.props.defaultIsUrl); - } - else { - this.props.onHsUrlChanged(this.state.hs_url); - this.props.onIsUrlChanged(this.state.is_url); + this.props.onServerConfigChange({ + hsUrl: this.props.defaultHsUrl, + isUrl: this.props.defaultIsUrl, + }); + } else { + this.props.onServerConfigChange({ + hsUrl: this.state.hs_url, + isUrl: this.state.is_url, + }); } }, showHelpPopup: function() { - var CustomServerDialog = sdk.getComponent('login.CustomServerDialog'); - Modal.createDialog(CustomServerDialog); + const CustomServerDialog = sdk.getComponent('login.CustomServerDialog'); + Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); }, render: function() { - var serverConfigStyle = {}; + const serverConfigStyle = {}; serverConfigStyle.display = this.state.configVisible ? 'block' : 'none'; - var toggleButton; + let toggleButton; if (this.props.withToggleButton) { toggleButton = ( -
    +
      
    ); @@ -143,11 +152,11 @@ module.exports = React.createClass({ return (
    - {toggleButton} + { toggleButton }
    - What does this mean? + { _t("What does this mean?") }
    ); - } + }, }); diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 7e338e8466..ab53918987 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -20,8 +20,8 @@ import React from 'react'; import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import sdk from '../../../index'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; +import { _t } from '../../../languageHandler'; export default class MAudioBody extends React.Component { constructor(props) { @@ -31,11 +31,11 @@ export default class MAudioBody extends React.Component { decryptedUrl: null, decryptedBlob: null, error: null, - } + }; } onPlayToggle() { this.setState({ - playing: !this.state.playing + playing: !this.state.playing, }); } @@ -49,9 +49,9 @@ export default class MAudioBody extends React.Component { } componentDidMount() { - var content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - var decryptedBlob; + let decryptedBlob; decryptFile(content.file).then(function(blob) { decryptedBlob = blob; return readBlobAsDataUri(decryptedBlob); @@ -70,14 +70,13 @@ export default class MAudioBody extends React.Component { } render() { - const content = this.props.mxEvent.getContent(); if (this.state.error !== null) { return ( - - Error decrypting audio + + { _t("Error decrypting audio") } ); } @@ -89,7 +88,7 @@ export default class MAudioBody extends React.Component { // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + {content.body} ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 4f5ca2d3be..c324c291e7 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -17,21 +17,22 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import filesize from 'filesize'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import {decryptFile} from '../../../utils/DecryptFile'; import Tinter from '../../../Tinter'; import request from 'browser-request'; -import q from 'q'; import Modal from '../../../Modal'; // A cached tinted copy of "img/download.svg" -var tintedDownloadImageURL; +let tintedDownloadImageURL; // Track a list of mounted MFileBody instances so that we can update // the "img/download.svg" when the tint changes. -var nextMountId = 0; +let nextMountId = 0; const mounts = {}; /** @@ -169,11 +170,11 @@ function computedStyle(element) { return ""; } const style = window.getComputedStyle(element, null); - var cssText = style.cssText; + let cssText = style.cssText; if (cssText == "") { // Firefox doesn't implement ".cssText" for computed styles. // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 - for (var i = 0; i < style.length; i++) { + for (let i = 0; i < style.length; i++) { cssText += style[i] + ":"; cssText += style.getPropertyValue(style[i]) + ";"; } @@ -191,7 +192,7 @@ module.exports = React.createClass({ }, contextTypes: { - appConfig: React.PropTypes.object, + appConfig: PropTypes.object, }, /** @@ -202,7 +203,7 @@ module.exports = React.createClass({ * @return {string} the human readable link text for the attachment. */ presentableTextForFile: function(content) { - var linkText = 'Attachment'; + let linkText = _t("Attachment"); if (content.body && content.body.length > 0) { // The content body should be the name of the file including a // file extension. @@ -261,7 +262,7 @@ module.exports = React.createClass({ const content = this.props.mxEvent.getContent(); const text = this.presentableTextForFile(content); const isEncrypted = content.file !== undefined; - const fileName = content.body && content.body.length > 0 ? content.body : "Attachment"; + const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const contentUrl = this._getContentUrl(); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -270,7 +271,7 @@ module.exports = React.createClass({ // Need to decrypt the attachment // Wait for the user to click on the link before downloading // and decrypting the attachment. - var decrypting = false; + let decrypting = false; const decrypt = () => { if (decrypting) { return false; @@ -281,9 +282,10 @@ module.exports = React.createClass({ decryptedBlob: blob, }); }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) - Modal.createDialog(ErrorDialog, { - description: "Error decrypting attachment" + console.warn("Unable to decrypt attachment: ", err); + Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, { + title: _t("Error"), + description: _t("Error decrypting attachment"), }); }).finally(() => { decrypting = false; @@ -293,9 +295,9 @@ module.exports = React.createClass({ return ( -
    + @@ -314,7 +316,7 @@ module.exports = React.createClass({ // We can't provide a Content-Disposition header like we would for HTTP. download: fileName, target: "_blank", - textContent: "Download " + text, + textContent: _t("Download %(text)s", { text: text }), }, "*"); }; @@ -325,16 +327,16 @@ module.exports = React.createClass({ } return ( -
    +
    - {/* + { /* * Add dummy copy of the "a" tag * We'll use it to learn how the download link * would have been styled if it was rendered inline. - */} - + */ } +
    -