diff --git a/.eslintignore b/.eslintignore index c4f7298047..e453170087 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,4 @@ src/component-index.js test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ +test/end-to-end-tests/element/ test/end-to-end-tests/synapse/ diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index db90d26ba7..1c0a3d1254 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -12,5 +12,5 @@ test/components/views/dialogs/InteractiveAuthDialog-test.js test/mock-clock.js src/component-index.js test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ +test/end-to-end-tests/element/ test/end-to-end-tests/synapse/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c5ba81ed..5aac4e2974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ +Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0) + + * Upgrade JS SDK to 9.2.0 + * [Release] Fix encrypted video playback in Chrome-based browsers + [\#5431](https://github.com/matrix-org/matrix-react-sdk/pull/5431) + +Changes in [3.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0-rc.1) (2020-11-18) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0...v3.9.0-rc.1) + + * Upgrade JS SDK to 9.2.0-rc.1 + * Translations update from Weblate + [\#5429](https://github.com/matrix-org/matrix-react-sdk/pull/5429) + * Fix message search summary text + [\#5428](https://github.com/matrix-org/matrix-react-sdk/pull/5428) + * Shrink new room intro top margin to half for encryption bubble tile + [\#5427](https://github.com/matrix-org/matrix-react-sdk/pull/5427) + * Small delight tweaks to improve rough corners in the app + [\#5418](https://github.com/matrix-org/matrix-react-sdk/pull/5418) + * Fix DM logic to always pick a more reliable DM room + [\#5424](https://github.com/matrix-org/matrix-react-sdk/pull/5424) + * Update styling of the Analytics toast + [\#5408](https://github.com/matrix-org/matrix-react-sdk/pull/5408) + * Fix vertical centering of the Homepage and button layout + [\#5420](https://github.com/matrix-org/matrix-react-sdk/pull/5420) + * Fix BaseAvatar sometimes messing up and duplicating the url + [\#5422](https://github.com/matrix-org/matrix-react-sdk/pull/5422) + * Disable buttons when required by MSC2790 + [\#5412](https://github.com/matrix-org/matrix-react-sdk/pull/5412) + * Fix drag drop file to upload for Safari + [\#5414](https://github.com/matrix-org/matrix-react-sdk/pull/5414) + * Fix poorly i18n'd string + [\#5416](https://github.com/matrix-org/matrix-react-sdk/pull/5416) + * Fix the feedback not closing without feedback/countly + [\#5417](https://github.com/matrix-org/matrix-react-sdk/pull/5417) + * Fix New Room Intro invite to this room button + [\#5419](https://github.com/matrix-org/matrix-react-sdk/pull/5419) + * Change how we expose Role in User Info and hide in DMs + [\#5413](https://github.com/matrix-org/matrix-react-sdk/pull/5413) + * Disallow sending of empty messages + [\#5390](https://github.com/matrix-org/matrix-react-sdk/pull/5390) + * hide some validation tooltips if fields are valid. + [\#5403](https://github.com/matrix-org/matrix-react-sdk/pull/5403) + * Improvements around new room empty space interactions + [\#5398](https://github.com/matrix-org/matrix-react-sdk/pull/5398) + * Implement call hold + [\#5366](https://github.com/matrix-org/matrix-react-sdk/pull/5366) + * Fix Skeleton UI showing up when not intended. + [\#5407](https://github.com/matrix-org/matrix-react-sdk/pull/5407) + * Close context menu when user clicks the Home button + [\#5406](https://github.com/matrix-org/matrix-react-sdk/pull/5406) + * Skip e2ee warn logout prompt if user has no megolm sessions to lose + [\#5410](https://github.com/matrix-org/matrix-react-sdk/pull/5410) + * Allow country names to be translated + [\#5405](https://github.com/matrix-org/matrix-react-sdk/pull/5405) + * Support thirdparty lookup for phone numbers + [\#5396](https://github.com/matrix-org/matrix-react-sdk/pull/5396) + * Change "Password" to "New Password" + [\#5371](https://github.com/matrix-org/matrix-react-sdk/pull/5371) + * Add customisation point for dehydration key + [\#5397](https://github.com/matrix-org/matrix-react-sdk/pull/5397) + * Rebrand Riot -> Element in the permalink classes + [\#5386](https://github.com/matrix-org/matrix-react-sdk/pull/5386) + * Invite / Create DM UX tweaks + [\#5387](https://github.com/matrix-org/matrix-react-sdk/pull/5387) + * Tweaks to toasts and post-registration landing + [\#5383](https://github.com/matrix-org/matrix-react-sdk/pull/5383) + Changes in [3.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0) (2020-11-09) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0) diff --git a/package.json b/package.json index a015728256..1e778f9875 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.8.0", + "version": "3.9.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -50,7 +50,7 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" }, "dependencies": { "@babel/runtime": "^7.10.5", @@ -76,10 +76,12 @@ "highlight.js": "^10.1.2", "html-entities": "^1.3.1", "is-ip": "^2.0.0", + "katex": "^0.12.0", + "cheerio": "^1.0.0-rc.3", "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.8", + "matrix-widget-api": "^0.1.0-beta.10", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/res/css/_common.scss b/res/css/_common.scss index 0317e89d20..7ab88d6f02 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -60,6 +60,10 @@ pre, code { color: $accent-color; } +.text-muted { + color: $muted-fg-color; +} + b { // On Firefox, the default weight for `` is `bolder` which results in no bold // effect since we only have specific weights of our fonts available. @@ -364,6 +368,11 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_buttons { margin-top: 20px; text-align: right; + + .mx_Dialog_buttons_additive { + // The consumer is responsible for positioning their elements. + float: left; + } } /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied diff --git a/res/css/_components.scss b/res/css/_components.scss index 002f95119d..707f73247d 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -45,8 +45,6 @@ @import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_LanguageSelector.scss"; @import "./views/auth/_PassphraseField.scss"; -@import "./views/auth/_ServerConfig.scss"; -@import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_Welcome.scss"; @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @@ -78,11 +76,13 @@ @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; +@import "./views/dialogs/_RegistrationEmailPromptDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss"; +@import "./views/dialogs/_ServerPickerDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @@ -91,6 +91,7 @@ @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; @@ -123,6 +124,8 @@ @import "./views/elements/_RichText.scss"; @import "./views/elements/_RoleButton.scss"; @import "./views/elements/_RoomAliasField.scss"; +@import "./views/elements/_SSOButtons.scss"; +@import "./views/elements/_ServerPicker.scss"; @import "./views/elements/_Slider.scss"; @import "./views/elements/_Spinner.scss"; @import "./views/elements/_StyledCheckbox.scss"; @@ -227,6 +230,7 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; +@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 2d5359c0eb..5bf2aee3ae 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -19,57 +19,6 @@ limitations under the License. min-height: 50px; } -/* position the indicator in the same place horizontally as .mx_EventTile_avatar. */ -.mx_RoomStatusBar_indicator { - padding-left: 17px; - padding-right: 12px; - margin-left: -73px; - margin-top: 15px; - float: left; - width: 24px; - text-align: center; -} - -.mx_RoomStatusBar_callBar { - height: 50px; - line-height: $font-50px; -} - -.mx_RoomStatusBar_placeholderIndicator span { - color: $primary-fg-color; - opacity: 0.5; - position: relative; - top: -4px; - /* - animation-duration: 1s; - animation-name: bounce; - animation-direction: alternate; - animation-iteration-count: infinite; - */ -} - -.mx_RoomStatusBar_placeholderIndicator span:nth-child(1) { - animation-delay: 0.3s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(2) { - animation-delay: 0.6s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(3) { - animation-delay: 0.9s; -} - -@keyframes bounce { - from { - opacity: 0.5; - top: 0; - } - - to { - opacity: 0.2; - top: -3px; - } -} - .mx_RoomStatusBar_typingIndicatorAvatars { width: 52px; margin-top: -1px; @@ -162,11 +111,6 @@ limitations under the License. margin-top: 10px; } - .mx_RoomStatusBar_callBar { - height: 40px; - line-height: $font-40px; - } - .mx_RoomStatusBar_typingBar { height: 40px; line-height: $font-40px; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 6a352d46a3..84c21364ce 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -231,9 +231,29 @@ limitations under the License. justify-content: center; } + &.mx_UserMenu_contextMenu_guestPrompts, &.mx_UserMenu_contextMenu_hostingLink { padding-top: 0; } + + &.mx_UserMenu_contextMenu_guestPrompts { + display: inline-block; + + > span { + font-weight: 600; + display: block; + + & + span { + margin-top: 8px; + } + } + + .mx_AccessibleButton_kind_link { + font-weight: normal; + font-size: inherit; + padding: 0; + } + } } .mx_IconizedContextMenu_icon { diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 02436833a2..a8cb7d7eee 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -18,7 +18,7 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; width: 100%; - margin-top: 35px; + margin-top: 24px; margin-bottom: 24px; box-sizing: border-box; text-align: center; @@ -33,12 +33,6 @@ limitations under the License. cursor: default; } -.mx_AuthBody a.mx_Login_sso_link:link, -.mx_AuthBody a.mx_Login_sso_link:hover, -.mx_AuthBody a.mx_Login_sso_link:visited { - color: $button-primary-fg-color; -} - .mx_Login_loader { display: inline; position: relative; @@ -91,6 +85,8 @@ limitations under the License. } div.mx_AccessibleButton_kind_link.mx_Login_forgot { + display: block; + margin: 0 auto; // style it as a link font-size: inherit; padding: 0; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 0ba0d10e06..8f0c758e7a 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -37,6 +37,10 @@ limitations under the License. color: $authpage-primary-color; } + h3.mx_AuthBody_centered { + text-align: center; + } + a:link, a:hover, a:visited { @@ -96,12 +100,6 @@ limitations under the License. } } -.mx_AuthBody_editServerDetails { - padding-left: 1em; - font-size: $font-12px; - font-weight: normal; -} - .mx_AuthBody_fieldRow { display: flex; margin-bottom: 10px; @@ -146,6 +144,14 @@ limitations under the License. display: block; text-align: center; width: 100%; + + > a { + font-weight: $font-semi-bold; + } +} + +.mx_SSOButtons + .mx_AuthBody_changeFlow { + margin-top: 24px; } .mx_AuthBody_spinner { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 05cddf2c48..0a5ac9b2bc 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -14,6 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InteractiveAuthEntryComponents_emailWrapper { + padding-right: 60px; + position: relative; + margin-top: 32px; + margin-bottom: 32px; + + &::before, &::after { + position: absolute; + width: 116px; + height: 116px; + content: ""; + right: -10px; + } + + &::before { + background-color: rgba(244, 246, 250, 0.91); + border-radius: 50%; + top: -20px; + } + + &::after { + background-image: url('$(res)/img/element-icons/email-prompt.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + top: -25px; + } +} + .mx_InteractiveAuthEntryComponents_msisdnWrapper { text-align: center; } diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss deleted file mode 100644 index a7e0057ab3..0000000000 --- a/res/css/views/auth/_ServerConfig.scss +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_ServerConfig_help:link { - opacity: 0.8; -} - -.mx_ServerConfig_error { - display: block; - color: $warning-color; -} - -.mx_ServerConfig_identityServer { - transform: scaleY(0); - transform-origin: top; - transition: transform 0.25s; - - &.mx_ServerConfig_identityServer_shown { - transform: scaleY(1); - } -} diff --git a/res/css/views/auth/_ServerTypeSelector.scss b/res/css/views/auth/_ServerTypeSelector.scss deleted file mode 100644 index fbd3d2655d..0000000000 --- a/res/css/views/auth/_ServerTypeSelector.scss +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_ServerTypeSelector { - display: flex; - margin-bottom: 28px; -} - -.mx_ServerTypeSelector_type { - margin: 0 5px; -} - -.mx_ServerTypeSelector_type:first-child { - margin-left: 0; -} - -.mx_ServerTypeSelector_type:last-child { - margin-right: 0; -} - -.mx_ServerTypeSelector_label { - text-align: center; - font-weight: 600; - color: $authpage-primary-color; - margin: 8px 0; -} - -.mx_ServerTypeSelector_type .mx_AccessibleButton { - padding: 10px; - border: 1px solid $input-border-color; - border-radius: 4px; -} - -.mx_ServerTypeSelector_type.mx_ServerTypeSelector_type_selected .mx_AccessibleButton { - border-color: $input-valid-border-color; -} - -.mx_ServerTypeSelector_logo { - display: flex; - justify-content: center; - height: 18px; - margin-bottom: 12px; - font-weight: 600; - color: $authpage-primary-color; -} - -.mx_ServerTypeSelector_logo > div { - display: flex; - width: 70%; - align-items: center; - justify-content: space-evenly; -} - -.mx_ServerTypeSelector_description { - font-size: $font-10px; -} diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7ac..cbddd97e18 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -41,7 +41,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; - border-radius: 40px; + border-radius: 125px; vertical-align: top; background-color: $avatar-bg-color; } diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss similarity index 73% rename from src/components/views/avatars/PulsedAvatar.tsx rename to res/css/views/dialogs/_RegistrationEmailPromptDialog.scss index b4e876b9f6..31fc6d7a04 100644 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +.mx_RegistrationEmailPromptDialog { + width: 417px; -interface IProps { + .mx_Dialog_content { + margin-bottom: 24px; + color: $tertiary-fg-color; + } + + .mx_Dialog_primary { + width: 100%; + } } - -const PulsedAvatar: React.FC = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/res/css/views/dialogs/_ServerPickerDialog.scss b/res/css/views/dialogs/_ServerPickerDialog.scss new file mode 100644 index 0000000000..b01b49d7af --- /dev/null +++ b/res/css/views/dialogs/_ServerPickerDialog.scss @@ -0,0 +1,78 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_ServerPickerDialog { + width: 468px; + box-sizing: border-box; + + .mx_Dialog_content { + margin-bottom: 0; + + > p { + color: $secondary-fg-color; + font-size: $font-14px; + margin: 16px 0; + + &:first-of-type { + margin-bottom: 40px; + } + + &:last-of-type { + margin: 0 24px 24px; + } + } + + > h4 { + font-size: $font-15px; + font-weight: $font-semi-bold; + color: $secondary-fg-color; + margin-left: 8px; + } + + > a { + color: $accent-color; + margin-left: 8px; + } + } + + .mx_ServerPickerDialog_otherHomeserverRadio { + input[type="radio"] + div { + margin-top: auto; + margin-bottom: auto; + } + } + + .mx_ServerPickerDialog_otherHomeserver { + border-top: none; + border-left: none; + border-right: none; + border-radius: unset; + + > input { + padding-left: 0; + } + + > label { + margin-left: 0; + } + } + + .mx_AccessibleButton_kind_primary { + width: calc(100% - 64px); + margin: 0 8px; + padding: 15px 18px; + } +} diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss new file mode 100644 index 0000000000..176919b84c --- /dev/null +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + + +.mx_WidgetCapabilitiesPromptDialog { + .text-muted { + font-size: $font-12px; + } + + .mx_Dialog_content { + margin-bottom: 16px; + } + + .mx_WidgetCapabilitiesPromptDialog_cap { + margin-top: 20px; + font-size: $font-15px; + line-height: $font-15px; + + .mx_WidgetCapabilitiesPromptDialog_byline { + color: $muted-fg-color; + margin-left: 26px; + font-size: $font-12px; + line-height: $font-12px; + } + } + + .mx_Dialog_buttons { + margin-top: 40px; // double normal + } + + .mx_SettingsFlag { + line-height: calc($font-14px + 7px + 7px); // 7px top & bottom padding + color: $muted-fg-color; + font-size: $font-12px; + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + + // downsize the switch + ball + width: $font-32px; + height: $font-15px; + + + &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { + left: calc(100% - $font-15px); + } + + .mx_ToggleSwitch_ball { + width: $font-15px; + height: $font-15px; + border-radius: $font-15px; + } + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss new file mode 100644 index 0000000000..f762468c7f --- /dev/null +++ b/res/css/views/elements/_SSOButtons.scss @@ -0,0 +1,49 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_SSOButtons { + display: flex; + justify-content: center; + + .mx_SSOButton { + position: relative; + width: 100%; + padding-left: 32px; + padding-right: 32px; + + > img { + object-fit: contain; + position: absolute; + left: 8px; + top: 4px; + } + } + + .mx_SSOButton_mini { + box-sizing: border-box; + width: 50px; // 48px + 1px border on all sides + height: 50px; // 48px + 1px border on all sides + + > img { + left: 12px; + top: 12px; + } + + & + .mx_SSOButton_mini { + margin-left: 24px; + } + } +} diff --git a/res/css/views/elements/_ServerPicker.scss b/res/css/views/elements/_ServerPicker.scss new file mode 100644 index 0000000000..ae1e445a9f --- /dev/null +++ b/res/css/views/elements/_ServerPicker.scss @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_ServerPicker { + margin-bottom: 14px; + border-bottom: 1px solid rgba(141, 151, 165, 0.2); + display: grid; + grid-template-columns: auto min-content; + grid-template-rows: auto auto auto; + font-size: $font-14px; + line-height: $font-20px; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 20px; + grid-column: 1; + grid-row: 1; + } + + .mx_ServerPicker_help { + width: 20px; + height: 20px; + background-color: $icon-button-color; + border-radius: 10px; + grid-column: 2; + grid-row: 1; + margin-left: auto; + text-align: center; + color: #ffffff; + font-size: 16px; + position: relative; + + &::before { + content: ''; + width: 24px; + height: 24px; + position: absolute; + top: -2px; + left: -2px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/i.svg'); + background: #ffffff; + } + } + + .mx_ServerPicker_server { + color: $primary-fg-color; + grid-column: 1; + grid-row: 2; + margin-bottom: 16px; + } + + .mx_ServerPicker_change { + padding: 0; + font-size: inherit; + grid-column: 2; + grid-row: 2; + } + + .mx_ServerPicker_desc { + margin-top: -12px; + color: $tertiary-fg-color; + grid-column: 1 / 2; + grid-row: 3; + margin-bottom: 16px; + } +} + +.mx_ServerPicker_helpDialog { + .mx_Dialog_content { + width: 456px; + } +} diff --git a/res/css/views/messages/_MVideoBody.scss b/res/css/views/messages/_MVideoBody.scss index 3b05c53f34..ac3491bc8f 100644 --- a/res/css/views/messages/_MVideoBody.scss +++ b/res/css/views/messages/_MVideoBody.scss @@ -18,5 +18,6 @@ span.mx_MVideoBody { video.mx_MVideoBody { max-width: 100%; height: auto; + border-radius: 4px; } } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 958d718b11..ece547d02b 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -186,6 +186,7 @@ $irc-line-height: $font-18px; overflow: hidden; text-overflow: ellipsis; min-width: var(--name-width); + text-align: end; } } } diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index af72a0dd69..4322ba341c 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_NewRoomIntro { - margin: 80px 0 48px 64px; + margin: 40px 0 48px 64px; .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) { &::before, &::after { diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index 78e7307bc0..6ea99585d2 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -33,7 +33,6 @@ limitations under the License. div:first-child { font-weight: $font-semi-bold; - margin-bottom: 8px; } .mx_AccessibleButton { @@ -41,6 +40,7 @@ limitations under the License. position: relative; padding: 0 0 0 24px; font-size: inherit; + margin-top: 8px; &::before { content: ''; @@ -53,6 +53,13 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; + } + + &.mx_RoomList_explorePrompt_startChat::before { + mask-image: url('$(res)/img/element-icons/feedback.svg'); + } + + &.mx_RoomList_explorePrompt_explore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } } diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index 94f42efe83..da86797f42 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -22,7 +22,7 @@ iframe { // Sticker picker depends on the fixed height previously used for all tiles - height: 273px; + height: 283px; // height of the popout minus the AppTile menu bar } } diff --git a/src/RoomListSorter.js b/res/css/views/toasts/_AnalyticsToast.scss similarity index 57% rename from src/RoomListSorter.js rename to res/css/views/toasts/_AnalyticsToast.scss index 0ff37a6af2..fdbe7f1c76 100644 --- a/src/RoomListSorter.js +++ b/res/css/views/toasts/_AnalyticsToast.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,18 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +.mx_AnalyticsToast { + .mx_AccessibleButton_kind_danger { + background: none; + color: $accent-color; + } -function tsOfNewestEvent(room) { - if (room.timeline.length) { - return room.timeline[room.timeline.length - 1].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; + .mx_AccessibleButton_kind_primary { + background: $accent-color; + color: #ffffff; } } - -export function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a, b) { - return tsOfNewestEvent(b) - tsOfNewestEvent(a); - }); -} diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 2aeaaa87dc..e62c354491 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -15,87 +15,196 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CallView_voice { - background-color: $accent-color; - color: $accent-fg-color; - cursor: pointer; - padding: 6px; - font-weight: bold; +.mx_CallView { + border-radius: 10px; + background-color: $voipcall-plinth-color; + padding-left: 8px; + padding-right: 8px; + // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place + pointer-events: initial; +} - border-radius: 8px; - min-width: 200px; +.mx_CallView_large { + padding-bottom: 10px; - display: flex; - align-items: center; - - img { - margin: 4px; - margin-right: 10px; - } - - > div { - display: flex; - flex-direction: column; - // Hacky vertical align - padding-top: 3px; - } - - > div > p, - > div > h1 { - padding: 0; - margin: 0; - font-size: $font-13px; - line-height: $font-15px; - } - - > div > p { - font-weight: bold; - } - - > * { - flex-grow: 0; - flex-shrink: 0; + .mx_CallView_voice { + height: 360px; } } -.mx_CallView_hangup { - position: absolute; +.mx_CallView_pip { + width: 320px; - right: 8px; - bottom: 10px; - - height: 35px; - width: 35px; - - border-radius: 35px; - - background-color: $notice-primary-color; - - z-index: 101; - - cursor: pointer; - - &::before { - content: ''; - position: absolute; - - height: 20px; - width: 20px; - - top: 6.5px; - left: 7.5px; - - mask: url('$(res)/img/hangup.svg'); - mask-size: contain; - background-size: contain; - - background-color: $primary-fg-color; + .mx_CallView_voice { + height: 180px; } } +.mx_CallView_voice { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: $inverted-bg-color; +} + .mx_CallView_video { width: 100%; position: relative; z-index: 30; } +.mx_CallView_header { + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; + + .mx_BaseAvatar { + margin-right: 12px; + } +} + +.mx_CallView_header_callType { + font-weight: bold; + vertical-align: middle; +} + +.mx_CallView_header_controls { + margin-left: auto; +} + +.mx_CallView_header_button { + display: inline-block; + vertical-align: middle; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: middle; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } +} + +.mx_CallView_header_button_fullscreen { + &::before { + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + } +} + +.mx_CallView_header_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } +} + +.mx_CallView_header_roomName { + font-weight: bold; + font-size: 12px; + line-height: initial; +} + +.mx_CallView_header_callTypeSmall { + font-size: 12px; + color: $secondary-fg-color; + line-height: initial; +} + +.mx_CallView_header_phoneIcon { + display: inline-block; + margin-right: 6px; + height: 16px; + width: 16px; + vertical-align: middle; + + &::before { + content: ''; + display: inline-block; + vertical-align: top; + + height: 16px; + width: 16px; + background-color: $warning-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } +} + +.mx_CallView_callControls { + position: absolute; + display: flex; + justify-content: center; + bottom: 5px; + width: 100%; + opacity: 1; + transition: opacity 0.5s; +} + +.mx_CallView_callControls_hidden { + opacity: 0.001; // opacity 0 can cause a re-layout + pointer-events: none; +} + +.mx_CallView_callControls_button { + cursor: pointer; + margin-left: 8px; + margin-right: 8px; + + + &::before { + content: ''; + display: inline-block; + + height: 48px; + width: 48px; + + background-repeat: no-repeat; + background-size: contain; + background-position: center; + } +} + +.mx_CallView_callControls_button_micOn { + &::before { + background-image: url('$(res)/img/voip/mic-on.svg'); + } +} + +.mx_CallView_callControls_button_micOff { + &::before { + background-image: url('$(res)/img/voip/mic-off.svg'); + } +} + +.mx_CallView_callControls_button_vidOn { + &::before { + background-image: url('$(res)/img/voip/vid-on.svg'); + } +} + +.mx_CallView_callControls_button_vidOff { + &::before { + background-image: url('$(res)/img/voip/vid-off.svg'); + } +} + +.mx_CallView_callControls_button_hangup { + &::before { + background-image: url('$(res)/img/voip/hangup.svg'); + } +} + +.mx_CallView_callControls_button_invisible { + visibility: hidden; + pointer-events: none; + position: absolute; +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index e5e3587dac..931410dba3 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoFeed video { - width: 100%; -} - .mx_VideoFeed_remote { width: 100%; background-color: #000; @@ -28,16 +24,12 @@ limitations under the License. width: 25%; height: 25%; position: absolute; - left: 10px; - bottom: 10px; + right: 10px; + top: 10px; z-index: 100; + border-radius: 4px; } -.mx_VideoFeed_local video { - width: auto; - height: 100%; -} - -.mx_VideoFeed_mirror video { +.mx_VideoFeed_mirror { transform: scale(-1, 1); } diff --git a/res/img/element-icons/call/expand.svg b/res/img/element-icons/call/expand.svg new file mode 100644 index 0000000000..91ef4d8a76 --- /dev/null +++ b/res/img/element-icons/call/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/video-muted.svg b/res/img/element-icons/call/video-muted.svg deleted file mode 100644 index d2aea71d11..0000000000 --- a/res/img/element-icons/call/video-muted.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/element-icons/call/voice-muted.svg b/res/img/element-icons/call/voice-muted.svg deleted file mode 100644 index 32abafb04a..0000000000 --- a/res/img/element-icons/call/voice-muted.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/call/voice-unmuted.svg b/res/img/element-icons/call/voice-unmuted.svg deleted file mode 100644 index e664080217..0000000000 --- a/res/img/element-icons/call/voice-unmuted.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/email-prompt.svg b/res/img/element-icons/email-prompt.svg new file mode 100644 index 0000000000..19b8f82449 --- /dev/null +++ b/res/img/element-icons/email-prompt.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/res/img/element-icons/i.svg b/res/img/element-icons/i.svg new file mode 100644 index 0000000000..6674f1ed8d --- /dev/null +++ b/res/img/element-icons/i.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/room/in-call.svg b/res/img/element-icons/room/in-call.svg deleted file mode 100644 index 0e574faa84..0000000000 --- a/res/img/element-icons/room/in-call.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/hangup.svg b/res/img/hangup.svg deleted file mode 100644 index be038d2b30..0000000000 --- a/res/img/hangup.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - Fill 72 + Path 98 - Created with Sketch. - - - - - - - - - - \ No newline at end of file diff --git a/res/img/voip/hangup.svg b/res/img/voip/hangup.svg new file mode 100644 index 0000000000..dfb20bd519 --- /dev/null +++ b/res/img/voip/hangup.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/mic-off.svg b/res/img/voip/mic-off.svg new file mode 100644 index 0000000000..6409f1fd07 --- /dev/null +++ b/res/img/voip/mic-off.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/mic-on.svg b/res/img/voip/mic-on.svg new file mode 100644 index 0000000000..3493b3c581 --- /dev/null +++ b/res/img/voip/mic-on.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/vid-off.svg b/res/img/voip/vid-off.svg new file mode 100644 index 0000000000..199d97ab97 --- /dev/null +++ b/res/img/voip/vid-off.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/vid-on.svg b/res/img/voip/vid-on.svg new file mode 100644 index 0000000000..d8146d01d3 --- /dev/null +++ b/res/img/voip/vid-on.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 6350439a4f..08fe2e9f57 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -108,6 +108,9 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; +// this probably shouldn't have it's own colour +$voipcall-plinth-color: #21262c; + // ******************** $theme-button-bg-color: #e3e8f0; @@ -214,7 +217,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); /* align images in buttons (eg spinners) */ vertical-align: middle; border: 0px; - border-radius: 4px; + border-radius: 8px; font-family: $font-family; font-size: $font-14px; color: $button-fg-color; @@ -274,6 +277,10 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); background-color: #080808; } } + + blockquote { + color: #919191; + } } // diff highlight colors diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 716d8c7385..3e3c299af9 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -105,6 +105,9 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; +// this probably shouldn't have it's own colour +$voipcall-plinth-color: #f2f5f8; + // ******************** $theme-button-bg-color: #e3e8f0; @@ -205,7 +208,7 @@ $composer-shadow-color: tranparent; /* align images in buttons (eg spinners) */ vertical-align: middle; border: 0px; - border-radius: 4px; + border-radius: 8px; font-family: $font-family; font-size: $font-14px; color: $button-fg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 8c42c5c97f..085d6d7f10 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -172,6 +172,9 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; +// this probably shouldn't have it's own colour +$voipcall-plinth-color: #f2f5f8; + // ******************** $theme-button-bg-color: #e3e8f0; @@ -328,7 +331,7 @@ $composer-shadow-color: tranparent; /* align images in buttons (eg spinners) */ vertical-align: middle; border: 0px; - border-radius: 4px; + border-radius: 8px; font-family: $font-family; font-size: $font-14px; color: $button-fg-color; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 5437a6de1c..4cfeeae05e 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -166,6 +166,9 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; +// this probably shouldn't have it's own colour +$voipcall-plinth-color: #f2f5f8; + // ******************** $theme-button-bg-color: #e3e8f0; @@ -332,7 +335,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04); /* align images in buttons (eg spinners) */ vertical-align: middle; border: 0px; - border-radius: 4px; + border-radius: 8px; font-family: $font-family; font-size: $font-14px; color: $button-fg-color; diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile index c153d11cc7..5351291f29 100644 --- a/scripts/ci/Dockerfile +++ b/scripts/ci/Dockerfile @@ -1,7 +1,7 @@ # Update on docker hub with the following commands in the directory of this file: -# docker build -t matrixdotorg/riotweb-ci-e2etests-env:latest . +# docker build -t vectorim/element-web-ci-e2etests-env:latest . # docker log -# docker push matrixdotorg/riotweb-ci-e2etests-env:latest +# docker push vectorim/element-web-ci-e2etests-env:latest FROM node:10 RUN apt-get update RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime diff --git a/scripts/ci/app-tests.sh b/scripts/ci/app-tests.sh new file mode 100755 index 0000000000..97e54dce66 --- /dev/null +++ b/scripts/ci/app-tests.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# +# script which is run by the CI build (after `yarn test`). +# +# clones element-web develop and runs the tests against our version of react-sdk. + +set -ev + +scripts/ci/layered.sh +cd element-web +yarn build:genfiles # so the tests can run. Faster version of `build` +yarn test diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh index 7a62c03b12..edb8870d8e 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -2,7 +2,7 @@ # # script which is run by the CI build (after `yarn test`). # -# clones riot-web develop and runs the tests against our version of react-sdk. +# clones element-web develop and runs the tests against our version of react-sdk. set -ev @@ -14,20 +14,20 @@ handle_error() { trap 'handle_error' ERR echo "--- Building Element" -scripts/ci/layered-riot-web.sh -cd ../riot-web -riot_web_dir=`pwd` +scripts/ci/layered.sh +cd element-web +element_web_dir=`pwd` CI_PACKAGE=true yarn build -cd ../matrix-react-sdk +cd .. # run end to end tests pushd test/end-to-end-tests -ln -s $riot_web_dir riot/riot-web +ln -s $element_web_dir element/element-web # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh # CHROME_PATH=$(which google-chrome-stable) ./run.sh echo "--- Install synapse & other dependencies" ./install.sh -# install static webserver to server symlinked local copy of riot -./riot/install-webserver.sh +# install static webserver to server symlinked local copy of element +./element/install-webserver.sh rm -r logs || true mkdir logs echo "+++ Running end-to-end tests" diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh deleted file mode 100755 index f58794b451..0000000000 --- a/scripts/ci/layered-riot-web.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -# Creates an environment similar to one that riot-web would expect for -# development. This means going one directory up (and assuming we're in -# a directory like /workdir/matrix-react-sdk) and putting riot-web and -# the js-sdk there. - -cd ../ # Assume we're at something like /workdir/matrix-react-sdk - -# Set up the js-sdk first -matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk -pushd matrix-js-sdk -yarn link -yarn install -popd - -# Now set up the react-sdk -pushd matrix-react-sdk -yarn link matrix-js-sdk -yarn link -yarn install -popd - -# Finally, set up riot-web -matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web -pushd riot-web -yarn link matrix-js-sdk -yarn link matrix-react-sdk -yarn install -yarn build:res -popd diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh new file mode 100755 index 0000000000..306f9c9974 --- /dev/null +++ b/scripts/ci/layered.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Creates a layered environment with the full repo for the app and SDKs cloned +# and linked. + +# Note that this style is different from the recommended developer setup: this +# file nests js-sdk and element-web inside react-sdk, while the local +# development setup places them all at the same level. We are nesting them here +# because some CI systems do not allow moving to a directory above the checkout +# for the primary repo (react-sdk in this case). + +# Set up the js-sdk first +scripts/fetchdep.sh matrix-org matrix-js-sdk +pushd matrix-js-sdk +yarn link +yarn install +popd + +# Now set up the react-sdk +yarn link matrix-js-sdk +yarn link +yarn install + +# Finally, set up element-web +scripts/fetchdep.sh vector-im element-web +pushd element-web +yarn link matrix-js-sdk +yarn link matrix-react-sdk +yarn install +yarn build:res +popd diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh deleted file mode 100755 index 337c0fe6c3..0000000000 --- a/scripts/ci/riot-unit-tests.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# -# script which is run by the CI build (after `yarn test`). -# -# clones riot-web develop and runs the tests against our version of react-sdk. - -set -ev - -scripts/ci/layered-riot-web.sh -cd ../riot-web -yarn build:genfiles # so the tests can run. Faster version of `build` -yarn test diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh new file mode 120000 index 0000000000..199dfb58fd --- /dev/null +++ b/scripts/ci/riot-unit-tests.sh @@ -0,0 +1 @@ +app-tests.sh \ No newline at end of file diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 0142305797..850eef25ec 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -34,7 +34,7 @@ elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then fi # Try the target branch of the push or PR. clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH -# Try the current branch from Jenkins. -clone $deforg $defrepo `"echo $GIT_BRANCH" | sed -e 's/^origin\///'` +# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds) +clone $deforg $defrepo $HEAD # Use the default branch as the last resort. clone $deforg $defrepo $defbranch diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 0a1f06f0b3..4f7c7126e9 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -248,15 +248,16 @@ export default abstract class BasePlatform { * @param {MatrixClient} mxClient the matrix client using which we should start the flow * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO. * @param {string} fragmentAfterLogin the hash to pass to the app during sso callback. + * @param {string} idpId The ID of the Identity Provider being targeted, optional. */ - startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) { + startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) { // persist hs url and is url for when the user is returned to the app with the login token localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); if (mxClient.getIdentityServerUrl()) { localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); } const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); - window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO + window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO } onKeyDown(ev: KeyboardEvent): boolean { diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 710eded2cd..b5f696008d 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -79,6 +79,8 @@ import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call"; import Analytics from './Analytics'; import CountlyAnalytics from "./CountlyAnalytics"; +import {UIFeature} from "./settings/UIFeature"; +import { CallError } from "matrix-js-sdk/src/webrtc/call"; enum AudioID { Ring = 'ringAudio', @@ -124,7 +126,7 @@ export default class CallHandler { return window.mxCallHandler; } - constructor() { + start() { dis.register(this.onAction); // add empty handlers for media actions, otherwise the media keys // end up causing the audio elements with our ring/ringback etc @@ -137,6 +139,27 @@ export default class CallHandler { navigator.mediaSession.setActionHandler('previoustrack', function() {}); navigator.mediaSession.setActionHandler('nexttrack', function() {}); } + + if (SettingsStore.getValue(UIFeature.Voip)) { + MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming); + } + } + + stop() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener('Call.incoming', this.onCallIncoming); + } + } + + private onCallIncoming = (call) => { + // we dispatch this synchronously to make sure that the event + // handlers on the call are set up immediately (so that if + // we get an immediate hangup, we don't get a stuck call) + dis.dispatch({ + action: 'incoming_call', + call: call, + }, true); } getCallForRoom(roomId: string): MatrixCall { @@ -204,11 +227,17 @@ export default class CallHandler { } private setCallListeners(call: MatrixCall) { - call.on(CallEvent.Error, (err) => { + call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; - Analytics.trackEvent('voip', 'callError', 'error', err); + Analytics.trackEvent('voip', 'callError', 'error', err.toString()); console.error("Call error:", err); + + if (err.code === CallErrorCode.NoUserMedia) { + this.showMediaCaptureError(call); + return; + } + if ( MatrixClientPeg.get().getTurnServers().length === 0 && SettingsStore.getValue("fallbackICEServerAllowed") === null @@ -277,8 +306,9 @@ export default class CallHandler { Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { title, description, }); - } else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) { - this.play(AudioID.Busy); + } else if ( + call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting + ) { Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { title: _t("Answered Elsewhere"), description: _t("The call was answered on another device."), @@ -355,6 +385,34 @@ export default class CallHandler { }, null, true); } + private showMediaCaptureError(call: MatrixCall) { + let title; + let description; + + if (call.type === CallType.Voice) { + title = _t("Unable to access microphone"); + description =
+ {_t( + "Call failed because no microphone could not be accessed. " + + "Check that a microphone is plugged in and set up correctly.", + )} +
; + } else if (call.type === CallType.Video) { + title = _t("Unable to access webcam / microphone"); + description =
+ {_t("Call failed because no webcam or microphone could not be accessed. Check that:")} +
    +
  • {_t("A microphone and webcam are plugged in and set up correctly")}
  • +
  • {_t("Permission is granted to use the webcam")}
  • +
  • {_t("No other application is using the webcam")}
  • +
+
; + } + + Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, { + title, description, + }, null, true); + } private placeCall( roomId: string, type: PlaceCallType, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 07bfd4858a..2301ad250b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,9 +27,12 @@ import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; +import katex from 'katex'; +import { AllHtmlEntities } from 'html-entities'; +import SettingsStore from './settings/SettingsStore'; +import cheerio from 'cheerio'; import {MatrixClientPeg} from './MatrixClientPeg'; -import SettingsStore from './settings/SettingsStore'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; @@ -240,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { 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', 'data-mx-spoiler', 'style'], // custom to matrix + span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + div: ['data-mx-maths'], a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], @@ -414,6 +418,21 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (isHtmlMessage) { isDisplayedWithHtml = true; safeBody = sanitizeHtml(formattedBody, sanitizeParams); + + if (SettingsStore.getValue("feature_latex_maths")) { + const phtml = cheerio.load(safeBody, + { _useHtmlParser2: true, decodeEntities: false }) + phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { + return katex.renderToString( + AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), + { + throwOnError: false, + displayMode: e.name == 'div', + output: "htmlAndMathml", + }); + }); + safeBody = phtml.html(); + } } } finally { delete sanitizeParams.textFilter; @@ -515,7 +534,6 @@ export function checkBlockNode(node: Node) { case "H6": case "PRE": case "BLOCKQUOTE": - case "DIV": case "P": case "UL": case "OL": @@ -528,6 +546,9 @@ export function checkBlockNode(node: Node) { case "TH": case "TD": return true; + case "DIV": + // don't treat math nodes as block nodes for deserializing + return !(node as HTMLElement).hasAttribute("data-mx-maths"); default: return false; } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 7469624f5c..ac96d59b09 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,8 @@ import {Jitsi} from "./widgets/Jitsi"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import CallHandler from './CallHandler'; +import LifecycleCustomisations from "./customisations/Lifecycle"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -588,9 +590,9 @@ export function logout(): void { if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions - // Also we sometimes want to re-log in a guest session - // if we abort the login - onLoggedOut(); + // Also we sometimes want to re-log in a guest session if we abort the login. + // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. + setImmediate(() => onLoggedOut()); return; } @@ -665,6 +667,7 @@ async function startMatrixClient(startSyncing = true): Promise { DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + CallHandler.sharedInstance().start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting // the thing just wastes CPU cycles, but should result in no actual functionality @@ -714,6 +717,7 @@ export async function onLoggedOut(): Promise { dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); await clearStorage({deleteEverything: true}); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** @@ -760,6 +764,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise; + private flows: Array; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -63,7 +77,6 @@ export default class Login { this.hsUrl = hsUrl; this.isUrl = isUrl; this.fallbackHsUrl = fallbackHsUrl; - this.currentFlowIndex = 0; this.flows = []; this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; this.tempClient = null; // memoize @@ -100,27 +113,13 @@ export default class Login { }); } - public async getFlows(): Promise> { + public async getFlows(): Promise> { const client = this.createTemporaryClient(); const { flows } = await client.loginFlows(); this.flows = flows; - this.currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. return this.flows; } - public chooseFlow(flowIndex): void { - this.currentFlowIndex = flowIndex; - } - - public getCurrentFlowStep(): string { - // technically the flow can have multiple steps, but no one does this - // for login so we can ignore it. - const flowStep = this.flows[this.currentFlowIndex]; - return flowStep ? flowStep.type : null; - } - public loginViaPassword( username: string, phoneCountry: string, diff --git a/src/Markdown.js b/src/Markdown.js index 492450e87d..dc4d442aff 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; function is_allowed_html_tag(node) { + if (node.literal != null && + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { + return true; + } + // Regex won't work for tags with attrs, but we only // allow anyway. const matches = /^<\/?(.*)>$/.exec(node.literal); @@ -30,6 +35,7 @@ function is_allowed_html_tag(node) { const tag = matches[1]; return ALLOWED_HTML_TAGS.indexOf(tag) > -1; } + return false; } diff --git a/src/Modal.tsx b/src/Modal.tsx index 2f761e7393..ab582b9b22 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -147,6 +147,15 @@ export class ModalManager { return this.appendDialogAsync(...rest); } + public closeCurrentModal(reason: string) { + const modal = this.getCurrentModal(); + if (!modal) { + return; + } + modal.closeReason = reason; + modal.close(); + } + private buildModal( prom: Promise, props?: IProps, diff --git a/src/Notifier.ts b/src/Notifier.ts index 1899896f9b..6460be20ad 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -34,6 +34,8 @@ import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import {SettingLevel} from "./settings/SettingLevel"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; +import RoomViewStore from "./stores/RoomViewStore"; +import UserActivity from "./UserActivity"; /* * Dispatches: @@ -376,6 +378,11 @@ export const Notifier = { const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { + if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) { + // don't bother notifying as user was recently active in this room + return; + } + if (this.isEnabled()) { this._displayPopupNotification(ev, room); } diff --git a/src/PasswordReset.js b/src/PasswordReset.js index 9472ddc633..b38a9de960 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -40,10 +40,6 @@ export default class PasswordReset { this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null; } - doesServerRequireIdServerParam() { - return this.client.doesServerRequireIdServerParam(); - } - /** * Attempt to reset the user's password. This will trigger a side-effect of * sending an email to the provided email address. @@ -78,9 +74,6 @@ export default class PasswordReset { sid: this.sessionId, client_secret: this.clientSecret, }; - if (await this.doesServerRequireIdServerParam()) { - creds.id_server = this.identityServerDomain; - } try { await this.client.setPassword({ diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 7eb7f5dbb2..06d3fb04e8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -40,11 +40,11 @@ export function inviteMultipleToRoom(roomId, addrs) { return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } -export function showStartChatInviteDialog() { +export function showStartChatInviteDialog(initialText) { // This dialog handles the room creation internally - we don't need to worry about it. const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Start DM', '', InviteDialog, {kind: KIND_DM}, + 'Start DM', '', InviteDialog, {kind: KIND_DM, initialText}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index d86d88a697..56e9abc0f2 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -455,7 +455,7 @@ function textForWidgetEvent(event) { let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name if (widgetName && widgetName.length > 0) { - widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' '; + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); } // If the widget was removed, its content should be {}, but this is sufficiently diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 58d8124122..48d0eb2ab1 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -257,6 +257,12 @@ const shortcuts: Record = { key: Key.SLASH, }], description: _td("Toggle this dialog"), + }, { + keybinds: [{ + modifiers: [CMD_OR_CTRL, Modifiers.ALT], + key: Key.H, + }], + description: _td("Go to Home View"), }, ], diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 482b9f6da2..bbc4187298 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td( some important links

- You can even use 'img' tags + You can even add images with Matrix URLs

`); diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index d11944e470..68bb4322e6 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -31,10 +31,26 @@ import {UPDATE_EVENT} from "../../stores/AsyncStore"; import {useEventEmitter} from "../../hooks/useEventEmitter"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader"; +import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; -const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); -const onClickExplore = () => dis.fire(Action.ViewRoomDirectory); -const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); +const onClickSendDm = () => { + Analytics.trackEvent('home_page', 'button', 'dm'); + CountlyAnalytics.instance.track("home_page_button", { button: "dm" }); + dis.dispatch({action: 'view_create_chat'}); +}; + +const onClickExplore = () => { + Analytics.trackEvent('home_page', 'button', 'room_directory'); + CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" }); + dis.fire(Action.ViewRoomDirectory); +}; + +const onClickNewRoom = () => { + Analytics.trackEvent('home_page', 'button', 'create_room'); + CountlyAnalytics.instance.track("home_page_button", { button: "create_room" }); + dis.dispatch({action: 'view_create_room'}); +}; interface IProps { justRegistered?: boolean; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index ab5b93794c..ec5afd13f0 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { DragDropContext } from 'react-beautiful-dnd'; -import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; +import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; @@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; // We need to fetch each pinned message individually (if we don't already have it) @@ -392,6 +393,7 @@ class LoggedInView extends React.Component { const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; + const modKey = isMac ? ev.metaKey : ev.ctrlKey; switch (ev.key) { case Key.PAGE_UP: @@ -436,6 +438,16 @@ class LoggedInView extends React.Component { } break; + case Key.H: + if (ev.altKey && modKey) { + dis.dispatch({ + action: 'view_home_page', + }); + Modal.closeCurrentModal("homeKeyboardShortcut"); + handled = true; + } + break; + case Key.ARROW_UP: case Key.ARROW_DOWN: if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 22cd73eff7..4a8d3cc718 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -34,7 +34,6 @@ import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; -import * as RoomListSorter from "../../RoomListSorter"; import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; @@ -48,7 +47,6 @@ import * as Lifecycle from '../../Lifecycle'; // LifecycleStore is not used but does listen to and dispatch actions import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import { getHomePageUrl } from '../../utils/pages'; import createRoom from "../../createRoom"; import {_t, _td, getCurrentLanguage} from '../../languageHandler'; @@ -87,38 +85,37 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; export enum Views { // a special initial state which is only used at startup, while we are // trying to re-animate a matrix client or register as a guest. - LOADING = 0, + LOADING, // we are showing the welcome view - WELCOME = 1, + WELCOME, // we are showing the login view - LOGIN = 2, + LOGIN, // we are showing the registration view - REGISTER = 3, - - // completing the registration flow - POST_REGISTRATION = 4, + REGISTER, // showing the 'forgot password' view - FORGOT_PASSWORD = 5, + FORGOT_PASSWORD, // showing flow to trust this new device with cross-signing - COMPLETE_SECURITY = 6, + COMPLETE_SECURITY, // flow to setup SSSS / cross-signing on this account - E2E_SETUP = 7, + E2E_SETUP, // we are logged in with an active matrix client. The logged_in state also // includes guests users as they too are logged in at the client level. - LOGGED_IN = 8, + LOGGED_IN, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT = 9, + SOFT_LOGOUT, } +const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; + // Actions that are redirected through the onboarding process prior to being // re-dispatched. NOTE: some actions are non-trivial and would require // re-factoring to be included in this list in future. @@ -562,11 +559,6 @@ export default class MatrixChat extends React.PureComponent { ThemeController.isLogin = true; this.themeWatcher.recheck(); break; - case 'start_post_registration': - this.setState({ - view: Views.POST_REGISTRATION, - }); - break; case 'start_password_recovery': this.setStateForNewView({ view: Views.FORGOT_PASSWORD, @@ -597,7 +589,7 @@ export default class MatrixChat extends React.PureComponent { MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({action: 'view_next_room'}); + dis.dispatch({action: 'view_home_page'}); } }, (err) => { modal.close(); @@ -626,9 +618,6 @@ export default class MatrixChat extends React.PureComponent { } break; } - case 'view_next_room': - this.viewNextRoom(1); - break; case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); @@ -653,8 +642,9 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewRoomDirectory: { const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, - 'mx_RoomDirectory_dialogWrapper', false, true); + Modal.createTrackedDialog('Room directory', '', RoomDirectory, { + initialText: payload.initialText, + }, 'mx_RoomDirectory_dialogWrapper', false, true); // View the welcome or home page if we need something to look at this.viewSomethingBehindModal(); @@ -677,7 +667,7 @@ export default class MatrixChat extends React.PureComponent { this.chatCreateOrReuse(payload.user_id); break; case 'view_create_chat': - showStartChatInviteDialog(); + showStartChatInviteDialog(payload.initialText || ""); break; case 'view_invite': showRoomInviteDialog(payload.roomId); @@ -807,35 +797,6 @@ export default class MatrixChat extends React.PureComponent { this.notifyNewScreen('register'); } - // TODO: Move to RoomViewStore - private viewNextRoom(roomIndexDelta: number) { - const allRooms = RoomListSorter.mostRecentActivityFirst( - MatrixClientPeg.get().getRooms(), - ); - // If there are 0 rooms or 1 room, view the home page because otherwise - // if there are 0, we end up trying to index into an empty array, and - // if there is 1, we end up viewing the same room. - if (allRooms.length < 2) { - dis.dispatch({ - action: 'view_home_page', - }); - return; - } - let roomIndex = -1; - for (let i = 0; i < allRooms.length; ++i) { - if (allRooms[i].roomId === this.state.currentRoomId) { - roomIndex = i; - break; - } - } - roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; - if (roomIndex < 0) roomIndex = allRooms.length - 1; - dis.dispatch({ - action: 'view_room', - room_id: allRooms[roomIndex].roomId, - }); - } - // switch view to the given room // // @param {Object} roomInfo Object containing data about the room to be joined @@ -1102,9 +1063,9 @@ export default class MatrixChat extends React.PureComponent { private forgetRoom(roomId: string) { MatrixClientPeg.get().forget(roomId).then(() => { - // Switch to another room view if we're currently viewing the historical room + // Switch to home page if we're currently viewing the forgotten room if (this.state.currentRoomId === roomId) { - dis.dispatch({ action: "view_next_room" }); + dis.dispatch({ action: "view_home_page" }); } }).catch((err) => { const errCode = err.errcode || _td("unknown error code"); @@ -1238,12 +1199,8 @@ export default class MatrixChat extends React.PureComponent { } else { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'view_welcome_page'}); - } else if (getHomePageUrl(this.props.config)) { - dis.dispatch({action: 'view_home_page'}); } else { - this.firstSyncPromise.promise.then(() => { - dis.dispatch({action: 'view_next_room'}); - }); + dis.dispatch({action: 'view_home_page'}); } } } @@ -1358,18 +1315,6 @@ export default class MatrixChat extends React.PureComponent { }); }); - if (SettingsStore.getValue(UIFeature.Voip)) { - cli.on('Call.incoming', function(call) { - // we dispatch this synchronously to make sure that the event - // handlers on the call are set up immediately (so that if - // we get an immediate hangup, we don't get a stuck call) - dis.dispatch({ - action: 'incoming_call', - call: call, - }, true); - }); - } - cli.on('Session.logged_out', function(errObj) { if (Lifecycle.isLoggingOut()) return; @@ -1554,6 +1499,14 @@ export default class MatrixChat extends React.PureComponent { } showScreen(screen: string, params?: {[key: string]: any}) { + const cli = MatrixClientPeg.get(); + const isLoggedOutOrGuest = !cli || cli.isGuest(); + if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { + // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + dis.dispatch({ action: "view_home_page" }); + return; + } + if (screen === 'register') { dis.dispatch({ action: 'start_registration', @@ -1570,7 +1523,7 @@ export default class MatrixChat extends React.PureComponent { params: params, }); } else if (screen === 'soft_logout') { - if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) { + if (cli.getUserId() && !Lifecycle.isSoftLogout()) { // Logged in - visit a room this.viewLastRoom(); } else { @@ -1621,14 +1574,6 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: 'view_my_groups', }); - } else if (screen === 'complete_security') { - dis.dispatch({ - action: 'start_complete_security', - }); - } else if (screen === 'post_registration') { - dis.dispatch({ - action: 'start_post_registration', - }); } else if (screen.indexOf('room/') === 0) { // Rooms can have the following formats: // #room_alias:domain or !opaque_id:domain @@ -1799,14 +1744,6 @@ export default class MatrixChat extends React.PureComponent { return Lifecycle.setLoggedIn(credentials); } - onFinishPostRegistration = () => { - // Don't confuse this with "PageType" which is the middle window to show - this.setState({ - view: Views.LOGGED_IN, - }); - this.showScreen("settings"); - }; - onSendEvent(roomId: string, event: MatrixEvent) { const cli = MatrixClientPeg.get(); if (!cli) { @@ -1971,13 +1908,6 @@ export default class MatrixChat extends React.PureComponent { accountPassword={this.accountPassword} /> ); - } else if (this.state.view === Views.POST_REGISTRATION) { - // needs to be before normal PageTypes as you are logged in technically - const PostRegistration = sdk.getComponent('structures.auth.PostRegistration'); - view = ( - - ); } else if (this.state.view === Views.LOGGED_IN) { // store errors stop the client syncing and require user intervention, so we'll // be showing a dialog. Don't show anything else. @@ -2041,6 +1971,7 @@ export default class MatrixChat extends React.PureComponent { onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} + fragmentAfterLogin={fragmentAfterLogin} {...this.getServerProperties()} /> ); diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index ece70e3a8f..e3323b05fa 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -44,6 +44,7 @@ function track(action) { export default class RoomDirectory extends React.Component { static propTypes = { + initialText: PropTypes.string, onFinished: PropTypes.func.isRequired, }; @@ -61,7 +62,7 @@ export default class RoomDirectory extends React.Component { error: null, instanceId: undefined, roomServer: MatrixClientPeg.getHomeserverName(), - filterString: null, + filterString: this.props.initialText || "", selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") ? selectedCommunityId : null, @@ -686,6 +687,7 @@ export default class RoomDirectory extends React.Component { onJoinClick={this.onJoinFromSearchClick} placeholder={placeholder} showJoinButton={showJoinButton} + initialText={this.props.initialText} /> {dropdown} ; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 526aecddd7..a64e40bc65 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Search")} + placeholder={_t("Filter")} autoComplete="off" /> ); @@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent { if (this.props.isMinimized) { icon = ( diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e6d2985073..c1c4ad6292 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -18,13 +18,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; -import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; -import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -42,13 +40,6 @@ export default class RoomStatusBar extends React.Component { // the room this statusbar is representing. room: PropTypes.object.isRequired, - // The active call in the room, if any (means we show the call bar - // along with the status of the call) - callState: PropTypes.string, - - // The type of the call in progress, or null if no call is in progress - callType: PropTypes.string, - // true if the room is being peeked at. This affects components that shouldn't // logically be shown when peeking, such as a prompt to invite people to a room. isPeeking: PropTypes.bool, @@ -115,12 +106,6 @@ export default class RoomStatusBar extends React.Component { }); }; - _showCallBar() { - return (this.props.callState && - (this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing) - ); - } - _onResendAllClick = () => { Resend.resendUnsentEvents(this.props.room); dis.fire(Action.FocusComposer); @@ -152,7 +137,7 @@ export default class RoomStatusBar extends React.Component { // changed - so we use '0' to indicate normal size, and other values to // indicate other sizes. _getSize() { - if (this._shouldShowConnectionError() || this._showCallBar()) { + if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; } else if (this.state.unsentMessages.length > 0) { return STATUS_BAR_EXPANDED_LARGE; @@ -160,22 +145,6 @@ export default class RoomStatusBar extends React.Component { return STATUS_BAR_HIDDEN; } - // return suitable content for the image on the left of the status bar. - _getIndicator() { - if (this._showCallBar()) { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - return ( - - ); - } - - if (this._shouldShowConnectionError()) { - return null; - } - - return null; - } - _shouldShowConnectionError() { // no conn bar trumps the "some not sent" msg since you can't resend without // a connection! @@ -266,25 +235,6 @@ export default class RoomStatusBar extends React.Component { ; } - _getCallStatusText() { - switch (this.props.callState) { - case CallState.CreateOffer: - case CallState.InviteSent: - return _t('Calling...'); - case CallState.Connecting: - case CallState.CreateAnswer: - return _t('Call connecting...'); - case CallState.Connected: - return _t('Active call'); - case CallState.WaitLocalMedia: - if (this.props.callType === CallType.Video) { - return _t('Starting camera...'); - } else { - return _t('Starting microphone...'); - } - } - } - // return suitable content for the main (text) part of the status bar. _getContent() { if (this._shouldShowConnectionError()) { @@ -307,26 +257,14 @@ export default class RoomStatusBar extends React.Component { return this._getUnsentMessageContent(); } - if (this._showCallBar()) { - return ( -
- { this._getCallStatusText() } -
- ); - } - return null; } render() { const content = this._getContent(); - const indicator = this._getIndicator(); return (
-
- { indicator } -
{ content }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index de7ae347dd..adcb401ec1 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -41,7 +41,7 @@ import rateLimitedFunc from '../../ratelimitedfunc'; import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch, {searchPagination} from '../../Searching'; -import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; +import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -56,7 +56,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; import {Action} from "../../dispatcher/actions"; import {SettingLevel} from "../../settings/SettingLevel"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import {IMatrixClientCreds} from "../../MatrixClientPeg"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; @@ -68,10 +67,9 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; -import TintableSvg from "../views/elements/TintableSvg"; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import WidgetStore from "../../stores/WidgetStore"; import {UPDATE_EVENT} from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; @@ -508,8 +506,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.on("middlePanelResized", this.onResize); } this.onResize(); - - document.addEventListener("keydown", this.onNativeKeyDown); } shouldComponentUpdate(nextProps, nextState) { @@ -592,8 +588,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - document.removeEventListener("keydown", this.onNativeKeyDown); - // Remove RoomStore listener if (this.roomStoreToken) { this.roomStoreToken.remove(); @@ -642,33 +636,6 @@ export default class RoomView extends React.Component { } }; - // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire - private onNativeKeyDown = ev => { - let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - - switch (ev.key) { - case Key.D: - if (ctrlCmdOnly) { - this.onMuteAudioClick(); - handled = true; - } - break; - - case Key.E: - if (ctrlCmdOnly) { - this.onMuteVideoClick(); - handled = true; - } - break; - } - - if (handled) { - ev.stopPropagation(); - ev.preventDefault(); - } - }; - private onReactKeyDown = ev => { let handled = false; @@ -1324,10 +1291,7 @@ export default class RoomView extends React.Component { }; private onSettingsClick = () => { - dis.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.RoomSummary, - }); + dis.dispatch({ action: "open_room_settings" }); }; private onCancelClick = () => { @@ -1368,7 +1332,7 @@ export default class RoomView extends React.Component { rejecting: true, }); this.context.leave(this.state.roomId).then(() => { - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1402,7 +1366,7 @@ export default class RoomView extends React.Component { await this.context.setIgnoredUsers(ignoredUsers); await this.context.leave(this.state.roomId); - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1758,8 +1722,6 @@ export default class RoomView extends React.Component { isStatusAreaExpanded = this.state.statusBarVisible; statusBar = { }; } - if (activeCall) { - let zoomButton; let videoMuteButton; - - if (activeCall.type === CallType.Video) { - zoomButton = ( -
- -
- ); - - videoMuteButton = -
- -
; - } - const voiceMuteButton = -
- -
; - - // wrap the existing status bar into a 'callStatusBar' which adds more knobs. - statusBar = -
- { voiceMuteButton } - { videoMuteButton } - { zoomButton } - { statusBar } -
; - } - // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. let searchResultsPanel; diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 84473031fa..513cca82c3 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> { let toast; if (totalCount !== 0) { const topToast = this.state.toasts[0]; - const {title, icon, key, component, props} = topToast; + const {title, icon, key, component, className, props} = topToast; const toastClasses = classNames("mx_Toast_toast", { "mx_Toast_hasIcon": icon, [`mx_Toast_icon_${icon}`]: icon, - }); + }, className); let countIndicator; if (isStacked || this.state.countSeen > 0) { diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 75208b8cfe..08bd472225 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -29,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; import {getHostingLink} from "../../utils/HostingLink"; -import {ButtonEvent} from "../views/elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -205,6 +205,16 @@ export default class UserMenu extends React.Component { this.setState({contextMenuPosition: null}); // also close the menu }; + private onSignInClick = () => { + dis.dispatch({ action: 'start_login' }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onRegisterClick = () => { + dis.dispatch({ action: 'start_registration' }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private onHomeClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -261,10 +271,29 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); - let hostingLink; + let topSection; const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( + if (MatrixClientPeg.get().isGuest()) { + topSection = ( +
+ {_t("Got an account? Sign in", {}, { + a: sub => ( + + {sub} + + ), + })} + {_t("New here? Create an account", {}, { + a: sub => ( + + {sub} + + ), + })} +
+ ) + } else if (signupLink) { + topSection = (
{_t( "Upgrade to your own domain", {}, @@ -422,6 +451,20 @@ export default class UserMenu extends React.Component { ) + } else if (MatrixClientPeg.get().isGuest()) { + primaryOptionList = ( + + + { homeButton } + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + ); } const classes = classNames({ @@ -451,7 +494,7 @@ export default class UserMenu extends React.Component { />
- {hostingLink} + {topSection} {primaryOptionList} {secondarySection} ; diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index f9f5263f7e..5a39fe9fd9 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -21,16 +21,14 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; -import SdkConfig from "../../../SdkConfig"; import PasswordReset from "../../../PasswordReset"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import ServerPicker from "../../views/elements/ServerPicker"; // Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; // Show the forgot password inputs const PHASE_FORGOT = 1; // Email is in the process of being sent @@ -62,7 +60,6 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - serverRequiresIdServer: null, }; constructor(props) { @@ -93,12 +90,8 @@ export default class ForgotPassword extends React.Component { serverConfig.isUrl, ); - const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl); - const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam(); - this.setState({ serverIsAlive: true, - serverRequiresIdServer, }); } catch (e) { this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); @@ -177,20 +170,6 @@ export default class ForgotPassword extends React.Component { }); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_FORGOT, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -205,24 +184,6 @@ export default class ForgotPassword extends React.Component { }); } - renderServerDetails() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - return ; - } - renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -246,57 +207,13 @@ export default class ForgotPassword extends React.Component { ); } - let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - // If custom URLs are allowed, wire up the server details edit link. - let editLink = null; - if (!SdkConfig.get()['disable_custom_urls']) { - editLink = - {_t('Change')} - ; - } - - if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) { - return
-

- {yourMatrixAccountText} - {editLink} -

- {_t( - "No identity server is configured: " + - "add one in server settings to reset your password.", - )} - - {_t('Sign in instead')} - -
; - } - return
{errorText} {serverDeadSection} -

- {yourMatrixAccountText} - {editLink} -

+
CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} + autoComplete="new-password" /> CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")} + autoComplete="new-password" />
{_t( @@ -380,9 +299,6 @@ export default class ForgotPassword extends React.Component { let resetPasswordJsx; switch (this.state.phase) { - case PHASE_SERVER_DETAILS: - resetPasswordJsx = this.renderServerDetails(); - break; case PHASE_FORGOT: resetPasswordJsx = this.renderForgot(); break; diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.tsx similarity index 56% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index c3cbac0442..606aeb44ab 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd +Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,33 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactNode} from 'react'; +import {MatrixError} from "matrix-js-sdk/src/http-api"; + import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; -import Login from '../../../Login'; +import Login, {ISSOFlow, LoginFlow} from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; import PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; - -// For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; - -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate login flow(s) for the server -const PHASE_LOGIN = 1; - -// Enable phases for login -const PHASES_ENABLED = true; +import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from "../../views/elements/ServerPicker"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -55,64 +47,80 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); +interface IProps { + serverConfig: ValidatedServerConfig; + // If true, the component will consider itself busy. + busy?: boolean; + isSyncing?: boolean; + // Secondary HS which we try to log into if the user is using + // the default HS but login fails. Useful for migrating to a + // different homeserver without confusing users. + fallbackHsUrl?: string; + defaultDeviceDisplayName?: string; + fragmentAfterLogin?: string; + + // Called when the user has logged in. Params: + // - The object returned by the login API + // - The user's password, if applicable, (may be cached in memory for a + // short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(data: IMatrixClientCreds, password: string): void; + + // login shouldn't know or care how registration, password recovery, etc is done. + onRegisterClick(): void; + onForgotPasswordClick?(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + flows?: LoginFlow[]; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; +} + /* * A wire component which glues together login UI components and Login logic */ -export default class LoginComponent extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - The object returned by the login API - // - The user's password, if applicable, (may be cached in memory for a - // short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +export default class LoginComponent extends React.PureComponent { + private unmounted = false; + private loginLogic: Login; - // If true, the component will consider itself busy. - busy: PropTypes.bool, - - // Secondary HS which we try to log into if the user is using - // the default HS but login fails. Useful for migrating to a - // different homeserver without confusing users. - fallbackHsUrl: PropTypes.string, - - defaultDeviceDisplayName: PropTypes.string, - - // login shouldn't know or care how registration, password recovery, - // etc is done. - onRegisterClick: PropTypes.func.isRequired, - onForgotPasswordClick: PropTypes.func, - onServerConfigChange: PropTypes.func.isRequired, - - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - isSyncing: PropTypes.bool, - }; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { super(props); - this._unmounted = false; - this.state = { busy: false, busyLoggingIn: null, errorText: null, loginIncorrect: false, - canTryLogin: true, // can we attempt to log in or are there validation errors? + canTryLogin: true, + + flows: null, - // used for preserving form values when changing homeserver username: "", phoneCountry: null, phoneNumber: "", - // Phase of the overall login dialog. - phase: PHASE_LOGIN, - // The current login flow, such as password, SSO, etc. - currentFlow: null, // we need to load the flows from the server - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", @@ -120,12 +128,12 @@ export default class LoginComponent extends React.Component { // map from login step type to a function which will render a control // letting you do that login type - this._stepRendererMap = { - 'm.login.password': this._renderPasswordStep, + this.stepRendererMap = { + 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep("cas"), - 'm.login.sso': () => this._renderSsoStep("sso"), + 'm.login.cas': () => this.renderSsoStep("cas"), + 'm.login.sso': () => this.renderSsoStep("sso"), }; CountlyAnalytics.instance.track("onboarding_login_begin"); @@ -134,11 +142,11 @@ export default class LoginComponent extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { - this._initLoginLogic(); + this.initLoginLogic(this.props.serverConfig); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -148,16 +156,9 @@ export default class LoginComponent extends React.Component { newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place - this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + this.initLoginLogic(newProps.serverConfig); } - onPasswordLoginError = errorText => { - this.setState({ - errorText, - loginIncorrect: Boolean(errorText), - }); - }; - isBusy = () => this.state.busy || this.props.busy; onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { @@ -194,13 +195,13 @@ export default class LoginComponent extends React.Component { loginIncorrect: false, }); - this._loginLogic.loginViaPassword( + this.loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { this.setState({serverIsAlive: true}); // it must be, we logged in. this.props.onLoggedIn(data, password); }, (error) => { - if (this._unmounted) { + if (this.unmounted) { return; } let errorText; @@ -212,21 +213,23 @@ export default class LoginComponent extends React.Component { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + error.data.admin_contact, + { + 'monthly_active_user': _td( + "This homeserver has hit its Monthly Active User limit.", + ), + '': _td( + "This homeserver has exceeded one of its resource limits.", + ), + }, + ); const errorDetail = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + error.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); errorText = (
{errorTop}
@@ -253,7 +256,7 @@ export default class LoginComponent extends React.Component { } } else { // other errors, not specific to doing a password login - errorText = this._errorTextFromError(error); + errorText = this.errorTextFromError(error); } this.setState({ @@ -291,7 +294,7 @@ export default class LoginComponent extends React.Component { // the busy state. In the case of a full MXID that resolves to the same // HS as Element's default HS though, there may not be any server change. // To avoid this trap, we clear busy here. For cases where the server - // actually has changed, `_initLoginLogic` will be called and manages + // actually has changed, `initLoginLogic` will be called and manages // busy state for its own liveness check. this.setState({ busy: false, @@ -304,7 +307,7 @@ export default class LoginComponent extends React.Component { message = e.translatedMessage; } - let errorText = message; + let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { errorText = this.state.errorText; @@ -330,21 +333,6 @@ export default class LoginComponent extends React.Component { }); }; - onPhoneNumberBlur = phoneNumber => { - // Validate the phone number entered - if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { - this.setState({ - errorText: _t('The phone number entered looks invalid'), - canTryLogin: false, - }); - } else { - this.setState({ - errorText: null, - canTryLogin: true, - }); - } - }; - onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -352,14 +340,16 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); - if (step === 'm.login.sso' || step === 'm.login.cas') { - // If we're showing SSO it means that registration is also probably disabled, - // so intercept the click and instead pretend the user clicked 'Sign in with SSO'. + const hasPasswordFlow = this.state.flows.find(flow => flow.type === "m.login.password"); + const ssoFlow = this.state.flows.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas"); + // If has no password flow but an SSO flow guess that the user wants to register with SSO. + // TODO: instead hide the Register button if registration is disabled by checking with the server, + // has no specific errCode currently and uses M_FORBIDDEN. + if (ssoFlow && !hasPasswordFlow) { ev.preventDefault(); ev.stopPropagation(); - const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; - PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, + const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas'; + PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin); } else { // Don't intercept - just go through to the register page @@ -367,24 +357,7 @@ export default class LoginComponent extends React.Component { } }; - onServerDetailsNextPhaseClick = () => { - this.setState({ - phase: PHASE_LOGIN, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - async _initLoginLogic(hsUrl, isUrl) { - hsUrl = hsUrl || this.props.serverConfig.hsUrl; - isUrl = isUrl || this.props.serverConfig.isUrl; - + private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -397,11 +370,10 @@ export default class LoginComponent extends React.Component { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); - this._loginLogic = loginLogic; + this.loginLogic = loginLogic; this.setState({ busy: true, - currentFlow: null, // reset flow loginIncorrect: false, }); @@ -425,42 +397,26 @@ export default class LoginComponent extends React.Component { busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e), }); - if (this.state.serverErrorIsFatal) { - // Server is dead: show server details prompt instead - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - return; - } } loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. - for (let i = 0; i < flows.length; i++ ) { - if (!this._isSupportedFlow(flows[i])) { - continue; - } + const supportedFlows = flows.filter(this.isSupportedFlow); - // we just pick the first flow where we support all the - // steps. (we don't have a UI for multiple logins so let's skip - // that for now). - loginLogic.chooseFlow(i); + if (supportedFlows.length > 0) { this.setState({ - currentFlow: this._getCurrentFlowStep(), + flows: supportedFlows, }); return; } - // we got to the end of the list without finding a suitable - // flow. + + // we got to the end of the list without finding a suitable flow. this.setState({ - errorText: _t( - "This homeserver doesn't offer any login flows which are " + - "supported by this client.", - ), + errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."), }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -471,28 +427,24 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow = (flow: LoginFlow): boolean => { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. - if (!this._stepRendererMap[flow.type]) { + if (!this.stepRendererMap[flow.type]) { console.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; - } + }; - _getCurrentFlowStep() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; - } - - _errorTextFromError(err) { + private errorTextFromError(err: MatrixError): ReactNode { let errCode = err.errcode; if (!errCode && err.httpStatus) { errCode = "HTTP " + err.httpStatus; } - let errorText = _t("Error: Problem communicating with the given homeserver.") + - (errCode ? " (" + errCode + ")" : ""); + let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -502,29 +454,27 @@ export default class LoginComponent extends React.Component { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", {}, - { - 'a': (sub) => { - return - { sub } - ; - }, + { + 'a': (sub) => { + return + { sub } + ; }, - ) } + }) } ; } else { errorText = { _t("Can't connect to homeserver - please check your connectivity, ensure your " + "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, - { - 'a': (sub) => - - { sub } - , - }, - ) } + { + 'a': (sub) => + + { sub } + , + }) } ; } } @@ -532,121 +482,63 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); + renderLoginComponentForFlows() { + if (!this.state.flows) return null; - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } + // this is the ideal order we want to show the flows in + const order = [ + "m.login.password", + "m.login.sso", + ]; - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { - return null; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - return ; + const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean); + return + { flows.map(flow => { + const stepRenderer = this.stepRendererMap[flow.type]; + return { stepRenderer() } + }) } + } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { - return null; - } - - const step = this.state.currentFlow; - - if (!step) { - return null; - } - - const stepRenderer = this._stepRendererMap[step]; - - if (stepRenderer) { - return stepRenderer(); - } - - return null; - } - - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - + private renderPasswordStep = () => { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); + private renderSsoStep = loginType => { + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - // XXX: This link does *not* have a target="_blank" because single sign-on relies on - // redirecting the user back to a URI once they're logged in. On the web, this means - // we use the same window and redirect back to Element. On Electron, this actually - // opens the SSO page in the Electron app itself due to - // https://github.com/electron/electron/issues/8841 and so happens to work. - // If this bug gets fixed, it will break SSO since it will open the SSO page in the - // user's browser, let them log into their SSO provider, then redirect their browser - // to vector://vector which, of course, will not work. return ( -
- - - -
+ flow.type === "m.login.password")} + /> ); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ? -
: null; +
: null; const errorText = this.state.errorText; @@ -686,9 +578,11 @@ export default class LoginComponent extends React.Component {
; } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( - - { _t('Create account') } - + + {_t("New? Create account", {}, { + a: sub => { sub }, + })} + ); } @@ -702,8 +596,11 @@ export default class LoginComponent extends React.Component { { errorTextSection } { serverDeadSection } - { this.renderServerComponent() } - { this.renderLoginComponentForStep() } + + { this.renderLoginComponentForFlows() } { footer } diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js deleted file mode 100644 index aa36de6596..0000000000 --- a/src/components/structures/auth/PostRegistration.js +++ /dev/null @@ -1,77 +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 React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import { _t } from '../../../languageHandler'; -import AuthPage from "../../views/auth/AuthPage"; - -export default class PostRegistration extends React.Component { - static propTypes = { - onComplete: PropTypes.func.isRequired, - }; - - state = { - avatarUrl: null, - errorString: null, - busy: false, - }; - - componentDidMount() { - // There is some assymetry between ChangeDisplayName and ChangeAvatar, - // as ChangeDisplayName will auto-get the name but ChangeAvatar expects - // the URL to be passed to you (because it's also used for room avatars). - const cli = MatrixClientPeg.get(); - this.setState({busy: true}); - const self = this; - cli.getProfileInfo(cli.credentials.userId).then(function(result) { - self.setState({ - avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), - busy: false, - }); - }, function(error) { - self.setState({ - errorString: _t("Failed to fetch avatar URL"), - busy: false, - }); - }); - } - - render() { - const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); - const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - return ( - - - -
- { _t('Set a display name:') } - - { _t('Upload an avatar:') } - - - { this.state.errorString } -
-
-
- ); - } -} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.tsx similarity index 54% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 630e04da9c..e1a2fc5590 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,109 +15,128 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactNode} from 'react'; +import {MatrixClient} from "matrix-js-sdk/src/client"; + import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import * as ServerType from '../../views/auth/ServerTypeSelector'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login from "../../../Login"; +import Login, {ISSOFlow} from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from '../../views/elements/ServerPicker'; -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate registration flow(s) for the server -const PHASE_REGISTRATION = 1; +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + fragmentAfterLogin?: string; -// Enable phases for registration -const PHASES_ENABLED = true; + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - The user's password, if available and applicable (may be cached in memory + // for a short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(params: { + userId: string; + deviceId: string + homeserverUrl: string; + identityServerUrl?: string; + accessToken: string; + }, password: string): void; + makeRegistrationUrl(params: { + /* eslint-disable camelcase */ + client_secret: string; + hs_url: string; + is_url?: string; + session_id: string; + /* eslint-enable camelcase */ + }): void; + // registration shouldn't know or care how login is done. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} -export default class Registration extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken - // - The user's password, if available and applicable (may be cached in memory - // for a short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +interface IState { + busy: boolean; + errorText?: ReactNode; + // true if we're waiting for the user to complete + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: boolean; + flows: { + stages: string[]; + }[]; + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; - clientSecret: PropTypes.string, - sessionId: PropTypes.string, - makeRegistrationUrl: PropTypes.func.isRequired, - idSid: PropTypes.string, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - brand: PropTypes.string, - email: PropTypes.string, - // registration shouldn't know or care how login is done. - onLoginClick: PropTypes.func.isRequired, - onServerConfigChange: PropTypes.func.isRequired, - defaultDeviceDisplayName: PropTypes.string, - }; + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; + // the SSO flow definition, this is fetched from /login as that's the only + // place it is exposed. + ssoFlow?: ISSOFlow; +} + +export default class Registration extends React.Component { + loginLogic: Login; constructor(props) { super(props); - const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); this.state = { busy: false, errorText: null, - // We remember the values entered by the user because - // the registration form will be unmounted during the - // course of registration, but if there's an error we - // want to bring back the registration form with the - // values the user entered still in it. We can keep - // them in this component's state since this component - // persist for the duration of the registration process. formVals: { email: this.props.email, }, - // true if we're waiting for the user to complete - // user-interactive auth - // If we've been given a session ID, we're resuming - // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), - serverType, - // Phase of the overall registration dialog. - phase: PHASE_REGISTRATION, flows: null, - // If set, we've registered but are not going to log - // the user in to their new account automatically. completedNoSignin: false, - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - - // Our matrix client - part of state because we can't render the UI auth - // component without it. - matrixClient: null, - - // whether the HS requires an ID server to register with a threepid - serverRequiresIdServer: null, - - // The user ID we've just registered - registeredUsername: null, - - // if a different user ID to the one we just registered is logged in, - // this is the user ID that's logged in. - differentLoggedInUserId: null, }; + + const {hsUrl, isUrl} = this.props.serverConfig; + this.loginLogic = new Login(hsUrl, isUrl, null, { + defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + }); } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -129,63 +145,10 @@ export default class Registration extends React.Component { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - this._replaceClient(newProps.serverConfig); - - // Handle cases where the user enters "https://matrix.org" for their server - // from the advanced option - we should default to FREE at that point. - const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig); - if (serverType !== this.state.serverType) { - // Reset the phase to default phase for the server type. - this.setState({ - serverType, - phase: this.getDefaultPhaseForServerType(serverType), - }); - } + this.replaceClient(newProps.serverConfig); } - getDefaultPhaseForServerType(type) { - switch (type) { - case ServerType.FREE: { - // Move directly to the registration phase since the server - // details are fixed. - return PHASE_REGISTRATION; - } - case ServerType.PREMIUM: - case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; - } - } - - onServerTypeChange = type => { - this.setState({ - serverType: type, - }); - - // When changing server types, set the HS / IS URLs to reasonable defaults for the - // the new type. - switch (type) { - case ServerType.FREE: { - const { serverConfig } = ServerType.TYPES.FREE; - this.props.onServerConfigChange(serverConfig); - break; - } - case ServerType.PREMIUM: - // We can accept whatever server config was the default here as this essentially - // acts as a slightly different "custom server"/ADVANCED option. - break; - case ServerType.ADVANCED: - // Use the default config from the config - this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]); - break; - } - - // Reset the phase to default phase for the server type. - this.setState({ - phase: this.getDefaultPhaseForServerType(type), - }); - }; - - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -194,7 +157,6 @@ export default class Registration extends React.Component { // the UI auth component while we don't have a matrix client) busy: true, }); - if (!serverConfig) serverConfig = this.props.serverConfig; // Do a liveliness check on the URLs try { @@ -222,16 +184,20 @@ export default class Registration extends React.Component { idBaseUrl: isUrl, }); - let serverRequiresIdServer = true; + this.loginLogic.setHomeserverUrl(hsUrl); + this.loginLogic.setIdentityServerUrl(isUrl); + + let ssoFlow: ISSOFlow; try { - serverRequiresIdServer = await cli.doesServerRequireIdServerParam(); + const loginFlows = await this.loginLogic.getFlows(); + ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow; } catch (e) { - console.log("Unable to determine is server needs id_server param", e); + console.error("Failed to get login flows to check for SSO support", e); } this.setState({ matrixClient: cli, - serverRequiresIdServer, + ssoFlow, busy: false, }); const showGenericError = (e) => { @@ -246,7 +212,7 @@ export default class Registration extends React.Component { // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { - await this._makeRegisterRequest(null); + await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. console.log("Expecting 401 from register request but got success!"); } @@ -259,26 +225,16 @@ export default class Registration extends React.Component { // At this point registration is pretty much disabled, but before we do that let's // quickly check to see if the server supports SSO instead. If it does, we'll send // the user off to the login page to figure their account out. - try { - const loginLogic = new Login(hsUrl, isUrl, null, { - defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + if (ssoFlow) { + // Redirect to login page - server probably expects SSO only + dis.dispatch({action: 'start_login'}); + } else { + this.setState({ + serverErrorIsFatal: true, // fatal because user cannot continue on this server + errorText: _t("Registration has been disabled on this homeserver."), + // add empty flows array to get rid of spinner + flows: [], }); - const flows = await loginLogic.getFlows(); - const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas'); - if (hasSsoFlow) { - // Redirect to login page - server probably expects SSO only - dis.dispatch({action: 'start_login'}); - } else { - this.setState({ - serverErrorIsFatal: true, // fatal because user cannot continue on this server - errorText: _t("Registration has been disabled on this homeserver."), - // add empty flows array to get rid of spinner - flows: [], - }); - } - } catch (e) { - console.error("Failed to get login flows to check for SSO support", e); - showGenericError(e); } } else { console.log("Unable to query for supported registration methods.", e); @@ -287,7 +243,7 @@ export default class Registration extends React.Component { } } - onFormSubmit = formVals => { + private onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, @@ -296,7 +252,7 @@ export default class Registration extends React.Component { }); }; - _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { + private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -310,28 +266,26 @@ export default class Registration extends React.Component { ); } - _onUIAuthFinished = async (success, response, extra) => { + private onUIAuthFinished = async (success, response, extra) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + response.data.admin_contact, + { + 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + '': _td("This homeserver has exceeded one of its resource limits."), + }, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + response.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); msg =

{errorTop}

{errorDetail}

@@ -339,11 +293,13 @@ export default class Registration extends React.Component { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; + msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); } if (!msisdnAvailable) { msg = _t('This server does not support authentication with a phone number.'); } + } else if (response.errcode === "M_USER_IN_USE") { + msg = _t("That username already exists, please try another."); } this.setState({ busy: false, @@ -358,6 +314,10 @@ export default class Registration extends React.Component { const newState = { doingUIAuth: false, registeredUsername: response.user_id, + differentLoggedInUserId: null, + completedNoSignin: false, + // we're still busy until we get unmounted: don't show the registration form again + busy: true, }; // The user came in through an email validation link. To avoid overwriting @@ -372,8 +332,6 @@ export default class Registration extends React.Component { `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; - } else { - newState.differentLoggedInUserId = null; } if (response.access_token) { @@ -385,9 +343,7 @@ export default class Registration extends React.Component { accessToken: response.access_token, }, this.state.formVals.password); - this._setupPushers(); - // we're still busy until we get unmounted: don't show the registration form again - newState.busy = true; + this.setupPushers(); } else { newState.busy = false; newState.completedNoSignin = true; @@ -396,7 +352,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +375,23 @@ export default class Registration extends React.Component { }); } - onLoginClick = ev => { + private onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - onGoToFormClicked = ev => { + private onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); - this._replaceClient(); + this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, - phase: PHASE_REGISTRATION, }); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_REGISTRATION, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - _makeRegisterRequest = auth => { + private makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -466,13 +407,15 @@ export default class Registration extends React.Component { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + inhibit_login: undefined, }; if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); }; - _getUIAuthInputs() { + private getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, @@ -483,7 +426,7 @@ export default class Registration extends React.Component { // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck = async ev => { + private onLoginClickWithCheck = async ev => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); @@ -493,72 +436,7 @@ export default class Registration extends React.Component { } }; - renderServerComponent() { - const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - // If we're on a different phase, we only show the server type selector, - // which is always shown if we allow custom URLs at all. - // (if there's a fatal server error, we need to show the full server - // config as the user may need to change servers to resolve the error). - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { - return
- -
; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - let serverDetails = null; - switch (this.state.serverType) { - case ServerType.FREE: - break; - case ServerType.PREMIUM: - serverDetails = ; - break; - case ServerType.ADVANCED: - serverDetails = ; - break; - } - - return
- - {serverDetails} -
; - } - - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { - return null; - } - + private renderRegisterComponent() { const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); const Spinner = sdk.getComponent('elements.Spinner'); const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); @@ -566,10 +444,10 @@ export default class Registration extends React.Component { if (this.state.matrixClient && this.state.doingUIAuth) { return
; } else if (this.state.flows.length) { - let onEditServerDetailsClick = null; - // If custom URLs are allowed and we haven't selected the Free server type, wire - // up the server details edit link. - if ( - PHASES_ENABLED && - !SdkConfig.get()['disable_custom_urls'] && - this.state.serverType !== ServerType.FREE - ) { - onEditServerDetailsClick = this.onEditServerDetailsClick; + let ssoSection; + if (this.state.ssoFlow) { + let continueWithSection; + const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] + || this.state.ssoFlow["identity_providers"] || []; + // when there is only a single (or 0) providers we show a wide button with `Continue with X` text + if (providers.length > 1) { + // i18n: ssoButtons is a placeholder to help translators understand context + continueWithSection =

+ { _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() } +

; + } + + // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context + ssoSection = + { continueWithSection } + +

+ { _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() } +

+
; } - return ; + return + { ssoSection } + + ; } } @@ -634,13 +530,15 @@ export default class Registration extends React.Component { ); } - const signIn = - { _t('Sign in instead') } - ; + const signIn = + {_t("Already have an account? Sign in here", {}, { + a: sub => { sub }, + })} + ; // Only show the 'go back' button if you're not looking at the form let goBack; - if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) { + if (this.state.doingUIAuth) { goBack = { _t('Go back') } ; @@ -658,7 +556,7 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

