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/.eslintignore.errorfiles b/.eslintignore.errorfiles new file mode 100644 index 0000000000..55eaf75e4b --- /dev/null +++ b/.eslintignore.errorfiles @@ -0,0 +1,161 @@ +# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. + +src/async-components/views/dialogs/EncryptedEventDialog.js +src/autocomplete/AutocompleteProvider.js +src/autocomplete/Autocompleter.js +src/autocomplete/Components.js +src/autocomplete/DuckDuckGoProvider.js +src/autocomplete/EmojiProvider.js +src/autocomplete/RoomProvider.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/FilePanel.js +src/components/structures/InteractiveAuth.js +src/components/structures/LoggedInView.js +src/components/structures/login/ForgotPassword.js +src/components/structures/login/Login.js +src/components/structures/login/PostRegistration.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/avatars/RoomAvatar.js +src/components/views/create_room/CreateRoomButton.js +src/components/views/create_room/Presets.js +src/components/views/create_room/RoomAlias.js +src/components/views/dialogs/ChatCreateOrReuseDialog.js +src/components/views/dialogs/ChatInviteDialog.js +src/components/views/dialogs/DeactivateAccountDialog.js +src/components/views/dialogs/InteractiveAuthDialog.js +src/components/views/dialogs/SetMxIdDialog.js +src/components/views/dialogs/UnknownDeviceDialog.js +src/components/views/elements/AccessibleButton.js +src/components/views/elements/ActionButton.js +src/components/views/elements/AddressSelector.js +src/components/views/elements/AddressTile.js +src/components/views/elements/CreateRoomButton.js +src/components/views/elements/DeviceVerifyButtons.js +src/components/views/elements/DirectorySearchBox.js +src/components/views/elements/Dropdown.js +src/components/views/elements/EditableText.js +src/components/views/elements/EditableTextContainer.js +src/components/views/elements/HomeButton.js +src/components/views/elements/LanguageDropdown.js +src/components/views/elements/MemberEventListSummary.js +src/components/views/elements/PowerSelector.js +src/components/views/elements/ProgressBar.js +src/components/views/elements/RoomDirectoryButton.js +src/components/views/elements/SettingsButton.js +src/components/views/elements/StartChatButton.js +src/components/views/elements/TintableSvg.js +src/components/views/elements/TruncatedList.js +src/components/views/elements/UserSelector.js +src/components/views/login/CaptchaForm.js +src/components/views/login/CasLogin.js +src/components/views/login/CountryDropdown.js +src/components/views/login/CustomServerDialog.js +src/components/views/login/InteractiveAuthEntryComponents.js +src/components/views/login/LoginHeader.js +src/components/views/login/PasswordLogin.js +src/components/views/login/RegistrationForm.js +src/components/views/login/ServerConfig.js +src/components/views/messages/MAudioBody.js +src/components/views/messages/MessageEvent.js +src/components/views/messages/MFileBody.js +src/components/views/messages/MImageBody.js +src/components/views/messages/MVideoBody.js +src/components/views/messages/RoomAvatarEvent.js +src/components/views/messages/TextualBody.js +src/components/views/messages/TextualEvent.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/MessageComposerInputOld.js +src/components/views/rooms/PresenceLabel.js +src/components/views/rooms/ReadReceiptMarker.js +src/components/views/rooms/RoomList.js +src/components/views/rooms/RoomNameEditor.js +src/components/views/rooms/RoomPreviewBar.js +src/components/views/rooms/RoomSettings.js +src/components/views/rooms/RoomTile.js +src/components/views/rooms/RoomTopicEditor.js +src/components/views/rooms/SearchableEntityList.js +src/components/views/rooms/SearchResultTile.js +src/components/views/rooms/TabCompleteBar.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/components/views/settings/DevicesPanelEntry.js +src/components/views/settings/EnableNotificationsButton.js +src/ContentMessages.js +src/HtmlUtils.js +src/ImageUtils.js +src/Invite.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/ScalarMessaging.js +src/TabComplete.js +src/TabCompleteEntries.js +src/TextForEvent.js +src/Tinter.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/all-tests.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/stub-component.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/skinned-sdk.js +test/stores/RoomViewStore-test.js +test/test-utils.js diff --git a/.eslintrc.js b/.eslintrc.js index 6cd0e1015e..74790a2964 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,7 @@ module.exports = { // to JSX. ignorePattern: '^\\s*<', ignoreComments: true, - code: 90, + code: 120, }], "valid-jsdoc": ["warn"], "new-cap": ["warn"], 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 index c280044246..87200871a5 100755 --- a/.travis-test-riot.sh +++ b/.travis-test-riot.sh @@ -9,16 +9,24 @@ set -ev RIOT_WEB_DIR=riot-web REACT_SDK_DIR=`pwd` -git clone --depth=1 --branch develop https://github.com/vector-im/riot-web.git \ +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 -(cd node_modules/matrix-js-sdk && 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 diff --git a/.travis.yml b/.travis.yml index 9a8f804644..4137d754bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,17 @@ +# 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. +sudo: false + 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: - - npm run test - - ./.travis-test-riot.sh + ./scripts/travis.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dda666de..8bc4bbcfce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,624 @@ +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) diff --git a/README.md b/README.md index 3627225299..0f5ef73365 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ 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/riot-web rather than this project). +Translation Status +================== +[![translationsstatus](https://translate.nordgedanken.de/widgets/riot-web/-/multi-auto.svg)](https://translate.nordgedanken.de/engage/riot-web/?utm_source=widget) + Developer Guide =============== @@ -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/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 6a77911c27..a0e8d2e893 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -2,7 +2,6 @@ set -e -export KARMAFLAGS="--no-colors" export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" nvm use 4 @@ -16,11 +15,16 @@ npm install (cd node_modules/matrix-js-sdk && npm install) # run the mocha tests -npm run test +npm run test -- --no-colors # run eslint npm run lintall -- -f checkstyle -o eslint.xml || true +# re-run the linter, excluding any files known to have errors or warnings. +./node_modules/.bin/eslint --max-warnings 0 \ + --ignore-path .eslintignore.errorfiles \ + src test + # delete the old tarball, if it exists rm -f matrix-react-sdk-*.tgz diff --git a/karma.conf.js b/karma.conf.js index 6d3047bb3b..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,11 +201,15 @@ 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: { diff --git a/package.json b/package.json index 00dc902cc9..a3bab88d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.8.8", + "version": "0.9.7", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -31,45 +31,51 @@ "reskindex": "scripts/reskindex.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", + "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/", "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" + "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", "commonmark": "^0.27.0", - "draft-js": "^0.8.1", + "counterpart": "^0.18.0", + "draft-js": "^0.10.1", "draft-js-export-html": "^0.5.0", "draft-js-export-markdown": "^0.2.0", - "emojione": "2.2.3", + "emojione": "2.2.7", "file-saver": "^1.3.3", - "filesize": "^3.1.2", - "flux": "^2.0.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", - "matrix-js-sdk": "0.7.7", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", - "q": "^1.4.1", + "prop-types": "^15.5.8", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", - "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" }, @@ -79,7 +85,7 @@ "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", @@ -88,6 +94,7 @@ "babel-preset-es2016": "^6.11.3", "babel-preset-es2017": "^6.14.0", "babel-preset-react": "^6.11.1", + "chokidar": "^1.6.1", "eslint": "^3.13.1", "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^4.0.1", @@ -95,16 +102,19 @@ "eslint-plugin-react": "^6.9.0", "expect": "^1.16.0", "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": "^1.2.0", "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", 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/, < 0) | .filePath' | + sed -e 's/.*matrix-react-sdk\///'; +} > "$out" 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..f349b06ad5 --- /dev/null +++ b/scripts/travis.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -ex + +npm run test +./.travis-test-riot.sh + +# run the linter, but exclude any files known to have errors or warnings. +./node_modules/.bin/eslint --max-warnings 0 \ + --ignore-path .eslintignore.errorfiles \ + src test diff --git a/src/AddThreepid.js b/src/AddThreepid.js index c89de4f5fa..337e38d867 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -15,7 +15,8 @@ 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, @@ -43,8 +44,8 @@ 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})`; } @@ -68,8 +69,8 @@ class AddThreepid { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_IN_USE') { - err.message = "This phone number is already in use"; + 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})`; } @@ -84,16 +85,15 @@ class AddThreepid { * 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; @@ -103,6 +103,7 @@ class AddThreepid { /** * 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. @@ -118,7 +119,7 @@ class AddThreepid { return MatrixClientPeg.get().addThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: identityServerDomain + id_server: identityServerDomain, }, this.bind); }); } diff --git a/src/Analytics.js b/src/Analytics.js new file mode 100644 index 0000000000..92691da1ea --- /dev/null +++ b/src/Analytics.js @@ -0,0 +1,153 @@ +/* + 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 } from './languageHandler'; +import MatrixClientPeg from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; + +function getRedactedUrl() { + const redactedHash = window.location.hash.replace(/#\/(room|user)\/(.+)/, "#/$1/"); + // hardcoded url to make piwik happy + return 'https://riot.im/app/' + redactedHash; +} + +const customVariables = { + 'App Platform': 1, + 'App Version': 2, + 'User Type': 3, + 'Chosen Language': 4, + 'Instance': 5, +}; + + +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']); + } + + login() { // not used currently + const cli = MatrixClientPeg.get(); + if (this.disabled || !cli) return; + + this._paq.push(['setUserId', `@${cli.getUserIdLocalpart()}:${cli.getDomain()}`]); + } + + _setVisitVariable(key, value) { + this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); + } + + setGuest(guest) { + if (this.disabled) return; + this._setVisitVariable('User Type', guest ? 'Guest' : 'Logged In'); + } +} + +if (!global.mxAnalytics) { + global.mxAnalytics = new Analytics(); +} +module.exports = global.mxAnalytics; diff --git a/src/Avatar.js b/src/Avatar.js index 76f5e55ff0..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 6eed22f436..5f8772c7aa 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,6 +57,7 @@ export default class BasePlatform { /** * Returns true if the platform supports displaying * notifications, otherwise false. + * @returns {boolean} whether the platform supports displaying notifications */ supportsNotifications(): boolean { return false; @@ -48,6 +66,7 @@ export default class BasePlatform { /** * 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 { return false; @@ -66,11 +85,14 @@ export default class BasePlatform { 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!"); } @@ -79,10 +101,12 @@ export default class BasePlatform { * with getUserMedia, return a string explaining why not. * Otherwise, return null. */ - screenCaptureErrorString() { + screenCaptureErrorString(): string { return "Not implemented"; } + isElectron(): boolean { return false; } + /** * Restarts the application, without neccessarily reloading * any application code diff --git a/src/CallHandler.js b/src/CallHandler.js index 42cc681d08..e3fbe9e5e3 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -51,12 +51,14 @@ limitations under the License. * } */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var PlatformPeg = require("./PlatformPeg"); -var Modal = require('./Modal'); -var sdk = require('./index'); -var Matrix = require("matrix-js-sdk"); -var dis = require("./dispatcher"); +import MatrixClientPeg from './MatrixClientPeg'; +import UserSettingsStore from './UserSettingsStore'; +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'; global.mxCalls = { //room_id: MatrixCall @@ -142,8 +144,8 @@ function _setCallListeners(call) { play("busyAudio"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Call Timeout", - description: "The remote side failed to pick up." + title: _t('Call Timeout'), + description: _t('The remote side failed to pick up') + '.', }); } else if (oldState === "invite_sent") { @@ -179,7 +181,8 @@ function _setCallState(call, roomId, status) { } dis.dispatch({ action: 'call_state', - room_id: roomId + room_id: roomId, + state: status, }); } @@ -203,8 +206,8 @@ function _onAction(payload) { console.log("Can't capture screen: " + screenCapErrorString); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Unable to capture screen", - description: screenCapErrorString + title: _t('Unable to capture screen'), + description: screenCapErrorString, }); return; } @@ -223,8 +226,8 @@ function _onAction(payload) { if (module.exports.getAnyActiveCall()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Existing Call", - description: "You are already in a call." + title: _t('Existing Call'), + description: _t('You are already in a call.'), }); return; // don't allow >1 call to be placed. } @@ -233,8 +236,8 @@ function _onAction(payload) { 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." + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), }); return; } @@ -249,15 +252,15 @@ function _onAction(payload) { if (members.length <= 1) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - description: "You cannot place a call with yourself." + description: _t('You cannot place a call with yourself.'), }); return; } 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, { + forceTURN: UserSettingsStore.getLocalSetting('webRtcForceTURN', false), + }); placeCall(call); } else { // > 2 @@ -275,14 +278,14 @@ function _onAction(payload) { if (!ConferenceHandler) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - description: "Conference calls are not supported in this client" + description: _t('Conference calls are not supported in this client'), }); } 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." + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), }); } else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) { @@ -294,14 +297,14 @@ function _onAction(payload) { // 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", + 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 is in development and may not be reliable.", + title: _t('Warning!'), + description: _t('Conference calling is in development and may not be reliable.'), onFinished: confirm=>{ if (confirm) { ConferenceHandler.createNewMatrixCall( @@ -312,8 +315,8 @@ function _onAction(payload) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Conference call failed: " + err); Modal.createDialog(ErrorDialog, { - title: "Failed to set up conference call", - description: "Conference call failed.", + title: _t('Failed to set up conference call'), + description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), }); }); } diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js new file mode 100644 index 0000000000..839b496845 --- /dev/null +++ b/src/CallMediaHandler.js @@ -0,0 +1,64 @@ +/* + 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 UserSettingsStore from './UserSettingsStore'; +import * as Matrix from 'matrix-js-sdk'; + +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() { + // this.getDevices().then((devices) => { + const localSettings = UserSettingsStore.getLocalSettings(); + // // if deviceId is not found, automatic fallback is in spec + // // recall previously stored inputs if any + Matrix.setMatrixCallAudioInput(localSettings['webrtc_audioinput']); + Matrix.setMatrixCallVideoInput(localSettings['webrtc_videoinput']); + // }); + }, + + setAudioInput: function(deviceId) { + UserSettingsStore.setLocalSetting('webrtc_audioinput', deviceId); + Matrix.setMatrixCallAudioInput(deviceId); + }, + + setVideoInput: function(deviceId) { + UserSettingsStore.setLocalSetting('webrtc_videoinput', deviceId); + Matrix.setMatrixCallVideoInput(deviceId); + }, +}; diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..1ae836574b --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,82 @@ +//@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} from 'draft-js'; +import * as RichText from './RichText'; +import Markdown from './Markdown'; +import _flow from 'lodash/flow'; +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'html' | 'markdown'; + +class HistoryItem { + message: string = ''; + format: MessageFormat = 'html'; + + constructor(message: string, format: MessageFormat) { + this.message = message; + this.format = format; + } + + toContentState(format: MessageFormat): ContentState { + let {message} = this; + if (format === 'markdown') { + if (this.format === 'html') { + message = _flow([RichText.htmlToContentState, RichText.stateToMarkdown])(message); + } + return ContentState.createFromText(message); + } else { + if (this.format === 'markdown') { + message = new Markdown(message).toHTML(); + } + return RichText.htmlToContentState(message); + } + } +} + +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; + } + + addItem(message: string, format: MessageFormat) { + const item = new HistoryItem(message, 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 4ab982c98f..9239de9d8f 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -16,11 +16,12 @@ limitations under the License. 'use strict'; -var q = require('q'); +import Promise from 'bluebird'; var extend = require('./extend'); var dis = require('./dispatcher'); var MatrixClientPeg = require('./MatrixClientPeg'); var sdk = require('./index'); +import { _t } from './languageHandler'; var Modal = require('./Modal'); var encrypt = require("browser-encrypt-attachment"); @@ -51,7 +52,7 @@ 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; @@ -94,7 +95,7 @@ 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"); @@ -153,7 +154,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"); @@ -209,7 +210,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 +229,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 +247,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 +261,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}; @@ -287,7 +294,7 @@ 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=>{ @@ -325,36 +332,37 @@ class ContentMessages { dis.dispatch({action: 'upload_started'}); var 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."; + var 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 + title: _t('Upload Failed'), + description: desc, }); } }).finally(() => { diff --git a/src/DateUtils.js b/src/DateUtils.js index 07bab4ae7b..78eef57eae 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. @@ -15,38 +16,90 @@ 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"]; +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}`; +} 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(); + formatDate: function(date, showTwelveHour=false) { + const now = new Date(); + const days = getDaysArray(); + const months = getMonthsArray(); if (date.toDateString() === now.toDateString()) { - return pad(date.getHours()) + ':' + pad(date.getMinutes()); + return this.formatTime(date); + } 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: this.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: this.formatTime(date), + }); } - 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()); - } - */ + return this.formatFullDate(date, showTwelveHour); }, - formatTime: function(date) { - //return pad(date.getHours()) + ':' + pad(date.getMinutes()); - return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2); - } -}; + formatFullDate: function(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: showTwelveHour ? twelveHourTime(date) : this.formatTime(date), + }); + }, + formatTime: function(date, showTwelveHour=false) { + if (showTwelveHour) { + return twelveHourTime(date); + } + return pad(date.getHours()) + ':' + pad(date.getMinutes()); + }, +}; diff --git a/src/Entities.js b/src/Entities.js index 7c3909f36f..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; }; @@ -137,5 +137,5 @@ module.exports = { return users.map(function(u) { return new UserEntity(u, showInviteButton, inviteFn); }); - } + }, }; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index a8e20f5ec1..87e714083b 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -23,8 +23,12 @@ var 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'; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); @@ -34,7 +38,7 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; * because we want to include emoji shortnames in title text */ export function unicodeToImage(str) { - let replaceWith, unicode, alt; + let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); str = str.replace(emojione.regUnicode, function(unicodeChar) { @@ -46,11 +50,14 @@ export function unicodeToImage(str) { // 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; } }); @@ -64,17 +71,24 @@ export function unicodeToImage(str) { * 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, ...unicode) { +export function charactersToImageNode(alt, useSvg, ...unicode) { const fileName = unicode.map((u) => { return u.toString(16); }).join('-'); - return {alt}; + const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG; + const fileType = useSvg ? 'svg' : 'png'; + return {alt}; } -export function stripParagraphs(html: string): string { + +export function processHtmlForSending(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; @@ -83,10 +97,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)); @@ -97,34 +122,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', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', - 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix - // We don't currently allow img itself by default, but this - // would make sense if we did - img: ['src'], + 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'], // URL schemes we permit allowedSchemes: ['http', 'https', 'ftp', 'mailto'], - // 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 @@ -139,22 +169,50 @@ var sanitizeHtmlParams = { 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) { + var 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 }; }, + '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.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. + let 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 @@ -335,6 +393,7 @@ export function bodyToHtml(content, highlights, opts) { } safeBody = sanitizeHtml(body, sanitizeHtmlParams); safeBody = unicodeToImage(safeBody); + safeBody = addCodeCopyButton(safeBody); } finally { delete sanitizeHtmlParams.textFilter; @@ -350,7 +409,24 @@ export function bodyToHtml(content, highlights, opts) { 'mx_EventTile_bigEmoji': emojiBody, 'markdown-body': isHtml, }); - return ; + return ; +} + +function addCodeCopyButton(safeBody) { + // Adds 'copy' buttons to pre blocks + // Note that this only manipulates the markup to add the buttons: + // we need to add the event handlers once the nodes are in the DOM + // since we can't save functions in the markup. + // This is done in TextualBody + const el = document.createElement("div"); + el.innerHTML = safeBody; + const codeBlocks = Array.from(el.getElementsByTagName("pre")); + codeBlocks.forEach(p => { + const button = document.createElement("span"); + button.className = "mx_EventTile_copyButton"; + p.appendChild(button); + }); + return el.innerHTML; } export function emojifyText(text) { diff --git a/src/KeyCode.js b/src/KeyCode.js index c9cac01239..ec5595b71b 100644 --- a/src/KeyCode.js +++ b/src/KeyCode.js @@ -21,6 +21,7 @@ module.exports = { ENTER: 13, SHIFT: 16, ESCAPE: 27, + SPACE: 32, PAGE_UP: 33, PAGE_DOWN: 34, END: 35, @@ -30,6 +31,30 @@ module.exports = { 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, }; diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js new file mode 100644 index 0000000000..1da4922153 --- /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.createDialog(KeyShareDialog, { + matrixClient: this._matrixClient, + userId: userId, + deviceId: deviceId, + onFinished: finished, + }); + this._currentUser = userId; + this._currentDevice = deviceId; + } +} + diff --git a/src/Lifecycle.js b/src/Lifecycle.js index f20716cae6..eb2156e780 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -15,10 +15,12 @@ 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 createMatrixClient from './utils/createMatrixClient'; +import Analytics from './Analytics'; import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; @@ -32,28 +34,19 @@ 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. @@ -67,54 +60,39 @@ import sdk from './index'; * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * true; defines the IS to use. * + * @returns {Promise} a promise which resolves when the above process completes. + * 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); } return _restoreFromLocalStorage().then((success) => { if (success) { - return; + return true; } if (enableGuest) { @@ -122,12 +100,32 @@ export function loadSession(opts) { } // 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, }); @@ -138,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, + 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; }); - }, (err) => { + }).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 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, }); @@ -168,83 +170,78 @@ 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 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 q(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 q(true); + return _doSetLoggedIn({ + userId: userId, + deviceId: deviceId, + accessToken: accessToken, + homeserverUrl: hsUrl, + identityServerUrl: isUrl, + guest: isGuest, + }, false).then(() => true); } catch (e) { return _handleRestoreFailure(e); } } else { console.log("No previous session found."); - return q(false); + return Promise.resolve(false); } } function _handleRestoreFailure(e) { console.log("Unable to restore session", e); - let msg = e.message; - if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { - msg = "You need to log back in to generate end-to-end encryption keys " - + "for this device and submit the public key to your homeserver. " - + "This is a once off; sorry for the inconvenience."; - - _clearLocalStorage(); - - return q.reject(new Error( - "Unable to restore previous session: " + msg, - )); - } - - const def = q.defer(); + const def = Promise.defer(); const SessionRestoreErrorDialog = sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); Modal.createDialog(SessionRestoreErrorDialog, { - error: msg, + error: e.message, onFinished: (success) => { def.resolve(success); }, @@ -253,7 +250,7 @@ function _handleRestoreFailure(e) { return def.promise.then((success) => { if (success) { // user clicked continue. - _clearLocalStorage(); + _clearStorage(); return false; } @@ -264,46 +261,82 @@ function _handleRestoreFailure(e) { let rtsClient = null; export function initRtsClient(url) { - rtsClient = new RtsClient(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) { + 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 => %s (guest=%s) hs=%s", - credentials.userId, credentials.guest, - credentials.homeserverUrl); + + console.log( + "setLoggedIn: mxid: " + credentials.userId + + " deviceId: " + credentials.deviceId + + " guest: " + credentials.guest + + " hs: " + credentials.homeserverUrl, + ); + // This is dispatched to indicate that the user is still in the process of logging in // because `teamPromise` may take some time to resolve, breaking the assumption that // `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms // later than MatrixChat might assume. - dis.dispatch({action: 'on_logging_in'}); + // + // 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.setGuest(credentials.guest); // 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); } @@ -314,25 +347,42 @@ export function setLoggedIn(credentials) { 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!"); } - // stop any running clients before we create a new one with these new credentials - stopMatrixClient(); - MatrixClientPeg.replaceUsingCreds(credentials); teamPromise.then((teamToken) => { dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); - }, (err) => { - console.warn("Failed to get team token on login", err); - dis.dispatch({action: 'on_logged_in', teamToken: null}); }); startMatrixClient(); + 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}`); } /** @@ -352,7 +402,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 @@ -363,15 +413,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 @@ -387,44 +439,54 @@ export function startMatrixClient() { } /* - * 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(); - cli.store.deleteAllData(); MatrixClientPeg.unset(); } } diff --git a/src/Login.js b/src/Login.js index 107a8825e9..049b79c2f4 100644 --- a/src/Login.js +++ b/src/Login.js @@ -16,8 +16,9 @@ limitations under the License. */ import Matrix from "matrix-js-sdk"; +import { _t } from "./languageHandler"; -import q from 'q'; +import Promise from 'bluebird'; import url from 'url'; export default class Login { @@ -96,11 +97,6 @@ export default class Login { 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; }); } @@ -148,7 +144,7 @@ export default class Login { const client = this._createTemporaryClient(); return client.login('m.login.password', loginParams).then(function(data) { - return q({ + return Promise.resolve({ homeserverUrl: self._hsUrl, identityServerUrl: self._isUrl, userId: data.user_id, @@ -156,15 +152,7 @@ export default class Login { 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 (error.httpStatus === 403) { if (self._fallbackHsUrl) { var fbClient = Matrix.createClient({ baseUrl: self._fallbackHsUrl, @@ -172,7 +160,7 @@ export default class Login { }); return fbClient.login('m.login.password', loginParams).then(function(data) { - return q({ + return Promise.resolve({ homeserverUrl: self._fallbackHsUrl, identityServerUrl: self._isUrl, userId: data.user_id, @@ -185,21 +173,23 @@ export default class Login { }); } } - 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); + 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(); - var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + const casUrl = client.getCasLoginUrl(url.format(parsedUrl)); window.location.href = casUrl; } } diff --git a/src/Markdown.js b/src/Markdown.js index 4a46ce4f24..5730e42a09 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -17,7 +17,7 @@ limitations under the License. import commonmark from 'commonmark'; import escape from 'lodash/escape'; -const ALLOWED_HTML_TAGS = ['del']; +const ALLOWED_HTML_TAGS = ['del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 452b67c4ee..4264828c7b 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.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,13 +17,10 @@ limitations under the License. 'use strict'; -import q from "q"; -import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; - -const localStorage = window.localStorage; +import createMatrixClient from './utils/createMatrixClient'; interface MatrixClientCreds { homeserverUrl: string, @@ -50,7 +48,6 @@ class MatrixClientPeg { this.opts = { initialSyncLimit: 20, }; - this.indexedDbWorkerScript = null; } /** @@ -61,7 +58,7 @@ class MatrixClientPeg { * @param {string} script href to the script to be passed to the web worker */ setIndexedDbWorkerScript(script) { - this.indexedDbWorkerScript = script; + createMatrixClient.indexedDbWorkerScript = script; } get(): MatrixClient { @@ -80,20 +77,38 @@ 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"; - let promise = this.matrixClient.store.startup(); - // log any errors when starting up the database (if one exists) - promise.catch((err) => { console.error(err); }); + try { + let 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. - promise.finally(() => { - this.get().startClient(opts); - }); + + console.log(`MatrixClientPeg: really starting MatrixClient`); + this.get().startClient(opts); + console.log(`MatrixClientPeg: MatrixClient started`); } getCredentials(): MatrixClientCreds { @@ -130,22 +145,7 @@ class MatrixClientPeg { timelineSupport: true, }; - if (localStorage) { - opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); - } - if (window.indexedDB && localStorage) { - // FIXME: bodge to remove old database. Remove this after a few weeks. - window.indexedDB.deleteDatabase("matrix-js-sdk:default"); - - opts.store = new Matrix.IndexedDBStore({ - indexedDB: window.indexedDB, - dbName: "riot-web-sync", - localStorage: localStorage, - workerScript: this.indexedDbWorkerScript, - }); - } - - 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. diff --git a/src/Modal.js b/src/Modal.js index 7be37da92e..e100105a88 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,6 +19,7 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); +import Analytics from './Analytics'; import sdk from './index'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -63,7 +64,6 @@ const AsyncWrapper = React.createClass({ render: function() { const {loader, ...otherProps} = this.props; - if (this.state.component) { const Component = this.state.component; return ; @@ -104,6 +104,9 @@ class ModalManager { } createDialog(Element, props, className) { + if (props && props.title) { + Analytics.trackEvent('Modal', props.title, 'createDialog'); + } return this.createDialogAsync((cb) => {cb(Element);}, props, className); } @@ -195,4 +198,7 @@ class ModalManager { } } -export default new ModalManager(); +if (!global.singletonModalManager) { + global.singletonModalManager = new ModalManager(); +} +export default global.singletonModalManager; diff --git a/src/Notifier.js b/src/Notifier.js index 92770877b7..40a65d4106 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -15,11 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -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'; /* * Dispatches: @@ -29,7 +33,7 @@ var dis = require("./dispatcher"); * } */ -var Notifier = { +const Notifier = { notifsByRoom: {}, notificationMessageForEvent: function(ev) { @@ -48,16 +52,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; @@ -68,7 +72,7 @@ var Notifier = { if (ev.getContent().body) msg = ev.getContent().body; } - var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( + const avatarUrl = ev.sender ? Avatar.avatarUrlForMember( ev.sender, 40, 40, 'crop' ) : null; @@ -83,7 +87,7 @@ var Notifier = { }, _playAudioNotification: function(ev, room) { - var e = document.getElementById("messageAudio"); + const e = document.getElementById("messageAudio"); if (e) { e.load(); e.play(); @@ -95,7 +99,7 @@ var Notifier = { this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); + MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; this.isSyncing = false; @@ -104,7 +108,7 @@ var Notifier = { stop: function() { if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); + MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } this.isSyncing = false; @@ -118,10 +122,13 @@ var Notifier = { setEnabled: function(enable, callback) { const plaf = PlatformPeg.get(); if (!plaf) return; + + 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) { + if (global.localStorage.getItem('audio_notifications_enabled') === null) { this.setAudioEnabled(this.isEnabled()); } } @@ -131,6 +138,14 @@ var Notifier = { plaf.requestNotificationPermission().done((result) => { if (result !== 'granted') { // The permission request was dismissed or denied + 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.createDialog(ErrorDialog, { + title: _t('Unable to enable Notifications'), + description, + }); return; } @@ -141,7 +156,7 @@ var Notifier = { if (callback) callback(); dis.dispatch({ action: "notifier_enabled", - value: true + value: true, }); }); // clear the notifications_hidden flag, so that if notifications are @@ -152,7 +167,7 @@ var Notifier = { global.localStorage.setItem('notifications_enabled', 'false'); dis.dispatch({ action: "notifier_enabled", - value: false + value: false, }); } }, @@ -165,7 +180,7 @@ var Notifier = { if (!global.localStorage) return true; - var enabled = global.localStorage.getItem('notifications_enabled'); + const enabled = global.localStorage.getItem('notifications_enabled'); if (enabled === null) return true; return enabled === 'true'; }, @@ -173,12 +188,12 @@ var Notifier = { setAudioEnabled: function(enable) { if (!global.localStorage) return; global.localStorage.setItem('audio_notifications_enabled', - enable ? 'true' : 'false'); + enable ? 'true' : 'false'); }, isAudioEnabled: function(enable) { if (!global.localStorage) return true; - var enabled = global.localStorage.getItem( + const enabled = global.localStorage.getItem( 'audio_notifications_enabled'); // default to true if the popups are enabled if (enabled === null) return this.isEnabled(); @@ -188,11 +203,13 @@ var Notifier = { 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 @@ -215,8 +232,7 @@ var Notifier = { onSyncStateChange: function(state) { if (state === "SYNCING") { this.isSyncing = true; - } - else if (state === "STOPPED" || state === "ERROR") { + } else if (state === "STOPPED" || state === "ERROR") { this.isSyncing = false; } }, @@ -225,22 +241,23 @@ var Notifier = { if (toStartOfTimeline) return; if (!room) return; if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; + if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; - var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { if (this.isEnabled()) { this._displayPopupNotification(ev, room); } if (actions.tweaks.sound && this.isAudioEnabled()) { + PlatformPeg.get().loudNotification(ev, room); this._playAudioNotification(ev, room); } } }, 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 @@ -255,7 +272,7 @@ var Notifier = { } delete this.notifsByRoom[room.roomId]; } - } + }, }; if (!global.mxNotifier) { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 5fac588a4f..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]--; @@ -68,9 +68,9 @@ module.exports.getKeyValueArrayDiffs = function(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 d87b363a6f..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. @@ -22,4 +23,6 @@ export default { 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/Resend.js b/src/Resend.js index bbd980ea7f..1fee5854ea 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -14,10 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var dis = require('./dispatcher'); -var sdk = require('./index'); -var Modal = require('./Modal'); +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; import { EventStatus } from 'matrix-js-sdk'; module.exports = { @@ -37,12 +35,10 @@ module.exports = { }, resend: function(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent( - event, room - ).done(function(res) { + MatrixClientPeg.get().resendEvent(event, room).done(function(res) { dis.dispatch({ action: 'message_sent', - event: event + event: event, }); }, function(err) { // XXX: temporary logging to try to diagnose @@ -58,7 +54,7 @@ module.exports = { dis.dispatch({ action: 'message_send_failed', - event: event + event: event, }); }); }, @@ -66,7 +62,7 @@ module.exports = { MatrixClientPeg.get().cancelPendingEvent(event); dis.dispatch({ action: 'message_send_cancelled', - event: event + event: event, }); }, }; diff --git a/src/RichText.js b/src/RichText.js index b1793d0ddf..c060565e2f 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -16,6 +16,7 @@ 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,9 +31,26 @@ 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 { +export const contentStateToHTML = (contentState: ContentState) => { + return stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u' + } + } + }); +}; + +export function htmlToContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } @@ -95,31 +113,6 @@ 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]; } @@ -146,9 +139,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { ) }); - markdownDecorators.push(emojiDecorator); - - return markdownDecorators; + // markdownDecorators.push(emojiDecorator); + // TODO Consider renabling "syntax highlighting" when we can do it properly + return [emojiDecorator]; } /** @@ -286,3 +279,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..83d8192c67 --- /dev/null +++ b/src/Roles.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 { _t } from './languageHandler'; + +export function levelRoleMap() { + return { + undefined: _t('Default'), + 0: _t('User'), + 50: _t('Moderator'), + 100: _t('Admin'), + }; +} + +export function textualPowerLevel(level, userDefault) { + const LEVEL_ROLE_MAP = this.levelRoleMap(); + if (LEVEL_ROLE_MAP[level]) { + return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`); + } else { + return level; + } +} diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 7a43c1891e..c06cc60c97 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -19,8 +19,7 @@ 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; } } @@ -32,5 +31,5 @@ function mostRecentActivityFirst(roomList) { } module.exports = { - mostRecentActivityFirst: mostRecentActivityFirst + mostRecentActivityFirst, }; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 7cb7d4b9de..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 08fa7f797f..2e3f4457f0 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, @@ -103,7 +102,7 @@ export function guessAndSetDMRoom(room, isDirect) { */ export function setDMRoom(roomId, userId) { if (MatrixClientPeg.get().isGuest()) { - return q(); + return Promise.resolve(); } const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); @@ -145,7 +144,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 index 8c3ce54b37..493b19599c 100644 --- a/src/RtsClient.js +++ b/src/RtsClient.js @@ -1,5 +1,7 @@ import 'whatwg-fetch'; +let fetchFunction = fetch; + function checkStatus(response) { if (!response.ok) { return response.text().then((text) => { @@ -31,7 +33,7 @@ const request = (url, opts) => { opts.body = JSON.stringify(opts.body); opts.headers['Content-Type'] = 'application/json'; } - return fetch(url, opts) + return fetchFunction(url, opts) .then(checkStatus) .then(parseJson); }; @@ -64,7 +66,7 @@ export default class RtsClient { client_secret: clientSecret, }, method: 'POST', - } + }, ); } @@ -74,7 +76,7 @@ export default class RtsClient { qs: { team_token: teamToken, }, - } + }, ); } @@ -91,7 +93,12 @@ export default class RtsClient { 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..b1d17b93a9 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -var q = require("q"); +import Promise from 'bluebird'; var request = require('browser-request'); var SdkConfig = require('./SdkConfig'); @@ -39,7 +39,7 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { var tok = window.localStorage.getItem("mx_scalar_token"); - if (tok) return q(tok); + if (tok) return Promise.resolve(tok); // No saved token, so do the dance to get one. First, we // need an openid bearer token from the HS. @@ -53,7 +53,7 @@ class ScalarAuthClient { } exchangeForScalarToken(openid_token_object) { - var defer = q.defer(); + var defer = Promise.defer(); var scalar_rest_url = SdkConfig.get().integrations_rest_url; request({ @@ -76,10 +76,13 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId) { + getScalarInterfaceUrlForRoom(roomId, screen) { var url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + if (screen) { + url += '&screen=' + encodeURIComponent(screen); + } return url; } @@ -89,4 +92,3 @@ class ScalarAuthClient { } module.exports = ScalarAuthClient; - diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index dbb7e405df..d14d439d66 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,91 @@ 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 + let widgetStateEvents = []; + stateEvents.forEach((ev) => { + if (ev.getContent().type && ev.getContent().url) { + widgetStateEvents.push(ev.event); // return the raw event + } + }) + + sendResponse(event, widgetStateEvents); +} + function setPlumbingState(event, roomId, status) { if (typeof status !== 'string') { throw new Error('Plumbing state status should be a string'); @@ -181,7 +373,7 @@ 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(() => { @@ -189,7 +381,7 @@ function setPlumbingState(event, roomId, status) { 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 +389,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,20 +397,20 @@ 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; } @@ -235,7 +427,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 +447,66 @@ 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); @@ -300,7 +543,7 @@ 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)) { + if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } @@ -313,13 +556,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 +574,33 @@ 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 === "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 +625,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 8d8e93a889..48ebf011f2 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -14,7 +14,7 @@ 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 @@ -30,8 +30,8 @@ class SdkConfig { } 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]]; } diff --git a/src/Skinner.js b/src/Skinner.js index 4482f2239c..f47572ba01 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; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 1ddcf4832d..dea3d27751 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -14,10 +14,11 @@ 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'; @@ -41,58 +42,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.", + 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]; } return success( MatrixClientPeg.get().setRoomAccountData( - room_id, "org.matrix.room.color_scheme", colorScheme - ) + roomId, "org.matrix.room.color_scheme", colorScheme, + ), ); } } @@ -100,22 +107,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 +130,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 +154,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 +185,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 +214,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 +227,13 @@ 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]), ); } } @@ -233,27 +241,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,32 +270,96 @@ 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()); - }) + }), + + // 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.createDialog(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 +376,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 +391,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 +400,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() {})); return cmds; - } + }, }; diff --git a/src/TabComplete.js b/src/TabComplete.js deleted file mode 100644 index 59ecc2ae20..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 + , ); - var error; - var addressSelector; + let error; + let addressSelector; if (this.state.error) { - error =
You have entered an invalid contact. Try using their Matrix ID or email address.
; + error =
{_t("You have entered an invalid contact. Try using their Matrix ID or email address.")}
; + } 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 { - const addressSelectorHeader =
- Searching known users -
; addressSelector = ( {this.addressSelector = ref;}} addressList={ this.state.queryList } onSelected={ this.onSelected } truncateAt={ TRUNCATE_QUERY_LIST } - header={ addressSelectorHeader } /> ); } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index fc9e55f666..7922b7b289 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; import classnames from 'classnames'; +import { _t } from '../../../languageHandler'; /* * A dialog for confirming a redaction. @@ -42,7 +43,7 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const title = "Confirm Redaction"; + const title = _t("Confirm Removal"); const confirmButtonClass = classnames({ 'mx_Dialog_primary': true, @@ -55,16 +56,16 @@ export default React.createClass({ title={title} >
- Are you sure you wish to redact (delete) this event? - Note that if you redact a room name or topic change, it could undo the change. + {_t("Are you sure you wish to remove (delete) this event? " + + "Note that if you delete a room name or topic change, it could undo the change.")}
diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 6cfaac65d4..b10df3ccef 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import classnames from 'classnames'; /* @@ -69,7 +70,7 @@ export default React.createClass({ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const title = this.props.action + " this person?"; + const title = _t("%(actionVerb)s this person?", { actionVerb: this.props.action}); const confirmButtonClass = classnames({ 'mx_Dialog_primary': true, 'danger': this.props.danger, @@ -82,7 +83,7 @@ export default React.createClass({
@@ -111,7 +112,7 @@ export default React.createClass({
diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js new file mode 100644 index 0000000000..23194f20a5 --- /dev/null +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -0,0 +1,199 @@ +/* +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'; + +// We match fairly liberally and leave it up to the server to reject if +// there are invalid characters etc. +const GROUP_REGEX = /^\+(.*?):(.*)$/; + +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) { + const parsedGroupId = this._parseGroupId(this.state.groupId); + let error = null; + if (parsedGroupId === null) { + error = _t( + "Group IDs must be of the form +localpart:%(domain)s", + {domain: MatrixClientPeg.get().getDomain()}, + ); + } else { + const domain = parsedGroupId[1]; + if (domain !== MatrixClientPeg.get().getDomain()) { + error = _t( + "It is currently only possible to create groups on your own home server: "+ + "use a group ID ending with %(domain)s", + {domain: MatrixClientPeg.get().getDomain()}, + ); + } + } + this.setState({ + groupIdError: error, + }); + return error; + }, + + _onFormSubmit: function(e) { + e.preventDefault(); + + if (this._checkGroupId()) return; + + const parsedGroupId = this._parseGroupId(this.state.groupId); + const profile = {}; + if (this.state.groupName !== '') { + profile.name = this.state.groupName; + } + this.setState({creating: true}); + MatrixClientPeg.get().createGroup({ + localpart: parsedGroupId[0], + profile: profile, + }).then((result) => { + dis.dispatch({ + action: 'view_group', + group_id: result.group_id, + }); + this.props.onFinished(true); + }).catch((e) => { + this.setState({createError: e}); + }).finally(() => { + this.setState({creating: false}); + }).done(); + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + /** + * Parse a string that may be a group ID + * If the string is a valid group ID, return a list of [localpart, domain], + * otherwise return null. + * + * @param {string} groupId The ID of the group + * @return {string[]} array of localpart, domain + */ + _parseGroupId: function(groupId) { + const matches = GROUP_REGEX.exec(this.state.groupId); + if (!matches || matches.length < 3) { + return null; + } + return [matches[1], matches[2]]; + }, + + 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('Room creation failed')}
+
{this.state.createError.message}
+
; + } + + return ( + +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ {this.state.groupIdError} +
+ {createErrorNode} +
+
+ + +
+
+
+ ); + }, +}); diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index b4879982bf..e3b7cca078 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -20,6 +20,7 @@ import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; import Velocity from 'velocity-vector'; +import { _t } from '../../../languageHandler'; export default class DeactivateAccountDialog extends React.Component { constructor(props, context) { @@ -56,10 +57,10 @@ export default class DeactivateAccountDialog extends React.Component { 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({ @@ -85,29 +86,29 @@ export default class DeactivateAccountDialog extends React.Component { 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 = ; } return (
- Deactivate Account + {_t("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")}:

+

+ {_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: React.PropTypes.string.isRequired, + device: React.PropTypes.object.isRequired, + onFinished: React.PropTypes.func.isRequired, +}; diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index 937595dfa8..bf48d1757b 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -27,6 +27,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'ErrorDialog', @@ -43,24 +44,30 @@ export default React.createClass({ getDefaultProps: function() { return { - title: "Error", - description: "An error has occurred.", - button: "OK", focus: true, + title: null, + description: null, + button: null, }; }, + componentDidMount: function() { + if (this.props.focus) { + this.refs.button.focus(); + } + }, + render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( + title={this.props.title || _t('Error')}>
- {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 145b4b6453..363ce89b57 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -15,11 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; - import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; @@ -46,12 +45,6 @@ export default React.createClass({ title: React.PropTypes.string, }, - getDefaultProps: function() { - return { - title: "Authentication", - }; - }, - getInitialState: function() { return { authError: null, @@ -85,7 +78,7 @@ export default React.createClass({ - Dismiss + {_t("Dismiss")}
); @@ -105,7 +98,7 @@ export default React.createClass({ return ( {content} diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js new file mode 100644 index 0000000000..61391d281c --- /dev/null +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -0,0 +1,172 @@ +/* +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 sdk from '../../../index'; + +import { _t } 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: React.PropTypes.object.isRequired, + userId: React.PropTypes.string.isRequired, + deviceId: React.PropTypes.string.isRequired, + onFinished: React.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.createDialog(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 = "You added a new device '%(displayName)s', which is" + + " requesting encryption keys."; + } else { + text = "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/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js deleted file mode 100644 index f4df5913d5..0000000000 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ /dev/null @@ -1,78 +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, - * }); - */ - -import React from 'react'; -import dis from '../../../dispatcher'; -import sdk from '../../../index'; - -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() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( - -
- {this.props.description} -
-
- - -
-
- ); - }, -}); diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 6012541b94..ec9b95d7f7 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'QuestionDialog', @@ -33,7 +34,6 @@ export default React.createClass({ title: "", description: "", extraButtons: null, - button: "OK", focus: true, hasCancelButton: true, }; @@ -51,7 +51,7 @@ export default React.createClass({ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const cancelButton = this.props.hasCancelButton ? ( ) : null; return ( @@ -64,7 +64,7 @@ export default React.createClass({
{this.props.extraButtons} {cancelButton} diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 358bbf1fec..a3eb7c6962 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -18,6 +18,7 @@ import React from 'react'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; +import { _t, _tJsx } from '../../../languageHandler'; export default React.createClass({ @@ -43,29 +44,32 @@ export default React.createClass({ if (SdkConfig.get().bug_report_endpoint_url) { bugreport = ( -

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

+ {_tJsx( + "Otherwise, click here to send a bug report.", + /(.*?)<\/a>/, (sub) => {sub}, + )}

); } return ( + title={_t('Unable to restore session')}>
-

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("We encountered an error trying to restore your previous session. If " + + "you continue, you will need to log in again, and encrypted chat " + + "history will be unreadable.")}

-

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

+

{_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 1047e05c26..0000000000 --- a/src/components/views/dialogs/SetDisplayNameDialog.js +++ /dev/null @@ -1,87 +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 React from 'react'; -import sdk from '../../../index'; -import MatrixClientPeg from '../../../MatrixClientPeg'; - -/** - * Prompt the user to set a display name. - * - * On success, `onFinished(true, newDisplayName)` is called. - */ -export default 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(); - }, - - onValueChange: function(ev) { - this.setState({ - value: ev.target.value - }); - }, - - onFormSubmit: function(ev) { - ev.preventDefault(); - this.props.onFinished(true, this.state.value); - return false; - }, - - render: function() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( - -
- 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..3c38064ee1 --- /dev/null +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -0,0 +1,164 @@ +/* +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 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: React.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.createDialog(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.createDialog(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.createDialog(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.createDialog(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.createDialog(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..4d4f672f2b --- /dev/null +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -0,0 +1,294 @@ +/* +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 sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import classnames from 'classnames'; +import KeyCode from '../../../KeyCode'; +import { _t, _tJsx } 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: React.PropTypes.func.isRequired, + // Called when the user requests to register with a different homeserver + onDifferentServerClicked: React.PropTypes.func.isRequired, + // Called if the user wants to switch to login instead + onLoginClick: React.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() { + // 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 } +

+ { _tJsx( + 'This will be your account name on the ' + + 'homeserver, or you can pick a different server.', + [ + /<\/span>/, + /(.*?)<\/a>/, + ], + [ + (sub) => {this.props.homeserverUrl}, + (sub) => {sub}, + ], + )} +

+

+ { _tJsx( + '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 6e40efffd8..673be42030 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'TextInputDialog', @@ -36,7 +37,6 @@ export default React.createClass({ title: "", value: "", description: "", - button: "OK", focus: true, }; }, @@ -73,7 +73,7 @@ export default React.createClass({
); } else { blacklistButton = ( ); } @@ -130,14 +99,14 @@ export default React.createClass({ verifyButton = ( ); } else { verifyButton = ( ); } diff --git a/src/components/views/elements/DirectorySearchBox.js b/src/components/views/elements/DirectorySearchBox.js index 467878caad..eaa6ee34ba 100644 --- a/src/components/views/elements/DirectorySearchBox.js +++ b/src/components/views/elements/DirectorySearchBox.js @@ -93,7 +93,7 @@ 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} diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js index 3b34d3cac1..c049c38a68 100644 --- a/src/components/views/elements/Dropdown.js +++ b/src/components/views/elements/Dropdown.js @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import classnames from 'classnames'; import AccessibleButton from './AccessibleButton'; +import { _t } from '../../../languageHandler'; class MenuOption extends React.Component { constructor(props) { @@ -114,8 +115,11 @@ export default class Dropdown extends React.Component { } componentWillReceiveProps(nextProps) { + if (!nextProps.children || nextProps.children.length === 0) { + return; + } this._reindexChildren(nextProps.children); - const firstChild = React.Children.toArray(nextProps.children)[0]; + const firstChild = nextProps.children[0]; this.setState({ highlightedOption: firstChild ? firstChild.key : null, }); @@ -149,10 +153,12 @@ export default class Dropdown extends React.Component { } _onInputClick(ev) { - this.setState({ - expanded: !this.state.expanded, - }); - ev.preventDefault(); + if (!this.state.expanded) { + this.setState({ + expanded: true, + }); + ev.preventDefault(); + } } _onMenuOptionClick(dropdownKey) { @@ -248,13 +254,10 @@ export default class Dropdown extends React.Component { ); }); - - if (!this.state.searchQuery) { - options.push( -
- Type to search... -
- ); + if (options.length === 0) { + return [
+ {_t("No results")} +
]; } return options; } @@ -267,16 +270,20 @@ export default class Dropdown extends React.Component { let menu; if (this.state.expanded) { - currentValue = ; + if (this.props.searchEnabled) { + currentValue = ; + } menu =
{this._getMenuOptions()}
; - } else { + } + + if (!currentValue) { const selectedChild = this.props.getShortOption ? this.props.getShortOption(this.props.value) : this.childrenByKey[this.props.value]; @@ -313,6 +320,7 @@ Dropdown.propTypes = { onOptionChange: React.PropTypes.func.isRequired, // Called when the value of the search field changes onSearchChange: React.PropTypes.func, + searchEnabled: React.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 diff --git a/src/components/views/elements/EditableTextContainer.js b/src/components/views/elements/EditableTextContainer.js index d6f9c555c6..aeb32c07ec 100644 --- a/src/components/views/elements/EditableTextContainer.js +++ b/src/components/views/elements/EditableTextContainer.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; -import q from 'q'; +import Promise from 'bluebird'; /** * A component which wraps an EditableText, with a spinner while updates take @@ -148,5 +148,5 @@ EditableTextContainer.defaultProps = { initialValue: "", placeholder: "", blurToSubmit: false, - onSubmit: function(v) {return q(); }, + onSubmit: function(v) {return Promise.resolve(); }, }; diff --git a/src/components/views/elements/HomeButton.js b/src/components/views/elements/HomeButton.js new file mode 100644 index 0000000000..4877f5dd43 --- /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..c5ed1f8942 --- /dev/null +++ b/src/components/views/elements/LanguageDropdown.js @@ -0,0 +1,120 @@ +/* +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 sdk from '../../../index'; +import UserSettingsStore from '../../../UserSettingsStore'; +import * as languageHandler from '../../../languageHandler'; + +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 _localSettings = UserSettingsStore.getLocalSettings(); + if (_localSettings.hasOwnProperty('language')) { + this.props.onOptionChange(_localSettings.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 value = null; + const _localSettings = UserSettingsStore.getLocalSettings(); + if (_localSettings.hasOwnProperty('language')) { + value = this.props.value || _localSettings.language; + } else { + const language = navigator.language || navigator.userLanguage; + value = this.props.value || language; + } + + return + {options} + + } +} + +LanguageDropdown.propTypes = { + className: React.PropTypes.string, + onOptionChange: React.PropTypes.func.isRequired, + value: React.PropTypes.string, +}; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index d7f876c16e..842b44b793 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -14,7 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; +import sdk from '../../../index'; const MemberAvatar = require('../avatars/MemberAvatar.js'); +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'MemberEventListSummary', @@ -110,9 +112,13 @@ module.exports = React.createClass({ return null; } + const EmojiText = sdk.getComponent('elements.EmojiText'); + return ( - {summaries.join(", ")} + + {summaries.join(", ")} + ); }, @@ -203,28 +209,146 @@ module.exports = React.createClass({ * @param {boolean} plural whether there were multiple users undergoing the same * transition. * @param {number} repeats the number of times the transition was repeated in a row. - * @returns {string} the written English equivalent of the transition. + * @returns {string} the written Human Readable equivalent of the transition. */ _getDescriptionForTransition(t, plural, repeats) { - const beConjugated = plural ? "were" : "was"; - const invitation = "their invitation" + (plural || (repeats > 1) ? "s" : ""); - + // 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; - const map = { - "joined": "joined", - "left": "left", - "joined_and_left": "joined and left", - "left_and_joined": "left and rejoined", - "invite_reject": "rejected " + invitation, - "invite_withdrawal": "had " + invitation + " withdrawn", - "invited": beConjugated + " invited", - "banned": beConjugated + " banned", - "unbanned": beConjugated + " unbanned", - "kicked": beConjugated + " kicked", - }; - - if (Object.keys(map).includes(t)) { - res = map[t] + (repeats > 1 ? " " + repeats + " times" : "" ); + switch(t) { + case "joined": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)sjoined %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)sjoined %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)sjoined", { severalUsers: "" }) + : _t("%(oneUser)sjoined", { oneUser: "" }); + } + break; + case "left": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)sleft %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)sleft %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)sleft", { severalUsers: "" }) + : _t("%(oneUser)sleft", { oneUser: "" }); + } + break; + case "joined_and_left": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)sjoined and left %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)sjoined and left %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)sjoined and left", { severalUsers: "" }) + : _t("%(oneUser)sjoined and left", { oneUser: "" }); + } + break; + case "left_and_joined": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)sleft and rejoined %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)sleft and rejoined %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)sleft and rejoined", { severalUsers: "" }) + : _t("%(oneUser)sleft and rejoined", { oneUser: "" }); + } + break; + case "invite_reject": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)srejected their invitations %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)srejected their invitation %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)srejected their invitations", { severalUsers: "" }) + : _t("%(oneUser)srejected their invitation", { oneUser: "" }); + } + break; + case "invite_withdrawal": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)shad their invitations withdrawn %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)shad their invitation withdrawn %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)shad their invitations withdrawn", { severalUsers: "" }) + : _t("%(oneUser)shad their invitation withdrawn", { oneUser: "" }); + } + break; + case "invited": + if (repeats > 1) { + res = (plural) + ? _t("were invited %(repeats)s times", { repeats: repeats }) + : _t("was invited %(repeats)s times", { repeats: repeats }); + } else { + res = (plural) + ? _t("were invited") + : _t("was invited"); + } + break; + case "banned": + if (repeats > 1) { + res = (plural) + ? _t("were banned %(repeats)s times", { repeats: repeats }) + : _t("was banned %(repeats)s times", { repeats: repeats }); + } else { + res = (plural) + ? _t("were banned") + : _t("was banned"); + } + break; + case "unbanned": + if (repeats > 1) { + res = (plural) + ? _t("were unbanned %(repeats)s times", { repeats: repeats }) + : _t("was unbanned %(repeats)s times", { repeats: repeats }); + } else { + res = (plural) + ? _t("were unbanned") + : _t("was unbanned"); + } + break; + case "kicked": + if (repeats > 1) { + res = (plural) + ? _t("were kicked %(repeats)s times", { repeats: repeats }) + : _t("was kicked %(repeats)s times", { repeats: repeats }); + } else { + res = (plural) + ? _t("were kicked") + : _t("was kicked"); + } + break; + case "changed_name": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)schanged their name %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)schanged their name %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)schanged their name", { severalUsers: "" }) + : _t("%(oneUser)schanged their name", { oneUser: "" }); + } + break; + case "changed_avatar": + if (repeats > 1) { + res = (plural) + ? _t("%(severalUsers)schanged their avatar %(repeats)s times", { severalUsers: "", repeats: repeats }) + : _t("%(oneUser)schanged their avatar %(repeats)s times", { oneUser: "", repeats: repeats }); + } else { + res = (plural) + ? _t("%(severalUsers)schanged their avatar", { severalUsers: "" }) + : _t("%(oneUser)schanged their avatar", { oneUser: "" }); + } + break; } return res; @@ -252,11 +376,12 @@ module.exports = React.createClass({ return items[0]; } else if (remaining) { items = items.slice(0, itemLimit); - const other = " other" + (remaining > 1 ? "s" : ""); - return items.join(', ') + ' and ' + remaining + other; + return (remaining > 1) + ? _t("%(items)s and %(remaining)s others", { items: items.join(', '), remaining: remaining } ) + : _t("%(items)s and one other", { items: items.join(', ') }); } else { const lastItem = items.pop(); - return items.join(', ') + ' and ' + lastItem; + return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); } }, @@ -267,7 +392,7 @@ module.exports = React.createClass({ ); }); return ( - + {avatars} ); @@ -289,7 +414,24 @@ module.exports = React.createClass({ switch (e.mxEvent.getContent().membership) { case 'invite': return 'invited'; case 'ban': return 'banned'; - case 'join': return 'joined'; + 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) { @@ -350,6 +492,7 @@ module.exports = React.createClass({ render: function() { 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; @@ -360,7 +503,7 @@ module.exports = React.createClass({ if (fewEvents) { return ( -
+
{expandedEvents}
); @@ -418,7 +561,7 @@ module.exports = React.createClass({ ); return ( -
+
{toggleButton} {summaryContainer} {expanded ?
 
: null} diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js new file mode 100644 index 0000000000..bc0a326338 --- /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..8d19eb5999 --- /dev/null +++ b/src/components/views/elements/Pill.js @@ -0,0 +1,195 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import sdk from '../../../index'; +import classNames from 'classnames'; +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); + }, + TYPE_USER_MENTION: 'TYPE_USER_MENTION', + TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', + }, + + props: { + // 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), + }, + + 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; + } + + // Default to the empty array if no match for simplicity + // resource and prefix will be undefined instead of throwing + const matrixToMatch = regex.exec(nextProps.url) || []; + + const resourceId = matrixToMatch[1]; // The room/user ID + const prefix = matrixToMatch[2]; // The first character of prefix + + const pillType = { + '@': Pill.TYPE_USER_MENTION, + '#': Pill.TYPE_ROOM_MENTION, + '!': Pill.TYPE_ROOM_MENTION, + }[prefix]; + + let member; + let room; + switch (pillType) { + 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); + }); + }, + + 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; + switch (this.state.pillType) { + 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 + avatar = ; + pillClass = 'mx_UserPill'; + } + } + break; + case Pill.TYPE_ROOM_MENTION: { + const room = this.state.room; + if (room) { + linkText = (room ? getDisplayAliasForRoom(room) : null) || resource; + 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 c7bfd4eec1..efeb81fe2d 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -16,18 +16,12 @@ limitations under the License. 'use strict'; -var React = require('react'); - -var roles = { - 0: 'User', - 50: 'Moderator', - 100: 'Admin', -}; +import React from 'react'; +import * as Roles from '../../../Roles'; +import { _t } from '../../../languageHandler'; +var LEVEL_ROLE_MAP = {}; var reverseRoles = {}; -Object.keys(roles).forEach(function(key) { - reverseRoles[roles[key]] = key; -}); module.exports = React.createClass({ displayName: 'PowerSelector', @@ -49,9 +43,16 @@ module.exports = React.createClass({ getInitialState: function() { return { - custom: (roles[this.props.value] === undefined), + custom: (LEVEL_ROLE_MAP[this.props.value] === undefined), }; }, + + componentWillMount: function() { + LEVEL_ROLE_MAP = Roles.levelRoleMap(); + Object.keys(LEVEL_ROLE_MAP).forEach(function(key) { + reverseRoles[LEVEL_ROLE_MAP[key]] = key; + }); + }, onSelectChange: function(event) { this.setState({ custom: event.target.value === "Custom" }); @@ -99,22 +100,34 @@ module.exports = React.createClass({ selectValue = "Custom"; } else { - selectValue = roles[this.props.value] || "Custom"; + selectValue = LEVEL_ROLE_MAP[this.props.value] || "Custom"; } var select; if (this.props.disabled) { select = { selectValue }; } else { + // Each level must have a definition in LEVEL_ROLE_MAP + const levels = [0, 50, 100]; + let options = levels.map((level) => { + return { + value: LEVEL_ROLE_MAP[level], + // Give a userDefault (users_default in the power event) of 0 but + // because level !== undefined, this should never be used. + text: Roles.textualPowerLevel(level, 0), + } + }); + options.push({ value: "Custom", text: _t("Custom level") }); + options = options.map((op) => { + return ; + }); + select = ; } diff --git a/src/components/views/elements/RoomDirectoryButton.js b/src/components/views/elements/RoomDirectoryButton.js new file mode 100644 index 0000000000..d964d9e5bc --- /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..ad09971689 --- /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/StartChatButton.js b/src/components/views/elements/StartChatButton.js new file mode 100644 index 0000000000..05a7db0b9c --- /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/TruncatedList.js b/src/components/views/elements/TruncatedList.js index 0ec2c15f0a..d6a8e1fb7e 100644 --- a/src/components/views/elements/TruncatedList.js +++ b/src/components/views/elements/TruncatedList.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ var React = require('react'); +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'TruncatedList', @@ -33,7 +34,7 @@ module.exports = React.createClass({ truncateAt: 2, createOverflowElement: function(overflowCount, totalCount) { return ( -
And {overflowCount} more...
+
{_t("And %(count)s more...", {count: overflowCount})}
); } }; diff --git a/src/components/views/elements/UserSelector.js b/src/components/views/elements/UserSelector.js index 266e10154f..955903aac0 100644 --- a/src/components/views/elements/UserSelector.js +++ b/src/components/views/elements/UserSelector.js @@ -16,7 +16,8 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'UserSelector', @@ -59,9 +60,9 @@ module.exports = React.createClass({ return
  • {user_id} - X
  • ; })} - +
    ); diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index 0977f947aa..f154cc4f1d 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -16,7 +16,9 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import { _t } from '../../../languageHandler'; + var DIV_ID = 'mx_recaptcha'; /** @@ -44,6 +46,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. @@ -73,6 +79,10 @@ module.exports = React.createClass({ } }, + componentWillUnmount: function() { + this._resetRecaptcha(); + }, + _renderRecaptcha: function(divId) { if (!global.grecaptcha) { console.error("grecaptcha not loaded!"); @@ -88,12 +98,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 { @@ -117,7 +133,7 @@ module.exports = React.createClass({ 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} diff --git a/src/components/views/login/CasLogin.js b/src/components/views/login/CasLogin.js index c818586d52..96e37875be 100644 --- a/src/components/views/login/CasLogin.js +++ b/src/components/views/login/CasLogin.js @@ -16,7 +16,8 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'CasLogin', @@ -28,7 +29,7 @@ module.exports = React.createClass({ render: function() { return (
    - +
    ); } diff --git a/src/components/views/login/CountryDropdown.js b/src/components/views/login/CountryDropdown.js index 9729c9e23f..7024db339c 100644 --- a/src/components/views/login/CountryDropdown.js +++ b/src/components/views/login/CountryDropdown.js @@ -19,7 +19,6 @@ import React from 'react'; import sdk from '../../../index'; import { COUNTRIES } from '../../../phonenumber'; -import { charactersToImageNode } from '../../../HtmlUtils'; const COUNTRIES_BY_ISO2 = new Object(null); for (const c of COUNTRIES) { @@ -27,22 +26,27 @@ for (const c of COUNTRIES) { } 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 == query) return true; + if (country.prefix.indexOf(query) !== -1) return true; return false; } -const MAX_DISPLAYED_ROWS = 2; - export default class CountryDropdown extends React.Component { constructor(props) { super(props); this._onSearchChange = this._onSearchChange.bind(this); + this._onOptionChange = this._onOptionChange.bind(this); + this._getShortOption = this._getShortOption.bind(this); this.state = { searchQuery: '', - } + }; } componentWillMount() { @@ -50,7 +54,7 @@ export default class CountryDropdown extends React.Component { // If no value is given, we start with the first // country selected, but our parent component // doesn't know this, therefore we do this. - this.props.onOptionChange(COUNTRIES[0].iso2); + this.props.onOptionChange(COUNTRIES[0]); } } @@ -60,14 +64,26 @@ export default class CountryDropdown extends React.Component { }); } + _onOptionChange(iso2) { + this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]); + } + _flagImgForIso2(iso2) { - // Unicode Regional Indicator Symbol letter 'A' - const RIS_A = 0x1F1E6; - const ASCII_A = 65; - return charactersToImageNode(iso2, - RIS_A + (iso2.charCodeAt(0) - ASCII_A), - RIS_A + (iso2.charCodeAt(1) - ASCII_A), - ); + 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() { @@ -93,14 +109,10 @@ export default class CountryDropdown extends React.Component { displayedCountries = COUNTRIES; } - if (displayedCountries.length > MAX_DISPLAYED_ROWS) { - displayedCountries = displayedCountries.slice(0, MAX_DISPLAYED_ROWS); - } - const options = displayedCountries.map((country) => { return
    {this._flagImgForIso2(country.iso2)} - {country.name} + {country.name} (+{country.prefix})
    ; }); @@ -108,18 +120,21 @@ export default class CountryDropdown extends React.Component { // values between mounting and the initial value propgating const value = this.props.value || COUNTRIES[0].iso2; - return {options} - + ; } } CountryDropdown.propTypes = { className: React.PropTypes.string, + isSmall: React.PropTypes.bool, + // if isSmall, show +44 in the selected value + showPrefix: React.PropTypes.bool, onOptionChange: React.PropTypes.func.isRequired, value: React.PropTypes.string, }; diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js index e6450adef1..f5c5c84e63 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,24 +24,24 @@ 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. + {_t("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. + {_t("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 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 c4084facb2..ae8a087fdd 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -20,6 +20,7 @@ import url from 'url'; import classnames from 'classnames'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -128,8 +129,8 @@ export const PasswordAuthEntry = React.createClass({ return (
    -

    To continue, please enter your password.

    -

    Password:

    +

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

    +

    {_t("Password:")}

    -

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

    -

    Please check your email to continue registration.

    +

    {_t("An email has been sent to")} {this.props.inputs.emailAddress}

    +

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

    ); } @@ -348,7 +349,7 @@ export const MsisdnAuthEntry = React.createClass({ }); } else { this.setState({ - errorText: "Token incorrect", + errorText: _t("Token incorrect"), }); } }).catch((e) => { @@ -369,8 +370,8 @@ export const MsisdnAuthEntry = React.createClass({ }); return (
    -

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

    -

    Please enter the code it contains:

    +

    {_t("A text message has been sent to")} +{this._msisdn}

    +

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


    - @@ -439,7 +440,7 @@ export const FallbackAuthEntry = React.createClass({ render: function() { return (
    - Start authentication + {_t("Start authentication")}
    {this.props.errorText}
    diff --git a/src/components/views/login/LoginFooter.js b/src/components/views/login/LoginFooter.js index 5ec57194e0..8bdec71685 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/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 61cb3da652..9f855616fc 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -16,100 +16,163 @@ limitations under the License. */ import React from 'react'; -import ReactDOM from 'react-dom'; import classNames from 'classnames'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import {field_input_incorrect} from '../../../UiEffects'; /** * 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, - initialPhoneCountry: React.PropTypes.string, - initialPhoneNumber: React.PropTypes.string, - initialPassword: React.PropTypes.string, - onUsernameChanged: React.PropTypes.func, - onPhoneCountryChanged: React.PropTypes.func, - onPhoneNumberChanged: React.PropTypes.func, - onPasswordChanged: React.PropTypes.func, - loginIncorrect: React.PropTypes.bool, - }, +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() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - 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(); + 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, - this.state.phoneCountry, - this.state.phoneNumber, + null, + null, this.state.password, ); - }, + } - onUsernameChanged: function(ev) { + onUsernameChanged(ev) { this.setState({username: ev.target.value}); this.props.onUsernameChanged(ev.target.value); - }, + } - onPhoneCountryChanged: function(country) { - this.setState({phoneCountry: country}); - this.props.onPhoneCountryChanged(country); - }, + onLoginTypeChange(loginType) { + this.setState({ + loginType: loginType, + username: "" // Reset because email and username use the same state + }); + } - onPhoneNumberChanged: function(ev) { + 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: function(ev) { + onPasswordChanged(ev) { this.setState({password: ev.target.value}); this.props.onPasswordChanged(ev.target.value); - }, + } - render: function() { + renderLoginField(loginType) { + switch(loginType) { + case PasswordLogin.LOGIN_FIELD_EMAIL: + return ; + case PasswordLogin.LOGIN_FIELD_MXID: + return ; + case PasswordLogin.LOGIN_FIELD_PHONE: + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + return
    + + +
    ; + } + } + + render() { var forgotPasswordJsx; if (this.props.onForgotPasswordClick) { forgotPasswordJsx = ( - Forgot your password? + { _t('Forgot your password?') } ); } @@ -119,38 +182,54 @@ module.exports = React.createClass({displayName: 'PasswordLogin', error: this.props.loginIncorrect, }); - const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + const Dropdown = sdk.getComponent('elements.Dropdown'); + + const loginField = this.renderLoginField(this.state.loginType); + return (
    - - or -
    - - +
    + + + { _t('my Matrix ID') } + { _t('Email address') } + { _t('Phone') } +
    -
    + {loginField} {this._passwordField = e;}} type="password" name="password" value={this.state.password} onChange={this.onPasswordChanged} - placeholder="Password" /> + placeholder={ _t('Password') } />
    {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: React.PropTypes.func.isRequired, // fn(username, password) + onForgotPasswordClick: React.PropTypes.func, // fn() + initialUsername: React.PropTypes.string, + initialPhoneCountry: React.PropTypes.string, + initialPhoneNumber: React.PropTypes.string, + initialPassword: React.PropTypes.string, + onUsernameChanged: React.PropTypes.func, + onPhoneCountryChanged: React.PropTypes.func, + onPhoneNumberChanged: React.PropTypes.func, + onPasswordChanged: React.PropTypes.func, + loginIncorrect: React.PropTypes.bool, +}; + +module.exports = PasswordLogin; diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 4868c9de63..ff07cd36e5 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -21,6 +21,7 @@ import sdk from '../../../index'; import Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import Modal from '../../../Modal'; +import { _t } from '../../../languageHandler'; const FIELD_EMAIL = 'field_email'; const FIELD_PHONE_COUNTRY = 'field_phone_country'; @@ -53,11 +54,6 @@ module.exports = React.createClass({ })).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, - minPasswordLength: React.PropTypes.number, onError: React.PropTypes.func, onRegisterClick: React.PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise @@ -100,30 +96,29 @@ module.exports = React.createClass({ if (this.refs.email.value == '') { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { - title: "Warning", + 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() { + _doSubmit: function(ev) { let email = this.refs.email.value.trim(); var promise = this.props.onRegisterClick({ - username: this.refs.username.value.trim() || this.props.guestUsername, + username: this.refs.username.value.trim(), password: this.refs.password.value.trim(), email: email, phoneCountry: this.state.phoneCountry, @@ -191,7 +186,7 @@ module.exports = React.createClass({ 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, @@ -270,7 +265,8 @@ module.exports = React.createClass({ _onPhoneCountryChange(newVal) { this.setState({ - phoneCountry: newVal, + phoneCountry: newVal.iso2, + phonePrefix: newVal.prefix, }); }, @@ -280,7 +276,7 @@ module.exports = React.createClass({ const emailSection = (
    - You are registering with {this.state.selectedTeam.name} + {_t("You are registering with %(SelectedTeamName)s", {SelectedTeamName: this.state.selectedTeam.name})}

    ); } @@ -313,14 +309,19 @@ module.exports = React.createClass({ const phoneSection = (
    + ); - let placeholderUserName = "User name"; - if (this.props.guestUsername) { - placeholderUserName += " (default: " + this.props.guestUsername + ")"; - } + let placeholderUserName = _t("User name"); return (
    @@ -348,16 +346,13 @@ module.exports = React.createClass({ className={this._classForField(FIELD_USERNAME, 'mx_Login_field')} onBlur={function() {self.validateField(FIELD_USERNAME);}} />
    - { this.props.guestUsername ? -
    Setting a user name will create a fresh account
    : null - } + placeholder={_t("Password")} defaultValue={this.props.defaultPassword} />
    diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index 4e6ed12f9e..a63d02416c 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -19,6 +19,7 @@ limitations under the License. var React = require('react'); var Modal = require('../../../Modal'); var sdk = require('../../../index'); +import { _t } from '../../../languageHandler'; /** * A pure UI component which displays the HS and IS to use. @@ -27,8 +28,7 @@ module.exports = React.createClass({ displayName: 'ServerConfig', propTypes: { - onHsUrlChanged: React.PropTypes.func, - onIsUrlChanged: React.PropTypes.func, + onServerConfigChange: React.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. @@ -50,8 +50,7 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - onHsUrlChanged: function() {}, - onIsUrlChanged: function() {}, + onServerConfigChange: function() {}, customHsUrl: "", customIsUrl: "", withToggleButton: false, @@ -75,7 +74,10 @@ module.exports = React.createClass({ this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { var 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, + }); }); }); }, @@ -85,7 +87,10 @@ module.exports = React.createClass({ this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { var 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, + }); }); }); }, @@ -102,12 +107,16 @@ module.exports = React.createClass({ configVisible: visible }); if (!visible) { - this.props.onHsUrlChanged(this.props.defaultHsUrl); - this.props.onIsUrlChanged(this.props.defaultIsUrl); + this.props.onServerConfigChange({ + hsUrl : this.props.defaultHsUrl, + isUrl : this.props.defaultIsUrl, + }); } else { - this.props.onHsUrlChanged(this.state.hs_url); - this.props.onIsUrlChanged(this.state.is_url); + this.props.onServerConfigChange({ + hsUrl : this.state.hs_url, + isUrl : this.state.is_url, + }); } }, @@ -123,19 +132,19 @@ module.exports = React.createClass({ var toggleButton; if (this.props.withToggleButton) { toggleButton = ( -
    +
      
    ); @@ -147,7 +156,7 @@ module.exports = React.createClass({
    - 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 73b9bdb200..52c1341e60 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) { @@ -77,7 +77,7 @@ export default class MAudioBody extends React.Component { return ( - Error decrypting audio + {_t("Error decrypting audio")} ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 86aee28269..bccae923eb 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -20,10 +20,10 @@ import React from 'react'; 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'; @@ -202,7 +202,7 @@ module.exports = React.createClass({ * @return {string} the human readable link text for the attachment. */ presentableTextForFile: function(content) { - var linkText = 'Attachment'; + var 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 +261,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"); @@ -283,7 +283,8 @@ module.exports = React.createClass({ }).catch((err) => { console.warn("Unable to decrypt attachment: ", err); Modal.createDialog(ErrorDialog, { - description: "Error decrypting attachment" + title: _t("Error"), + description: _t("Error decrypting attachment"), }); }).finally(() => { decrypting = false; @@ -295,7 +296,7 @@ module.exports = React.createClass({ @@ -314,7 +315,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 }), }, "*"); }; @@ -346,7 +347,7 @@ module.exports = React.createClass({ return (
    - + { fileName }
    @@ -360,9 +361,9 @@ module.exports = React.createClass({ return ( @@ -371,7 +372,7 @@ module.exports = React.createClass({ } else { var extra = text ? (': ' + text) : ''; return - Invalid file{extra} + { _t("Invalid file%(extra)s", { extra: extra }) } ; } }, diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ab163297d7..dcc073c80e 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -24,8 +24,9 @@ import Modal from '../../../Modal'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; -import q from 'q'; +import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'MImageBody', @@ -56,6 +57,7 @@ module.exports = React.createClass({ const ImageView = sdk.getComponent("elements.ImageView"); const params = { src: httpUrl, + name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), mxEvent: this.props.mxEvent, }; @@ -121,7 +123,7 @@ module.exports = React.createClass({ this.fixupHeight(); const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - let thumbnailPromise = q(null); + let thumbnailPromise = Promise.resolve(null); if (content.info.thumbnail_file) { thumbnailPromise = decryptFile( content.info.thumbnail_file, @@ -190,7 +192,7 @@ module.exports = React.createClass({ return ( - Error decrypting image + {_t("Error decrypting image")} ); } @@ -237,13 +239,13 @@ module.exports = React.createClass({ } else if (content.body) { return ( - Image '{content.body}' cannot be displayed. + {_t("Image '%(Body)s' cannot be displayed.", {Body: content.body})} ); } else { return ( - This image cannot be displayed. + {_t("This image cannot be displayed.")} ); } diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index d843115caf..06c233e012 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -19,11 +19,10 @@ limitations under the License. import React from 'react'; import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Model from '../../../Modal'; -import sdk from '../../../index'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; -import q from 'q'; +import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'MVideoBody', @@ -80,7 +79,7 @@ module.exports = React.createClass({ const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { return this.state.decryptedThumbnailUrl; - } else if (content.info.thumbnail_url) { + } else if (content.info && content.info.thumbnail_url) { return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); } else { return null; @@ -90,7 +89,7 @@ module.exports = React.createClass({ componentDidMount: function() { const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - var thumbnailPromise = q(null); + var thumbnailPromise = Promise.resolve(null); if (content.info.thumbnail_file) { thumbnailPromise = decryptFile( content.info.thumbnail_file @@ -128,7 +127,7 @@ module.exports = React.createClass({ return ( - Error decrypting video + {_t("Error decrypting video")} ); } diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js new file mode 100644 index 0000000000..ed790953dc --- /dev/null +++ b/src/components/views/messages/RoomAvatarEvent.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 MatrixClientPeg from '../../../MatrixClientPeg'; +import { ContentRepo } from 'matrix-js-sdk'; +import { _t, _tJsx } from '../../../languageHandler'; +import sdk from '../../../index'; +import Modal from '../../../Modal'; +import AccessibleButton from '../elements/AccessibleButton'; + +module.exports = React.createClass({ + displayName: 'RoomAvatarEvent', + + propTypes: { + /* the MatrixEvent to show */ + mxEvent: React.PropTypes.object.isRequired, + }, + + onAvatarClick: function(name) { + var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(this.props.mxEvent.getContent().url); + var ImageView = sdk.getComponent("elements.ImageView"); + var params = { + src: httpUrl, + name: name, + }; + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + }, + + render: function() { + var ev = this.props.mxEvent; + var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + + var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + var name = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', { + senderDisplayName: senderDisplayName, + roomName: room ? room.name : '', + }); + + if (!ev.getContent().url || ev.getContent().url.trim().length === 0) { + return ( +
    + { _t('%(senderDisplayName)s removed the room avatar.', {senderDisplayName: senderDisplayName}) } +
    + ); + } + + var url = ContentRepo.getHttpUriForMxc( + MatrixClientPeg.get().getHomeserverUrl(), + ev.getContent().url, + Math.ceil(14 * window.devicePixelRatio), + Math.ceil(14 * window.devicePixelRatio), + 'crop' + ); + + // it sucks that _tJsx doesn't support normal _t substitutions :(( + return ( +
    + { _tJsx('$senderDisplayName changed the room avatar to ', + [ + /\$senderDisplayName/, + //, + ], + [ + (sub) => senderDisplayName, + (sub) => + + + , + ] + ) + } +
    + ); + }, +}); diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js index 9e6fba2127..e224714a27 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.js @@ -30,7 +30,7 @@ export default function SenderProfile(props) { } return ( - {`${name || ''} ${props.aux || ''}`} ); } diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 9aebb30f54..6d4d01a196 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -28,6 +28,11 @@ import ScalarAuthClient from '../../../ScalarAuthClient'; import Modal from '../../../Modal'; import SdkConfig from '../../../SdkConfig'; import dis from '../../../dispatcher'; +import { _t } from '../../../languageHandler'; +import UserSettingsStore from "../../../UserSettingsStore"; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import {RoomMember} from 'matrix-js-sdk'; +import classNames from 'classnames'; linkifyMatrix(linkify); @@ -62,9 +67,26 @@ module.exports = React.createClass({ }; }, + copyToClipboard: function(text) { + const textArea = document.createElement("textarea"); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + try { + const successful = document.execCommand('copy'); + } catch (err) { + console.log('Unable to copy'); + } + document.body.removeChild(textArea); + }, + componentDidMount: function() { this._unmounted = false; + // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer + // are still sent as plaintext URLs. If these are ever pillified in the composer, + // we should be pillify them here by doing the linkifying BEFORE the pillifying. + this.pillifyLinks(this.refs.content.children); linkifyElement(this.refs.content, linkifyMatrix.options); this.calculateUrlPreview(); @@ -76,10 +98,29 @@ module.exports = React.createClass({ setTimeout(() => { if (this._unmounted) return; for (let i = 0; i < blocks.length; i++) { - highlight.highlightBlock(blocks[i]); + if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) { + highlight.highlightBlock(blocks[i]) + } else { + // Only syntax highlight if there's a class starting with language- + let classes = blocks[i].className.split(/\s+/).filter(function (cl) { + return cl.startsWith('language-'); + }); + + if (classes.length != 0) { + highlight.highlightBlock(blocks[i]); + } + } } }, 10); } + // add event handlers to the 'copy code' buttons + const buttons = ReactDOM.findDOMNode(this).getElementsByClassName("mx_EventTile_copyButton"); + for (let i = 0; i < buttons.length; i++) { + buttons[i].onclick = (e) => { + const copyCode = buttons[i].parentNode.getElementsByTagName("code")[0]; + this.copyToClipboard(copyCode.textContent); + }; + } } }, @@ -109,9 +150,15 @@ module.exports = React.createClass({ if (this.props.showUrlPreview && !this.state.links.length) { var links = this.findLinks(this.refs.content.children); if (links.length) { - this.setState({ links: links.map((link)=>{ - return link.getAttribute("href"); - })}); + // de-dup the links (but preserve ordering) + const seen = new Set(); + links = links.filter((link) => { + if (seen.has(link)) return false; + seen.add(link); + return true; + }); + + this.setState({ links: links }); // lazy-load the hidden state of the preview widget from localstorage if (global.localStorage) { @@ -122,17 +169,42 @@ module.exports = React.createClass({ } }, + pillifyLinks: function(nodes) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.tagName === "A" && node.getAttribute("href")) { + const href = node.getAttribute("href"); + + // If the link is a (localised) matrix.to link, replace it with a pill + const Pill = sdk.getComponent('elements.Pill'); + if (Pill.isMessagePillUrl(href)) { + const pillContainer = document.createElement('span'); + + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const pill = ; + + ReactDOM.render(pill, pillContainer); + node.parentNode.replaceChild(pillContainer, node); + } + } else if (node.children && node.children.length) { + this.pillifyLinks(node.children); + } + } + }, + findLinks: function(nodes) { var links = []; + for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.tagName === "A" && node.getAttribute("href")) { if (this.isLinkPreviewable(node)) { - links.push(node); + links.push(node.getAttribute("href")); } } - else if (node.tagName === "PRE" || node.tagName === "CODE") { + else if (node.tagName === "PRE" || node.tagName === "CODE" || + node.tagName === "BLOCKQUOTE") { continue; } else if (node.children && node.children.length) { @@ -190,10 +262,9 @@ module.exports = React.createClass({ onEmoteSenderClick: function(event) { const mxEvent = this.props.mxEvent; - const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); dis.dispatch({ - action: 'insert_displayname', - displayname: name.replace(' (IRC)', ''), + action: 'insert_mention', + user_id: mxEvent.getSender(), }); }, @@ -229,14 +300,14 @@ module.exports = React.createClass({ let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let integrationsUrl = SdkConfig.get().integrations_ui_url; Modal.createDialog(QuestionDialog, { - title: "Add an Integration", + title: _t("Add an Integration"), description:
    - You are about to be taken to a third-party site so you can - authenticate your account for use with {integrationsUrl}.
    - Do you wish to continue? + {_t("You are about to be taken to a third-party site so you can " + + "authenticate your account for use with %(integrationsUrl)s. " + + "Do you wish to continue?", { integrationsUrl: integrationsUrl })}
    , - button: "Continue", + button: _t("Continue"), onFinished: function(confirmed) { if (!confirmed) { return; diff --git a/src/components/views/messages/TextualEvent.js b/src/components/views/messages/TextualEvent.js index 8319dbd434..088f7cbbc6 100644 --- a/src/components/views/messages/TextualEvent.js +++ b/src/components/views/messages/TextualEvent.js @@ -24,6 +24,11 @@ import sdk from '../../../index'; module.exports = React.createClass({ displayName: 'TextualEvent', + propTypes: { + /* the MatrixEvent to show */ + mxEvent: React.PropTypes.object.isRequired, + }, + render: function() { const EmojiText = sdk.getComponent('elements.EmojiText'); var text = TextForEvent.textForEvent(this.props.mxEvent); diff --git a/src/components/views/messages/UnknownBody.js b/src/components/views/messages/UnknownBody.js index 9b1bd74087..1b6f6426e5 100644 --- a/src/components/views/messages/UnknownBody.js +++ b/src/components/views/messages/UnknownBody.js @@ -16,7 +16,8 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'UnknownBody', @@ -24,7 +25,7 @@ module.exports = React.createClass({ render: function() { const text = this.props.mxEvent.getContent().body; return ( - + {text} ); diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index 6543f2a17d..ba0663153e 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -var q = require("q"); +import Promise from 'bluebird'; var React = require('react'); var ObjectUtils = require("../../../ObjectUtils"); var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require("../../../index"); +import { _t } from '../../../languageHandler'; var Modal = require("../../../Modal"); module.exports = React.createClass({ @@ -103,7 +104,7 @@ module.exports = React.createClass({ } if (oldCanonicalAlias !== this.state.canonicalAlias) { console.log("AliasSettings: Updating canonical alias"); - promises = [q.all(promises).then( + promises = [Promise.all(promises).then( MatrixClientPeg.get().sendStateEvent( this.props.roomId, "m.room.canonical_alias", { alias: this.state.canonicalAlias @@ -154,8 +155,8 @@ module.exports = React.createClass({ else { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Invalid alias format", - description: "'" + alias + "' is not a valid format for an alias", + title: _t('Invalid alias format'), + description: _t('\'%(alias)s\' is not a valid format for an alias', { alias: alias }), }); } }, @@ -170,8 +171,8 @@ module.exports = React.createClass({ else { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Invalid address format", - description: "'" + alias + "' is not a valid format for an address", + title: _t('Invalid address format'), + description: _t('\'%(alias)s\' is not a valid format for an address', { alias: alias }), }); } }, @@ -203,7 +204,7 @@ module.exports = React.createClass({ if (this.props.canSetCanonicalAlias) { canonical_alias_section = ( - Disable URL previews by default for participants in this room + {_t("Disable URL previews by default for participants in this room")} ; } else { disableRoomPreviewUrls = ; } + let urlPreviewText = null; + if (UserSettingsStore.getUrlPreviewsDisabled()) { + urlPreviewText = ( + _tJsx("You have disabled URL previews by default.", /(.*?)<\/a>/, (sub)=>{sub}) + ); + } + else { + urlPreviewText = ( + _tJsx("You have enabled URL previews by default.", /(.*?)<\/a>/, (sub)=>{sub}) + ); + } + return (
    -

    URL Previews

    +

    {_t("URL Previews")}

    { disableRoomPreviewUrls }
    ); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js new file mode 100644 index 0000000000..5427d4ec6d --- /dev/null +++ b/src/components/views/rooms/AppsDrawer.js @@ -0,0 +1,207 @@ +/* +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 React from 'react'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import AppTile from '../elements/AppTile'; +import Modal from '../../../Modal'; +import dis from '../../../dispatcher'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import ScalarAuthClient from '../../../ScalarAuthClient'; +import ScalarMessaging from '../../../ScalarMessaging'; +import { _t } from '../../../languageHandler'; +import WidgetUtils from '../../../WidgetUtils'; + + +module.exports = React.createClass({ + displayName: 'AppsDrawer', + + propTypes: { + room: React.PropTypes.object.isRequired, + }, + + getInitialState: function() { + return { + apps: this._getApps(), + }; + }, + + componentWillMount: function() { + ScalarMessaging.startListening(); + MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); + }, + + componentDidMount: function() { + this.scalarClient = null; + if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { + this.scalarClient = new ScalarAuthClient(); + this.scalarClient.connect().done(() => { + this.forceUpdate(); + if (this.state.apps && this.state.apps.length < 1) { + this.onClickAddWidget(); + } + // TODO -- Handle Scalar errors + // }, + // (err) => { + // this.setState({ + // scalar_error: err, + // }); + }); + } + }, + + componentWillUnmount: function() { + ScalarMessaging.stopListening(); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); + } + }, + + /** + * Encodes a URI according to a set of template variables. Variables will be + * passed through encodeURIComponent. + * @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'. + * @param {Object} variables The key/value pairs to replace the template + * variables with. E.g. { "$bar": "baz" }. + * @return {string} The result of replacing all template variables e.g. '/foo/baz'. + */ + encodeUri: function(pathTemplate, variables) { + for (const key in variables) { + if (!variables.hasOwnProperty(key)) { + continue; + } + pathTemplate = pathTemplate.replace( + key, encodeURIComponent(variables[key]), + ); + } + return pathTemplate; + }, + + _initAppConfig: function(appId, app) { + const user = MatrixClientPeg.get().getUser(this.props.userId); + const params = { + '$matrix_user_id': this.props.userId, + '$matrix_room_id': this.props.room.roomId, + '$matrix_display_name': user ? user.displayName : this.props.userId, + '$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '', + }; + + if(app.data) { + Object.keys(app.data).forEach((key) => { + params['$' + key] = app.data[key]; + }); + } + + app.id = appId; + app.name = app.name || app.type; + app.url = this.encodeUri(app.url, params); + + return app; + }, + + onRoomStateEvents: function(ev, state) { + if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') { + return; + } + this._updateApps(); + }, + + _getApps: function() { + const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets'); + if (!appsStateEvents) { + return []; + } + + return appsStateEvents.filter((ev) => { + return ev.getContent().type && ev.getContent().url; + }).map((ev) => { + return this._initAppConfig(ev.getStateKey(), ev.getContent()); + }); + }, + + _updateApps: function() { + const apps = this._getApps(); + if (apps.length < 1) { + dis.dispatch({ + action: 'appsDrawer', + show: false, + }); + } + this.setState({ + apps: apps, + }); + }, + + _canUserModify: function() { + try { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + } catch(err) { + console.error(err); + return false; + } + }, + + onClickAddWidget: function(e) { + if (e) { + e.preventDefault(); + } + + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? + this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') : + null; + Modal.createDialog(IntegrationsManager, { + src: src, + }, "mx_IntegrationsManager"); + }, + + render: function() { + const apps = this.state.apps.map( + (app, index, arr) => { + return (); + }); + + const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() && + (
    + [+] {_t('Add a widget')} +
    ); + + return ( +
    +
    + {apps} +
    + {addWidget} +
    + ); + }, +}); diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 9be91e068a..1ea2eada7c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -4,8 +4,9 @@ import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; import sdk from '../../../index'; -import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter'; -import Q from 'q'; +import type {Completion} from '../../../autocomplete/Autocompleter'; +import Promise from 'bluebird'; +import UserSettingsStore from '../../../UserSettingsStore'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -39,26 +40,62 @@ export default class Autocomplete extends React.Component { }; } - async componentWillReceiveProps(props, state) { - if (props.query === this.props.query) { - return null; - } - - return await this.complete(props.query, props.selection); - } - - async complete(query, selection) { - let forceComplete = this.state.forceComplete; - const completionPromise = getCompletions(query, selection, forceComplete); - this.completionPromise = completionPromise; - const completions = await this.completionPromise; - - // There's a newer completion request, so ignore results. - if (completionPromise !== this.completionPromise) { + componentWillReceiveProps(newProps, state) { + // Query hasn't changed so don't try to complete it + if (newProps.query === this.props.query) { return; } - const completionList = flatMap(completions, provider => provider.completions); + this.complete(newProps.query, newProps.selection); + } + + complete(query, selection) { + this.queryRequested = query; + if (this.debounceCompletionsRequest) { + clearTimeout(this.debounceCompletionsRequest); + } + if (query === "") { + this.setState({ + // Clear displayed completions + completions: [], + completionList: [], + // Reset selected completion + selectionOffset: COMPOSER_SELECTED, + // Hide the autocomplete box + hide: true, + }); + return Promise.resolve(null); + } + let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200); + + // Don't debounce if we are already showing completions + if (this.state.completions.length > 0 || this.state.forceComplete) { + autocompleteDelay = 0; + } + + const deferred = Promise.defer(); + this.debounceCompletionsRequest = setTimeout(() => { + this.processQuery(query, selection).then(() => { + deferred.resolve(); + }); + }, autocompleteDelay); + return deferred.promise; + } + + processQuery(query, selection) { + return getCompletions( + query, selection, this.state.forceComplete, + ).then((completions) => { + // Only ever process the completions for the most recent query being processed + if (query !== this.queryRequested) { + return; + } + this.processCompletions(completions); + }); + } + + processCompletions(completions) { + const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. let selectionOffset = COMPOSER_SELECTED; @@ -69,33 +106,26 @@ export default class Autocomplete extends React.Component { const currentSelection = this.state.selectionOffset === 0 ? null : this.state.completionList[this.state.selectionOffset - 1].completion; selectionOffset = completionList.findIndex( - completion => completion.completion === currentSelection); + (completion) => completion.completion === currentSelection); if (selectionOffset === -1) { selectionOffset = COMPOSER_SELECTED; } else { selectionOffset++; // selectionOffset is 1-indexed! } - } else { - // If no completions were returned, we should turn off force completion. - forceComplete = false; } let hide = this.state.hide; - // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern - const oldMatches = this.state.completions.map(completion => !!completion.command.command), - newMatches = completions.map(completion => !!completion.command.command); - - // So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one - if (!isEqual(oldMatches, newMatches)) { - hide = false; - } + // If `completion.command.command` is truthy, then a provider has matched with the query + const anyMatches = completions.some((completion) => !!completion.command.command); + hide = !anyMatches; this.setState({ completions, completionList, selectionOffset, hide, - forceComplete, + // Force complete is turned off each time since we can't edit the query in that case + forceComplete: false, }); } @@ -146,12 +176,13 @@ export default class Autocomplete extends React.Component { } forceComplete() { - const done = Q.defer(); + const done = Promise.defer(); this.setState({ forceComplete: true, + hide: false, }, () => { this.complete(this.props.query, this.props.selection).then(() => { - done.resolve(); + done.resolve(this.countCompletions()); }); }); return done.promise; @@ -169,7 +200,7 @@ export default class Autocomplete extends React.Component { } setSelection(selectionOffset: number) { - this.setState({selectionOffset}); + this.setState({selectionOffset, hide: false}); } componentDidUpdate() { @@ -185,21 +216,24 @@ export default class Autocomplete extends React.Component { } } + setState(state, func) { + super.setState(state, func); + } + render() { const EmojiText = sdk.getComponent('views.elements.EmojiText'); let position = 1; - let renderedCompletions = this.state.completions.map((completionResult, i) => { - let completions = completionResult.completions.map((completion, i) => { - + const renderedCompletions = this.state.completions.map((completionResult, i) => { + const completions = completionResult.completions.map((completion, i) => { const className = classNames('mx_Autocomplete_Completion', { 'selected': position === this.state.selectionOffset, }); - let componentPosition = position; + const componentPosition = position; position++; - let onMouseOver = () => this.setSelection(componentPosition); - let onClick = () => { + const onMouseOver = () => this.setSelection(componentPosition); + const onClick = () => { this.setSelection(componentPosition); this.onCompletionClicked(); }; @@ -220,7 +254,7 @@ export default class Autocomplete extends React.Component { {completionResult.provider.renderCompletions(completions)}
    ) : null; - }).filter(completion => !!completion); + }).filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? (
    this.container = e}> diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 365cc18f99..a50743a25d 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -14,11 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var sdk = require('../../../index'); -var dis = require("../../../dispatcher"); -var ObjectUtils = require('../../../ObjectUtils'); +import React from 'react'; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import sdk from '../../../index'; +import dis from "../../../dispatcher"; +import ObjectUtils from '../../../ObjectUtils'; +import AppsDrawer from './AppsDrawer'; +import { _t, _tJsx} from '../../../languageHandler'; +import UserSettingsStore from '../../../UserSettingsStore'; + module.exports = React.createClass({ displayName: 'AuxPanel', @@ -26,6 +30,8 @@ module.exports = React.createClass({ propTypes: { // js-sdk room object room: React.PropTypes.object.isRequired, + userId: React.PropTypes.string.isRequired, + showApps: React.PropTypes.bool, // Conference Handler implementation conferenceHandler: React.PropTypes.object, @@ -68,45 +74,53 @@ module.exports = React.createClass({ }, render: function() { - var CallView = sdk.getComponent("voip.CallView"); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const CallView = sdk.getComponent("voip.CallView"); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); - var fileDropTarget = null; + let fileDropTarget = null; if (this.props.draggingFile) { fileDropTarget = (
    + title={_t("Drop File Here")}>
    - Drop file here to upload + {_t("Drop file here to upload")}
    ); } - var conferenceCallNotification = null; + let conferenceCallNotification = null; if (this.props.displayConfCallNotification) { - var supportedText, joinText; + let supportedText = ''; + let joinNode; if (!MatrixClientPeg.get().supportsVoip()) { - supportedText = " (unsupported)"; - } - else { - joinText = ( - Join as { this.onConferenceNotificationClick(event, 'voice');}} - href="#">voice or { this.onConferenceNotificationClick(event, 'video'); }} - href="#">video. + supportedText = _t(" (unsupported)"); + } else { + joinNode = ( + {_tJsx( + "Join as voice or video.", + [/(.*?)<\/voiceText>/, /(.*?)<\/videoText>/], + [ + (sub) => { this.onConferenceNotificationClick(event, 'voice');}} href="#">{sub}, + (sub) => { this.onConferenceNotificationClick(event, 'video');}} href="#">{sub}, + ] + )} ); - } + // XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages, + // but there are translations for this in the languages we do have so I'm leaving it for now. conferenceCallNotification = (
    - Ongoing conference call{ supportedText }. { joinText } + {_t("Ongoing conference call%(supportedText)s.", {supportedText: supportedText})} +   + {joinNode}
    ); } - var callView = ( + const callView = ( ); + let appsDrawer = null; + if(UserSettingsStore.isFeatureEnabled('matrix_apps') && this.props.showApps) { + appsDrawer = ; + } + return (
    + { appsDrawer } { fileDropTarget } { callView } { conferenceCallNotification } diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index 71e8fb0be7..7002e45c3b 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -21,6 +21,7 @@ var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require('../../../index'); import AccessibleButton from '../elements/AccessibleButton'; +import { _t } from '../../../languageHandler'; var PRESENCE_CLASS = { @@ -115,7 +116,7 @@ module.exports = React.createClass({ nameEl = (
    - {name} + {name} @@ -124,7 +125,7 @@ module.exports = React.createClass({ } else { nameEl = ( - {name} + {name} ); } @@ -140,10 +141,10 @@ module.exports = React.createClass({ var power; var powerLevel = this.props.powerLevel; if (powerLevel >= 50 && powerLevel < 99) { - power = Mod; + power = {_t("Moderator")}/; } if (powerLevel >= 99) { - power = Admin; + power = {_t("Admin")}/; } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 8b8e52ae83..b3831a7d0d 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -16,13 +16,15 @@ limitations under the License. 'use strict'; + var React = require('react'); var classNames = require("classnames"); +import { _t } from '../../../languageHandler'; var Modal = require('../../../Modal'); var sdk = require('../../../index'); var TextForEvent = require('../../../TextForEvent'); -import WithMatrixClient from '../../../wrappers/WithMatrixClient'; +import withMatrixClient from '../../../wrappers/withMatrixClient'; var ContextualMenu = require('../../structures/ContextualMenu'); import dis from '../../../dispatcher'; @@ -36,10 +38,12 @@ var eventTileTypes = { 'm.call.answer' : 'messages.TextualEvent', 'm.call.hangup' : 'messages.TextualEvent', 'm.room.name' : 'messages.TextualEvent', + 'm.room.avatar' : 'messages.RoomAvatarEvent', 'm.room.topic' : 'messages.TextualEvent', 'm.room.third_party_invite' : 'messages.TextualEvent', 'm.room.history_visibility' : 'messages.TextualEvent', 'm.room.encryption' : 'messages.TextualEvent', + 'm.room.power_levels' : 'messages.TextualEvent', }; var MAX_READ_AVATARS = 5; @@ -55,7 +59,7 @@ var MAX_READ_AVATARS = 5; // | '--------------------------------------' | // '----------------------------------------------------------' -module.exports = WithMatrixClient(React.createClass({ +module.exports = withMatrixClient(React.createClass({ displayName: 'EventTile', propTypes: { @@ -128,6 +132,9 @@ module.exports = WithMatrixClient(React.createClass({ * for now. */ tileShape: React.PropTypes.string, + + // show twelve hour timestamps + isTwelveHour: React.PropTypes.bool, }, getInitialState: function() { @@ -186,13 +193,12 @@ module.exports = WithMatrixClient(React.createClass({ } }, - _verifyEvent: function(mxEvent) { - var verified = null; - - if (mxEvent.isEncrypted()) { - verified = this.props.matrixClient.isEventSenderVerified(mxEvent); + _verifyEvent: async function(mxEvent) { + if (!mxEvent.isEncrypted()) { + return; } + const verified = await this.props.matrixClient.isEventSenderVerified(mxEvent); this.setState({ verified: verified }); @@ -283,21 +289,17 @@ module.exports = WithMatrixClient(React.createClass({ }, getReadAvatars: function() { + + // return early if there are no read receipts + if (!this.props.readReceipts || this.props.readReceipts.length === 0) { + return (); + } + const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); const avatars = []; const receiptOffset = 15; let left = 0; - // It's possible that the receipt was sent several days AFTER the event. - // If it is, we want to display the complete date along with the HH:MM:SS, - // rather than just HH:MM:SS. - let dayAfterEvent = new Date(this.props.mxEvent.getTs()); - dayAfterEvent.setDate(dayAfterEvent.getDate() + 1); - dayAfterEvent.setHours(0); - dayAfterEvent.setMinutes(0); - dayAfterEvent.setSeconds(0); - let dayAfterEventTime = dayAfterEvent.getTime(); - var receipts = this.props.readReceipts || []; for (var i = 0; i < receipts.length; ++i) { var receipt = receipts[i]; @@ -333,7 +335,7 @@ module.exports = WithMatrixClient(React.createClass({ suppressAnimation={this._suppressReadReceiptAnimation} onClick={this.toggleAllReadAvatars} timestamp={receipt.ts} - showFullTimestamp={receipt.ts >= dayAfterEventTime} + showTwelveHour={this.props.isTwelveHour} /> ); } @@ -355,10 +357,10 @@ module.exports = WithMatrixClient(React.createClass({ }, onSenderProfileClick: function(event) { - var mxEvent = this.props.mxEvent; + const mxEvent = this.props.mxEvent; dis.dispatch({ - action: 'insert_displayname', - displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''), + action: 'insert_mention', + user_id: mxEvent.getSender(), }); }, @@ -379,6 +381,7 @@ module.exports = WithMatrixClient(React.createClass({ dis.dispatch({ action: 'view_room', event_id: this.props.mxEvent.getId(), + highlighted: true, room_id: this.props.mxEvent.getRoomId(), }); }, @@ -394,8 +397,7 @@ module.exports = WithMatrixClient(React.createClass({ var msgtype = content.msgtype; var eventType = this.props.mxEvent.getType(); - // Info messages are basically information about commands processed on a - // room, or emote messages + // Info messages are basically information about commands processed on a room var isInfoMessage = (eventType !== 'm.room.message'); var EventTileType = sdk.getComponent(eventTileTypes[eventType]); @@ -409,9 +411,10 @@ module.exports = WithMatrixClient(React.createClass({ var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted; - var classes = classNames({ + const classes = classNames({ mx_EventTile: true, mx_EventTile_info: isInfoMessage, + mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_encrypting: this.props.eventSendStatus == 'encrypting', mx_EventTile_sending: isSending, mx_EventTile_notSent: this.props.eventSendStatus == 'not_sent', @@ -423,7 +426,8 @@ module.exports = WithMatrixClient(React.createClass({ menu: this.state.menu, mx_EventTile_verified: this.state.verified == true, mx_EventTile_unverified: this.state.verified == false, - mx_EventTile_bad: this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted', + mx_EventTile_bad: msgtype === 'm.bad.encrypted', + mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_redacted: isRedacted, }); @@ -468,9 +472,9 @@ module.exports = WithMatrixClient(React.createClass({ if (needsSenderProfile) { let aux = null; if (!this.props.tileShape) { - if (msgtype === 'm.image') aux = "sent an image"; - else if (msgtype === 'm.video') aux = "sent a video"; - else if (msgtype === 'm.file') aux = "uploaded a file"; + if (msgtype === 'm.image') aux = _t('sent an image'); + else if (msgtype === 'm.video') aux = _t('sent a video'); + else if (msgtype === 'm.file') aux = _t('uploaded a file'); sender = ; } else { @@ -478,36 +482,34 @@ module.exports = WithMatrixClient(React.createClass({ } } - var editButton = ( - + const editButton = ( + ); - - var e2e; + let e2e; // cosmetic padlocks: if ((e2eEnabled && this.props.eventSendStatus) || this.props.mxEvent.getType() === 'm.room.encryption') { - e2e = ; + e2e = {_t("Encrypted; } // real padlocks else if (this.props.mxEvent.isEncrypted() || (e2eEnabled && this.props.eventSendStatus)) { if (this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted') { - e2e = ; + e2e = {_t("Undecryptable")}; } else if (this.state.verified == true || (e2eEnabled && this.props.eventSendStatus)) { - e2e = ; + e2e = {_t("Encrypted; } else { - e2e = ; + e2e = {_t("Encrypted; } } else if (e2eEnabled) { - e2e = ; + e2e = {_t("Unencrypted; } const timestamp = this.props.mxEvent.getTs() ? - : null; + : null; if (this.props.tileShape === "notif") { - var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); - + const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); return (
    diff --git a/src/components/views/rooms/ForwardMessage.js b/src/components/views/rooms/ForwardMessage.js new file mode 100644 index 0000000000..3c97128a02 --- /dev/null +++ b/src/components/views/rooms/ForwardMessage.js @@ -0,0 +1,68 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2017 Michael Telatynski + + 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 dis from '../../../dispatcher'; +import KeyCode from '../../../KeyCode'; + + +module.exports = React.createClass({ + displayName: 'ForwardMessage', + + propTypes: { + onCancelClick: React.PropTypes.func.isRequired, + }, + + componentWillMount: function() { + dis.dispatch({ + action: 'ui_opacity', + leftOpacity: 1.0, + rightOpacity: 0.3, + middleOpacity: 0.5, + }); + }, + + componentDidMount: function() { + document.addEventListener('keydown', this._onKeyDown); + }, + + componentWillUnmount: function() { + dis.dispatch({ + action: 'ui_opacity', + sideOpacity: 1.0, + middleOpacity: 1.0, + }); + document.removeEventListener('keydown', this._onKeyDown); + }, + + _onKeyDown: function(ev) { + switch (ev.keyCode) { + case KeyCode.ESCAPE: + this.props.onCancelClick(); + break; + } + }, + + render: function() { + return ( +
    +

    {_t('Please select the destination room for this message')}

    +
    + ); + }, +}); diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index ef8fb29cbc..35e6d28b1f 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -100,7 +100,9 @@ module.exports = React.createClass({ render: function() { var p = this.state.preview; - if (!p) return
    ; + if (!p || Object.keys(p).length === 0) { + return
    ; + } // FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing? var image = p["og:image"]; diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index d4c00dda76..33b919835c 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; export default class MemberDeviceInfo extends React.Component { render() { @@ -25,19 +26,19 @@ export default class MemberDeviceInfo extends React.Component { if (this.props.device.isBlocked()) { indicator = (
    - Blacklisted + {_t("Blacklisted")}/
    ); } else if (this.props.device.isVerified()) { indicator = (
    - Verified + {_t("Verified")}/
    ); } else { indicator = (
    - Unverified + {_t("Unverified")}/
    ); } @@ -49,7 +50,7 @@ export default class MemberDeviceInfo extends React.Component { // add the deviceId as a titletext to help with debugging return (
    + title={_t("device id: ") + this.props.device.deviceId} >
    {deviceName} diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 1459ad3eb7..290bd35483 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -31,14 +31,17 @@ import classNames from 'classnames'; import dis from '../../../dispatcher'; import Modal from '../../../Modal'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import createRoom from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import Unread from '../../../Unread'; import { findReadReceiptFromUserId } from '../../../utils/Receipt'; -import WithMatrixClient from '../../../wrappers/WithMatrixClient'; +import withMatrixClient from '../../../wrappers/withMatrixClient'; import AccessibleButton from '../elements/AccessibleButton'; +import GeminiScrollbar from 'react-gemini-scrollbar'; -module.exports = WithMatrixClient(React.createClass({ + +module.exports = withMatrixClient(React.createClass({ displayName: 'MemberInfo', propTypes: { @@ -133,8 +136,12 @@ module.exports = WithMatrixClient(React.createClass({ if (userId == this.props.member.userId) { // no need to re-download the whole thing; just update our copy of // the list. - var devices = this.props.matrixClient.getStoredDevicesForUser(userId); - this.setState({devices: devices}); + + // Promise.resolve to handle transition from static result to promise; can be removed + // in future + Promise.resolve(this.props.matrixClient.getStoredDevicesForUser(userId)).then((devices) => { + this.setState({devices: devices}); + }); } }, @@ -201,14 +208,15 @@ module.exports = WithMatrixClient(React.createClass({ var client = this.props.matrixClient; var self = this; - client.downloadKeys([member.userId], true).finally(function() { + client.downloadKeys([member.userId], true).then(() => { + return client.getStoredDevicesForUser(member.userId); + }).finally(function() { self._cancelDeviceList = null; - }).done(function() { + }).done(function(devices) { if (cancelled) { // we got cancelled - presumably a different user now return; } - var devices = client.getStoredDevicesForUser(member.userId); self._disambiguateDevices(devices); self.setState({devicesLoading: false, devices: devices}); }, function(err) { @@ -219,7 +227,7 @@ module.exports = WithMatrixClient(React.createClass({ onKick: function() { const membership = this.props.member.membership; - const kickLabel = membership === "invite" ? "Disinvite" : "Kick"; + const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); Modal.createDialog(ConfirmUserActionDialog, { member: this.props.member, @@ -241,8 +249,8 @@ module.exports = WithMatrixClient(React.createClass({ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Kick error: " + err); Modal.createDialog(ErrorDialog, { - title: "Error", - description: "Failed to kick user", + title: _t("Failed to kick"), + description: ((err && err.message) ? err.message : "Operation failed"), }); } ).finally(()=>{ @@ -256,7 +264,7 @@ module.exports = WithMatrixClient(React.createClass({ const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); Modal.createDialog(ConfirmUserActionDialog, { member: this.props.member, - action: this.props.member.membership == 'ban' ? 'Unban' : 'Ban', + action: this.props.member.membership == 'ban' ? _t("Unban") : _t("Ban"), askReason: this.props.member.membership != 'ban', danger: this.props.member.membership != 'ban', onFinished: (proceed, reason) => { @@ -283,8 +291,8 @@ module.exports = WithMatrixClient(React.createClass({ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Ban error: " + err); Modal.createDialog(ErrorDialog, { - title: "Error", - description: "Failed to ban user", + title: _t("Error"), + description: _t("Failed to ban user"), }); } ).finally(()=>{ @@ -333,8 +341,8 @@ module.exports = WithMatrixClient(React.createClass({ }, function(err) { console.error("Mute error: " + err); Modal.createDialog(ErrorDialog, { - title: "Error", - description: "Failed to mute user", + title: _t("Error"), + description: _t("Failed to mute user"), }); } ).finally(()=>{ @@ -374,16 +382,12 @@ module.exports = WithMatrixClient(React.createClass({ console.log("Mod toggle success"); }, function(err) { if (err.errcode == 'M_GUEST_ACCESS_FORBIDDEN') { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "This action cannot be performed by a guest user. Please register to be able to do this." - }); + dis.dispatch({action: 'view_set_mxid'}); } else { console.error("Toggle moderator error:" + err); Modal.createDialog(ErrorDialog, { - title: "Error", - description: "Failed to toggle moderator status", + title: _t("Error"), + description: _t("Failed to toggle moderator status"), }); } } @@ -403,8 +407,8 @@ module.exports = WithMatrixClient(React.createClass({ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to change power level " + err); Modal.createDialog(ErrorDialog, { - title: "Error", - description: "Failed to change power level", + title: _t("Error"), + description: _t("Failed to change power level"), }); } ).finally(()=>{ @@ -432,13 +436,13 @@ module.exports = WithMatrixClient(React.createClass({ if (parseInt(myPower) === parseInt(powerLevel)) { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { - title: "Warning", + title: _t("Warning!"), description:
    - You will not be able to undo this change as you are promoting the user to have the same power level as yourself.
    - Are you sure? + { _t("You will not be able to undo this change as you are promoting the user to have the same power level as yourself.") }
    + { _t("Are you sure?") }
    , - button: "Continue", + button: _t("Continue"), onFinished: function(confirmed) { if (confirmed) { self._applyPowerChange(roomId, target, powerLevel, powerLevelEvent); @@ -581,9 +585,9 @@ module.exports = WithMatrixClient(React.createClass({ // still loading devComponents = ; } else if (devices === null) { - devComponents = "Unable to load device list"; + devComponents = _t("Unable to load device list"); } else if (devices.length === 0) { - devComponents = "No devices with registered encryption keys"; + devComponents = _t("No devices with registered encryption keys"); } else { devComponents = []; for (var i = 0; i < devices.length; i++) { @@ -595,7 +599,7 @@ module.exports = WithMatrixClient(React.createClass({ return (
    -

    Devices

    +

    { _t("Devices") }

    {devComponents}
    @@ -644,11 +648,11 @@ module.exports = WithMatrixClient(React.createClass({
    -
    Start new chat
    +
    { _t("Start a chat") }
    ; startChat =
    -

    Direct chats

    +

    { _t("Direct chats") }

    {tiles} {startNewChat}
    ; @@ -661,7 +665,7 @@ module.exports = WithMatrixClient(React.createClass({ if (this.state.can.kick) { const membership = this.props.member.membership; - const kickLabel = membership === "invite" ? "Disinvite" : "Kick"; + const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); kickButton = ( @@ -670,9 +674,9 @@ module.exports = WithMatrixClient(React.createClass({ ); } if (this.state.can.ban) { - let label = 'Ban'; + let label = _t("Ban"); if (this.props.member.membership == 'ban') { - label = 'Unban'; + label = _t("Unban"); } banButton = ( @@ -691,7 +695,7 @@ module.exports = WithMatrixClient(React.createClass({ ); } if (this.state.can.toggleMod) { - var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; + var giveOpLabel = this.state.isTargetMod ? _t("Revoke Moderator") : _t("Make Moderator"); giveModButton = {giveOpLabel} ; @@ -704,7 +708,7 @@ module.exports = WithMatrixClient(React.createClass({ if (kickButton || banButton || muteButton || giveModButton) { adminTools =
    -

    Admin tools

    +

    {_t("Admin tools")}

    {muteButton} @@ -717,34 +721,49 @@ module.exports = WithMatrixClient(React.createClass({ const memberName = this.props.member.name; + if (this.props.member.user) { + var presenceState = this.props.member.user.presence; + var presenceLastActiveAgo = this.props.member.user.lastActiveAgo; + var presenceLastTs = this.props.member.user.lastPresenceTs; + var presenceCurrentlyActive = this.props.member.user.currentlyActive; + } + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var PowerSelector = sdk.getComponent('elements.PowerSelector'); + var PresenceLabel = sdk.getComponent('rooms.PresenceLabel'); const EmojiText = sdk.getComponent('elements.EmojiText'); return (
    - -
    - -
    - - {memberName} - -
    -
    - { this.props.member.userId } + + +
    +
    -
    - Level: + + {memberName} + +
    +
    + { this.props.member.userId } +
    +
    + { _t("Level:") } +
    +
    + +
    -
    - { adminTools } + { adminTools } - { startChat } + { startChat } - { this._renderDevices() } + { this._renderDevices() } - { spinner } + { spinner } +
    ); } diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index bd386ed1bb..e5f4f08572 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.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,9 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ var React = require('react'); +import { _t } from '../../../languageHandler'; var classNames = require('classnames'); var Matrix = require("matrix-js-sdk"); -var q = require('q'); +import Promise from 'bluebird'; var MatrixClientPeg = require("../../../MatrixClientPeg"); var Modal = require("../../../Modal"); var Entities = require("../../../Entities"); @@ -27,12 +29,6 @@ var CallHandler = require("../../../CallHandler"); var Invite = require("../../../Invite"); var INITIAL_LOAD_NUM_MEMBERS = 30; -var SHARE_HISTORY_WARNING = - - Newly invited users will see the history of this room.
    - If you'd prefer invited users not to see messages that were sent before they joined,
    - turn off, 'Share message history with new users' in the settings for this room. -
    ; module.exports = React.createClass({ displayName: 'MemberList', @@ -205,9 +201,9 @@ module.exports = React.createClass({ _createOverflowTile: function(overflowCount, totalCount) { // For now we'll pretend this is any entity. It should probably be a separate tile. - var EntityTile = sdk.getComponent("rooms.EntityTile"); - var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "..."; + const EntityTile = sdk.getComponent("rooms.EntityTile"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const text = _t("and %(count)s others...", { count: overflowCount }); return ( @@ -352,7 +348,7 @@ module.exports = React.createClass({ if (invitedMemberTiles.length > 0) { invitedSection = (
    -

    Invited

    +

    { _t("Invited") }

    {invitedMemberTiles}
    @@ -363,8 +359,8 @@ module.exports = React.createClass({ var inputBox = (
    + onChange={this.onSearchQueryChanged} value={this.state.searchQuery} + placeholder={ _t('Filter room members') } />
    ); diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 5becef9ede..d7e6b4a1ec 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -22,6 +22,7 @@ var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require('../../../index'); var dis = require('../../../dispatcher'); var Modal = require("../../../Modal"); +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'MemberTile', @@ -63,7 +64,7 @@ module.exports = React.createClass({ }, getPowerLabel: function() { - return this.props.member.userId + " (power " + this.props.member.powerLevel + ")"; + return _t("%(userName)s (power %(powerLevelNumber)s)", {userName: this.props.member.userId, powerLevelNumber: this.props.member.powerLevel}); }, render: function() { diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 8a3b128908..14f52706ec 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -13,16 +13,14 @@ 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 CallHandler = require('../../../CallHandler'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var Modal = require('../../../Modal'); -var sdk = require('../../../index'); -var dis = require('../../../dispatcher'); +import React from 'react'; +import { _t } from '../../../languageHandler'; +import CallHandler from '../../../CallHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; import Autocomplete from './Autocomplete'; -import classNames from 'classnames'; - import UserSettingsStore from '../../../UserSettingsStore'; @@ -32,12 +30,12 @@ export default class MessageComposer extends React.Component { this.onCallClick = this.onCallClick.bind(this); this.onHangupClick = this.onHangupClick.bind(this); this.onUploadClick = this.onUploadClick.bind(this); + this.onShowAppsClick = this.onShowAppsClick.bind(this); + this.onHideAppsClick = this.onHideAppsClick.bind(this); this.onUploadFileSelected = this.onUploadFileSelected.bind(this); + this.uploadFiles = this.uploadFiles.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this.onInputContentChanged = this.onInputContentChanged.bind(this); - this.onUpArrow = this.onUpArrow.bind(this); - this.onDownArrow = this.onDownArrow.bind(this); - this._tryComplete = this._tryComplete.bind(this); this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); @@ -50,12 +48,11 @@ export default class MessageComposer extends React.Component { inputState: { style: [], blockType: null, - isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true), + isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false), wordCount: 0, }, showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), }; - } componentDidMount() { @@ -80,36 +77,33 @@ export default class MessageComposer extends React.Component { onUploadClick(ev) { if (MatrixClientPeg.get().isGuest()) { - let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't upload files. Please register to upload.", - }); + dis.dispatch({action: 'view_set_mxid'}); return; } this.refs.uploadInput.click(); } - onUploadFileSelected(files, isPasted) { - if (!isPasted) - files = files.target.files; + onUploadFileSelected(files) { + this.uploadFiles(files.target.files); + } + uploadFiles(files) { let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let TintableSvg = sdk.getComponent("elements.TintableSvg"); let fileList = []; for (let i=0; i - {files[i].name || 'Attachment'} + {files[i].name || _t('Attachment')} ); } Modal.createDialog(QuestionDialog, { - title: "Upload Files", + title: _t('Upload Files'), description: (
    -

    Are you sure you want upload the following files?

    +

    { _t('Are you sure you want to upload the following files?') }

      {fileList}
    @@ -119,7 +113,7 @@ export default class MessageComposer extends React.Component { if(shouldUpload) { // MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file if (files) { - for(var i=0; i console.log('Sent state'), (e) => console.error(e)); + // } + // } + onCallClick(ev) { + // NOTE -- Will be replaced by Jitsi code (currently commented) dis.dispatch({ action: 'place_call', type: ev.shiftKey ? "screensharing" : "video", room_id: this.props.room.roomId, }); + // this._startCallApp(false); } onVoiceCallClick(ev) { + // NOTE -- Will be replaced by Jitsi code (currently commented) dis.dispatch({ action: 'place_call', - type: 'voice', + type: "voice", room_id: this.props.room.roomId, }); + // this._startCallApp(true); + } + + onShowAppsClick(ev) { + dis.dispatch({ + action: 'appsDrawer', + show: true, + }); + } + + onHideAppsClick(ev) { + dis.dispatch({ + action: 'appsDrawer', + show: false, + }); } onInputContentChanged(content: string, selection: {start: number, end: number}) { @@ -171,21 +213,6 @@ export default class MessageComposer extends React.Component { this.setState({inputState}); } - onUpArrow() { - return this.refs.autocomplete.onUpArrow(); - } - - onDownArrow() { - return this.refs.autocomplete.onDownArrow(); - } - - _tryComplete(): boolean { - if (this.refs.autocomplete) { - return this.refs.autocomplete.onCompletionClicked(); - } - return false; - } - _onAutocompleteConfirm(range, completion) { if (this.messageComposerInput) { this.messageComposerInput.setDisplayedCompletion(range, completion); @@ -208,19 +235,18 @@ export default class MessageComposer extends React.Component { } render() { - var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); - var uploadInputStyle = {display: 'none'}; - var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" + - (UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old")); + const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); + const uploadInputStyle = {display: 'none'}; + const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); - var controls = []; + const controls = []; controls.push(
    -
    +
    , ); let e2eImg, e2eTitle, e2eClass; @@ -228,47 +254,61 @@ export default class MessageComposer extends React.Component { if (roomIsEncrypted) { // FIXME: show a /!\ if there are untrusted devices in the room... e2eImg = 'img/e2e-verified.svg'; - e2eTitle = 'Encrypted room'; + e2eTitle = _t('Encrypted room'); e2eClass = 'mx_MessageComposer_e2eIcon'; } else { e2eImg = 'img/e2e-unencrypted.svg'; - e2eTitle = 'Unencrypted room'; + e2eTitle = _t('Unencrypted room'); e2eClass = 'mx_MessageComposer_e2eIcon mx_filterFlipColor'; } controls.push( {e2eTitle} + />, ); - var callButton, videoCallButton, hangupButton; + let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton; if (this.props.callState && this.props.callState !== 'ended') { hangupButton =
    - Hangup + {
    ; - } - else { + } else { callButton = -
    +
    ; videoCallButton = -
    +
    ; } - var canSendMessages = this.props.room.currentState.maySendMessage( + // Apps + if (UserSettingsStore.isFeatureEnabled('matrix_apps')) { + if (this.props.showApps) { + hideAppsButton = +
    + +
    ; + } else { + showAppsButton = +
    + +
    ; + } + } + + const canSendMessages = this.props.room.currentState.maySendMessage( MatrixClientPeg.get().credentials.userId); if (canSendMessages) { // This also currently includes the call buttons. Really we should // check separately for whether we can call, but this is slightly // complex because of conference calls. - var uploadButton = ( + const uploadButton = (
    + onClick={this.onUploadClick} title={ _t('Upload file') }> ); const placeholderText = roomIsEncrypted ? - "Send an encrypted message…" : "Send a message (unencrypted)…"; + _t('Send an encrypted message') + '…' : _t('Send a message (unencrypted)') + '…'; controls.push( this.messageComposerInput = c} + ref={(c) => this.messageComposerInput = c} key="controls_input" onResize={this.props.onResize} room={this.props.room} placeholder={placeholderText} - tryComplete={this._tryComplete} - onUpArrow={this.onUpArrow} - onDownArrow={this.onDownArrow} - onUploadFileSelected={this.onUploadFileSelected} - tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete + onFilesPasted={this.uploadFiles} onContentChanged={this.onInputContentChanged} onInputStateChanged={this.onInputStateChanged} />, formattingButton, uploadButton, hangupButton, callButton, - videoCallButton + videoCallButton, + showAppsButton, + hideAppsButton, ); } else { controls.push(
    - You do not have permission to post to this room -
    + { _t('You do not have permission to post to this room') } +
    , ); } - let autoComplete; - if (UserSettingsStore.isFeatureEnabled('rich_text_editor')) { - autoComplete =
    - -
    ; - } - - const {style, blockType} = this.state.inputState; const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map( - name => { + (name) => { const active = style.includes(name) || blockType === name; const suffix = active ? '-o-n' : ''; const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); - const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; - const className = classNames("mx_MessageComposer_format_button", { - mx_MessageComposer_format_button_disabled: disabled, - mx_filterFlipColor: true, - }); + const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; return ; @@ -357,30 +378,26 @@ export default class MessageComposer extends React.Component { {controls}
    - {UserSettingsStore.isFeatureEnabled('rich_text_editor') ? -
    -
    - {formatButtons} -
    - - -
    -
    : null - } +
    +
    + {formatButtons} +
    + + +
    +
    ); } } MessageComposer.propTypes = { - tabComplete: React.PropTypes.any, - // a callback which is called when the height of the composer is // changed due to a change in content. onResize: React.PropTypes.func, @@ -395,5 +412,8 @@ MessageComposer.propTypes = { uploadFile: React.PropTypes.func.isRequired, // opacity for dynamic UI fading effects - opacity: React.PropTypes.number + opacity: React.PropTypes.number, + + // string representing the current room app drawer state + showApps: React.PropTypes.bool, }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 51c9ba881b..743caf3a76 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -16,23 +16,23 @@ limitations under the License. import React from 'react'; import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; -import {Editor, EditorState, RichUtils, CompositeDecorator, - convertFromRaw, convertToRaw, Modifier, EditorChangeType, - getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; +import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier, + getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState, + Entity} from 'draft-js'; -import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; import escape from 'lodash/escape'; -import Q from 'q'; +import Promise from 'bluebird'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import SlashCommands from '../../../SlashCommands'; +import KeyCode from '../../../KeyCode'; import Modal from '../../../Modal'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher'; -import KeyCode from '../../../KeyCode'; import UserSettingsStore from '../../../UserSettingsStore'; import * as RichText from '../../../RichText'; @@ -40,12 +40,20 @@ import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; -import {onSendMessageFailed} from './MessageComposerInputOld'; +import ComposerHistoryManager from '../../../ComposerHistoryManager'; +import MessageComposerStore from '../../../stores/MessageComposerStore'; + +import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix'; +const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); +const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g'); + +import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione'; +const EMOJI_SHORTNAMES = Object.keys(emojioneList); +const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); +const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; -const KEY_M = 77; - const ZWS_CODE = 8203; const ZWS = String.fromCharCode(ZWS_CODE); // zero width space function stateToMarkdown(state) { @@ -55,16 +63,53 @@ function stateToMarkdown(state) { ''); // this is *not* a zero width space, trust me :) } +function onSendMessageFailed(err, room) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); + if (err.name === "UnknownDeviceError") { + dis.dispatch({ + action: 'unknown_device_error', + err: err, + room: room, + }); + } + dis.dispatch({ + action: 'message_send_failed', + }); +} + /* * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { + static propTypes = { + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, + + // js-sdk Room object + room: React.PropTypes.object.isRequired, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func, + + onInputStateChanged: React.PropTypes.func, + }; + static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes - if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { + if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { return 'toggle-mode'; } + // Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I + if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) { + // When null is returned, draft-js will NOT preventDefault, allowing dev tools + // to be toggled when the editor is focussed + return null; + } + return getDefaultKeyBinding(e); } @@ -78,51 +123,84 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; + historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); this.onAction = this.onAction.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); - this.handlePastedFiles = this.handlePastedFiles.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this); - this.setEditorState = this.setEditorState.bind(this); this.onUpArrow = this.onUpArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this); this.onTab = this.onTab.bind(this); this.onEscape = this.onEscape.bind(this); this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); + this.onTextPasted = this.onTextPasted.bind(this); - const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); + const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); this.state = { // whether we're in rich text or markdown mode isRichtextEnabled, // the currently displayed editor state (note: this is always what is modified on input) - editorState: null, + editorState: this.createEditorState( + isRichtextEnabled, + MessageComposerStore.getContentState(this.props.room.roomId), + ), // the original editor state, before we started tabbing through completions originalEditorState: null, - }; - // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled - /* eslint react/no-direct-mutation-state:0 */ - this.state.editorState = this.createEditorState(); + // the virtual state "above" the history stack, the message currently being composed that + // we want to persist whilst browsing history + currentlyComposedEditorState: null, + + // whether there were any completions + someCompletions: null, + }; this.client = MatrixClientPeg.get(); } + findLinkEntities(contentBlock, callback) { + contentBlock.findEntityRanges( + (character) => { + const entityKey = character.getEntity(); + return ( + entityKey !== null && + Entity.get(entityKey).getType() === 'LINK' + ); + }, callback, + ); + } /* * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled * - contentState was passed in */ createEditorState(richText: boolean, contentState: ?ContentState): EditorState { - let decorators = richText ? RichText.getScopedRTDecorators(this.props) : - RichText.getScopedMDDecorators(this.props), - compositeDecorator = new CompositeDecorator(decorators); + const decorators = richText ? RichText.getScopedRTDecorators(this.props) : + RichText.getScopedMDDecorators(this.props); + decorators.push({ + strategy: this.findLinkEntities.bind(this), + component: (entityProps) => { + const Pill = sdk.getComponent('elements.Pill'); + const {url} = Entity.get(entityProps.entityKey).getData(); + if (Pill.isPillUrl(url)) { + return ; + } + + return ( + + {entityProps.children} + + ); + }, + }); + const compositeDecorator = new CompositeDecorator(decorators); let editorState = null; if (contentState) { @@ -134,110 +212,13 @@ export default class MessageComposerInput extends React.Component { return EditorState.moveFocusToEnd(editorState); } - componentWillMount() { - const component = this; - this.sentHistory = { - // The list of typed messages. Index 0 is more recent - data: [], - // The position in data currently displayed - position: -1, - // The room the history is for. - roomId: null, - // The original text before they hit UP - originalText: null, - // The textarea element to set text to. - element: null, - - init: function(element, roomId) { - this.roomId = roomId; - this.element = element; - this.position = -1; - var storedData = window.sessionStorage.getItem( - "mx_messagecomposer_history_" + roomId - ); - if (storedData) { - this.data = JSON.parse(storedData); - } - if (this.roomId) { - this.setLastTextEntry(); - } - }, - - push: function(text) { - // store a message in the sent history - this.data.unshift(text); - window.sessionStorage.setItem( - "mx_messagecomposer_history_" + this.roomId, - JSON.stringify(this.data) - ); - // reset history position - this.position = -1; - this.originalText = null; - }, - - // move in the history. Returns true if we managed to move. - next: function(offset) { - if (this.position === -1) { - // user is going into the history, save the current line. - this.originalText = this.element.value; - } - else { - // user may have modified this line in the history; remember it. - this.data[this.position] = this.element.value; - } - - if (offset > 0 && this.position === (this.data.length - 1)) { - // we've run out of history - return false; - } - - // retrieve the next item (bounded). - var newPosition = this.position + offset; - newPosition = Math.max(-1, newPosition); - newPosition = Math.min(newPosition, this.data.length - 1); - this.position = newPosition; - - if (this.position !== -1) { - // show the message - this.element.value = this.data[this.position]; - } - else if (this.originalText !== undefined) { - // restore the original text the user was typing. - this.element.value = this.originalText; - } - - return true; - }, - - saveLastTextEntry: function() { - // save the currently entered text in order to restore it later. - // NB: This isn't 'originalText' because we want to restore - // sent history items too! - let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); - window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); - }, - - setLastTextEntry: function() { - let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); - if (contentJSON) { - let content = convertFromRaw(JSON.parse(contentJSON)); - component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); - } - }, - }; - } - componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - this.sentHistory.init( - this.refs.editor, - this.props.room.roomId - ); + this.historyManager = new ComposerHistoryManager(this.props.room.roomId); } componentWillUnmount() { dis.unregister(this.dispatcherRef); - this.sentHistory.saveLastTextEntry(); } componentWillUpdate(nextProps, nextState) { @@ -249,37 +230,35 @@ export default class MessageComposerInput extends React.Component { } } - onAction(payload) { - let editor = this.refs.editor; + onAction = (payload) => { + const editor = this.refs.editor; let contentState = this.state.editorState.getCurrentContent(); switch (payload.action) { case 'focus_composer': editor.focus(); break; - - // TODO change this so we insert a complete user alias - - case 'insert_displayname': { - contentState = Modifier.replaceText( - contentState, - this.state.editorState.getSelection(), - `${payload.displayname}: ` - ); - let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); - editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); - this.onEditorContentChanged(editorState); - editor.focus(); + case 'insert_mention': { + // Pretend that we've autocompleted this user because keeping two code + // paths for inserting a user pill is not fun + const selection = this.state.editorState.getSelection(); + const member = this.props.room.getMember(payload.user_id); + const completion = member ? member.name.replace(' (IRC)', '') : payload.user_id; + this.setDisplayedCompletion({ + completion, + selection, + href: `https://matrix.to/#/${payload.user_id}`, + suffix: selection.getStartOffset() === 0 ? ': ' : ' ', + }); } - break; - + break; case 'quote': { let {body, formatted_body} = payload.event.getContent(); formatted_body = formatted_body || escape(body); if (formatted_body) { - let content = RichText.HTMLtoContentState(`
    ${formatted_body}
    `); + let content = RichText.htmlToContentState(`
    ${formatted_body}
    `); if (!this.state.isRichtextEnabled) { - content = ContentState.createFromText(stateToMarkdown(content)); + content = ContentState.createFromText(RichText.stateToMarkdown(content)); } const blockMap = content.getBlockMap(); @@ -294,13 +273,14 @@ export default class MessageComposerInput extends React.Component { contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); } let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + editorState = EditorState.moveSelectionToEnd(editorState); this.onEditorContentChanged(editorState); editor.focus(); } } - break; + break; } - } + }; onTypingActivity() { this.isTyping = true; @@ -320,7 +300,7 @@ export default class MessageComposerInput extends React.Component { startUserTypingTimer() { this.stopUserTypingTimer(); - var self = this; + const self = this; this.userTypingTimer = setTimeout(function() { self.isTyping = false; self.sendTyping(self.isTyping); @@ -337,7 +317,7 @@ export default class MessageComposerInput extends React.Component { startServerTypingTimer() { if (!this.serverTypingTimer) { - var self = this; + const self = this; this.serverTypingTimer = setTimeout(function() { if (self.isTyping) { self.sendTyping(self.isTyping); @@ -355,9 +335,10 @@ export default class MessageComposerInput extends React.Component { } sendTyping(isTyping) { + if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT + this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } @@ -368,99 +349,244 @@ export default class MessageComposerInput extends React.Component { } } - // Called by Draft to change editor contents, and by setEditorState - onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) { + // Called by Draft to change editor contents + onEditorContentChanged = (editorState: EditorState) => { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); - const contentChanged = Q.defer(); - /* If a modification was made, set originalEditorState to null, since newState is now our original */ + const currentBlock = editorState.getSelection().getStartKey(); + const currentSelection = editorState.getSelection(); + const currentStartOffset = editorState.getSelection().getStartOffset(); + + const block = editorState.getCurrentContent().getBlockForKey(currentBlock); + const text = block.getText(); + + const entityBeforeCurrentOffset = block.getEntityAt(currentStartOffset - 1); + const entityAtCurrentOffset = block.getEntityAt(currentStartOffset); + + // If the cursor is on the boundary between an entity and a non-entity and the + // text before the cursor has whitespace at the end, set the entity state of the + // character before the cursor (the whitespace) to null. This allows the user to + // stop editing the link. + if (entityBeforeCurrentOffset && !entityAtCurrentOffset && + /\s$/.test(text.slice(0, currentStartOffset))) { + editorState = RichUtils.toggleLink( + editorState, + currentSelection.merge({ + anchorOffset: currentStartOffset - 1, + focusOffset: currentStartOffset, + }), + null, + ); + // Reset selection + editorState = EditorState.forceSelection(editorState, currentSelection); + } + + // Automatic replacement of plaintext emoji to Unicode emoji + if (UserSettingsStore.getSyncedSetting('MessageComposerInput.autoReplaceEmoji', false)) { + // The first matched group includes just the matched plaintext emoji + const emojiMatch = REGEX_EMOJI_WHITESPACE.exec(text.slice(0, currentStartOffset)); + if(emojiMatch) { + // plaintext -> hex unicode + const emojiUc = asciiList[emojiMatch[1]]; + // hex unicode -> shortname -> actual unicode + const unicodeEmoji = shortnameToUnicode(EMOJI_UNICODE_TO_SHORTNAME[emojiUc]); + const newContentState = Modifier.replaceText( + editorState.getCurrentContent(), + currentSelection.merge({ + anchorOffset: currentStartOffset - emojiMatch[1].length - 1, + focusOffset: currentStartOffset, + }), + unicodeEmoji, + ); + editorState = EditorState.push( + editorState, + newContentState, + 'insert-characters', + ); + editorState = EditorState.forceSelection(editorState, newContentState.getSelectionAfter()); + } + } + + /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, - originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState, - }, () => contentChanged.resolve()); + originalEditorState: null, + }); + }; - if (editorState.getCurrentContent().hasText()) { - this.onTypingActivity(); - } else { - this.onFinishedTyping(); + /** + * We're overriding setState here because it's the most convenient way to monitor changes to the editorState. + * Doing it using a separate function that calls setState is a possibility (and was the old approach), but that + * approach requires a callback and an extra setState whenever trying to set multiple state properties. + * + * @param state + * @param callback + */ + setState(state, callback) { + if (state.editorState != null) { + state.editorState = RichText.attachImmutableEntitiesToEmoji( + state.editorState); + + if (state.editorState.getCurrentContent().hasText()) { + this.onTypingActivity(); + } else { + this.onFinishedTyping(); + } + + // Record the editor state for this room so that it can be retrieved after + // switching to another room and back + dis.dispatch({ + action: 'content_state', + room_id: this.props.room.roomId, + content_state: state.editorState.getCurrentContent(), + }); + + if (!state.hasOwnProperty('originalEditorState')) { + state.originalEditorState = null; + } } - if (this.props.onContentChanged) { - const textContent = editorState.getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), - editorState.getCurrentContent().getBlocksAsArray()); + super.setState(state, () => { + if (callback != null) { + callback(); + } - this.props.onContentChanged(textContent, selection); - } - return contentChanged.promise; - } + const textContent = this.state.editorState.getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets( + this.state.editorState.getSelection(), + this.state.editorState.getCurrentContent().getBlocksAsArray()); + if (this.props.onContentChanged) { + this.props.onContentChanged(textContent, selection); + } - setEditorState(editorState: EditorState) { - return this.onEditorContentChanged(editorState, false); + // Scroll to the bottom of the editor if the cursor is on the last line of the + // composer. For some reason the editor won't scroll automatically if we paste + // blocks of text in or insert newlines. + if (textContent.slice(selection.start).indexOf("\n") === -1) { + this.refs.editor.refs.editor.scrollTop = this.refs.editor.refs.editor.scrollHeight; + } + }); } enableRichtext(enabled: boolean) { + if (enabled === this.state.isRichtextEnabled) return; + let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); - contentState = RichText.HTMLtoContentState(md.toHTML()); + contentState = RichText.htmlToContentState(md.toHTML()); } else { - let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) } contentState = ContentState.createFromText(markdown); } - this.setEditorState(this.createEditorState(enabled, contentState)).then(() => { - this.setState({ - isRichtextEnabled: enabled, - }); - - UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); + this.setState({ + editorState: this.createEditorState(enabled, contentState), + isRichtextEnabled: enabled, }); + UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } - handleKeyCommand(command: string): boolean { + handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; } - let newState: ?EditorState = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. if (this.state.isRichtextEnabled) { // These are block types, not handled by RichUtils by default. const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; + const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); + + const shouldToggleBlockFormat = ( + command === 'backspace' || + command === 'split-block' + ) && currentBlockType !== 'unstyled'; if (blockCommands.includes(command)) { - this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command)); + newState = RichUtils.toggleBlockType(this.state.editorState, command); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default - this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH')); + newState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'); + } else if (shouldToggleBlockFormat) { + const currentStartOffset = this.state.editorState.getSelection().getStartOffset(); + if (currentStartOffset === 0) { + // Toggle current block type (setting it to 'unstyled') + newState = RichUtils.toggleBlockType(this.state.editorState, currentBlockType); + } } } else { - let contentState = this.state.editorState.getCurrentContent(), - selection = this.state.editorState.getSelection(); + const contentState = this.state.editorState.getCurrentContent(); + const multipleLinesSelected = RichText.hasMultiLineSelection(this.state.editorState); - let modifyFn = { - 'bold': text => `**${text}**`, - 'italic': text => `*${text}*`, - 'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - 'strike': text => `~~${text}~~`, - 'code': text => `\`${text}\``, - 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), - 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), - 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), + const selectionState = this.state.editorState.getSelection(); + const start = selectionState.getStartOffset(); + const end = selectionState.getEndOffset(); + + // If multiple lines are selected or nothing is selected, insert a code block + // instead of applying inline code formatting. This is an attempt to mimic what + // happens in non-MD mode. + const treatInlineCodeAsBlock = multipleLinesSelected || start === end; + const textMdCodeBlock = (text) => `\`\`\`\n${text}\n\`\`\`\n`; + const modifyFn = { + 'bold': (text) => `**${text}**`, + 'italic': (text) => `*${text}*`, + 'underline': (text) => `${text}`, + 'strike': (text) => `${text}`, + // ("code" is triggered by ctrl+j by draft-js by default) + 'code': (text) => treatInlineCodeAsBlock ? textMdCodeBlock(text) : `\`${text}\``, + 'code-block': textMdCodeBlock, + 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join('') + '\n', + 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), + 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), }[command]; + const selectionAfterOffset = { + 'bold': -2, + 'italic': -1, + 'underline': -4, + 'strike': -6, + 'code': treatInlineCodeAsBlock ? -5 : -1, + 'code-block': -5, + 'blockquote': -2, + }[command]; + + // Returns a function that collapses a selectionState to its end and moves it by offset + const collapseAndOffsetSelection = (selectionState, offset) => { + const key = selectionState.getEndKey(); + return new SelectionState({ + anchorKey: key, anchorOffset: offset, + focusKey: key, focusOffset: offset, + }); + }; + if (modifyFn) { + const previousSelection = this.state.editorState.getSelection(); + const newContentState = RichText.modifyText(contentState, previousSelection, modifyFn); newState = EditorState.push( this.state.editorState, - RichText.modifyText(contentState, selection, modifyFn), - 'insert-characters' + newContentState, + 'insert-characters', ); + + let newSelection = newContentState.getSelectionAfter(); + // If the selection range is 0, move the cursor inside the formatted body + if (previousSelection.getStartOffset() === previousSelection.getEndOffset() && + previousSelection.getStartKey() === previousSelection.getEndKey() && + selectionAfterOffset !== undefined + ) { + const selectedBlock = newContentState.getBlockForKey(previousSelection.getAnchorKey()); + const blockLength = selectedBlock.getText().length; + const newOffset = blockLength + selectionAfterOffset; + newSelection = collapseAndOffsetSelection(newSelection, newOffset); + } + + newState = EditorState.forceSelection(newState, newSelection); } } @@ -469,15 +595,33 @@ export default class MessageComposerInput extends React.Component { } if (newState != null) { - this.setEditorState(newState); + this.setState({editorState: newState}); return true; } return false; } - handlePastedFiles(files) { - this.props.onUploadFileSelected(files, true); + onTextPasted(text: string, html?: string) { + const currentSelection = this.state.editorState.getSelection(); + const currentContent = this.state.editorState.getCurrentContent(); + + let contentState = null; + if (html && this.state.isRichtextEnabled) { + contentState = Modifier.replaceWithFragment( + currentContent, + currentSelection, + RichText.htmlToContentState(html).getBlockMap(), + ); + } else { + contentState = Modifier.replaceText(currentContent, currentSelection, text); + } + + let newEditorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + + newEditorState = EditorState.forceSelection(newEditorState, contentState.getSelectionAfter()); + this.onEditorContentChanged(newEditorState); + return true; } handleReturn(ev) { @@ -486,6 +630,18 @@ export default class MessageComposerInput extends React.Component { return true; } + const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); + if( + ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'] + .includes(currentBlockType) + ) { + // By returning false, we allow the default draft-js key binding to occur, + // which in this case invokes "split-block". This creates a new block of the + // same type, allowing the user to delete it with backspace. + // See handleKeyCommand (when command === 'backspace') + return false; + } + const contentState = this.state.editorState.getCurrentContent(); if (!contentState.hasText()) { return true; @@ -494,11 +650,21 @@ export default class MessageComposerInput extends React.Component { let contentText = contentState.getPlainText(), contentHTML; - var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); + // Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour. + // We have to do this now as opposed to after calculating the contentText for MD + // mode because entity positions may not be maintained when using + // md.toPlaintext(). + // Unfortunately this means we lose mentions in history when in MD mode. This + // would be fixed if history was stored as contentState. + contentText = this.removeMDLinks(contentState, ['@']); + + // Some commands (/join) require pills to be replaced with their text content + const commandText = this.removeMDLinks(contentState, ['#']); + const cmd = SlashCommands.processInput(this.props.room.roomId, commandText); if (cmd) { if (!cmd.error) { this.setState({ - editorState: this.createEditorState() + editorState: this.createEditorState(), }); } if (cmd.promise) { @@ -506,30 +672,83 @@ export default class MessageComposerInput extends React.Component { console.log("Command success."); }, function(err) { console.error("Command failure: %s", err); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Server error", - description: "Server unavailable, overloaded, or something else went wrong.", + title: _t("Server error"), + description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), }); }); - } - else if (cmd.error) { + } else if (cmd.error) { console.error(cmd.error); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Command error", - description: cmd.error + title: _t("Command error"), + description: cmd.error, }); } return true; } if (this.state.isRichtextEnabled) { - contentHTML = HtmlUtils.stripParagraphs( - RichText.contentStateToHTML(contentState) - ); + // We should only send HTML if any block is styled or contains inline style + let shouldSendHTML = false; + const blocks = contentState.getBlocksAsArray(); + if (blocks.some((block) => block.getType() !== 'unstyled')) { + shouldSendHTML = true; + } else { + const characterLists = blocks.map((block) => block.getCharacterList()); + // For each block of characters, determine if any inline styles are applied + // and if yes, send HTML + characterLists.forEach((characters) => { + const numberOfStylesForCharacters = characters.map( + (character) => character.getStyle().toArray().length, + ).toArray(); + // If any character has more than 0 inline styles applied, send HTML + if (numberOfStylesForCharacters.some((styles) => styles > 0)) { + shouldSendHTML = true; + } + }); + } + if (!shouldSendHTML) { + const hasLink = blocks.some((block) => { + return block.getCharacterList().filter((c) => { + const entityKey = c.getEntity(); + return entityKey && Entity.get(entityKey).getType() === 'LINK'; + }).size > 0; + }); + shouldSendHTML = hasLink; + } + if (shouldSendHTML) { + contentHTML = HtmlUtils.processHtmlForSending( + RichText.contentStateToHTML(contentState), + ); + } } else { - const md = new Markdown(contentText); + // Use the original contentState because `contentText` has had mentions + // stripped and these need to end up in contentHTML. + + // Replace all Entities of type `LINK` with markdown link equivalents. + // TODO: move this into `Markdown` and do the same conversion in the other + // two places (toggling from MD->RT mode and loading MD history into RT mode) + // but this can only be done when history includes Entities. + const pt = contentState.getBlocksAsArray().map((block) => { + let blockText = block.getText(); + let offset = 0; + this.findLinkEntities(block, (start, end) => { + const entity = Entity.get(block.getEntityAt(start)); + if (entity.getType() !== 'LINK') { + return; + } + const text = blockText.slice(offset + start, offset + end); + const url = entity.getData().url; + const mdLink = `[${text}](${url})`; + blockText = blockText.slice(0, offset + start) + mdLink + blockText.slice(offset + end); + offset += mdLink.length - text.length; + }); + return blockText; + }).join('\n'); + + const md = new Markdown(pt); if (md.isPlainText()) { contentText = md.toPlaintext(); } else { @@ -540,20 +759,28 @@ export default class MessageComposerInput extends React.Component { let sendHtmlFn = this.client.sendHtmlMessage; let sendTextFn = this.client.sendTextMessage; + if (this.state.isRichtextEnabled) { + this.historyManager.addItem( + contentHTML ? contentHTML : contentText, + contentHTML ? 'html' : 'markdown', + ); + } else { + // Always store MD input as input history + this.historyManager.addItem(contentText, 'markdown'); + } + if (contentText.startsWith('/me')) { - contentText = contentText.replace('/me ', ''); + contentText = contentText.substring(4); // bit of a hack, but the alternative would be quite complicated - if (contentHTML) contentHTML = contentHTML.replace('/me ', ''); + if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, ''); sendHtmlFn = this.client.sendHtmlEmote; sendTextFn = this.client.sendEmoteMessage; } - // XXX: We don't actually seem to use this history? - this.sentHistory.push(contentHTML || contentText); let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( - this.client, this.props.room.roomId, contentText, contentHTML + this.client, this.props.room.roomId, contentText, contentHTML, ); } else { sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText); @@ -569,90 +796,201 @@ export default class MessageComposerInput extends React.Component { editorState: this.createEditorState(), }); - this.autocomplete.hide(); - return true; } - async onUpArrow(e) { - const completion = this.autocomplete.onUpArrow(); - if (completion != null) { - e.preventDefault(); + onUpArrow = (e) => { + this.onVerticalArrow(e, true); + }; + + onDownArrow = (e) => { + this.onVerticalArrow(e, false); + }; + + onVerticalArrow = (e, up) => { + if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + return; } - return await this.setDisplayedCompletion(completion); - } - async onDownArrow(e) { - const completion = this.autocomplete.onDownArrow(); - e.preventDefault(); - return await this.setDisplayedCompletion(completion); - } + // Select history only if we are not currently auto-completing + if (this.autocomplete.state.completionList.length === 0) { + // Don't go back in history if we're in the middle of a multi-line message + const selection = this.state.editorState.getSelection(); + const blockKey = selection.getStartKey(); + const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock(); + const lastBlock = this.state.editorState.getCurrentContent().getLastBlock(); - // tab and shift-tab are mapped to down and up arrow respectively - async onTab(e) { - e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes - const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); - if (!didTab && this.autocomplete) { - this.autocomplete.forceComplete().then(() => { - this.onDownArrow(e); + let canMoveUp = false; + let canMoveDown = false; + if (blockKey === firstBlock.getKey()) { + canMoveUp = selection.getStartOffset() === selection.getEndOffset() && + selection.getStartOffset() === 0; + } + + if (blockKey === lastBlock.getKey()) { + canMoveDown = selection.getStartOffset() === selection.getEndOffset() && + selection.getStartOffset() === lastBlock.getText().length; + } + + if ((up && !canMoveUp) || (!up && !canMoveDown)) return; + + const selected = this.selectHistory(up); + if (selected) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + } + } else { + this.moveAutocompleteSelection(up); + } + }; + + selectHistory = async (up) => { + const delta = up ? -1 : 1; + + // True if we are not currently selecting history, but composing a message + if (this.historyManager.currentIndex === this.historyManager.history.length) { + // We can't go any further - there isn't any more history, so nop. + if (!up) { + return; + } + this.setState({ + currentlyComposedEditorState: this.state.editorState, }); + } else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) { + // True when we return to the message being composed currently + this.setState({ + editorState: this.state.currentlyComposedEditorState, + }); + this.historyManager.currentIndex = this.historyManager.history.length; + return; } - } - onEscape(e) { + const newContent = this.historyManager.getItem(delta, this.state.isRichtextEnabled ? 'html' : 'markdown'); + if (!newContent) return false; + let editorState = EditorState.push( + this.state.editorState, + newContent, + 'insert-characters', + ); + + // Move selection to the end of the selected history + let newSelection = SelectionState.createEmpty(newContent.getLastBlock().getKey()); + newSelection = newSelection.merge({ + focusOffset: newContent.getLastBlock().getLength(), + anchorOffset: newContent.getLastBlock().getLength(), + }); + editorState = EditorState.forceSelection(editorState, newSelection); + + this.setState({editorState}); + return true; + }; + + onTab = async (e) => { + this.setState({ + someCompletions: null, + }); + e.preventDefault(); + if (this.autocomplete.state.completionList.length === 0) { + // Force completions to show for the text currently entered + const completionCount = await this.autocomplete.forceComplete(); + this.setState({ + someCompletions: completionCount > 0, + }); + // Select the first item by moving "down" + await this.moveAutocompleteSelection(false); + } else { + await this.moveAutocompleteSelection(e.shiftKey); + } + }; + + moveAutocompleteSelection = (up) => { + const completion = up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow(); + return this.setDisplayedCompletion(completion); + }; + + onEscape = async (e) => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); } - this.setDisplayedCompletion(null); // restore originalEditorState - } + await this.setDisplayedCompletion(null); // restore originalEditorState + }; /* If passed null, restores the original editor content from state.originalEditorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. */ - async setDisplayedCompletion(displayedCompletion: ?Completion): boolean { + setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => { const activeEditorState = this.state.originalEditorState || this.state.editorState; if (displayedCompletion == null) { if (this.state.originalEditorState) { - this.setEditorState(this.state.originalEditorState); + let editorState = this.state.originalEditorState; + // This is a workaround from https://github.com/facebook/draft-js/issues/458 + // Due to the way we swap editorStates, Draft does not rerender at times + editorState = EditorState.forceSelection(editorState, + editorState.getSelection()); + this.setState({editorState}); + } return false; } - const {range = {}, completion = ''} = displayedCompletion; + const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion; + + let entityKey; + let mdCompletion; + if (href) { + entityKey = Entity.create('LINK', 'IMMUTABLE', { + url: href, + isCompletion: true, + }); + } + + let selection; + if (range) { + selection = RichText.textOffsetsToSelectionState( + range, activeEditorState.getCurrentContent().getBlocksAsArray(), + ); + } else { + selection = activeEditorState.getSelection(); + } let contentState = Modifier.replaceText( activeEditorState.getCurrentContent(), - RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()), - completion + selection, + mdCompletion || completion, + null, + entityKey, ); + // Move the selection to the end of the block + const afterSelection = contentState.getSelectionAfter(); + if (suffix) { + contentState = Modifier.replaceText(contentState, afterSelection, suffix); + } + let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); - const originalEditorState = activeEditorState; - - await this.setEditorState(editorState); - this.setState({originalEditorState}); + this.setState({editorState, originalEditorState: activeEditorState}); // for some reason, doing this right away does not update the editor :( - setTimeout(() => this.refs.editor.focus(), 50); + // setTimeout(() => this.refs.editor.focus(), 50); return true; - } + }; onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { e.preventDefault(); // don't steal focus from the editor! const command = { - code: 'code-block', - quote: 'blockquote', - bullet: 'unordered-list-item', - numbullet: 'ordered-list-item', - }[name] || name; + code: 'code-block', + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name] || name; this.handleKeyCommand(command); } /* returns inline style and block type of current SelectionState so MessageComposer can render formatting - buttons. */ + buttons. */ getSelectionInfo(editorState: EditorState) { const styleName = { BOLD: 'bold', @@ -663,8 +1001,8 @@ export default class MessageComposerInput extends React.Component { const originalStyle = editorState.getCurrentInlineStyle().toArray(); const style = originalStyle - .map(style => styleName[style] || null) - .filter(styleName => !!styleName); + .map((style) => styleName[style] || null) + .filter((styleName) => !!styleName); const blockName = { 'code-block': 'code', @@ -683,10 +1021,48 @@ export default class MessageComposerInput extends React.Component { }; } - onMarkdownToggleClicked(e) { + getAutocompleteQuery(contentState: ContentState) { + // Don't send markdown links to the autocompleter + return this.removeMDLinks(contentState, ['@', '#']); + } + + removeMDLinks(contentState: ContentState, prefixes: string[]) { + const plaintext = contentState.getPlainText(); + if (!plaintext) return ''; + return plaintext.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL, + (markdownLink, text, resource, prefix, offset) => { + if (!prefixes.includes(prefix)) return markdownLink; + // Calculate the offset relative to the current block that the offset is in + let sum = 0; + const blocks = contentState.getBlocksAsArray(); + let block; + for (let i = 0; i < blocks.length; i++) { + block = blocks[i]; + sum += block.getLength(); + if (sum > offset) { + sum -= block.getLength(); + break; + } + } + offset -= sum; + + const entityKey = block.getEntityAt(offset); + const entity = entityKey ? Entity.get(entityKey) : null; + if (entity && entity.getData().isCompletion) { + // This is a completed mention, so do not insert MD link, just text + return text; + } else { + // This is either a MD link that was typed into the composer or another + // type of pill (e.g. room pill) + return markdownLink; + } + }); + } + + onMarkdownToggleClicked = (e) => { e.preventDefault(); // don't steal focus from the editor! this.handleKeyCommand('toggle-mode'); - } + }; render() { const activeEditorState = this.state.originalEditorState || this.state.editorState; @@ -703,11 +1079,11 @@ export default class MessageComposerInput extends React.Component { } const className = classNames('mx_MessageComposer_input', { - mx_MessageComposer_input_empty: hidePlaceholder, + mx_MessageComposer_input_empty: hidePlaceholder, + mx_MessageComposer_input_error: this.state.someCompletions === false, }); const content = activeEditorState.getCurrentContent(); - const contentText = content.getPlainText(); const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(), activeEditorState.getCurrentContent().getBlocksAsArray()); @@ -717,15 +1093,16 @@ export default class MessageComposerInput extends React.Component { this.autocomplete = e} onConfirm={this.setDisplayedCompletion} - query={contentText} - selection={selection} /> + query={this.getAutocompleteQuery(content)} + selection={selection}/>
    + spellCheck={true}/>
    ); @@ -747,8 +1125,6 @@ export default class MessageComposerInput extends React.Component { } MessageComposerInput.propTypes = { - tabComplete: React.PropTypes.any, - // a callback which is called when the height of the composer is // changed due to a change in content. onResize: React.PropTypes.func, @@ -759,14 +1135,7 @@ MessageComposerInput.propTypes = { // called with current plaintext content (as a string) whenever it changes onContentChanged: React.PropTypes.func, - onUpArrow: React.PropTypes.func, - - onDownArrow: React.PropTypes.func, - - onUploadFileSelected: React.PropTypes.func, - - // attempts to confirm currently selected completion, returns whether actually confirmed - tryComplete: React.PropTypes.func, + onFilesPasted: React.PropTypes.func, onInputStateChanged: React.PropTypes.func, }; diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js deleted file mode 100644 index f0b650eb04..0000000000 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ /dev/null @@ -1,447 +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 MatrixClientPeg = require("../../../MatrixClientPeg"); -var SlashCommands = require("../../../SlashCommands"); -var Modal = require("../../../Modal"); -var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; -var sdk = require('../../../index'); - -var dis = require("../../../dispatcher"); -var KeyCode = require("../../../KeyCode"); -var Markdown = require("../../../Markdown"); - -var TYPING_USER_TIMEOUT = 10000; -var TYPING_SERVER_TIMEOUT = 30000; -var MARKDOWN_ENABLED = true; - -export function onSendMessageFailed(err, room) { - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); - if (err.name === "UnknownDeviceError") { - dis.dispatch({ - action: 'unknown_device_error', - err: err, - room: room, - }); - } - dis.dispatch({ - action: 'message_send_failed', - }); -} - -/* - * The textInput part of the MessageComposer - */ -export default React.createClass({ - displayName: 'MessageComposerInput', - - statics: { - // the height we limit the composer to - MAX_HEIGHT: 100, - }, - - propTypes: { - tabComplete: React.PropTypes.any, - - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: React.PropTypes.func, - - // js-sdk Room object - room: React.PropTypes.object.isRequired, - - // The text to use a placeholder in the input box - placeholder: React.PropTypes.string.isRequired, - }, - - componentWillMount: function() { - this.oldScrollHeight = 0; - this.markdownEnabled = MARKDOWN_ENABLED; - var self = this; - this.sentHistory = { - // The list of typed messages. Index 0 is more recent - data: [], - // The position in data currently displayed - position: -1, - // The room the history is for. - roomId: null, - // The original text before they hit UP - originalText: null, - // The textarea element to set text to. - element: null, - - init: function(element, roomId) { - this.roomId = roomId; - this.element = element; - this.position = -1; - var storedData = window.sessionStorage.getItem( - "history_" + roomId - ); - if (storedData) { - this.data = JSON.parse(storedData); - } - if (this.roomId) { - this.setLastTextEntry(); - } - }, - - push: function(text) { - // store a message in the sent history - this.data.unshift(text); - window.sessionStorage.setItem( - "history_" + this.roomId, - JSON.stringify(this.data) - ); - // reset history position - this.position = -1; - this.originalText = null; - }, - - // move in the history. Returns true if we managed to move. - next: function(offset) { - if (this.position === -1) { - // user is going into the history, save the current line. - this.originalText = this.element.value; - } - else { - // user may have modified this line in the history; remember it. - this.data[this.position] = this.element.value; - } - - if (offset > 0 && this.position === (this.data.length - 1)) { - // we've run out of history - return false; - } - - // retrieve the next item (bounded). - var newPosition = this.position + offset; - newPosition = Math.max(-1, newPosition); - newPosition = Math.min(newPosition, this.data.length - 1); - this.position = newPosition; - - if (this.position !== -1) { - // show the message - this.element.value = this.data[this.position]; - } - else if (this.originalText !== undefined) { - // restore the original text the user was typing. - this.element.value = this.originalText; - } - - self.resizeInput(); - return true; - }, - - saveLastTextEntry: function() { - // save the currently entered text in order to restore it later. - // NB: This isn't 'originalText' because we want to restore - // sent history items too! - var text = this.element.value; - window.sessionStorage.setItem("input_" + this.roomId, text); - }, - - setLastTextEntry: function() { - var text = window.sessionStorage.getItem("input_" + this.roomId); - if (text) { - this.element.value = text; - self.resizeInput(); - } - } - }; - }, - - componentDidMount: function() { - this.dispatcherRef = dis.register(this.onAction); - this.sentHistory.init( - this.refs.textarea, - this.props.room.roomId - ); - this.resizeInput(); - if (this.props.tabComplete) { - this.props.tabComplete.setTextArea(this.refs.textarea); - } - }, - - componentWillUnmount: function() { - dis.unregister(this.dispatcherRef); - this.sentHistory.saveLastTextEntry(); - }, - - onAction: function(payload) { - var textarea = this.refs.textarea; - switch (payload.action) { - case 'focus_composer': - textarea.focus(); - break; - case 'insert_displayname': - if (textarea.value.length) { - var left = textarea.value.substring(0, textarea.selectionStart); - var right = textarea.value.substring(textarea.selectionEnd); - if (right.length) { - left += payload.displayname; - } - else { - left = left.replace(/( ?)$/, " " + payload.displayname); - } - textarea.value = left + right; - textarea.focus(); - textarea.setSelectionRange(left.length, left.length); - } - else { - textarea.value = payload.displayname + ": "; - textarea.focus(); - } - break; - } - }, - - onKeyDown: function(ev) { - if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) { - var input = this.refs.textarea.value; - if (input.length === 0) { - ev.preventDefault(); - return; - } - this.sentHistory.push(input); - this.onEnter(ev); - } - else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { - var oldSelectionStart = this.refs.textarea.selectionStart; - // Remember the keyCode because React will recycle the synthetic event - var keyCode = ev.keyCode; - // set a callback so we can see if the cursor position changes as - // a result of this event. If it doesn't, we cycle history. - setTimeout(() => { - if (this.refs.textarea.selectionStart == oldSelectionStart) { - this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); - this.resizeInput(); - } - }, 0); - } - - if (this.props.tabComplete) { - this.props.tabComplete.onKeyDown(ev); - } - - var self = this; - setTimeout(function() { - if (self.refs.textarea && self.refs.textarea.value != '') { - self.onTypingActivity(); - } else { - self.onFinishedTyping(); - } - }, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :( - }, - - resizeInput: function() { - // scrollHeight is at least equal to clientHeight, so we have to - // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value - this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS - var newHeight = Math.min(this.refs.textarea.scrollHeight, - this.constructor.MAX_HEIGHT); - this.refs.textarea.style.height = Math.ceil(newHeight) + "px"; - this.oldScrollHeight = this.refs.textarea.scrollHeight; - - if (this.props.onResize) { - // kick gemini-scrollbar to re-layout - this.props.onResize(); - } - }, - - onKeyUp: function(ev) { - if (this.refs.textarea.scrollHeight !== this.oldScrollHeight || - ev.keyCode === KeyCode.DELETE || - ev.keyCode === KeyCode.BACKSPACE) - { - this.resizeInput(); - } - }, - - onEnter: function(ev) { - var contentText = this.refs.textarea.value; - - // bodge for now to set markdown state on/off. We probably want a separate - // area for "local" commands which don't hit out to the server. - if (contentText.indexOf("/markdown") === 0) { - ev.preventDefault(); - this.refs.textarea.value = ''; - if (contentText.indexOf("/markdown on") === 0) { - this.markdownEnabled = true; - } - else if (contentText.indexOf("/markdown off") === 0) { - this.markdownEnabled = false; - } - else { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Unknown command", - description: "Usage: /markdown on|off" - }); - } - return; - } - - var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); - if (cmd) { - ev.preventDefault(); - if (!cmd.error) { - this.refs.textarea.value = ''; - } - if (cmd.promise) { - cmd.promise.done(function() { - console.log("Command success."); - }, function(err) { - console.error("Command failure: %s", err); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Server error", - description: "Server unavailable, overloaded, or something else went wrong.", - }); - }); - } - else if (cmd.error) { - console.error(cmd.error); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Command error", - description: cmd.error - }); - } - return; - } - - var isEmote = /^\/me( |$)/i.test(contentText); - var sendMessagePromise; - - if (isEmote) { - contentText = contentText.substring(4); - } - else if (contentText[0] === '/') { - contentText = contentText.substring(1); - } - - let send_markdown = false; - let mdown; - if (this.markdownEnabled) { - mdown = new Markdown(contentText); - send_markdown = !mdown.isPlainText(); - } - - if (send_markdown) { - const htmlText = mdown.toHTML(); - sendMessagePromise = isEmote ? - MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : - MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); - } - else { - if (mdown) contentText = mdown.toPlaintext(); - sendMessagePromise = isEmote ? - MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : - MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); - } - - sendMessagePromise.done(function(res) { - dis.dispatch({ - action: 'message_sent' - }); - }, (e) => onSendMessageFailed(e, this.props.room)); - - this.refs.textarea.value = ''; - this.resizeInput(); - ev.preventDefault(); - }, - - onTypingActivity: function() { - this.isTyping = true; - if (!this.userTypingTimer) { - this.sendTyping(true); - } - this.startUserTypingTimer(); - this.startServerTypingTimer(); - }, - - onFinishedTyping: function() { - this.isTyping = false; - this.sendTyping(false); - this.stopUserTypingTimer(); - this.stopServerTypingTimer(); - }, - - startUserTypingTimer: function() { - this.stopUserTypingTimer(); - var self = this; - this.userTypingTimer = setTimeout(function() { - self.isTyping = false; - self.sendTyping(self.isTyping); - self.userTypingTimer = null; - }, TYPING_USER_TIMEOUT); - }, - - stopUserTypingTimer: function() { - if (this.userTypingTimer) { - clearTimeout(this.userTypingTimer); - this.userTypingTimer = null; - } - }, - - startServerTypingTimer: function() { - if (!this.serverTypingTimer) { - var self = this; - this.serverTypingTimer = setTimeout(function() { - if (self.isTyping) { - self.sendTyping(self.isTyping); - self.startServerTypingTimer(); - } - }, TYPING_SERVER_TIMEOUT / 2); - } - }, - - stopServerTypingTimer: function() { - if (this.serverTypingTimer) { - clearTimeout(this.servrTypingTimer); - this.serverTypingTimer = null; - } - }, - - sendTyping: function(isTyping) { - MatrixClientPeg.get().sendTyping( - this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT - ).done(); - }, - - refreshTyping: function() { - if (this.typingTimeout) { - clearTimeout(this.typingTimeout); - this.typingTimeout = null; - } - }, - - onInputClick: function(ev) { - this.refs.textarea.focus(); - }, - - render: function() { - return ( -
    -