-

+

{_t("Continue with previous account")}

; @@ -667,7 +565,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } else { @@ -677,7 +575,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } @@ -687,10 +585,15 @@ export default class Registration extends React.Component {
; } else { body =
-

{ _t('Create your account') }

+

{ _t('Create account') }

{ errorText } { serverDeadSection } - { this.renderServerComponent() } + { this.renderRegisterComponent() } { goBack } { signIn } diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a539c8c9ee..fdc1aec96d 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -24,8 +24,8 @@ import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import SSOButtons from "../../views/elements/SSOButtons"; const LOGIN_VIEW = { LOADING: 1, @@ -101,10 +101,11 @@ export default class SoftLogout extends React.Component { // Note: we don't use the existing Login class because it is heavily flow-based. We don't // care about login flows here, unless it is the single flow we support. const client = MatrixClientPeg.get(); - const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]); + const flows = (await client.loginFlows()).flows; + const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]); const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; - this.setState({loginView: chosenView}); + this.setState({ flows, loginView: chosenView }); } onPasswordChange = (ev) => { @@ -240,13 +241,18 @@ export default class SoftLogout extends React.Component { introText = _t("Sign in and regain access to your account."); } // else we already have a message and should use it (key backup warning) + const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType); + return (

{introText}

- flow.type === "m.login.password")} />
); diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 5cce93f0b8..e2d7d594fa 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -102,6 +102,10 @@ export default class CaptchaForm extends React.Component { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); + // clear error if re-rendered + this.setState({ + errorText: null, + }); CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded"); } catch (e) { this.setState({ diff --git a/src/components/views/auth/CustomServerDialog.js b/src/components/views/auth/CustomServerDialog.js deleted file mode 100644 index 138f8c4689..0000000000 --- a/src/components/views/auth/CustomServerDialog.js +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -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 SdkConfig from '../../../SdkConfig'; - -export default class CustomServerDialog extends React.Component { - render() { - const brand = SdkConfig.get().brand; - return ( -
-
- { _t("Custom Server Options") } -
-
-

{_t( - "You can use the custom server options to sign into other " + - "Matrix servers by specifying a different homeserver URL. This " + - "allows you to use %(brand)s with an existing Matrix account on a " + - "different homeserver.", - { brand }, - )}

-
-
- -
-
- ); - } -} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index f49e6959fb..60e57afc98 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -18,7 +18,6 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import url from 'url'; import classnames from 'classnames'; import * as sdk from '../../../index'; @@ -421,12 +420,12 @@ export class EmailIdentityAuthEntry extends React.Component { return ; } else { return ( -
-

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

+

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

-

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

+

{ _t("Open the link in the email to continue registration.") }

); } @@ -500,17 +499,11 @@ export class MsisdnAuthEntry extends React.Component { }); try { - const requiresIdServerParam = - await this.props.matrixClient.doesServerRequireIdServerParam(); let result; if (this._submitUrl) { result = await this.props.matrixClient.submitMsisdnTokenOtherUrl( this._submitUrl, this._sid, this.props.clientSecret, this.state.token, ); - } else if (requiresIdServerParam) { - result = await this.props.matrixClient.submitMsisdnToken( - this._sid, this.props.clientSecret, this.state.token, - ); } else { throw new Error("The registration with MSISDN flow is misconfigured"); } @@ -519,12 +512,6 @@ export class MsisdnAuthEntry extends React.Component { sid: this._sid, client_secret: this.props.clientSecret, }; - if (requiresIdServerParam) { - const idServerParsedUrl = url.parse( - this.props.matrixClient.getIdentityServerUrl(), - ); - creds.id_server = idServerParsedUrl.host; - } this.props.submitAuthDict({ type: MsisdnAuthEntry.LOGIN_TYPE, // TODO: Remove `threepid_creds` once servers support proper UIA diff --git a/src/components/views/auth/ModularServerConfig.js b/src/components/views/auth/ModularServerConfig.js deleted file mode 100644 index 28fd16379d..0000000000 --- a/src/components/views/auth/ModularServerConfig.js +++ /dev/null @@ -1,124 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import * as ServerType from '../../views/auth/ServerTypeSelector'; -import ServerConfig from "./ServerConfig"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -// TODO: TravisR - Can this extend ServerConfig for most things? - -/* - * Configure the Modular server name. - * - * This is a variant of ServerConfig with only the HS field and different body - * text that is specific to the Modular case. - */ -export default class ModularServerConfig extends ServerConfig { - static propTypes = ServerConfig.propTypes; - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl); - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
-

{_t("Your server")}

- {_t( - "Enter the location of your Element Matrix Services homeserver. It may use your own " + - "domain name or be a subdomain of element.io.", - {}, { - a: sub => - {sub} - , - }, - )} - -
- -
- {submitButton} - -
- ); - } -} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed0872..e240ad61ca 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn"; import SdkConfig from "../../../SdkConfig"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import {_t, _td} from "../../../languageHandler"; -import Field from "../elements/Field"; +import Field, {IInputProps} from "../elements/Field"; -interface IProps { +interface IProps extends Omit { autoFocus?: boolean; id?: string; className?: string; diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js deleted file mode 100644 index 405f9051b9..0000000000 --- a/src/components/views/auth/PasswordLogin.js +++ /dev/null @@ -1,377 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AccessibleButton from "../elements/AccessibleButton"; -import CountlyAnalytics from "../../../CountlyAnalytics"; - -/** - * A pure UI component which displays a username/password form. - */ -export default class PasswordLogin extends React.Component { - static propTypes = { - onSubmit: PropTypes.func.isRequired, // fn(username, password) - onError: PropTypes.func, - onEditServerDetailsClick: PropTypes.func, - onForgotPasswordClick: PropTypes.func, // fn() - initialUsername: PropTypes.string, - initialPhoneCountry: PropTypes.string, - initialPhoneNumber: PropTypes.string, - initialPassword: PropTypes.string, - onUsernameChanged: PropTypes.func, - onPhoneCountryChanged: PropTypes.func, - onPhoneNumberChanged: PropTypes.func, - onPasswordChanged: PropTypes.func, - loginIncorrect: PropTypes.bool, - disableSubmit: PropTypes.bool, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - busy: PropTypes.bool, - }; - - static defaultProps = { - onError: function() {}, - onEditServerDetailsClick: null, - onUsernameChanged: function() {}, - onUsernameBlur: function() {}, - onPasswordChanged: function() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - onPhoneNumberBlur: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - initialPassword: "", - loginIncorrect: false, - disableSubmit: false, - }; - - static LOGIN_FIELD_EMAIL = "login_field_email"; - static LOGIN_FIELD_MXID = "login_field_mxid"; - static LOGIN_FIELD_PHONE = "login_field_phone"; - - 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, - }; - - this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this); - this.onSubmitForm = this.onSubmitForm.bind(this); - this.onUsernameChanged = this.onUsernameChanged.bind(this); - this.onUsernameBlur = this.onUsernameBlur.bind(this); - this.onLoginTypeChange = this.onLoginTypeChange.bind(this); - this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); - this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); - this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this); - this.onPasswordChanged = this.onPasswordChanged.bind(this); - this.isLoginEmpty = this.isLoginEmpty.bind(this); - } - - onForgotPasswordClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - this.props.onForgotPasswordClick(); - } - - onSubmitForm(ev) { - ev.preventDefault(); - - let username = ''; // XXX: Synapse breaks if you send null here: - let phoneCountry = null; - let phoneNumber = null; - let error; - - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - username = this.state.username; - if (!username) { - error = _t('The email field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_MXID: - username = this.state.username; - if (!username) { - error = _t('The username field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_PHONE: - phoneCountry = this.state.phoneCountry; - phoneNumber = this.state.phoneNumber; - if (!phoneNumber) { - error = _t('The phone number field must not be blank.'); - } - break; - } - - if (error) { - this.props.onError(error); - return; - } - - if (!this.state.password) { - this.props.onError(_t('The password field must not be blank.')); - return; - } - - this.props.onSubmit( - username, - phoneCountry, - phoneNumber, - this.state.password, - ); - } - - onUsernameChanged(ev) { - this.setState({username: ev.target.value}); - this.props.onUsernameChanged(ev.target.value); - } - - onUsernameFocus() { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_focus"); - } - } - - onUsernameBlur(ev) { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_blur"); - } - this.props.onUsernameBlur(ev.target.value); - } - - onLoginTypeChange(ev) { - const loginType = ev.target.value; - this.props.onError(null); // send a null error to clear any error messages - this.setState({ - loginType: loginType, - username: "", // Reset because email and username use the same state - }); - CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); - } - - 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); - } - - onPhoneNumberFocus() { - CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); - } - - onPhoneNumberBlur(ev) { - this.props.onPhoneNumberBlur(ev.target.value); - CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); - } - - onPasswordChanged(ev) { - this.setState({password: ev.target.value}); - this.props.onPasswordChanged(ev.target.value); - } - - renderLoginField(loginType, autoFocus) { - const Field = sdk.getComponent('elements.Field'); - - const classes = {}; - - switch (loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_MXID: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_PHONE: { - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - classes.error = this.props.loginIncorrect && !this.state.phoneNumber; - - const phoneCountry = ; - - return ; - } - } - } - - isLoginEmpty() { - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - case PasswordLogin.LOGIN_FIELD_MXID: - return !this.state.username; - case PasswordLogin.LOGIN_FIELD_PHONE: - return !this.state.phoneCountry || !this.state.phoneNumber; - } - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - - let forgotPasswordJsx; - - if (this.props.onForgotPasswordClick) { - forgotPasswordJsx = - {_t('Not sure of your password? Set a new one', {}, { - a: sub => ( - - {sub} - - ), - })} - ; - } - - const pwFieldClass = classNames({ - error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field - }); - - // If login is empty, autoFocus login, otherwise autoFocus password. - // this is for when auto server discovery remounts us when the user tries to tab from username to password - const autoFocusPassword = !this.isLoginEmpty(); - const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); - - let loginType; - if (!SdkConfig.get().disable_3pid_login) { - loginType = ( -
- - - - - - -
- ); - } - - return ( -
- -
- {loginType} - {loginField} - - {forgotPasswordJsx} - { !this.props.busy && } - -
- ); - } -} diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx new file mode 100644 index 0000000000..84e583c3a5 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -0,0 +1,485 @@ +/* +Copyright 2015, 2016, 2017, 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import { _t } from '../../../languageHandler'; +import SdkConfig from '../../../SdkConfig'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AccessibleButton from "../elements/AccessibleButton"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import withValidation from "../elements/Validation"; +import * as Email from "../../../email"; +import Field from "../elements/Field"; +import CountryDropdown from "./CountryDropdown"; + +// For validating phone numbers without country codes +const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; + +interface IProps { + username: string; // also used for email address + phoneCountry: string; + phoneNumber: string; + + serverConfig: ValidatedServerConfig; + loginIncorrect?: boolean; + disableSubmit?: boolean; + busy?: boolean; + + onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void; + onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void; + onUsernameChanged?(username: string): void; + onUsernameBlur?(username: string): void; + onPhoneCountryChanged?(phoneCountry: string): void; + onPhoneNumberChanged?(phoneNumber: string): void; + onForgotPasswordClick?(): void; +} + +interface IState { + fieldValid: Partial>; + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, + password: "", +} + +enum LoginField { + Email = "login_field_email", + MatrixId = "login_field_mxid", + Phone = "login_field_phone", + Password = "login_field_phone", +} + +/* + * A pure UI component which displays a username/password form. + * The email/username/phone fields are fully-controlled, the password field is not. + */ +export default class PasswordLogin extends React.PureComponent { + static defaultProps = { + onUsernameChanged: function() {}, + onUsernameBlur: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + loginIncorrect: false, + disableSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + // Field error codes by field ID + fieldValid: {}, + loginType: LoginField.MatrixId, + password: "", + }; + } + + private onForgotPasswordClick = ev => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.onForgotPasswordClick(); + }; + + private onSubmitForm = async ev => { + ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + + let username = ''; // XXX: Synapse breaks if you send null here: + let phoneCountry = null; + let phoneNumber = null; + + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + username = this.props.username; + break; + case LoginField.Phone: + phoneCountry = this.props.phoneCountry; + phoneNumber = this.props.phoneNumber; + break; + } + + this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password); + }; + + private onUsernameChanged = ev => { + this.props.onUsernameChanged(ev.target.value); + }; + + private onUsernameFocus = () => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_focus"); + } + }; + + private onUsernameBlur = ev => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_blur"); + } + this.props.onUsernameBlur(ev.target.value); + }; + + private onLoginTypeChange = ev => { + const loginType = ev.target.value; + this.setState({ loginType }); + this.props.onUsernameChanged(""); // Reset because email and username use the same state + CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); + }; + + private onPhoneCountryChanged = country => { + this.props.onPhoneCountryChanged(country.iso2); + }; + + private onPhoneNumberChanged = ev => { + this.props.onPhoneNumberChanged(ev.target.value); + }; + + private onPhoneNumberFocus = () => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); + }; + + private onPhoneNumberBlur = ev => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); + }; + + private onPasswordChanged = ev => { + this.setState({password: ev.target.value}); + }; + + private async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement as HTMLElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + this.state.loginType, + LoginField.Password, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise(resolve => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + private allFieldsValid() { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { + if (!this.state.fieldValid[keys[i]]) { + return false; + } + } + return true; + } + + private findFirstInvalidField(fieldIDs: LoginField[]) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + private markFieldValid(fieldID: LoginField, valid: boolean) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + + private validateUsernameRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter username"), + }, + ], + }); + + private onUsernameValidate = async (fieldState) => { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(LoginField.MatrixId, result.valid); + return result; + }; + + private validateEmailRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter email address"), + }, { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], + }); + + private onEmailValidate = async (fieldState) => { + const result = await this.validateEmailRules(fieldState); + this.markFieldValid(LoginField.Email, result.valid); + return result; + }; + + private validatePhoneNumberRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter phone number"), + }, { + key: "number", + test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), + }, + ], + }); + + private onPhoneNumberValidate = async (fieldState) => { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + }; + + private validatePasswordRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter password"), + }, + ], + }); + + private onPasswordValidate = async (fieldState) => { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + } + + private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { + const classes = { + error: false, + }; + + switch (loginType) { + case LoginField.Email: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.Email] = field} + />; + case LoginField.MatrixId: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.MatrixId] = field} + />; + case LoginField.Phone: { + classes.error = this.props.loginIncorrect && !this.props.phoneNumber; + + const phoneCountry = ; + + return this[LoginField.Password] = field} + />; + } + } + } + + private isLoginEmpty() { + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + return !this.props.username; + case LoginField.Phone: + return !this.props.phoneCountry || !this.props.phoneNumber; + } + } + + render() { + let forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = + {_t("Forgot password?")} + ; + } + + const pwFieldClass = classNames({ + error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field + }); + + // If login is empty, autoFocus login, otherwise autoFocus password. + // this is for when auto server discovery remounts us when the user tries to tab from username to password + const autoFocusPassword = !this.isLoginEmpty(); + const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); + + let loginType; + if (!SdkConfig.get().disable_3pid_login) { + loginType = ( +
+ + + + + + +
+ ); + } + + return ( +
+
+ {loginType} + {loginField} + this[LoginField.Password] = field} + /> + {forgotPasswordJsx} + { !this.props.busy && } + +
+ ); + } +} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.tsx similarity index 62% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index 419443984a..a0c7ab7b4f 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; @@ -30,34 +28,59 @@ import withValidation from '../elements/Validation'; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import Field from '../elements/Field'; +import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; -const FIELD_EMAIL = 'field_email'; -const FIELD_PHONE_NUMBER = 'field_phone_number'; -const FIELD_USERNAME = 'field_username'; -const FIELD_PASSWORD = 'field_password'; -const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +enum RegistrationField { + Email = "field_email", + PhoneNumber = "field_phone_number", + Username = "field_username", + Password = "field_password", + PasswordConfirm = "field_password_confirm", +} const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. +interface IProps { + // Values pre-filled in the input boxes when the component loads + defaultEmail?: string; + defaultPhoneCountry?: string; + defaultPhoneNumber?: string; + defaultUsername?: string; + defaultPassword?: string; + flows: { + stages: string[]; + }[]; + serverConfig: ValidatedServerConfig; + canSubmit?: boolean; + + onRegisterClick(params: { + username: string; + password: string; + email?: string; + phoneCountry?: string; + phoneNumber?: string; + }): Promise; + onEditServerDetailsClick?(): void; +} + +interface IState { + // Field error codes by field ID + fieldValid: Partial>; + // The ISO2 country code selected in the phone number entry + phoneCountry: string; + username: string; + email: string; + phoneNumber: string; + password: string; + passwordConfirm: string; + passwordComplexity?: number; +} + /* * A pure UI component which displays a registration form. */ -export default class RegistrationForm extends React.Component { - static propTypes = { - // Values pre-filled in the input boxes when the component loads - defaultEmail: PropTypes.string, - defaultPhoneCountry: PropTypes.string, - defaultPhoneNumber: PropTypes.string, - defaultUsername: PropTypes.string, - defaultPassword: PropTypes.string, - onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise - onEditServerDetailsClick: PropTypes.func, - flows: PropTypes.arrayOf(PropTypes.object).isRequired, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - canSubmit: PropTypes.bool, - serverRequiresIdServer: PropTypes.bool, - }; - +export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, canSubmit: true, @@ -67,9 +90,7 @@ export default class RegistrationForm extends React.Component { super(props); this.state = { - // Field error codes by field ID fieldValid: {}, - // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: this.props.defaultUsername || "", email: this.props.defaultEmail || "", @@ -82,8 +103,9 @@ export default class RegistrationForm extends React.Component { CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); + ev.persist(); if (!this.props.canSubmit) return; @@ -93,46 +115,31 @@ export default class RegistrationForm extends React.Component { return; } - const self = this; if (this.state.email === '') { - const haveIs = Boolean(this.props.serverConfig.isUrl); - - let desc; - if (this.props.serverRequiresIdServer && !haveIs) { - desc = _t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - ); - } else if (this._showEmail()) { - desc = _t( - "If you don't specify an email address, you won't be able to reset your password. " + - "Are you sure?", - ); + if (this.showEmail()) { + CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); + Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, { + onFinished: async (confirmed: boolean, email?: string) => { + if (confirmed) { + this.setState({ + email, + }, () => { + this.doSubmit(ev); + }); + } + }, + }); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } - - CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); - - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { - title: _t("Warning!"), - description: desc, - button: _t("Continue"), - onFinished(confirmed) { - if (confirmed) { - self._doSubmit(ev); - } - }, - }); } else { - self._doSubmit(ev); + this.doSubmit(ev); } }; - _doSubmit(ev) { + private doSubmit(ev) { const email = this.state.email.trim(); CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { @@ -155,20 +162,20 @@ export default class RegistrationForm extends React.Component { } } - async verifyFieldsBeforeSubmit() { + private async verifyFieldsBeforeSubmit() { // Blur the active element if any, so we first run its blur validation, // which is less strict than the pass we're about to do below for all fields. - const activeElement = document.activeElement; + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ - FIELD_USERNAME, - FIELD_PASSWORD, - FIELD_PASSWORD_CONFIRM, - FIELD_EMAIL, - FIELD_PHONE_NUMBER, + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, ]; // Run all fields with stricter validation that no longer allows empty @@ -209,7 +216,7 @@ export default class RegistrationForm extends React.Component { /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid() { + private allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -219,7 +226,7 @@ export default class RegistrationForm extends React.Component { return true; } - findFirstInvalidField(fieldIDs) { + private findFirstInvalidField(fieldIDs: RegistrationField[]) { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -228,7 +235,7 @@ export default class RegistrationForm extends React.Component { return null; } - markFieldValid(fieldID, valid) { + private markFieldValid(fieldID: RegistrationField, valid: boolean) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ @@ -236,26 +243,26 @@ export default class RegistrationForm extends React.Component { }); } - onEmailChange = ev => { + private onEmailChange = ev => { this.setState({ email: ev.target.value, }); }; - onEmailValidate = async fieldState => { + private onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); - this.markFieldValid(FIELD_EMAIL, result.valid); + this.markFieldValid(RegistrationField.Email, result.valid); return result; }; - validateEmailRules = withValidation({ + private validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), }, @@ -267,29 +274,29 @@ export default class RegistrationForm extends React.Component { ], }); - onPasswordChange = ev => { + private onPasswordChange = ev => { this.setState({ password: ev.target.value, }); }; - onPasswordValidate = result => { - this.markFieldValid(FIELD_PASSWORD, result.valid); + private onPasswordValidate = result => { + this.markFieldValid(RegistrationField.Password, result.valid); }; - onPasswordConfirmChange = ev => { + private onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); }; - onPasswordConfirmValidate = async fieldState => { + private onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(RegistrationField.PasswordConfirm, result.valid); return result; }; - validatePasswordConfirmRules = withValidation({ + private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -298,65 +305,64 @@ export default class RegistrationForm extends React.Component { }, { key: "match", - test({ value }) { + test(this: RegistrationForm, { value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, - ], + ], }); - onPhoneCountryChange = newVal => { + private onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, - phonePrefix: newVal.prefix, }); }; - onPhoneNumberChange = ev => { + private onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); }; - onPhoneNumberValidate = async fieldState => { + private onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); - this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + this.markFieldValid(RegistrationField.PhoneNumber, result.valid); return result; }; - validatePhoneNumberRules = withValidation({ + private validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), }, { key: "email", test: ({ value }) => !value || phoneNumberLooksValid(value), - invalid: () => _t("Doesn't look like a valid phone number"), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), }, ], }); - onUsernameChange = ev => { + private onUsernameChange = ev => { this.setState({ username: ev.target.value, }); }; - onUsernameValidate = async fieldState => { + private onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); - this.markFieldValid(FIELD_USERNAME, result.valid); + this.markFieldValid(RegistrationField.Username, result.valid); return result; }; - validateUsernameRules = withValidation({ + private validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), hideDescriptionIfValid: true, rules: [ @@ -379,7 +385,7 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is required */ - _authStepIsRequired(step) { + private authStepIsRequired(step: string) { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); @@ -391,46 +397,36 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is used */ - _authStepIsUsed(step) { + private authStepIsUsed(step: string) { return this.props.flows.some((flow) => { return flow.stages.includes(step); }); } - _showEmail() { - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') - ) { + private showEmail() { + if (!this.authStepIsUsed('m.login.email.identity')) { return false; } return true; } - _showPhoneNumber() { + private showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - !threePidLogin || - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.msisdn') - ) { + if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) { return false; } return true; } - renderEmail() { - if (!this._showEmail()) { + private renderEmail() { + if (!this.showEmail()) { return null; } - const Field = sdk.getComponent('elements.Field'); - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ? _t("Email") : _t("Email (optional)"); return this[FIELD_EMAIL] = field} + ref={field => this[RegistrationField.Email] = field} type="text" label={emailPlaceholder} value={this.state.email} @@ -441,10 +437,10 @@ export default class RegistrationForm extends React.Component { />; } - renderPassword() { + private renderPassword() { return this[FIELD_PASSWORD] = field} + fieldRef={field => this[RegistrationField.Password] = field} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} @@ -455,13 +451,12 @@ export default class RegistrationForm extends React.Component { } renderPasswordConfirm() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_PASSWORD_CONFIRM] = field} + ref={field => this[RegistrationField.PasswordConfirm] = field} type="password" autoComplete="new-password" - label={_t("Confirm")} + label={_t("Confirm password")} value={this.state.passwordConfirm} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} @@ -471,12 +466,11 @@ export default class RegistrationForm extends React.Component { } renderPhoneNumber() { - if (!this._showPhoneNumber()) { + if (!this.showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - const Field = sdk.getComponent('elements.Field'); - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + const phoneLabel = this.authStepIsRequired('m.login.msisdn') ? _t("Phone") : _t("Phone (optional)"); const phoneCountry = ; return this[FIELD_PHONE_NUMBER] = field} + ref={field => this[RegistrationField.PhoneNumber] = field} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -497,13 +491,13 @@ export default class RegistrationForm extends React.Component { } renderUsername() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} + placeholder={_t("Username").toLocaleLowerCase()} value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} @@ -513,72 +507,33 @@ export default class RegistrationForm extends React.Component { } render() { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Create your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - const registerButton = ( ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
- {_t( - "Set an email for account recovery. " + - "Use email or phone to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email or phone to optionally be discoverable by existing contacts.") + }
; } else { emailHelperText =
- {_t( - "Set an email for account recovery. " + - "Use email to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email to optionally be discoverable by existing contacts.") + }
; } } - const haveIs = Boolean(this.props.serverConfig.isUrl); - let noIsText = null; - if (this.props.serverRequiresIdServer && !haveIs) { - noIsText =
- {_t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - )} -
; - } return (
-

- {yourMatrixAccountText} - {editLink} -

{this.renderUsername()} @@ -592,7 +547,6 @@ export default class RegistrationForm extends React.Component { {this.renderPhoneNumber()}
{ emailHelperText } - { noIsText } { registerButton }
diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js deleted file mode 100644 index e04bf9e25a..0000000000 --- a/src/components/views/auth/ServerConfig.js +++ /dev/null @@ -1,291 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -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 Modal from '../../../Modal'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import { createClient } from 'matrix-js-sdk/src/matrix'; -import classNames from 'classnames'; -import CountlyAnalytics from "../../../CountlyAnalytics"; - -/* - * A pure UI component which displays the HS and IS to use. - */ - -export default class ServerConfig extends React.PureComponent { - static propTypes = { - onServerConfigChange: PropTypes.func.isRequired, - - // The current configuration that the user is expecting to change. - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - - delayTimeMs: PropTypes.number, // time to wait before invoking onChanged - - // Called after the component calls onServerConfigChange - onAfterSubmit: PropTypes.func, - - // Optional text for the submit button. If falsey, no button will be shown. - submitText: PropTypes.string, - - // Optional class for the submit button. Only applies if the submit button - // is to be rendered. - submitClass: PropTypes.string, - - // Whether the flow this component is embedded in requires an identity - // server when the homeserver says it will need one. Default false. - showIdentityServerIfRequiredByHomeserver: PropTypes.bool, - }; - - static defaultProps = { - onServerConfigChange: function() {}, - delayTimeMs: 0, - }; - - constructor(props) { - super(props); - - this.state = { - busy: false, - errorText: "", - hsUrl: props.serverConfig.hsUrl, - isUrl: props.serverConfig.isUrl, - showIdentityServer: false, - }; - - CountlyAnalytics.instance.track("onboarding_custom_server"); - } - - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase - if (newProps.serverConfig.hsUrl === this.state.hsUrl && - newProps.serverConfig.isUrl === this.state.isUrl) return; - - this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); - if (!result) { - return result; - } - - // If the UI flow this component is embedded in requires an identity - // server when the homeserver says it will need one, check first and - // reveal this field if not already shown. - // XXX: This a backward compatibility path for homeservers that require - // an identity server to be passed during certain flows. - // See also https://github.com/matrix-org/synapse/pull/5868. - if ( - this.props.showIdentityServerIfRequiredByHomeserver && - !this.state.showIdentityServer && - await this.isIdentityServerRequiredByHomeserver() - ) { - this.setState({ - showIdentityServer: true, - }); - return null; - } - - return result; - } - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({ - hsUrl: defaultConfig.hsUrl, - isUrl: defaultConfig.isUrl, - busy: false, - errorText: "", - }); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - - const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); - if (!stateForError.isFatalError) { - this.setState({ - busy: false, - }); - // carry on anyway - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true); - this.props.onServerConfigChange(result); - return result; - } else { - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - } - - async isIdentityServerRequiredByHomeserver() { - // XXX: We shouldn't have to create a whole new MatrixClient just to - // check if the homeserver requires an identity server... Should it be - // extracted to a static utils function...? - return createClient({ - baseUrl: this.state.hsUrl, - }).doesServerRequireIdServerParam(); - } - - onHomeserverBlur = (ev) => { - this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { - this.validateServer(); - }); - }; - - onHomeserverChange = (ev) => { - const hsUrl = ev.target.value; - this.setState({ hsUrl }); - }; - - onIdentityServerBlur = (ev) => { - this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => { - this.validateServer(); - }); - }; - - onIdentityServerChange = (ev) => { - const isUrl = ev.target.value; - this.setState({ isUrl }); - }; - - onSubmit = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const result = await this.validateServer(); - if (!result) return; // Do not continue. - - if (this.props.onAfterSubmit) { - this.props.onAfterSubmit(); - } - }; - - _waitThenInvoke(existingTimeoutId, fn) { - if (existingTimeoutId) { - clearTimeout(existingTimeoutId); - } - return setTimeout(fn.bind(this), this.props.delayTimeMs); - } - - showHelpPopup = () => { - const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog'); - Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); - }; - - _renderHomeserverSection() { - const Field = sdk.getComponent('elements.Field'); - return
- {_t("Enter your custom homeserver URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
; - } - - _renderIdentityServerSection() { - const Field = sdk.getComponent('elements.Field'); - const classes = classNames({ - "mx_ServerConfig_identityServer": true, - "mx_ServerConfig_identityServer_shown": this.state.showIdentityServer, - }); - return
- {_t("Enter your custom identity server URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
; - } - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const errorText = this.state.errorText - ? {this.state.errorText} - : null; - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
-

{_t("Other servers")}

- {errorText} - {this._renderHomeserverSection()} - {this._renderIdentityServerSection()} - {submitButton} -
- ); - } -} diff --git a/src/components/views/auth/ServerTypeSelector.js b/src/components/views/auth/ServerTypeSelector.js deleted file mode 100644 index 71e7ac7f0e..0000000000 --- a/src/components/views/auth/ServerTypeSelector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; -import classnames from 'classnames'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import {makeType} from "../../../utils/TypeUtils"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -export const FREE = 'Free'; -export const PREMIUM = 'Premium'; -export const ADVANCED = 'Advanced'; - -export const TYPES = { - FREE: { - id: FREE, - label: () => _t('Free'), - logo: () => , - description: () => _t('Join millions for free on the largest public server'), - serverConfig: makeType(ValidatedServerConfig, { - hsUrl: "https://matrix-client.matrix.org", - hsName: "matrix.org", - hsNameIsDifferent: false, - isUrl: "https://vector.im", - }), - }, - PREMIUM: { - id: PREMIUM, - label: () => _t('Premium'), - logo: () => , - description: () => _t('Premium hosting for organisations Learn more', {}, { - a: sub => - {sub} - , - }), - identityServerUrl: "https://vector.im", - }, - ADVANCED: { - id: ADVANCED, - label: () => _t('Advanced'), - logo: () =>
- - {_t('Other')} -
, - description: () => _t('Find other public servers or use a custom server'), - }, -}; - -export function getTypeFromServerConfig(config) { - const {hsUrl} = config; - if (!hsUrl) { - return null; - } else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) { - return FREE; - } else if (new URL(hsUrl).hostname.endsWith('.modular.im')) { - // This is an unlikely case to reach, as Modular defaults to hiding the - // server type selector. - return PREMIUM; - } else { - return ADVANCED; - } -} - -export default class ServerTypeSelector extends React.PureComponent { - static propTypes = { - // The default selected type. - selected: PropTypes.string, - // Handler called when the selected type changes. - onChange: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - const { - selected, - } = props; - - this.state = { - selected, - }; - } - - updateSelectedType(type) { - if (this.state.selected === type) { - return; - } - this.setState({ - selected: type, - }); - if (this.props.onChange) { - this.props.onChange(type); - } - } - - onClick = (e) => { - e.stopPropagation(); - const type = e.currentTarget.dataset.id; - this.updateSelectedType(type); - }; - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const serverTypes = []; - for (const type of Object.values(TYPES)) { - const { id, label, logo, description } = type; - const classes = classnames( - "mx_ServerTypeSelector_type", - `mx_ServerTypeSelector_type_${id}`, - { - "mx_ServerTypeSelector_type_selected": id === this.state.selected, - }, - ); - - serverTypes.push(
-
- {label()} -
- -
- {logo()} -
-
- {description()} -
-
-
); - } - - return
- {serverTypes} -
; - } -} diff --git a/src/components/views/auth/SignInToText.js b/src/components/views/auth/SignInToText.js deleted file mode 100644 index 7564096b7d..0000000000 --- a/src/components/views/auth/SignInToText.js +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -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 * as sdk from "../../../index"; -import PropTypes from "prop-types"; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; - -export default class SignInToText extends React.PureComponent { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onEditServerDetailsClick: PropTypes.func, - }; - - render() { - let signInToText = _t('Sign in to your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - signInToText = _t('Sign in to your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - - return

- {signInToText} - {editLink} -

; - } -} diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index bc4514f8a6..6b871e4f24 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -149,7 +149,7 @@ export default class MessageContextMenu extends React.Component { onRedactClick = () => { const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed) => { + onFinished: async (proceed, reason) => { if (!proceed) return; const cli = MatrixClientPeg.get(); @@ -157,6 +157,8 @@ export default class MessageContextMenu extends React.Component { await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), + undefined, + reason ? { reason } : {}, ); } catch (e) { const code = e.errcode || e.statusCode; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 7656e70341..8026942038 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -57,7 +57,7 @@ const WidgetContextMenu: React.FC = ({ let unpinButton; if (showUnpin) { const onUnpinClick = () => { - WidgetStore.instance.unpinWidget(app.id); + WidgetStore.instance.unpinWidget(room.roomId, app.id); onFinished(); }; @@ -143,7 +143,7 @@ const WidgetContextMenu: React.FC = ({ let moveLeftButton; if (showUnpin && widgetIndex > 0) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(app.id, -1); + WidgetStore.instance.movePinnedWidget(roomId, app.id, -1); onFinished(); }; @@ -153,7 +153,7 @@ const WidgetContextMenu: React.FC = ({ let moveRightButton; if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(app.id, 1); + WidgetStore.instance.movePinnedWidget(roomId, app.id, 1); onFinished(); }; diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index 3106df1d5b..2216f9a93a 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler'; */ export default class ConfirmRedactDialog extends React.Component { render() { - const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); + const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); return ( - - + ); } } diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js index 8125bc3edd..97ae968ff3 100644 --- a/src/components/views/dialogs/InfoDialog.js +++ b/src/components/views/dialogs/InfoDialog.js @@ -31,6 +31,7 @@ export default class InfoDialog extends React.Component { onFinished: PropTypes.func, hasCloseButton: PropTypes.bool, onKeyDown: PropTypes.func, + fixedWidth: PropTypes.bool, }; static defaultProps = { @@ -54,6 +55,7 @@ export default class InfoDialog extends React.Component { contentId='mx_Dialog_content' hasCancel={this.props.hasCloseButton} onKeyDown={this.props.onKeyDown} + fixedWidth={this.props.fixedWidth} >
{ this.props.description } diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 99878569d3..c039c191c5 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -31,7 +31,7 @@ import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; -import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom"; +import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; @@ -41,6 +41,7 @@ import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import {Room} from "matrix-js-sdk/src/models/room"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -308,10 +309,14 @@ export default class InviteDialog extends React.PureComponent { // The room ID this dialog is for. Only required for KIND_INVITE. roomId: PropTypes.string, + + // Initial value to populate the filter with + initialText: PropTypes.string, }; static defaultProps = { kind: KIND_DM, + initialText: "", }; _debounceTimer: number = null; @@ -338,7 +343,7 @@ export default class InviteDialog extends React.PureComponent { this.state = { targets: [], // array of Member objects (see interface above) - filterText: "", + filterText: this.props.initialText, recents: InviteDialog.buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(alreadyInvited), @@ -356,6 +361,12 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } + componentDidMount() { + if (this.props.initialText) { + this._updateSuggestions(this.props.initialText); + } + } + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room @@ -575,7 +586,12 @@ export default class InviteDialog extends React.PureComponent { const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. - const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + let existingRoom: Room; + if (targetIds.length === 1) { + existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]); + } else { + existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + } if (existingRoom) { dis.dispatch({ action: 'view_room', @@ -687,6 +703,115 @@ export default class InviteDialog extends React.PureComponent { } }; + _updateSuggestions = async (term) => { + MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { + if (term !== this.state.filterText) { + // Discard the results - we were probably too slow on the server-side to make + // these results useful. This is a race we want to avoid because we could overwrite + // more accurate results. + return; + } + + if (!r.results) r.results = []; + + // While we're here, try and autocomplete a search result for the mxid itself + // if there's no matches (and the input looks like a mxid). + if (term[0] === '@' && term.indexOf(':') > 1) { + try { + const profile = await MatrixClientPeg.get().getProfileInfo(term); + if (profile) { + // If we have a profile, we have enough information to assume that + // the mxid can be invited - add it to the list. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: profile['displayname'], + avatar_url: profile['avatar_url'], + }); + } + } catch (e) { + console.warn("Non-fatal error trying to make an invite for a user ID"); + console.warn(e); + + // Add a result anyways, just without a profile. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: term, + avatar_url: null, + }); + } + } + + this.setState({ + serverResultsMixin: r.results.map(u => ({ + userId: u.user_id, + user: new DirectoryMember(u), + })), + }); + }).catch(e => { + console.error("Error searching user directory:"); + console.error(e); + this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal + }); + + // Whenever we search the directory, also try to search the identity server. It's + // all debounced the same anyways. + if (!this.state.canUseIdentityServer) { + // The user doesn't have an identity server set - warn them of that. + this.setState({tryingIdentityServer: true}); + return; + } + if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { + // Start off by suggesting the plain email while we try and resolve it + // to a real account. + this.setState({ + // per above: the userId is a lie here - it's just a regular identifier + threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}], + }); + try { + const authClient = new IdentityAuthClient(); + const token = await authClient.getAccessToken(); + if (term !== this.state.filterText) return; // abandon hope + + const lookup = await MatrixClientPeg.get().lookupThreePid( + 'email', + term, + undefined, // callback + token, + ); + if (term !== this.state.filterText) return; // abandon hope + + if (!lookup || !lookup.mxid) { + // We weren't able to find anyone - we're already suggesting the plain email + // as an alternative, so do nothing. + return; + } + + // We append the user suggestion to give the user an option to click + // the email anyways, and so we don't cause things to jump around. In + // theory, the user would see the user pop up and think "ah yes, that + // person!" + const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); + if (term !== this.state.filterText || !profile) return; // abandon hope + this.setState({ + threepidResultsMixin: [...this.state.threepidResultsMixin, { + user: new DirectoryMember({ + user_id: lookup.mxid, + display_name: profile.displayname, + avatar_url: profile.avatar_url, + }), + userId: lookup.mxid, + }], + }); + } catch (e) { + console.error("Error searching identity server:"); + console.error(e); + this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal + } + } + }; + _updateFilter = (e) => { const term = e.target.value; this.setState({filterText: term}); @@ -697,113 +822,8 @@ export default class InviteDialog extends React.PureComponent { if (this._debounceTimer) { clearTimeout(this._debounceTimer); } - this._debounceTimer = setTimeout(async () => { - MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { - if (term !== this.state.filterText) { - // Discard the results - we were probably too slow on the server-side to make - // these results useful. This is a race we want to avoid because we could overwrite - // more accurate results. - return; - } - - if (!r.results) r.results = []; - - // While we're here, try and autocomplete a search result for the mxid itself - // if there's no matches (and the input looks like a mxid). - if (term[0] === '@' && term.indexOf(':') > 1) { - try { - const profile = await MatrixClientPeg.get().getProfileInfo(term); - if (profile) { - // If we have a profile, we have enough information to assume that - // the mxid can be invited - add it to the list. We stick it at the - // top so it is most obviously presented to the user. - r.results.splice(0, 0, { - user_id: term, - display_name: profile['displayname'], - avatar_url: profile['avatar_url'], - }); - } - } catch (e) { - console.warn("Non-fatal error trying to make an invite for a user ID"); - console.warn(e); - - // Add a result anyways, just without a profile. We stick it at the - // top so it is most obviously presented to the user. - r.results.splice(0, 0, { - user_id: term, - display_name: term, - avatar_url: null, - }); - } - } - - this.setState({ - serverResultsMixin: r.results.map(u => ({ - userId: u.user_id, - user: new DirectoryMember(u), - })), - }); - }).catch(e => { - console.error("Error searching user directory:"); - console.error(e); - this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal - }); - - // Whenever we search the directory, also try to search the identity server. It's - // all debounced the same anyways. - if (!this.state.canUseIdentityServer) { - // The user doesn't have an identity server set - warn them of that. - this.setState({tryingIdentityServer: true}); - return; - } - if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { - // Start off by suggesting the plain email while we try and resolve it - // to a real account. - this.setState({ - // per above: the userId is a lie here - it's just a regular identifier - threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}], - }); - try { - const authClient = new IdentityAuthClient(); - const token = await authClient.getAccessToken(); - if (term !== this.state.filterText) return; // abandon hope - - const lookup = await MatrixClientPeg.get().lookupThreePid( - 'email', - term, - undefined, // callback - token, - ); - if (term !== this.state.filterText) return; // abandon hope - - if (!lookup || !lookup.mxid) { - // We weren't able to find anyone - we're already suggesting the plain email - // as an alternative, so do nothing. - return; - } - - // We append the user suggestion to give the user an option to click - // the email anyways, and so we don't cause things to jump around. In - // theory, the user would see the user pop up and think "ah yes, that - // person!" - const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); - if (term !== this.state.filterText || !profile) return; // abandon hope - this.setState({ - threepidResultsMixin: [...this.state.threepidResultsMixin, { - user: new DirectoryMember({ - user_id: lookup.mxid, - display_name: profile.displayname, - avatar_url: profile.avatar_url, - }), - userId: lookup.mxid, - }], - }); - } catch (e) { - console.error("Error searching identity server:"); - console.error(e); - this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal - } - } + this._debounceTimer = setTimeout(() => { + this._updateSuggestions(term); }, 150); // 150ms debounce (human reaction time + some) }; diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index c8a736e8a6..484e8f0dcf 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -31,12 +31,14 @@ import { ModalButtonKind, Widget, WidgetApiFromWidgetAction, + WidgetKind, } from "matrix-widget-api"; import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import RoomViewStore from "../../../stores/RoomViewStore"; import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; +import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -63,7 +65,7 @@ export default class ModalWidgetDialog extends React.PureComponent + const isDisabled = this.state.disabledButtonIds.includes(def.id); + + return { def.label } ; }); diff --git a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx new file mode 100644 index 0000000000..b7cc81c113 --- /dev/null +++ b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx @@ -0,0 +1,96 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from "react"; + +import { _t } from '../../../languageHandler'; +import { IDialogProps } from "./IDialogProps"; +import {useRef, useState} from "react"; +import Field from "../elements/Field"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import withValidation from "../elements/Validation"; +import * as Email from "../../../email"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps extends IDialogProps { + onFinished(continued: boolean, email?: string): void; +} + +const validation = withValidation({ + rules: [ + { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], +}); + +const RegistrationEmailPromptDialog: React.FC = ({onFinished}) => { + const [email, setEmail] = useState(""); + const fieldRef = useRef(); + + const onSubmit = async () => { + if (email) { + const valid = await fieldRef.current.validate({ allowEmpty: false }); + + if (!valid) { + fieldRef.current.focus(); + fieldRef.current.validate({ allowEmpty: false, focused: true }); + return; + } + } + + onFinished(true, email); + }; + + return onFinished(false)} + fixedWidth={false} + > +
+

{_t("Just a heads up, if you don't add an email and forget your password, you could " + + "permanently lose access to your account.", {}, { + b: sub => {sub}, + })}

+
+ { + setEmail(ev.target.value); + }} + onValidate={async fieldState => await validation(fieldState)} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")} + /> + +
+ +
; +}; + +export default RegistrationEmailPromptDialog; diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index a43b284c42..9d9313f08f 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -53,9 +53,9 @@ export default class RoomSettingsDialog extends React.Component { } _onAction = (payload) => { - // When room changes below us, close the room settings + // When view changes below us, close the room settings // whilst the modal is open this can only be triggered when someone hits Leave Room - if (payload.action === 'view_next_room') { + if (payload.action === 'view_home_page') { this.props.onFinished(); } }; diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx new file mode 100644 index 0000000000..9eb819c98e --- /dev/null +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -0,0 +1,203 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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, {createRef} from 'react'; + +import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import BaseDialog from './BaseDialog'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from "../elements/AccessibleButton"; +import SdkConfig from "../../../SdkConfig"; +import Field from "../elements/Field"; +import StyledRadioButton from "../elements/StyledRadioButton"; +import TextWithTooltip from "../elements/TextWithTooltip"; +import withValidation, {IFieldState} from "../elements/Validation"; + +interface IProps { + title?: string; + serverConfig: ValidatedServerConfig; + onFinished(config?: ValidatedServerConfig): void; +} + +interface IState { + defaultChosen: boolean; + otherHomeserver: string; +} + +export default class ServerPickerDialog extends React.PureComponent { + private readonly defaultServer: ValidatedServerConfig; + private readonly fieldRef = createRef(); + private validatedConf: ValidatedServerConfig; + + constructor(props) { + super(props); + + const config = SdkConfig.get(); + this.defaultServer = config["validated_server_config"] as ValidatedServerConfig; + this.state = { + defaultChosen: this.props.serverConfig.isDefault, + otherHomeserver: this.props.serverConfig.isDefault ? "" : this.props.serverConfig.hsUrl, + }; + } + + private onDefaultChosen = () => { + this.setState({ defaultChosen: true }); + }; + + private onOtherChosen = () => { + this.setState({ defaultChosen: false }); + }; + + private onHomeserverChange = (ev) => { + this.setState({ otherHomeserver: ev.target.value }); + }; + + // TODO: Do we want to support .well-known lookups here? + // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to + // find their homeserver without demanding they use "https://matrix.org" + private validate = withValidation({ + deriveData: async ({ value: hsUrl }) => { + // Always try and use the defaults first + const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; + if (defaultConfig.hsUrl === hsUrl) return {}; + + try { + this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl); + return {}; + } catch (e) { + console.error(e); + + const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); + if (!stateForError.isFatalError) { + // carry on anyway + this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true); + return {}; + } else { + let error = _t("Unable to validate homeserver/identity server"); + if (e.translatedMessage) { + error = e.translatedMessage; + } + return { error }; + } + } + }, + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Specify a homeserver"), + }, { + key: "valid", + test: async function({ value }, { error }) { + if (!value) return true; + return !error; + }, + invalid: function({ error }) { + return error; + }, + }, + ], + }); + + private onHomeserverValidate = (fieldState: IFieldState) => this.validate(fieldState); + + private onSubmit = async (ev) => { + ev.preventDefault(); + + const valid = await this.fieldRef.current.validate({ allowEmpty: false }); + + if (!valid) { + this.fieldRef.current.focus(); + this.fieldRef.current.validate({ allowEmpty: false, focused: true }); + return; + } + + this.props.onFinished(this.validatedConf); + }; + + public render() { + let text; + if (this.defaultServer.hsName === "matrix.org") { + text = _t("Matrix.org is the biggest public homeserver in the world, so it’s a good place for many."); + } + + let defaultServerName = this.defaultServer.hsName; + if (this.defaultServer.hsNameIsDifferent) { + defaultServerName = ( + + ); + } + + return +
+

+ {_t("We call the places you where you can host your account ‘homeservers’.")} {text} +

+ + + {defaultServerName} + + + + + +

+ {_t("Use your preferred Matrix homeserver if you have one, or host your own.")} +

+ + + {_t("Continue")} + + +

{_t("Learn more")}

+ + {_t("About homeservers")} + +
+
; + } +} diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx new file mode 100644 index 0000000000..535e0b7b8e --- /dev/null +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -0,0 +1,147 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import { + Capability, + Widget, + WidgetEventCapability, + WidgetKind, +} from "matrix-widget-api"; +import { objectShallowClone } from "../../../utils/objects"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import DialogButtons from "../elements/DialogButtons"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import { CapabilityText } from "../../../widgets/CapabilityText"; + +export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { + return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); +} + +function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) { + localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps)); +} + +interface IProps extends IDialogProps { + requestedCapabilities: Set; + widget: Widget; + widgetKind: WidgetKind; // TODO: Refactor into the Widget class +} + +interface IBooleanStates { + // @ts-ignore - TS wants a string key, but we know better + [capability: Capability]: boolean; +} + +interface IState { + booleanStates: IBooleanStates; + rememberSelection: boolean; +} + +export default class WidgetCapabilitiesPromptDialog extends React.PureComponent { + private eventPermissionsMap = new Map(); + + constructor(props: IProps) { + super(props); + + const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities); + parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e)); + + const states: IBooleanStates = {}; + this.props.requestedCapabilities.forEach(c => states[c] = true); + + this.state = { + booleanStates: states, + rememberSelection: true, + }; + } + + private onToggle = (capability: Capability) => { + const newStates = objectShallowClone(this.state.booleanStates); + newStates[capability] = !newStates[capability]; + this.setState({booleanStates: newStates}); + }; + + private onRememberSelectionChange = (newVal: boolean) => { + this.setState({rememberSelection: newVal}); + }; + + private onSubmit = async (ev) => { + this.closeAndTryRemember(Object.entries(this.state.booleanStates) + .filter(([_, isSelected]) => isSelected) + .map(([cap]) => cap)); + }; + + private onReject = async (ev) => { + this.closeAndTryRemember([]); // nothing was approved + }; + + private closeAndTryRemember(approved: Capability[]) { + if (this.state.rememberSelection) { + setRememberedCapabilitiesForWidget(this.props.widget, approved); + } + this.props.onFinished({approved}); + } + + public render() { + const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => { + const text = CapabilityText.for(cap, this.props.widgetKind); + const byline = text.byline + ? {text.byline} + : null; + + return ( +
+ this.onToggle(cap)} + >{text.primary} + {byline} +
+ ); + }); + + return ( + +
+
+
{_t("This widget would like to:")}
+ {checkboxRows} + } + /> +
+
+
+ ); + } +} diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js index e793b85079..7ed3d04318 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js @@ -17,18 +17,17 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; import * as sdk from "../../../index"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import WidgetUtils from "../../../utils/WidgetUtils"; -import {SettingLevel} from "../../../settings/SettingLevel"; +import {Widget} from "matrix-widget-api"; +import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore"; export default class WidgetOpenIDPermissionsDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, - widgetUrl: PropTypes.string.isRequired, - widgetId: PropTypes.string.isRequired, - isUserWidget: PropTypes.bool.isRequired, + widget: PropTypes.objectOf(Widget).isRequired, + widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api + inRoomId: PropTypes.string, }; constructor() { @@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component { if (this.state.rememberSelection) { console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`); - const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); - if (!currentValues.allow) currentValues.allow = []; - if (!currentValues.deny) currentValues.deny = []; - - const securityKey = WidgetUtils.getWidgetSecurityKey( - this.props.widgetId, - this.props.widgetUrl, - this.props.isUserWidget); - (allowed ? currentValues.allow : currentValues.deny).push(securityKey); - SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues); + WidgetPermissionStore.instance.setOIDCState( + this.props.widget, this.props.widgetKind, this.props.inRoomId, + allowed ? OIDCState.Allowed : OIDCState.Denied, + ); } this.props.onFinished(allowed); @@ -84,7 +77,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component { "A widget located at %(widgetUrl)s would like to verify your identity. " + "By allowing this, the widget will be able to verify your user ID, but not " + "perform actions as you.", { - widgetUrl: this.props.widgetUrl.split("?")[0], + widgetUrl: this.props.widget.templateUrl.split("?")[0], }, )}

diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index f3ebe24c15..7e0ae965bb 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -23,7 +23,6 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import AccessibleButton from './AccessibleButton'; import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; import Spinner from './Spinner'; @@ -375,11 +374,13 @@ export default class AppTile extends React.Component { />
); - // if the widget would be allowed to remain on screen, we must put it in - // a PersistedElement from the get-go, otherwise the iframe will be - // re-mounted later when we do. - if (this.props.whitelistCapabilities.includes('m.always_on_screen')) { - const PersistedElement = sdk.getComponent("elements.PersistedElement"); + + if (!this.props.userWidget) { + // All room widgets can theoretically be allowed to remain on screen, so we + // wrap them all in a PersistedElement from the get-go. If we wait, the iframe + // will be re-mounted later, which means the widget has to start over, which is + // bad. + // Also wrap the PersistedElement in a div to fix the height, otherwise // AppTile's border is in the wrong place appTileBody =
@@ -474,10 +475,6 @@ AppTile.propTypes = { handleMinimisePointerEvents: PropTypes.bool, // Optionally hide the popout widget icon showPopout: PropTypes.bool, - // Widget capabilities to allow by default (without user confirmation) - // NOTE -- Use with caution. This is intended to aid better integration / UX - // basic widget capabilities, e.g. injecting sticker message events. - whitelistCapabilities: PropTypes.array, // Is this an instance of a user widget userWidget: PropTypes.bool, }; @@ -488,7 +485,6 @@ AppTile.defaultProps = { showTitle: true, showPopout: true, handleMinimisePointerEvents: false, - whitelistCapabilities: [], userWidget: false, miniMode: false, }; diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index 001292b6b7..3417485eb8 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -54,6 +54,9 @@ export default class DialogButtons extends React.Component { // disables only the primary button primaryDisabled: PropTypes.bool, + + // something to stick next to the buttons, optionally + additive: PropTypes.element, }; static defaultProps = { @@ -85,8 +88,14 @@ export default class DialogButtons extends React.Component { ; } + let additive = null; + if (this.props.additive) { + additive =
{this.props.additive}
; + } + return (
+ { additive } { cancelButton } { this.props.children }
; + + { joinButton } + +
; } } @@ -109,4 +114,5 @@ DirectorySearchBox.propTypes = { onJoinClick: PropTypes.func, placeholder: PropTypes.string, showJoinButton: PropTypes.bool, + initialText: PropTypes.string, }; diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 7fd154047d..f5754da9ae 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -61,10 +61,14 @@ interface IProps { tooltipClassName?: string; // If specified, an additional class name to apply to the field container className?: string; + // On what events should validation occur; by default on all + validateOnFocus?: boolean; + validateOnBlur?: boolean; + validateOnChange?: boolean; // All other props pass through to the . } -interface IInputProps extends IProps, InputHTMLAttributes { +export interface IInputProps extends IProps, InputHTMLAttributes { // The element to create. Defaults to "input". element?: "input"; // The input's value. This is a controlled component, so the value is required. @@ -100,6 +104,9 @@ export default class Field extends React.PureComponent { public static readonly defaultProps = { element: "input", type: "text", + validateOnFocus: true, + validateOnBlur: true, + validateOnChange: true, }; /* @@ -137,9 +144,11 @@ export default class Field extends React.PureComponent { this.setState({ focused: true, }); - this.validate({ - focused: true, - }); + if (this.props.validateOnFocus) { + this.validate({ + focused: true, + }); + } // Parent component may have supplied its own `onFocus` as well if (this.props.onFocus) { this.props.onFocus(ev); @@ -147,7 +156,9 @@ export default class Field extends React.PureComponent { }; private onChange = (ev) => { - this.validateOnChange(); + if (this.props.validateOnChange) { + this.validateOnChange(); + } // Parent component may have supplied its own `onChange` as well if (this.props.onChange) { this.props.onChange(ev); @@ -158,16 +169,18 @@ export default class Field extends React.PureComponent { this.setState({ focused: false, }); - this.validate({ - focused: false, - }); + if (this.props.validateOnBlur) { + this.validate({ + focused: false, + }); + } // Parent component may have supplied its own `onBlur` as well if (this.props.onBlur) { this.props.onBlur(ev); } }; - private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) { + public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) { if (!this.props.onValidate) { return; } @@ -196,12 +209,15 @@ export default class Field extends React.PureComponent { feedbackVisible: false, }); } + + return valid; } public render() { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { element, prefixComponent, postfixComponent, className, onValidate, children, - tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props; + tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus, + ...inputProps} = this.props; // Set some defaults for the element const ref = input => this.input = input; diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index b5e117b42a..8517da6dfb 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -21,6 +21,8 @@ import AccessibleButton from "./AccessibleButton"; import Tooltip from './Tooltip'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useTimeout} from "../../../hooks/useTimeout"; +import Analytics from "../../../Analytics"; +import CountlyAnalytics from '../../../CountlyAnalytics'; export const AVATAR_SIZE = 52; @@ -56,6 +58,8 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva onChange={async (ev) => { if (!ev.target.files?.length) return; setBusy(true); + Analytics.trackEvent("mini_avatar", "upload"); + CountlyAnalytics.instance.track("mini_avatar_upload"); const file = ev.target.files[0]; const uri = await cli.uploadContent(file); await setAvatarUrl(uri); diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index 64825dfc96..a1e805c085 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component { appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), persistentWidgetInRoomId, appEvent.getId(), ); - const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId); const AppTile = sdk.getComponent('elements.AppTile'); return ; diff --git a/src/components/views/elements/SSOButton.js b/src/components/views/elements/SSOButton.js deleted file mode 100644 index 1126ae3cd7..0000000000 --- a/src/components/views/elements/SSOButton.js +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -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 PlatformPeg from "../../../PlatformPeg"; -import AccessibleButton from "./AccessibleButton"; -import {_t} from "../../../languageHandler"; - -const SSOButton = ({matrixClient, loginType, fragmentAfterLogin, ...props}) => { - const onClick = () => { - PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin); - }; - - return ( - - {_t("Sign in with single sign-on")} - - ); -}; - -SSOButton.propTypes = { - matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client - loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis - fragmentAfterLogin: PropTypes.string, -}; - -export default SSOButton; diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx new file mode 100644 index 0000000000..f819b48cf6 --- /dev/null +++ b/src/components/views/elements/SSOButtons.tsx @@ -0,0 +1,110 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import {MatrixClient} from "matrix-js-sdk/src/client"; + +import PlatformPeg from "../../../PlatformPeg"; +import AccessibleButton from "./AccessibleButton"; +import {_t} from "../../../languageHandler"; +import {IIdentityProvider, ISSOFlow} from "../../../Login"; +import classNames from "classnames"; + +interface ISSOButtonProps extends Omit { + idp: IIdentityProvider; + mini?: boolean; +} + +const SSOButton: React.FC = ({ + matrixClient, + loginType, + fragmentAfterLogin, + idp, + primary, + mini, + ...props +}) => { + const kind = primary ? "primary" : "primary_outline"; + const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on"); + + const onClick = () => { + PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id); + }; + + let icon; + if (idp && idp.icon && idp.icon.startsWith("https://")) { + icon = {label}; + } + + const classes = classNames("mx_SSOButton", { + mx_SSOButton_mini: mini, + }); + + if (mini) { + // TODO fallback icon + return ( + + { icon } + + ); + } + + return ( + + { icon } + { label } + + ); +}; + +interface IProps { + matrixClient: MatrixClient; + flow: ISSOFlow; + loginType?: "sso" | "cas"; + fragmentAfterLogin?: string; + primary?: boolean; +} + +const SSOButtons: React.FC = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => { + const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || []; + if (providers.length < 2) { + return
+ +
; + } + + return
+ { providers.map(idp => ( + + )) } +
; +}; + +export default SSOButtons; diff --git a/src/components/views/elements/ServerPicker.tsx b/src/components/views/elements/ServerPicker.tsx new file mode 100644 index 0000000000..7637ab7b8d --- /dev/null +++ b/src/components/views/elements/ServerPicker.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 AccessibleButton from "./AccessibleButton"; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import {_t} from "../../../languageHandler"; +import TextWithTooltip from "./TextWithTooltip"; +import SdkConfig from "../../../SdkConfig"; +import Modal from "../../../Modal"; +import ServerPickerDialog from "../dialogs/ServerPickerDialog"; +import InfoDialog from "../dialogs/InfoDialog"; + +interface IProps { + title?: string; + dialogTitle?: string; + serverConfig: ValidatedServerConfig; + onServerConfigChange?(config: ValidatedServerConfig): void; +} + +const showPickerDialog = ( + title: string, + serverConfig: ValidatedServerConfig, + onFinished: (config: ValidatedServerConfig) => void, +) => { + Modal.createTrackedDialog("Server Picker", "", ServerPickerDialog, { title, serverConfig, onFinished }); +}; + +const onHelpClick = () => { + Modal.createTrackedDialog('Custom Server Dialog', '', InfoDialog, { + title: _t("Server Options"), + description: _t("You can use the custom server options to sign into other Matrix servers by specifying " + + "a different homeserver URL. This allows you to use Element with an existing Matrix account on " + + "a different homeserver."), + button: _t("Dismiss"), + hasCloseButton: false, + fixedWidth: false, + }, "mx_ServerPicker_helpDialog"); +}; + +const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => { + let editBtn; + if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) { + const onClick = () => { + showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => { + if (config) { + onServerConfigChange(config); + } + }); + }; + editBtn = + {_t("Edit")} + ; + } + + let serverName = serverConfig.hsName; + if (serverConfig.hsNameIsDifferent) { + serverName = ; + } + + let desc; + if (serverConfig.hsName === "matrix.org") { + desc = + {_t("Join millions for free on the largest public server")} + ; + } + + return
+

{title || _t("Homeserver")}

+ + {serverName} + { editBtn } + { desc } +
+} + +export default ServerPicker; diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index 31f7c866b1..1b0659ab9b 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -32,7 +32,7 @@ interface IRule { interface IArgs { rules: IRule[]; - description(this: T, derivedData: D): React.ReactChild; + description?(this: T, derivedData: D): React.ReactChild; hideDescriptionIfValid?: boolean; deriveData?(data: Data): Promise; } diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx index 82aa32d3b7..b87efd472a 100644 --- a/src/components/views/messages/MJitsiWidgetEvent.tsx +++ b/src/components/views/messages/MJitsiWidgetEvent.tsx @@ -35,7 +35,7 @@ export default class MJitsiWidgetEvent extends React.PureComponent { const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender(); let joinCopy = _t('Join the conference at the top of this room'); - if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) { + if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) { joinCopy = _t('Join the conference from the room information card on the right'); } diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index fb987a4f0d..9628f11809 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -39,6 +39,8 @@ interface IState { } export default class MVideoBody extends React.PureComponent { + private videoRef = React.createRef(); + constructor(props) { super(props); this.state = { @@ -71,7 +73,7 @@ export default class MVideoBody extends React.PureComponent { } } - _getContentUrl(): string|null { + private getContentUrl(): string|null { const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { return this.state.decryptedUrl; @@ -80,7 +82,12 @@ export default class MVideoBody extends React.PureComponent { } } - _getThumbUrl(): string|null { + private hasContentUrl(): boolean { + const url = this.getContentUrl(); + return url && !url.startsWith("data:"); + } + + private getThumbUrl(): string|null { const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { return this.state.decryptedThumbnailUrl; @@ -118,7 +125,10 @@ export default class MVideoBody extends React.PureComponent { } else { console.log("NOT preloading video"); this.setState({ - decryptedUrl: null, + // For Chrome and Electron, we need to set some non-empty `src` to + // enable the play button. Firefox does not seem to care either + // way, so it's fine to do for all browsers. + decryptedUrl: `data:${content?.info?.mimetype},`, decryptedThumbnailUrl: thumbnailUrl, decryptedBlob: null, }); @@ -142,8 +152,8 @@ export default class MVideoBody extends React.PureComponent { } } - async _videoOnPlay() { - if (this._getContentUrl() || this.state.fetchingData || this.state.error) { + private videoOnPlay = async () => { + if (this.hasContentUrl() || this.state.fetchingData || this.state.error) { // We have the file, we are fetching the file, or there is an error. return; } @@ -164,6 +174,9 @@ export default class MVideoBody extends React.PureComponent { decryptedUrl: contentUrl, decryptedBlob: decryptedBlob, fetchingData: false, + }, () => { + if (!this.videoRef.current) return; + this.videoRef.current.play(); }); this.props.onHeightChanged(); } @@ -195,8 +208,8 @@ export default class MVideoBody extends React.PureComponent { ); } - const contentUrl = this._getContentUrl(); - const thumbUrl = this._getThumbUrl(); + const contentUrl = this.getContentUrl(); + const thumbUrl = this.getThumbUrl(); let height = null; let width = null; let poster = null; @@ -215,9 +228,20 @@ export default class MVideoBody extends React.PureComponent { } return ( - diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 621e85e1d4..4ce4b75f9b 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -83,9 +83,10 @@ export const useWidgets = (room: Room) => { interface IAppRowProps { app: IApp; + room: Room; } -const AppRow: React.FC = ({ app }) => { +const AppRow: React.FC = ({ app, room }) => { const name = WidgetUtils.getWidgetName(app); const dataTitle = WidgetUtils.getWidgetDataTitle(app); const subtitle = dataTitle && " - " + dataTitle; @@ -100,10 +101,10 @@ const AppRow: React.FC = ({ app }) => { }); }; - const isPinned = WidgetStore.instance.isPinned(app.id); + const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id); const togglePin = isPinned - ? () => { WidgetStore.instance.unpinWidget(app.id); } - : () => { WidgetStore.instance.pinWidget(app.id); }; + ? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); } + : () => { WidgetStore.instance.pinWidget(room.roomId, app.id); }; const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); let contextMenu; @@ -118,7 +119,7 @@ const AppRow: React.FC = ({ app }) => { />; } - const cannotPin = !isPinned && !WidgetStore.instance.canPin(app.id); + const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id); let pinTitle: string; if (cannotPin) { @@ -183,7 +184,7 @@ const AppsSection: React.FC = ({ room }) => { }; return - { apps.map(app => ) } + { apps.map(app => ) } { apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 66b689ddb9..cdb4c43b09 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -28,7 +28,7 @@ import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; import {_t} from '../../../languageHandler'; -import createRoom, {privateShouldBeEncrypted} from '../../../createRoom'; +import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; @@ -105,17 +105,7 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice }; async function openDMForUser(matrixClient: MatrixClient, userId: string) { - const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId); - const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => { - const room = matrixClient.getRoom(roomId); - if (!room || room.getMyMembership() === "leave") { - return lastActiveRoom; - } - if (!lastActiveRoom || lastActiveRoom.getLastActiveTimestamp() < room.getLastActiveTimestamp()) { - return room; - } - return lastActiveRoom; - }, null); + const lastActiveRoom = findDMForUser(matrixClient, userId); if (lastActiveRoom) { dis.dispatch({ diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 7dbb77df18..593bd0dde7 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -42,7 +42,7 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { const apps = useWidgets(room); const app = apps.find(a => a.id === widgetId); - const isPinned = app && WidgetStore.instance.isPinned(app.id); + const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); @@ -103,7 +103,6 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { creatorUserId={app.creatorUserId} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} waitForIframeLoad={app.waitForIframeLoad} - whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)} /> ; }; diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 9cf213b44e..3208844bc5 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component { if (!this.props.showApps) return
; const apps = this.state.apps.map((app, index, arr) => { - const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId); - return (); }); diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 6fc1d3e1ad..c59b3555b9 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -29,9 +29,10 @@ import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; -import {Key} from "../../../Keyboard"; +import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; function _isReply(mxEvent) { @@ -136,7 +137,10 @@ export default class EditMessageComposer extends React.Component { if (event.metaKey || event.altKey || event.shiftKey) { return; } - if (event.key === Key.ENTER) { + const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); + const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) + : event.key === Key.ENTER; + if (send) { this._sendEdit(); event.preventDefault(); } else if (event.key === Key.ESCAPE) { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index c358ef610d..11277daa57 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -745,13 +745,22 @@ export default class EventTile extends React.Component { } if (this.props.mxEvent.sender && avatarSize) { + let member; + // set member to receiver (target) if it is a 3PID invite + // so that the correct avatar is shown as the text is + // `$target accepted the invitation for $email` + if (this.props.mxEvent.getContent().third_party_invite) { + member = this.props.mxEvent.target; + } else { + member = this.props.mxEvent.sender; + } avatar = ( -
- -
+
+ +
); } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index d952c137cd..6e677f2b01 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -58,6 +58,7 @@ interface IProps { interface IState { sublists: ITagMap; + isNameFiltering: boolean; } const TAG_ORDER: TagID[] = [ @@ -183,6 +184,7 @@ export default class RoomList extends React.PureComponent { this.state = { sublists: {}, + isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(), }; this.dispatcherRef = defaultDispatcher.register(this.onAction); @@ -253,7 +255,8 @@ export default class RoomList extends React.PureComponent { return CustomRoomTagStore.getTags()[t]; }); - let doUpdate = arrayHasDiff(previousListIds, newListIds); + const isNameFiltering = !!RoomListStore.instance.getFirstNameFilterCondition(); + let doUpdate = this.state.isNameFiltering !== isNameFiltering || arrayHasDiff(previousListIds, newListIds); if (!doUpdate) { // so we didn't have the visible sublists change, but did the contents of those // sublists change significantly enough to break the sticky headers? Probably, so @@ -275,14 +278,20 @@ export default class RoomList extends React.PureComponent { const newSublists = objectWithOnly(newLists, newListIds); const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v)); - this.setState({sublists}, () => { + this.setState({sublists, isNameFiltering}, () => { this.props.onResize(); }); } }; + private onStartChat = () => { + const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; + dis.dispatch({ action: "view_create_chat", initialText }); + }; + private onExplore = () => { - dis.fire(Action.ViewRoomDirectory); + const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; + dis.dispatch({ action: Action.ViewRoomDirectory, initialText }); }; private renderCommunityInvites(): TemporaryTile[] { @@ -332,8 +341,9 @@ export default class RoomList extends React.PureComponent { return p; }, [] as TagID[]); - // show a skeleton UI if the user is in no rooms - const showSkeleton = Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length); + // show a skeleton UI if the user is in no rooms and they are not filtering + const showSkeleton = !this.state.isNameFiltering && + Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length); for (const orderedTagId of tagOrder) { const orderedRooms = this.state.sublists[orderedTagId] || []; @@ -370,10 +380,21 @@ export default class RoomList extends React.PureComponent { public render() { let explorePrompt: JSX.Element; if (!this.props.isMinimized) { - if (RoomListStore.instance.getFirstNameFilterCondition()) { + if (this.state.isNameFiltering) { explorePrompt =
{_t("Can't see what you’re looking for?")}
- + + {_t("Start a new chat")} + + {_t("Explore all public rooms")}
; @@ -385,7 +406,18 @@ export default class RoomList extends React.PureComponent { if (unfilteredRooms.length < 1 && unfilteredHistorical < 1) { explorePrompt =
{_t("Use the + to make a new room or explore existing ones below")}
- + + {_t("Start a new chat")} + + {_t("Explore all public rooms")}
; diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index b5ae3285b9..a2574bf60c 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -422,7 +422,7 @@ export default class RoomSublist extends React.Component { room = this.state.rooms && this.state.rooms[0]; } else { // find the first room with a count of the same colour as the badge count - room = this.state.rooms.find((r: Room) => { + room = RoomListStore.instance.unfilteredLists[this.props.tagId].find((r: Room) => { const notifState = this.notificationState.getForRoom(r); return notifState.count > 0 && notifState.color === this.notificationState.color; }); diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 5e0611a953..cd9ae33357 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -38,10 +38,11 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import {_t, _td} from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import {Key} from "../../../Keyboard"; +import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; @@ -142,7 +143,11 @@ export default class SendMessageComposer extends React.Component { return; } const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; - if (event.key === Key.ENTER && !hasModifier) { + const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); + const send = ctrlEnterToSend + ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) + : event.key === Key.ENTER && !hasModifier; + if (send) { this._sendMessage(); event.preventDefault(); } else if (event.key === Key.ARROW_UP) { diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index ae7ed48898..0b81f82721 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component { showPopout={false} onMinimiseClick={this._onHideStickersClick} handleMinimisePointerEvents={true} - whitelistCapabilities={['m.sticker', 'visibility']} userWidget={true} /> diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index bafbc816b9..22b758b1ca 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -21,9 +21,18 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import AccessibleButton from '../elements/AccessibleButton'; import Spinner from '../elements/Spinner'; +import withValidation from '../elements/Validation'; import { _t } from '../../../languageHandler'; import * as sdk from "../../../index"; import Modal from "../../../Modal"; +import PassphraseField from "../auth/PassphraseField"; +import CountlyAnalytics from "../../../CountlyAnalytics"; + +const FIELD_OLD_PASSWORD = 'field_old_password'; +const FIELD_NEW_PASSWORD = 'field_new_password'; +const FIELD_NEW_PASSWORD_CONFIRM = 'field_new_password_confirm'; + +const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. export default class ChangePassword extends React.Component { static propTypes = { @@ -63,6 +72,7 @@ export default class ChangePassword extends React.Component { } state = { + fieldValid: {}, phase: ChangePassword.Phases.Edit, oldPassword: "", newPassword: "", @@ -168,26 +178,84 @@ export default class ChangePassword extends React.Component { ); }; + markFieldValid(fieldID, valid) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + onChangeOldPassword = (ev) => { this.setState({ oldPassword: ev.target.value, }); }; + onOldPasswordValidate = async fieldState => { + const result = await this.validateOldPasswordRules(fieldState); + this.markFieldValid(FIELD_OLD_PASSWORD, result.valid); + return result; + }; + + validateOldPasswordRules = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Passwords can't be empty"), + }, + ], + }); + onChangeNewPassword = (ev) => { this.setState({ newPassword: ev.target.value, }); }; + onNewPasswordValidate = result => { + this.markFieldValid(FIELD_NEW_PASSWORD, result.valid); + }; + onChangeNewPasswordConfirm = (ev) => { this.setState({ newPasswordConfirm: ev.target.value, }); }; - onClickChange = (ev) => { + onNewPasswordConfirmValidate = async fieldState => { + const result = await this.validatePasswordConfirmRules(fieldState); + this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid); + return result; + }; + + validatePasswordConfirmRules = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Confirm password"), + }, + { + key: "match", + test({ value }) { + return !value || value === this.state.newPassword; + }, + invalid: () => _t("Passwords don't match"), + }, + ], + }); + + onClickChange = async (ev) => { ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + const oldPassword = this.state.oldPassword; const newPassword = this.state.newPassword; const confirmPassword = this.state.newPasswordConfirm; @@ -201,9 +269,75 @@ export default class ChangePassword extends React.Component { } }; - render() { - // TODO: Live validation on `new pw == confirm pw` + async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement; + if (activeElement) { + activeElement.blur(); + } + const fieldIDsInDisplayOrder = [ + FIELD_OLD_PASSWORD, + FIELD_NEW_PASSWORD, + FIELD_NEW_PASSWORD_CONFIRM, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise(resolve => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + allFieldsValid() { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { + if (!this.state.fieldValid[keys[i]]) { + return false; + } + } + return true; + } + + findFirstInvalidField(fieldIDs) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + render() { const rowClassName = this.props.rowClassName; const buttonClassName = this.props.buttonClassName; @@ -213,28 +347,35 @@ export default class ChangePassword extends React.Component {
this[FIELD_OLD_PASSWORD] = field} type="password" label={_t('Current password')} value={this.state.oldPassword} onChange={this.onChangeOldPassword} + onValidate={this.onOldPasswordValidate} />
- this[FIELD_NEW_PASSWORD] = field} type="password" - label={_t('New Password')} + label='New Password' + minScore={PASSWORD_MIN_SCORE} value={this.state.newPassword} autoFocus={this.props.autoFocusNewPasswordInput} onChange={this.onChangeNewPassword} + onValidate={this.onNewPasswordValidate} autoComplete="new-password" />
this[FIELD_NEW_PASSWORD_CONFIRM] = field} type="password" label={_t("Confirm password")} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} + onValidate={this.onNewPasswordConfirmValidate} autoComplete="new-password" />
diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.js index 1fe18cb207..ec6ccacc9a 100644 --- a/src/components/views/settings/EventIndexPanel.js +++ b/src/components/views/settings/EventIndexPanel.js @@ -130,10 +130,13 @@ export default class EventIndexPanel extends React.Component {
{_t("Securely cache encrypted messages locally for them " + - "to appear in search results, using %(size)s to store messages from %(count)s rooms.", + "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", { size: formatBytes(this.state.eventIndexSize, 0), - count: formatCountLong(this.state.roomCount), + // This drives the singular / plural string + // selection for "room" / "rooms" only. + count: this.state.roomCount, + rooms: formatCountLong(this.state.roomCount), }, )}
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 9f9acd8e3c..209f245b11 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -394,7 +394,7 @@ export default class AppearanceUserSettingsTab extends React.Component this.setState({showAdvanced: !this.state.showAdvanced})} > - {this.state.showAdvanced ? "Hide advanced" : "Show advanced"} + {this.state.showAdvanced ? _t("Hide advanced") : _t("Show advanced")}
; let advanced: React.ReactNode; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index bba337ee85..d6a4921f1a 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -33,6 +33,7 @@ export default class PreferencesUserSettingsTab extends React.Component { 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.suggestEmoji', 'sendTypingNotifications', + 'MessageComposerInput.ctrlEnterToSend', ]; static TIMELINE_SETTINGS = [ diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 3d9235792b..8e1b0dd963 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp"; import SettingsStore from "../../../settings/SettingsStore"; import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +const SHOW_CALL_IN_STATES = [ + CallState.Connected, + CallState.InviteSent, + CallState.Connecting, + CallState.CreateAnswer, + CallState.CreateOffer, + CallState.WaitLocalMedia, +]; + interface IProps { } @@ -94,14 +103,13 @@ export default class CallPreview extends React.Component { const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId); const showCall = ( this.state.activeCall && - this.state.activeCall.state === CallState.Connected && + SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) && !callForRoom ); if (showCall) { return ( diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 653a72cca0..db6d2b7ae0 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,12 +21,13 @@ import dis from '../../../dispatcher/dispatcher'; import CallHandler from '../../../CallHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import AccessibleButton from '../elements/AccessibleButton'; import VideoFeed, { VideoFeedType } from "./VideoFeed"; import RoomAvatar from "../avatars/RoomAvatar"; -import PulsedAvatar from '../avatars/PulsedAvatar'; import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent } from 'matrix-js-sdk/src/webrtc/call'; +import classNames from 'classnames'; +import AccessibleButton from '../elements/AccessibleButton'; +import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard'; interface IProps { // js-sdk room object. If set, we will only show calls for the given @@ -43,9 +44,6 @@ interface IProps { // in a way that is likely to cause a resize. onResize?: any; - // classname applied to view, - className?: string; - // Whether to show the hang up icon:W showHangup?: boolean; } @@ -53,6 +51,10 @@ interface IProps { interface IState { call: MatrixCall; isLocalOnHold: boolean, + micMuted: boolean, + vidMuted: boolean, + callState: CallState, + controlsVisible: boolean, } function getFullScreenElement() { @@ -83,10 +85,15 @@ function exitFullscreen() { if (exitMethod) exitMethod.call(document); } +const CONTROLS_HIDE_DELAY = 1000; +// Height of the header duplicated from CSS because we need to subtract it from our max +// height to get the max height of the video +const HEADER_HEIGHT = 44; + export default class CallView extends React.Component { private dispatcherRef: string; - private container = createRef(); - + private contentRef = createRef(); + private controlsHideTimer: number = null; constructor(props: IProps) { super(props); @@ -94,6 +101,10 @@ export default class CallView extends React.Component { this.state = { call, isLocalOnHold: call ? call.isLocalOnHold() : null, + micMuted: call ? call.isMicrophoneMuted() : null, + vidMuted: call ? call.isLocalVideoMuted() : null, + callState: call ? call.state : null, + controlsVisible: true, } this.updateCallListeners(null, call); @@ -101,9 +112,11 @@ export default class CallView extends React.Component { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + document.addEventListener('keydown', this.onNativeKeyDown); } public componentWillUnmount() { + document.removeEventListener("keydown", this.onNativeKeyDown); this.updateCallListeners(this.state.call, null); dis.unregister(this.dispatcherRef); } @@ -111,11 +124,11 @@ export default class CallView extends React.Component { private onAction = (payload) => { switch (payload.action) { case 'video_fullscreen': { - if (!this.container.current) { + if (!this.contentRef.current) { return; } if (payload.fullscreen) { - requestFullscreen(this.container.current); + requestFullscreen(this.contentRef.current); } else if (getFullScreenElement()) { exitFullscreen(); } @@ -125,9 +138,21 @@ export default class CallView extends React.Component { const newCall = this.getCall(); if (newCall !== this.state.call) { this.updateCallListeners(this.state.call, newCall); + let newControlsVisible = this.state.controlsVisible; + if (newCall && !this.state.call) { + newControlsVisible = true; + if (this.controlsHideTimer !== null) { + clearTimeout(this.controlsHideTimer); + } + this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY); + } this.setState({ call: newCall, isLocalOnHold: newCall ? newCall.isLocalOnHold() : null, + micMuted: newCall ? newCall.isMicrophoneMuted() : null, + vidMuted: newCall ? newCall.isLocalVideoMuted() : null, + callState: newCall ? newCall.state : null, + controlsVisible: newControlsVisible, }); } if (!newCall && getFullScreenElement()) { @@ -144,11 +169,6 @@ export default class CallView extends React.Component { if (this.props.room) { const roomId = this.props.room.roomId; call = CallHandler.sharedInstance().getCallForRoom(roomId); - - // We don't currently show voice calls in this view when in the room: - // they're represented in the room status bar at the bottom instead - // (but this will all change with the new designs) - if (call && call.type == CallType.Voice) call = null; } else { call = CallHandler.sharedInstance().getAnyActiveCall(); // Ignore calls if we can't get the room associated with them. @@ -160,7 +180,7 @@ export default class CallView extends React.Component { } } - if (call && call.state == CallState.Ended) return null; + if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null; return call; } @@ -177,67 +197,240 @@ export default class CallView extends React.Component { }); }; - public render() { - let view: React.ReactNode; + private onFullscreenClick = () => { + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); + }; - if (this.state.call) { - if (this.state.call.type === "voice") { - const client = MatrixClientPeg.get(); - const callRoom = client.getRoom(this.state.call.roomId); + private onExpandClick = () => { + dis.dispatch({ + action: 'view_room', + room_id: this.state.call.roomId, + }); + }; - let caption = _t("Active call"); - if (this.state.isLocalOnHold) { - // we currently have no UI for holding / unholding a call (apart from slash - // commands) so we don't disintguish between when we've put the call on hold - // (ie. we'd show an unhold button) and when the other side has put us on hold - // (where obviously we would not show such a button). - caption = _t("Call Paused"); + private onControlsHideTimer = () => { + this.controlsHideTimer = null; + this.setState({ + controlsVisible: false, + }); + } + + private onMouseMove = () => { + this.showControls(); + } + + private showControls() { + if (!this.state.controlsVisible) { + this.setState({ + controlsVisible: true, + }); + } + if (this.controlsHideTimer !== null) { + clearTimeout(this.controlsHideTimer); + } + this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY); + } + + private onMicMuteClick = () => { + if (!this.state.call) return; + + const newVal = !this.state.micMuted; + + this.state.call.setMicrophoneMuted(newVal); + this.setState({micMuted: newVal}); + } + + private onVidMuteClick = () => { + if (!this.state.call) return; + + const newVal = !this.state.vidMuted; + + this.state.call.setLocalVideoMuted(newVal); + this.setState({vidMuted: newVal}); + } + + // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire + // Note that this assumes we always have a callview on screen at any given time + // CallHandler would probably be a better place for this + private onNativeKeyDown = ev => { + let handled = false; + const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); + + switch (ev.key) { + case Key.D: + if (ctrlCmdOnly) { + this.onMicMuteClick(); + // show the controls to give feedback + this.showControls(); + handled = true; } + break; - view = - - - -
-

{callRoom.name}

-

{ caption }

-
-
; - } else { - // For video calls, we currently ignore the call hold state altogether - // (the video will just go black) - - // if we're fullscreen, we don't want to set a maxHeight on the video element. - const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight; - view =
- - -
; - } + case Key.E: + if (ctrlCmdOnly) { + this.onVidMuteClick(); + // show the controls to give feedback + this.showControls(); + handled = true; + } + break; } - let hangup: React.ReactNode; - if (this.props.showHangup) { - hangup =
{ - dis.dispatch({ - action: 'hangup', - room_id: this.state.call.roomId, - }); - }} + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + private onRoomAvatarClick = () => { + dis.dispatch({ + action: 'view_room', + room_id: this.state.call.roomId, + }); + } + + public render() { + if (!this.state.call) return null; + + const client = MatrixClientPeg.get(); + const callRoom = client.getRoom(this.state.call.roomId); + + let callControls; + if (this.props.room) { + const micClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_micOn: !this.state.micMuted, + mx_CallView_callControls_button_micOff: this.state.micMuted, + }); + + const vidClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_vidOn: !this.state.vidMuted, + mx_CallView_callControls_button_vidOff: this.state.vidMuted, + }); + + // Put the other states of the mic/video icons in the document to make sure they're cached + // (otherwise the icon disappears briefly when toggled) + const micCacheClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_micOn: this.state.micMuted, + mx_CallView_callControls_button_micOff: !this.state.micMuted, + mx_CallView_callControls_button_invisible: true, + }); + + const vidCacheClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_vidOn: this.state.micMuted, + mx_CallView_callControls_button_vidOff: !this.state.micMuted, + mx_CallView_callControls_button_invisible: true, + }); + + const callControlsClasses = classNames({ + mx_CallView_callControls: true, + mx_CallView_callControls_hidden: !this.state.controlsVisible, + }); + + const vidMuteButton = this.state.call.type === CallType.Video ?
: null; + + callControls =
+
+
{ + dis.dispatch({ + action: 'hangup', + room_id: this.state.call.roomId, + }); + }} + /> + {vidMuteButton} +
+
+
; + } + + // The 'content' for the call, ie. the videos for a video call and profile picture + // for voice calls (fills the bg) + let contentView: React.ReactNode; + + if (this.state.call.type === CallType.Video) { + // if we're fullscreen, we don't want to set a maxHeight on the video element. + const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT; + contentView =
+ + + {callControls} +
; + } else { + const avatarSize = this.props.room ? 200 : 75; + contentView =
+ + {callControls} +
; + } + + const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call"); + let myClassName; + + let fullScreenButton; + if (this.state.call.type === CallType.Video && this.props.room) { + fullScreenButton =
; } - return
- {view} - {hangup} + let expandButton; + if (!this.props.room) { + expandButton =
; + } + + const headerControls =
+ {fullScreenButton} + {expandButton} +
; + + let header: React.ReactNode; + if (this.props.room) { + header =
+
+ {callTypeText} + {headerControls} +
; + myClassName = 'mx_CallView_large'; + } else { + header =
+ + + +
+
{callRoom.name}
+
{callTypeText}
+
+ {headerControls} +
; + myClassName = 'mx_CallView_pip'; + } + + return
+ {header} + {contentView}
; } } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 355dff9ff6..0403a9eb75 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import CallHandler from '../../../CallHandler'; -import PulsedAvatar from '../avatars/PulsedAvatar'; import RoomAvatar from '../avatars/RoomAvatar'; import FormButton from '../elements/FormButton'; import { CallState } from 'matrix-js-sdk/lib/webrtc/call'; @@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component { return
- - - +

{caller}

{incomingCallText}

diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 9dba9fa9c8..5fb71a6d69 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component { let videoStyle = {}; if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; - return
- -
; + return