diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aac4e29749..151888a17ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,86 @@ +Changes in [3.10.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0) (2020-12-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0-rc.1...v3.10.0) + + * Upgrade to JS SDK 9.3.0 + +Changes in [3.10.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0-rc.1) (2020-12-02) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0...v3.10.0-rc.1) + + * Upgrade to JS SDK 9.3.0-rc.1 + * Translations update from Weblate + [\#5461](https://github.com/matrix-org/matrix-react-sdk/pull/5461) + * Fix VoIP call plinth on dark theme + [\#5460](https://github.com/matrix-org/matrix-react-sdk/pull/5460) + * Add sanity checking around widget pinning + [\#5459](https://github.com/matrix-org/matrix-react-sdk/pull/5459) + * Update i18n for Appearance User Settings + [\#5457](https://github.com/matrix-org/matrix-react-sdk/pull/5457) + * Only show 'answered elsewhere' if we tried to answer too + [\#5455](https://github.com/matrix-org/matrix-react-sdk/pull/5455) + * Fixed Avatar for 3PID invites + [\#5442](https://github.com/matrix-org/matrix-react-sdk/pull/5442) + * Slightly better error if we can't capture user media + [\#5449](https://github.com/matrix-org/matrix-react-sdk/pull/5449) + * Make it possible in-code to hide rooms from the room list + [\#5445](https://github.com/matrix-org/matrix-react-sdk/pull/5445) + * Fix the stickerpicker + [\#5447](https://github.com/matrix-org/matrix-react-sdk/pull/5447) + * Add live password validation to change password dialog + [\#5436](https://github.com/matrix-org/matrix-react-sdk/pull/5436) + * LaTeX rendering in element-web using KaTeX + [\#5244](https://github.com/matrix-org/matrix-react-sdk/pull/5244) + * Add lifecycle customisation point after logout + [\#5448](https://github.com/matrix-org/matrix-react-sdk/pull/5448) + * Simplify UserMenu for Guests as they can't use most of the options + [\#5421](https://github.com/matrix-org/matrix-react-sdk/pull/5421) + * Fix known issues with modal widgets + [\#5444](https://github.com/matrix-org/matrix-react-sdk/pull/5444) + * Fix existing widgets not having approved capabilities for their function + [\#5443](https://github.com/matrix-org/matrix-react-sdk/pull/5443) + * Use the WidgetDriver to run OIDC requests + [\#5440](https://github.com/matrix-org/matrix-react-sdk/pull/5440) + * Add a customisation point for widget permissions and fix amnesia issues + [\#5439](https://github.com/matrix-org/matrix-react-sdk/pull/5439) + * Fix Widget event notification text including spurious space + [\#5441](https://github.com/matrix-org/matrix-react-sdk/pull/5441) + * Move call listener out of MatrixChat + [\#5438](https://github.com/matrix-org/matrix-react-sdk/pull/5438) + * New Look in-Call View + [\#5432](https://github.com/matrix-org/matrix-react-sdk/pull/5432) + * Support arbitrary widgets sticking to the screen + sending stickers + [\#5435](https://github.com/matrix-org/matrix-react-sdk/pull/5435) + * Auth typescripting and validation tweaks + [\#5433](https://github.com/matrix-org/matrix-react-sdk/pull/5433) + * Add new widget API actions for changing rooms and sending/receiving events + [\#5385](https://github.com/matrix-org/matrix-react-sdk/pull/5385) + * Revert room header click behaviour to opening room settings + [\#5434](https://github.com/matrix-org/matrix-react-sdk/pull/5434) + * Add option to send/edit a message with Ctrl + Enter / Command + Enter + [\#5160](https://github.com/matrix-org/matrix-react-sdk/pull/5160) + * Add Analytics instrumentation to the Homepage + [\#5409](https://github.com/matrix-org/matrix-react-sdk/pull/5409) + * Fix encrypted video playback in Chrome-based browsers + [\#5430](https://github.com/matrix-org/matrix-react-sdk/pull/5430) + * Add border-radius for video + [\#5333](https://github.com/matrix-org/matrix-react-sdk/pull/5333) + * Push name to the end, near text, in IRC layout + [\#5166](https://github.com/matrix-org/matrix-react-sdk/pull/5166) + * Disable notifications for the room you have recently been active in + [\#5325](https://github.com/matrix-org/matrix-react-sdk/pull/5325) + * Search through the list of unfiltered rooms rather than the rooms in the + state which are already filtered by the search text + [\#5331](https://github.com/matrix-org/matrix-react-sdk/pull/5331) + * Lighten blockquote colour in dark mode + [\#5353](https://github.com/matrix-org/matrix-react-sdk/pull/5353) + * Specify community description img must be mxc urls + [\#5364](https://github.com/matrix-org/matrix-react-sdk/pull/5364) + * Add keyboard shortcut to close the current conversation + [\#5253](https://github.com/matrix-org/matrix-react-sdk/pull/5253) + * Redirect user home from auth screens if they are already logged in + [\#5423](https://github.com/matrix-org/matrix-react-sdk/pull/5423) + 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) diff --git a/package.json b/package.json index c2160650fe0..a318618ae82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.9.0", + "version": "3.10.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -58,6 +58,7 @@ "blueimp-canvas-to-blob": "^3.27.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", + "cheerio": "^1.0.0-rc.3", "classnames": "^2.2.6", "commonmark": "^0.29.1", "counterpart": "^0.18.6", @@ -76,10 +77,11 @@ "highlight.js": "^10.1.2", "html-entities": "^1.3.1", "is-ip": "^2.0.0", + "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.19", - "matrix-js-sdk": "9.2.0", - "matrix-widget-api": "^0.1.0-beta.8", + "matrix-js-sdk": "9.3.0", + "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 0317e89d203..7ab88d6f02b 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 eae67a84a2e..445ed70ff41 100644 --- a/res/css/_components.scss +++ b/res/css/_components.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"; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 2d5359c0eb2..5bf2aee3aed 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 6a352d46a3c..84c21364ce8 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/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7acf..cbddd97e186 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/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss new file mode 100644 index 00000000000..176919b84c9 --- /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/messages/_MVideoBody.scss b/res/css/views/messages/_MVideoBody.scss index 3b05c53f340..ac3491bc8ff 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 958d718b11c..ece547d02be 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/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index 94f42efe837..da86797f424 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/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 2aeaaa87dc2..e62c3544919 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 { + 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; +} + +.mx_CallView_large { + padding-bottom: 10px; + + .mx_CallView_voice { + height: 360px; + } +} + +.mx_CallView_pip { + width: 320px; + + .mx_CallView_voice { + height: 180px; + } +} + .mx_CallView_voice { - background-color: $accent-color; - color: $accent-fg-color; - cursor: pointer; - padding: 6px; - font-weight: bold; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: $inverted-bg-color; +} - border-radius: 8px; - min-width: 200px; +.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; - img { - margin: 4px; - margin-right: 10px; + .mx_BaseAvatar { + margin-right: 12px; } +} - > div { - display: flex; - flex-direction: column; - // Hacky vertical align - padding-top: 3px; - } +.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; - > div > p, - > div > h1 { - padding: 0; - margin: 0; - font-size: $font-13px; - line-height: $font-15px; + &::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; } +} - > div > p { - font-weight: bold; +.mx_CallView_header_button_fullscreen { + &::before { + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); } +} - > * { - flex-grow: 0; - flex-shrink: 0; +.mx_CallView_header_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); } } -.mx_CallView_hangup { - position: absolute; +.mx_CallView_header_roomName { + font-weight: bold; + font-size: 12px; + line-height: initial; +} - right: 8px; - bottom: 10px; +.mx_CallView_header_callTypeSmall { + font-size: 12px; + color: $secondary-fg-color; + line-height: initial; +} - height: 35px; - width: 35px; +.mx_CallView_header_phoneIcon { + display: inline-block; + margin-right: 6px; + height: 16px; + width: 16px; + vertical-align: middle; - border-radius: 35px; + &::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'); + } +} - background-color: $notice-primary-color; +.mx_CallView_callControls { + position: absolute; + display: flex; + justify-content: center; + bottom: 5px; + width: 100%; + opacity: 1; + transition: opacity 0.5s; +} - z-index: 101; +.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: ''; - position: absolute; - - height: 20px; - width: 20px; + display: inline-block; - top: 6.5px; - left: 7.5px; + height: 48px; + width: 48px; - mask: url('$(res)/img/hangup.svg'); - mask-size: contain; + 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'); + } +} - background-color: $primary-fg-color; +.mx_CallView_callControls_button_micOff { + &::before { + background-image: url('$(res)/img/voip/mic-off.svg'); } } -.mx_CallView_video { - width: 100%; - position: relative; - z-index: 30; +.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 e5e3587dacf..931410dba32 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 00000000000..91ef4d8a76d --- /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 d2aea71d11c..00000000000 --- 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 32abafb04af..00000000000 --- 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 e6640802174..00000000000 --- a/res/img/element-icons/call/voice-unmuted.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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 0e574faa84a..00000000000 --- 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 be038d2b30e..00000000000 --- 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 00000000000..dfb20bd519e --- /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 00000000000..6409f1fd073 --- /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 00000000000..3493b3c5818 --- /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 00000000000..199d97ab974 --- /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 00000000000..d8146d01d34 --- /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 6350439a4f8..1b7ff9598d7 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; @@ -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 716d8c73857..932a37b46e8 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; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 8c42c5c97fc..dba8fa6415e 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; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 5437a6de1cf..f89b9f2c755 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; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 710eded2cda..b5f696008d8 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:")} + +
; + } + + 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 07bfd4858ae..2301ad250b4 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 7469624f5c5..ac96d59b09d 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$') != 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 2f761e73937..ab582b9b227 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 1899896f9b7..6460be20ad2 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/TextForEvent.js b/src/TextForEvent.js index d86d88a6975..56e9abc0f2c 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 58d8124122e..48d0eb2ab11 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 482b9f6da23..bbc41872981 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 d11944e4706..68bb4322e67 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 ab5b93794c0..ec5afd13f0d 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 405c7d85157..98cc490b3ad 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -87,38 +87,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. @@ -570,11 +569,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, @@ -1367,18 +1361,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; @@ -1563,6 +1545,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', @@ -1579,7 +1569,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 { @@ -1630,14 +1620,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 @@ -1808,14 +1790,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) { @@ -1980,13 +1954,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. diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e6d29850734..c1c4ad6292a 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 0239136677f..affa6d00608 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; @@ -1323,10 +1290,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 = () => { @@ -1760,8 +1724,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/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 75208b8cfec..08bd472225a 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/Login.js b/src/components/structures/auth/Login.tsx similarity index 71% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index c3cbac0442d..4cd8981a65d 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 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,8 +14,8 @@ 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, {ComponentProps, ReactNode} from 'react'; + import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; import Login from '../../../Login'; @@ -31,15 +29,12 @@ 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; +import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +import ServerConfig from "../../views/auth/ServerConfig"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import SignInToText from "../../views/auth/SignInToText"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; // Enable phases for login const PHASES_ENABLED = true; @@ -55,64 +50,88 @@ _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; +} + +enum Phase { + // Show controls to configure server details + ServerDetails, + // Show the appropriate login flow(s) for the server + Login, +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // Phase of the overall login dialog. + phase: Phase; + // The current login flow, such as password, SSO, etc. + // we need to load the flows from the server + currentFlow?: 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, - - // 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, - }; +export default class LoginComponent extends React.Component { + private unmounted = false; + private loginLogic: Login; + 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? - - // used for preserving form values when changing homeserver + canTryLogin: true, 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. + phase: Phase.Login, + currentFlow: null, serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", @@ -120,12 +139,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 +153,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 +167,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 +206,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 +224,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 +267,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 +305,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 +318,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 +344,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 +351,14 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); + 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'. ev.preventDefault(); ev.stopPropagation(); const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; - PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, + PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin); } else { // Don't intercept - just go through to the register page @@ -367,24 +366,21 @@ export default class LoginComponent extends React.Component { } }; - onServerDetailsNextPhaseClick = () => { + private onServerDetailsNextPhaseClick = () => { this.setState({ - phase: PHASE_LOGIN, + phase: Phase.Login, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); }; - 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,7 +393,7 @@ 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, @@ -428,7 +424,7 @@ export default class LoginComponent extends React.Component { if (this.state.serverErrorIsFatal) { // Server is dead: show server details prompt instead this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); return; } @@ -437,7 +433,7 @@ export default class LoginComponent extends React.Component { 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])) { + if (!this.isSupportedFlow(flows[i])) { continue; } @@ -446,7 +442,7 @@ export default class LoginComponent extends React.Component { // that for now). loginLogic.chooseFlow(i); this.setState({ - currentFlow: this._getCurrentFlowStep(), + currentFlow: this.getCurrentFlowStep(), }); return; } @@ -460,7 +456,7 @@ export default class LoginComponent extends React.Component { }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -471,28 +467,28 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow(flow) { // 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; + private getCurrentFlowStep() { + return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null; } - _errorTextFromError(err) { + private errorTextFromError(err) { 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("Error: Problem communicating with the given homeserver.") + + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -502,29 +498,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,18 +526,16 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - + private renderServerComponent() { if (SdkConfig.get()['disable_custom_urls']) { return null; } - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { + if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) { return null; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -558,8 +550,8 @@ export default class LoginComponent extends React.Component { />; } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { + private renderLoginComponentForStep() { + if (PHASES_ENABLED && this.state.phase !== Phase.Login) { return null; } @@ -569,7 +561,7 @@ export default class LoginComponent extends React.Component { return null; } - const stepRenderer = this._stepRendererMap[step]; + const stepRenderer = this.stepRendererMap[step]; if (stepRenderer) { return stepRenderer(); @@ -578,9 +570,7 @@ export default class LoginComponent extends React.Component { return null; } - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - + private renderPasswordStep = () => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -589,29 +579,25 @@ export default class LoginComponent extends React.Component { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - + private renderSsoStep = loginType => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -632,7 +618,7 @@ export default class LoginComponent extends React.Component { @@ -641,12 +627,10 @@ export default class LoginComponent extends React.Component { }; 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; diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js deleted file mode 100644 index aa36de65961..00000000000 --- 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 78% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 80bf3b72cd9..f97f20cf598 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,8 +15,9 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ComponentProps, 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'; @@ -34,36 +32,96 @@ import Login from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; // 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; +enum Phase { + // Show controls to configure server details + ServerDetails = 0, + // Show the appropriate registration flow(s) for the server + Registration = 1, +} + +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + + // 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; +} + +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; + serverType: ServerType.FREE | ServerType.PREMIUM | ServerType.ADVANCED; + // Phase of the overall registration dialog. + phase: Phase; + 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; + + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // whether the HS requires an ID server to register with a threepid + serverRequiresIdServer?: boolean; + // 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; +} // Enable phases for registration const PHASES_ENABLED = true; -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, - - 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, - }; - +export default class Registration extends React.Component { constructor(props) { super(props); @@ -71,56 +129,22 @@ export default class Registration extends React.Component { 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, + 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, }; } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -129,7 +153,7 @@ 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); + 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. @@ -138,25 +162,25 @@ export default class Registration extends React.Component { // Reset the phase to default phase for the server type. this.setState({ serverType, - phase: this.getDefaultPhaseForServerType(serverType), + phase: Registration.getDefaultPhaseForServerType(serverType), }); } } - getDefaultPhaseForServerType(type) { + private static getDefaultPhaseForServerType(type: IState["serverType"]) { switch (type) { case ServerType.FREE: { // Move directly to the registration phase since the server // details are fixed. - return PHASE_REGISTRATION; + return Phase.Registration; } case ServerType.PREMIUM: case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; + return Phase.ServerDetails; } } - onServerTypeChange = type => { + private onServerTypeChange = (type: IState["serverType"]) => { this.setState({ serverType: type, }); @@ -181,11 +205,11 @@ export default class Registration extends React.Component { // Reset the phase to default phase for the server type. this.setState({ - phase: this.getDefaultPhaseForServerType(type), + phase: Registration.getDefaultPhaseForServerType(type), }); }; - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -194,7 +218,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 { @@ -246,7 +269,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!"); } @@ -287,7 +310,7 @@ export default class Registration extends React.Component { } } - onFormSubmit = formVals => { + private onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, @@ -296,7 +319,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 +333,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,7 +360,7 @@ 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.'); @@ -358,6 +379,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 +397,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 +408,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 +417,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +440,38 @@ 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, + phase: Phase.Registration, }); }; - onServerDetailsNextPhaseClick = async () => { + private onServerDetailsNextPhaseClick = async () => { this.setState({ - phase: PHASE_REGISTRATION, + phase: Phase.Registration, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); }; - _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 +487,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 +506,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,7 +516,7 @@ export default class Registration extends React.Component { } }; - renderServerComponent() { + private renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); @@ -503,7 +526,7 @@ export default class Registration extends React.Component { } // Hide the server picker once the user is doing UI Auth unless encountered a fatal server error - if (this.state.phase !== PHASE_SERVER_DETAILS && this.state.doingUIAuth && !this.state.serverErrorIsFatal) { + if (this.state.phase !== Phase.ServerDetails && this.state.doingUIAuth && !this.state.serverErrorIsFatal) { return null; } @@ -511,7 +534,7 @@ export default class Registration extends React.Component { // 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) { + if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails && !this.state.serverErrorIsFatal) { return
; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -559,8 +582,8 @@ export default class Registration extends React.Component {
; } - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { + private renderRegisterComponent() { + if (PHASES_ENABLED && this.state.phase !== Phase.Registration) { return null; } @@ -571,10 +594,10 @@ export default class Registration extends React.Component { if (this.state.matrixClient && this.state.doingUIAuth) { return { _t('Go back') } ; @@ -651,7 +674,7 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

-

+

{_t("Continue with previous account")}

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

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

; } else { @@ -670,7 +693,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}, }, )}

; } @@ -679,7 +702,7 @@ export default class Registration extends React.Component { { regDoneText }
; } else { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { + let yourMatrixAccountText: ReactNode = _t('Create your Matrix account on %(serverName)s', { serverName: this.props.serverConfig.hsName, }); if (this.props.serverConfig.hsNameIsDifferent) { @@ -717,7 +740,7 @@ export default class Registration extends React.Component { { errorText } { serverDeadSection } { this.renderServerComponent() } - { this.state.phase !== PHASE_SERVER_DETAILS &&

+ { this.state.phase !== Phase.ServerDetails &&

{yourMatrixAccountText} {editLink}

} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed08721..e240ad61ca4 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 405f9051b9e..00000000000 --- 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 00000000000..fced2e08d01 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -0,0 +1,495 @@ +/* +Copyright 2015, 2016, 2017, 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 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"; +import SignInToText from "./SignInToText"; + +// 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; + onEditServerDetailsClick?(): 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 = { + onEditServerDetailsClick: null, + 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("Doesn't look like a valid phone number"), + }, + ], + }); + + 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('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} + 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 77% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index 70c10174276..f6fb3bb3ea1 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'; @@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; -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; + serverRequiresIdServer?: 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 - 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, @@ -66,9 +89,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 || "", @@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component { CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); if (!this.props.canSubmit) return; @@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component { return; } - const self = this; if (this.state.email === '') { const haveIs = Boolean(this.props.serverConfig.isUrl); @@ -102,14 +122,14 @@ export default class RegistrationForm extends React.Component { "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()) { + } 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?", ); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } @@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component { title: _t("Warning!"), description: desc, button: _t("Continue"), - onFinished(confirmed) { + onFinished: (confirmed) => { if (confirmed) { - self._doSubmit(ev); + this.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", { @@ -154,20 +174,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 @@ -208,7 +228,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]]) { @@ -218,7 +238,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]; @@ -227,7 +247,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({ @@ -235,26 +255,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)"), }, @@ -266,29 +286,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", @@ -297,41 +317,40 @@ 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)"), }, @@ -343,19 +362,19 @@ export default class RegistrationForm extends React.Component { ], }); - 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: [ @@ -378,7 +397,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); }); @@ -390,46 +409,46 @@ 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() { + private showEmail() { const haveIs = Boolean(this.props.serverConfig.isUrl); if ( (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') + !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') + !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} @@ -440,10 +459,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} @@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component { 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 password")} @@ -470,12 +489,12 @@ 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} @@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component { const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} @@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component { ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
{_t( "Set an email for account recovery. " + diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/src/components/views/avatars/PulsedAvatar.tsx deleted file mode 100644 index b4e876b9f62..00000000000 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ /dev/null @@ -1,28 +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'; - -interface IProps { -} - -const PulsedAvatar: React.FC = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 7656e703411..80269420381 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/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index c8a736e8a66..484e8f0dcfa 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/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx new file mode 100644 index 00000000000..535e0b7b8ec --- /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 e793b850790..7ed3d043187 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 f3ebe24c153..7e0ae965bb1 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 001292b6b74..3417485eb8e 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 } to continue.": "Įveskite savo Slaptafrazę arba , kad tęstumėte.", + "%(creator)s created this DM.": "%(creator)s sukūrė šį tiesioginio susirašymo kambarį.", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Šiame pokalbyje esate tik jūs dviese, nebent kuris nors iš jūsų pakvies ką nors prisijungti.", + "This is the beginning of your direct message history with .": "Tai yra jūsų tiesioginių žinučių su istorijos pradžia.", + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Žinutės čia yra visapusiškai užšifruotos. Patvirtinkite %(displayName)s jų profilyje - paspauskite ant jų pseudoportreto.", + "See when the avatar changes in your active room": "Matyti kada jūsų aktyviame kambaryje pasikeičia pseudoportretas", + "Change the avatar of your active room": "Pakeisti jūsų aktyvaus kambario pseudoportretą", + "See when the avatar changes in this room": "Matyti kada šiame kambaryje pasikeičia pseudoportretas", + "Change the avatar of this room": "Pakeisti šio kambario pseudoportretą", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Žinutės šiame kambaryje yra visapusiškai užšifruotos. Kai žmonės prisijungia, jūs galite patvirtinti juos jų profilyje, tiesiog paspauskite ant jų pseudoportreto.", + "Mentions & Keywords": "Paminėjimai ir Raktažodžiai" } diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index 8122e93f453..f5171c84727 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -385,7 +385,7 @@ "Error decrypting audio": "Błąd deszyfrowania audio", "Error decrypting image": "Błąd deszyfrowania obrazu", "Error decrypting video": "Błąd deszyfrowania wideo", - "Tried to load a specific point in this room's timeline, but was unable to find it.": "Próbowano załadować konkretny punkt na osi czasu w tym pokoju, ale nie nie można go znaleźć.", + "Tried to load a specific point in this room's timeline, but was unable to find it.": "Próbowano załadować konkretny punkt na osi czasu w tym pokoju, ale nie można go znaleźć.", "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "Wyeksportowany plik pozwoli każdej osobie będącej w stanie go odczytać na deszyfrację jakichkolwiek zaszyfrowanych wiadomości, które możesz zobaczyć, tak więc zalecane jest zachowanie ostrożności. Aby w tym pomóc, powinieneś/aś wpisać hasło poniżej; hasło to będzie użyte do zaszyfrowania wyeksportowanych danych. Późniejsze zaimportowanie tych danych będzie możliwe tylko po uprzednim podaniu owego hasła.", " (unsupported)": " (niewspierany)", "Idle": "Bezczynny(-a)", @@ -1150,7 +1150,7 @@ "Disconnect from the identity server ?": "Odłączyć od serwera tożsamości ?", "Disconnect": "Odłącz", "Identity Server (%(server)s)": "Serwer tożsamości (%(server)s)", - "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "Używasz , aby odnajdywać i móc być odnajdywanym przez istniejące kontakty, które znasz. Możesz zmienić serwer tożsamości poniżej.", + "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "Używasz , aby odnajdywać i móc być odnajdywanym przez istniejące kontakty, które znasz. Możesz zmienić serwer tożsamości poniżej.", "Identity Server": "Serwer Tożsamości", "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "Nie używasz serwera tożsamości. Aby odkrywać i być odkrywanym przez istniejące kontakty które znasz, dodaj jeden poniżej.", "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Odłączenie się od serwera tożsamości oznacza, że inni nie będą mogli Cię odnaleźć ani Ty nie będziesz w stanie zaprosić nikogo za pomocą e-maila czy telefonu.", @@ -1584,5 +1584,391 @@ "Cancel entering passphrase?": "Anulować wpisywanie hasła?", "Room name or address": "Nazwa lub adres pokoju", "This will end the conference for everyone. Continue?": "Czy na pewno chcesz zakończyc połączenie grupowe? To zakończy je dla wszystkich uczestnikow.", - "End conference": "Zakończ połączenie grupowe" + "End conference": "Zakończ połączenie grupowe", + "Attach files from chat or just drag and drop them anywhere in a room.": "Załącz pliki w rozmowie lub upuść je w dowolnym miejscu rozmowy.", + "Sign in with SSO": "Zaloguj się z SSO", + "No files visible in this room": "Brak plików widocznych w tym pokoju", + "Document": "Dokument", + "Service": "Usługa", + "Summary": "Opis", + "To continue you need to accept the terms of this service.": "Aby kontynuować, musisz zaakceptować zasady użytkowania.", + "Connecting to integration manager...": "Łączenie z zarządcą integracji…", + "Add widgets, bridges & bots": "Dodaj widżety, mostki i boty", + "Forget this room": "Zapomnij o tym pokoju", + "You were kicked from %(roomName)s by %(memberName)s": "Zostałeś(-aś) wyrzucony(-a) z %(roomName)s przez %(memberName)s", + "List options": "Ustawienia listy", + "Explore all public rooms": "Przeglądaj wszystkie publiczne pokoje", + "Explore public rooms": "Przeglądaj publiczne pokoje", + "Verification Requests": "Żądania weryfikacji", + "View Servers in Room": "Zobacz serwery w pokoju", + "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Zmiany tego, kto może przeglądać historię wyszukiwania dotyczą tylko przyszłych wiadomości w pokoju. Widoczność wcześniejszej historii nie zmieni się.", + "No other published addresses yet, add one below": "Brak innych opublikowanych adresów, dodaj jakiś poniżej", + "Other published addresses:": "Inne opublikowane adresy:", + "Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "Opublikowane adresy mogą być używane, aby każdy mógł dołączyć do Twojego pokoju. Aby opublikować adres, należy wcześniej ustawić lokalny adres.", + "Room settings": "Ustawienia pokoju", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Wiadomości w tym pokoju są szyfrowane end-to-end. Jeżeli ludzie dołączą do niego, możesz zweryfikować ich na ich profilu, naciskając na ich awatar.", + "Messages in this room are not end-to-end encrypted.": "Wiadomości w tym pokoju nie są szyfrowane end-to-end.", + "Show files": "Zobacz pliki", + "%(count)s people|one": "%(count)s osoba", + "%(count)s people|other": "%(count)s ludzi(e)", + "About": "Informacje", + "Add a topic to help people know what it is about.": "Dodaj temat, aby poinformować ludzi czego to dotyczy.", + "Show info about bridges in room settings": "Pokazuj informacje o mostkach w ustawieniach pokoju", + "about a day from now": "około dnia od teraz", + "about an hour from now": "około godziny od teraz", + "about a minute from now": "około minuty od teraz", + "Room Info": "Informacje o pokoju", + "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Zgłoszenie tej wiadomości wyśle administratorowi serwera unikatowe „ID wydarzenia”. Jeżeli wiadomości w tym pokoju są szyfrowane, administrator serwera może nie być w stanie przeczytać treści wiadomości, lub zobaczyć plików bądź zdjęć.", + "Send report": "Wyślij zgłoszenie", + "Report Content to Your Homeserver Administrator": "Zgłoś zawartość do administratora swojego serwera", + "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Prywatne pokoje można odnaleźć i dołączyć do nich tylko przez zaproszenie. Do publicznych pokojów może dołączyć każdy w tej społeczności.", + "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Prywatne pokoje można odnaleźć i dołączyć do nich tylko przez zaproszenie. Do publicznych pokojów każdy może dołączyć.", + "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Możesz ustawić tę opcję, jeżeli pokój będzie używany wyłącznie do współpracy wewnętrznych zespołów na Twoim serwerze. To nie może być później zmienione.", + "Block anyone not part of %(serverName)s from ever joining this room.": "Zablokuj wszystkich niebędących użytkownikami %(serverName)s w tym pokoju.", + "You can’t disable this later. Bridges & most bots won’t work yet.": "Nie możesz wyłączyć tego później. Mostki i większość botów nie będą działać.", + "Matrix rooms": "Pokoje Matrix", + "Start a conversation with someone using their name or username (like ).": "Rozpocznij konwersację z innymi korzystając z ich nazwy lub nazwy użytkownika (np. ).", + "Start a conversation with someone using their name, email address or username (like ).": "Rozpocznij konwersację z innymi korzystając z ich nazwy, adresu e-mail lub nazwy użytkownika (np. ).", + "Show %(count)s more|one": "Pokaż %(count)s więcej", + "Show %(count)s more|other": "Pokaż %(count)s więcej", + "Room options": "Ustawienia pokoju", + "Manually verify all remote sessions": "Ręcznie weryfikuj wszystkie zdalne sesje", + "Privacy": "Prywatność", + "This version of %(brand)s does not support searching encrypted messages": "Ta wersja %(brand)s nie obsługuje wyszukiwania zabezpieczonych wiadomości", + "Use the Desktop app to search encrypted messages": "Używaj Aplikacji desktopowej, aby wyszukiwać zaszyfrowane wiadomości", + "Message search": "Wyszukiwanie wiadomości", + "Enable message search in encrypted rooms": "Włącz wyszukiwanie wiadomości w szyfrowanych pokojach", + "New version of %(brand)s is available": "Dostępna jest nowa wersja %(brand)s", + "Update %(brand)s": "Aktualizuj %(brand)s", + "Set up Secure Backup": "Skonfiguruj bezpieczny backup", + "Ok": "OK", + "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Wysyłaj anonimowe dane o wykorzystywaniu, które pomogą nam usprawnić %(brand)s. To będzie korzystać z pliku cookie.", + "Help us improve %(brand)s": "Pomóż nam usprawnić %(brand)s", + "Unknown App": "Nieznana aplikacja", + "Enable desktop notifications": "Włącz powiadomienia na pulpicie", + "Don't miss a reply": "Nie przegap odpowiedzi", + "A session's public name is visible to people you communicate with": "Publiczna nazwa sesji jest widoczna dla osób z którymi się komunikujesz", + "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Zarządzaj nazwami i unieważnaj sesje poniżej, lub weryfikuj je na swoim profilu.", + "Where you’re logged in": "Gdzie jesteś zalogowany(-a)", + "Review where you’re logged in": "Przejrzyj, gdzie jesteś zalogowany(-a)", + "Show tray icon and minimize window to it on close": "Pokazuj ikonę w zasobniku i minimalizuj okno do zasobnika przy zamknięciu", + "Display your community flair in rooms configured to show it.": "Wyświetlaj swój wyróżnik społeczności w pokojach skonfigurowanych, aby go używać.", + "System font name": "Nazwa czcionki systemowej", + "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Wybierz nazwę czcionki zainstalowanej w systemie, a %(brand)s spróbuje jej użyć.", + "Use a system font": "Użyj czcionki systemowej", + "Enable experimental, compact IRC style layout": "Włącz eksperymentalny, kompaktowy układ w stylu IRC", + "Use a more compact ‘Modern’ layout": "Użyj bardziej kompaktowego „nowoczesnego” układu", + "Use custom size": "Użyj niestandardowego rozmiaru", + "Appearance Settings only affect this %(brand)s session.": "Ustawienia wyglądu wpływają tylko na tę sesję %(brand)s.", + "Customise your appearance": "Dostosuj wygląd", + "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Użyj Zarządcy Integracji aby zarządzać botami, widżetami i pakietami naklejek.", + "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Użyj Zarządcy Integracji %(serverName)s aby zarządzać botami, widżetami i pakietami naklejek.", + "There are two ways you can provide feedback and help us improve %(brand)s.": "Są dwa sposoby na przekazanie informacji zwrotnych i pomoc w usprawnieniu %(brand)s.", + "Feedback sent": "Wysłano informacje zwrotne", + "Send feedback": "Wyślij informacje zwrotne", + "Feedback": "Informacje zwrotne", + "You have no visible notifications in this room.": "Nie masz widocznych powiadomień w tym pokoju.", + "%(creator)s created this DM.": "%(creator)s utworzył(a) tę wiadomość bezpośrednią.", + "You do not have permission to create rooms in this community.": "Nie masz uprawnień do tworzenia pokojów w tej społeczności.", + "Cannot create rooms in this community": "Nie można utworzyć pokojów w tej społeczności", + "Liberate your communication": "Uwolnij swoją komunikację", + "Welcome to %(appName)s": "Witamy w %(appName)s", + "Now, let's help you get started": "Teraz pomożemy Ci zacząć", + "Welcome %(name)s": "Witaj, %(name)s", + "Israel": "Izrael", + "Isle of Man": "Man", + "Ireland": "Irlandia", + "Iraq": "Irak", + "Iran": "Iran", + "Indonesia": "Indonezja", + "India": "Indie", + "Iceland": "Islandia", + "Hungary": "Węgry", + "Hong Kong": "Hong Kong", + "Honduras": "Honduras", + "Heard & McDonald Islands": "Wyspy Heard i McDonald", + "Haiti": "Haiti", + "Guyana": "Gujana", + "Guinea-Bissau": "Gwinea Bissau", + "Guinea": "Gwinea", + "Guernsey": "Guernsey", + "Guatemala": "Gwatemala", + "Guam": "Guam", + "Guadeloupe": "Gwadelupa", + "Grenada": "Grenada", + "Greenland": "Grenlandia", + "Greece": "Grecja", + "Gibraltar": "Gibraltar", + "Ghana": "Ghana", + "Germany": "Niemcy", + "Georgia": "Gruzja", + "Gambia": "Gambia", + "Gabon": "Gabon", + "French Southern Territories": "Francuskie Terytoria Południowe i Antarktyczne", + "French Polynesia": "Polinezja Francuska", + "French Guiana": "Gujana Francuska", + "France": "Francja", + "Finland": "Finlandia", + "Fiji": "Fidżi", + "Faroe Islands": "Wyspy Owcze", + "Falkland Islands": "Falklandy", + "Ethiopia": "Etiopia", + "Estonia": "Estonia", + "Eritrea": "Erytrea", + "Equatorial Guinea": "Gwinea Równikowa", + "El Salvador": "Salwador", + "Egypt": "Egipt", + "Ecuador": "Ekwador", + "Dominican Republic": "Dominikana", + "Dominica": "Dominika", + "Djibouti": "Dżibuti", + "Denmark": "Dania", + "Côte d’Ivoire": "Wybrzeże Kości Słoniowej", + "Czech Republic": "Czechy", + "Cyprus": "Cypr", + "Curaçao": "Curaçao", + "Cuba": "Kuba", + "Croatia": "Chorwacja", + "Costa Rica": "Kostaryka", + "Cook Islands": "Wyspy Cooka", + "Congo - Kinshasa": "Kinszasa", + "Congo - Brazzaville": "Kongo", + "Comoros": "Komory", + "Colombia": "Kolumbia", + "Cocos (Keeling) Islands": "Wyspy Kokosowe", + "Christmas Island": "Wyspa Bożego Narodzenia", + "China": "Chiny", + "Chile": "Chile", + "Chad": "Czad", + "Central African Republic": "Republika Środkowoafrykańska", + "Cayman Islands": "Kajmany", + "Caribbean Netherlands": "Holandia Karaibska", + "Cape Verde": "Republika Zielonego Przylądka", + "Canada": "Kanada", + "Cameroon": "Kamerun", + "Cambodia": "Kambodża", + "Burundi": "Burundi", + "Burkina Faso": "Burkina Faso", + "Bulgaria": "Bułgaria", + "Brunei": "Brunei", + "British Virgin Islands": "Brytyjskie Wyspy Dziewicze", + "British Indian Ocean Territory": "Brytyjskie Terytorium Oceanu Indyjskiego", + "Brazil": "Brazylia", + "Bouvet Island": "Wyspa Bouveta", + "Botswana": "Botswana", + "Bosnia": "Bośnia", + "Bolivia": "Boliwia", + "Bhutan": "Bhutan", + "Bermuda": "Bermudy", + "Benin": "Benin", + "Belize": "Belize", + "Belgium": "Belgia", + "Belarus": "Białoruś", + "Barbados": "Barbados", + "Bangladesh": "Bangladesz", + "Bahrain": "Bahrajn", + "Bahamas": "Bahamy", + "Azerbaijan": "Azerbejdżan", + "Austria": "Austria", + "Australia": "Australia", + "Aruba": "Aruba", + "Armenia": "Armenia", + "Argentina": "Argentyna", + "Antigua & Barbuda": "Antigua i Barbuda", + "Antarctica": "Antarktyda", + "Anguilla": "Anguilla", + "Angola": "Angola", + "Andorra": "Andora", + "American Samoa": "Samoa Amerykańskie", + "Algeria": "Algeria", + "Albania": "Albania", + "Åland Islands": "Wyspy Alandzkie", + "Afghanistan": "Afganistan", + "United States": "Stany Zjednoczone", + "United Kingdom": "Wielka Brytania", + "Marshall Islands": "Wyspy Marshalla", + "Malta": "Malta", + "Mali": "Mali", + "Maldives": "Malediwy", + "Malaysia": "Malezja", + "Malawi": "Malawi", + "Madagascar": "Madagaskar", + "Macedonia": "Macedonia", + "Macau": "Makau", + "Luxembourg": "Luksemburg", + "Lithuania": "Litwa", + "Liechtenstein": "Liechtenstein", + "Libya": "Libia", + "Liberia": "Liberia", + "Lesotho": "Lesotho", + "Lebanon": "Liban", + "Latvia": "Łotwa", + "Laos": "Laos", + "Kyrgyzstan": "Kirgistan", + "Kuwait": "Kuwejt", + "Kosovo": "Kosowo", + "Kiribati": "Kiribati", + "Kenya": "Kenia", + "Kazakhstan": "Kazachstan", + "Jordan": "Jordania", + "Jersey": "Jersey", + "User rules": "Zasady użytkownika", + "Server rules": "Zasady serwera", + "not found": "nie znaleziono", + "Decline (%(counter)s)": "Odrzuć (%(counter)s)", + "Starting backup...": "Rozpoczynanie kopii zapasowej…", + "User Autocomplete": "Autouzupełnianie użytkowników", + "Community Autocomplete": "Autouzupełnianie społeczności", + "Room Autocomplete": "Autouzupełnianie pokojów", + "Notification Autocomplete": "Autouzupełnianie powiadomień", + "Emoji Autocomplete": "Autouzupełnianie emoji", + "Phone (optional)": "Telefon (opcjonalny)", + "Upload Error": "Błąd wysyłania", + "GitHub issue": "Błąd na GitHubie", + "Close dialog": "Zamknij okno dialogowe", + "Show all": "Zobacz wszystko", + "Deactivate user": "Dezaktywuj użytkownika", + "Deactivate user?": "Dezaktywować użytkownika?", + "Revoke invite": "Wygaś zaproszenie", + "Code block": "Blok kodu", + "Ban users": "Zablokuj użytkowników", + "Kick users": "Wyrzuć użytkowników", + "Syncing...": "Synchronizacja…", + "General failure": "Ogólny błąd", + "Removing…": "Usuwanie…", + "Premium": "Premium", + "Cancelling…": "Anulowanie…", + "Algorithm:": "Algorytm:", + "Bulk options": "Masowe działania", + "Modern": "Współczesny", + "Compact": "Kompaktowy", + "Approve": "Zatwierdź", + "Incompatible Database": "Niekompatybilna baza danych", + "Show": "Pokaż", + "Information": "Informacje", + "Categories": "Kategorie", + "Reactions": "Reakcje", + "Role": "Rola", + "Trusted": "Zaufane", + "Accepting…": "Akceptowanie…", + "Re-join": "Dołącz ponownie", + "Unencrypted": "Nieszyfrowane", + "Revoke": "Unieważnij", + "Encrypted": "Szyfrowane", + "Unsubscribe": "Odsubskrybuj", + "None": "Brak", + "exists": "istnieje", + "Change the topic of this room": "Zmień temat tego pokoju", + "Change which room you're viewing": "Zmień pokój który przeglądasz", + "Send stickers into your active room": "Wyślij naklejki w swoim aktywnym pokoju", + "Send stickers into this room": "Wyślij naklejki w tym pokoju", + "Zimbabwe": "Zimbabwe", + "Zambia": "Zambia", + "Yemen": "Jemen", + "Western Sahara": "Sahara Zachodnia", + "Wallis & Futuna": "Wallis i Futuna", + "Vietnam": "Wietnam", + "Venezuela": "Wenezuela", + "Vatican City": "Watykan", + "Vanuatu": "Vanuatu", + "Uzbekistan": "Uzbekistan", + "Uruguay": "Urugwaj", + "United Arab Emirates": "Zjednoczone Emiraty Arabskie", + "Ukraine": "Ukraina", + "Uganda": "Uganda", + "U.S. Virgin Islands": "Wyspy Dziewicze Stanów Zjednoczonych", + "Tuvalu": "Tuvalu", + "Turks & Caicos Islands": "Turks i Caicos", + "Turkmenistan": "Turkmenistan", + "Turkey": "Turcja", + "Tunisia": "Tunezja", + "Trinidad & Tobago": "Trynidad i Tobago", + "Tonga": "Tonga", + "Tokelau": "Tokelau", + "Togo": "Togo", + "Timor-Leste": "Timor Wschodni", + "Thailand": "Tajlandia", + "Tanzania": "Tanzania", + "Tajikistan": "Tadżykistan", + "Taiwan": "Tajwan", + "São Tomé & Príncipe": "Wyspy Świętego Tomasza i Książęca", + "Syria": "Syria", + "Switzerland": "Szwajcaria", + "Sweden": "Szwecja", + "Swaziland": "Eswatini", + "Svalbard & Jan Mayen": "Svalbard i Jan Mayen", + "Suriname": "Surinam", + "Sudan": "Sudan", + "St. Vincent & Grenadines": "Saint Vincent i Grenadyny", + "St. Pierre & Miquelon": "Saint-Pierre i Miquelon", + "St. Martin": "Sint Maarten", + "St. Lucia": "Saint Lucia", + "St. Kitts & Nevis": "Saint Kitts & Nevis", + "St. Helena": "Święta Helena", + "St. Barthélemy": "Wspólnota Saint-Barthélemy", + "Sri Lanka": "Sri Lanka", + "Spain": "Hiszpania", + "South Sudan": "Sudan Południowy", + "South Korea": "Korea Południowa", + "South Georgia & South Sandwich Islands": "Georgia Południowa i Sandwich Południowy", + "South Africa": "Republika Południowej Afryki", + "Somalia": "Somalia", + "Solomon Islands": "Wyspy Salomona", + "Slovenia": "Słowenia", + "Slovakia": "Słowacja", + "Sint Maarten": "Sint Maarten", + "Singapore": "Singapur", + "Sierra Leone": "Sierra Leone", + "Seychelles": "Seszele", + "Serbia": "Serbia", + "Senegal": "Senegal", + "Saudi Arabia": "Arabia Saudyjska", + "San Marino": "San Marino", + "Samoa": "Samoa", + "Réunion": "Reunion", + "Rwanda": "Rwanda", + "Russia": "Rosja", + "Romania": "Rumunia", + "Qatar": "Katar", + "Puerto Rico": "Portoryko", + "Portugal": "Portugalia", + "Poland": "Polska", + "Pitcairn Islands": "Pitcairn", + "Philippines": "Filipiny", + "Peru": "Peru", + "Paraguay": "Paragwaj", + "Papua New Guinea": "Papua Nowa Gwinea", + "Panama": "Panama", + "Palestine": "Palestyna", + "Palau": "Palau", + "Pakistan": "Pakistan", + "Oman": "Oman", + "Norway": "Norwegia", + "Northern Mariana Islands": "Mariany Północne", + "North Korea": "Korea Północna", + "Norfolk Island": "Norfolk", + "Niue": "Niue", + "Nigeria": "Nigeria", + "Niger": "Niger", + "Nicaragua": "Nikaragua", + "New Zealand": "Nowa Zelandia", + "New Caledonia": "Nowa Kaledonia", + "Netherlands": "Holandia", + "Nepal": "Nepal", + "Nauru": "Nauru", + "Namibia": "Namibia", + "Myanmar": "Mjanma", + "Mozambique": "Mozambik", + "Morocco": "Maroko", + "Montserrat": "Montserrat", + "Montenegro": "Czarnogóra", + "Mongolia": "Mongolia", + "Monaco": "Monako", + "Moldova": "Mołdawia", + "Micronesia": "Mikronezja", + "Mexico": "Meksyk", + "Mayotte": "Majotta", + "Mauritius": "Mauritius", + "Mauritania": "Mauretania", + "Martinique": "Martynika" } diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index aa4bfc41cad..613f8893706 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -501,7 +501,7 @@ "Visibility in Room List": "Visibilidade na lista de salas", "Visible to everyone": "Visível para todos", "Only visible to community members": "Apenas visível para participantes da comunidade", - "Filter community rooms": "Filtrar salas da comunidade", + "Filter community rooms": "Pesquisar salas da comunidade", "Something went wrong when trying to get your communities.": "Não foi possível carregar suas comunidades.", "Display your community flair in rooms configured to show it.": "Mostrar o ícone da sua comunidade nas salas que o permitem.", "You're not currently a member of any communities.": "No momento, você não é participante de nenhuma comunidade.", @@ -2772,5 +2772,92 @@ "Uzbekistan": "Uzbequistão", "Role": "Função", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|one": "Armazene localmente com segurança as mensagens criptografadas para que apareçam nos resultados da pesquisa, usando %(size)s para armazenar mensagens de %(count)s sala.", - "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|other": "Armazene localmente com segurança as mensagens criptografadas para que apareçam nos resultados da pesquisa, usando %(size)s para armazenar mensagens de %(count)s salas." + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|other": "Armazene localmente com segurança as mensagens criptografadas para que apareçam nos resultados da pesquisa, usando %(size)s para armazenar mensagens de %(count)s salas.", + "Filter": "Pesquisar", + "Start a new chat": "Iniciar uma nova conversa", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Armazene mensagens criptografadas de forma segura localmente para que apareçam nos resultados das buscas. %(size)s é necessário para armazenar mensagens de %(rooms)s sala.", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Armazene mensagens criptografadas de forma segura localmente para que apareçam nos resultados das buscas. %(size)s é necessário para armazenar mensagens de %(rooms)s salas.", + "Filter rooms and people": "Pesquisar salas e pessoas", + "Open the link in the email to continue registration.": "Abra o link no e-mail para continuar o registro.", + "A confirmation email has been sent to %(emailAddress)s": "Um e-mail de confirmação foi enviado para %(emailAddress)s", + "Go to Home View": "Ir para a tela inicial", + "

HTML for your community's page

\n

\n Use the long description to introduce new members to the community, or distribute\n some important links\n

\n

\n You can even add images with Matrix URLs \n

\n": "

HTML para a página da sua comunidade

\n

\n Escreva uma descrição longa para apresentar novos membros à comunidade, ou liste\n alguns links importantes\n

\n

\n Você pode até adicionar fotos com URLs na Matrix \n

\n", + "Remain on your screen while running": "Permaneça na tela, quando executar", + "Remain on your screen when viewing another room, when running": "Permaneça na tela ao visualizar outra sala, quando executar", + "New here? Create an account": "Novo por aqui? Crie uma conta", + "Got an account? Sign in": "Já tem uma conta? Login", + "Use Command + Enter to send a message": "Usar Command + Enter para enviar uma mensagem", + "Enter phone number": "Digite o número de telefone", + "Enter email address": "Digite o endereço de e-mail", + "Decline All": "Recusar tudo", + "Approve": "Autorizar", + "This widget would like to:": "Este widget gostaria de:", + "Approve widget permissions": "Autorizar as permissões do widget", + "Return to call": "Retornar para a chamada", + "Fill Screen": "Preencher a tela", + "Voice Call": "Chamada de voz", + "Video Call": "Chamada de vídeo", + "Use Ctrl + Enter to send a message": "Usar Ctrl + Enter para enviar uma mensagem", + "Render LaTeX maths in messages": "Renderizar fórmulas matemáticas LaTeX em mensagens", + "See %(msgtype)s messages posted to your active room": "Veja mensagens de %(msgtype)s enviadas nesta sala ativa", + "See %(msgtype)s messages posted to this room": "Veja mensagens de %(msgtype)s enviadas nesta sala", + "Send %(msgtype)s messages as you in your active room": "Enviar mensagens de %(msgtype)s nesta sala ativa", + "Send %(msgtype)s messages as you in this room": "Enviar mensagens de %(msgtype)s nesta sala", + "See general files posted to your active room": "Veja os arquivos enviados nesta sala ativa", + "See general files posted to this room": "Veja os arquivos enviados nesta sala", + "Send general files as you in your active room": "Enviar arquivos nesta sala ativa", + "Send general files as you in this room": "Enviar arquivos nesta sala", + "See videos posted to your active room": "Veja os vídeos enviados nesta sala ativa", + "See videos posted to this room": "Veja os vídeos enviados nesta sala", + "Send videos as you in your active room": "Enviar vídeos nesta sala ativa", + "Send videos as you in this room": "Enviar vídeos nesta sala", + "See images posted to your active room": "Veja as fotos enviadas nesta sala ativa", + "See images posted to this room": "Veja as fotos enviadas nesta sala", + "Send images as you in your active room": "Enviar fotos nesta sala ativa", + "Send images as you in this room": "Enviar fotos nesta sala", + "See emotes posted to your active room": "Veja emojis enviados nesta sala ativa", + "See emotes posted to this room": "Veja emojis enviados nesta sala", + "Send emotes as you in your active room": "Enviar emojis nesta sala ativa", + "Send emotes as you in this room": "Enviar emojis nesta sala", + "See text messages posted to your active room": "Veja as mensagens de texto enviadas nesta sala ativa", + "See text messages posted to this room": "Veja as mensagens de texto enviadas nesta sala", + "Send text messages as you in your active room": "Enviar mensagens de texto nesta sala ativa", + "Send text messages as you in this room": "Enviar mensagens de texto nesta sala", + "See messages posted to your active room": "Veja as mensagens enviadas nesta sala ativa", + "See messages posted to this room": "Veja as mensagens enviadas nesta sala", + "Send messages as you in your active room": "Enviar mensagens nesta sala ativa", + "Send messages as you in this room": "Enviar mensagens nesta sala", + "The %(capability)s capability": "A permissão %(capability)s", + "See %(eventType)s events posted to your active room": "Veja eventos de %(eventType)s enviados nesta sala ativa", + "Send %(eventType)s events as you in your active room": "Enviar eventos de %(eventType)s nesta sala ativa", + "See %(eventType)s events posted to this room": "Veja eventos de %(eventType)s postados nesta sala", + "Send %(eventType)s events as you in this room": "Enviar eventos de %(eventType)s nesta sala", + "with state key %(stateKey)s": "com chave de estado %(stateKey)s", + "with an empty state key": "com uma chave de estado vazia", + "See when anyone posts a sticker to your active room": "Veja quando alguém enviar uma figurinha nesta sala ativa", + "Send stickers to your active room as you": "Enviar figurinhas para esta sala ativa", + "See when a sticker is posted in this room": "Veja quando uma figurinha for enviada nesta sala", + "Send stickers to this room as you": "Enviar figurinhas para esta sala", + "See when the avatar changes in your active room": "Veja quando a foto desta sala ativa for alterada", + "Change the avatar of your active room": "Alterar a foto desta sala ativa", + "See when the avatar changes in this room": "Veja quando a foto desta sala for alterada", + "Change the avatar of this room": "Alterar a foto desta sala", + "See when the name changes in your active room": "Veja quando o nome desta sala ativa for alterado", + "Change the name of your active room": "Alterar o nome desta sala ativa", + "See when the name changes in this room": "Veja quando o nome desta sala for alterado", + "Change the name of this room": "Alterar o nome desta sala", + "See when the topic changes in your active room": "Veja quando a descrição for alterada nesta sala ativa", + "Change the topic of your active room": "Alterar a descrição desta sala ativa", + "See when the topic changes in this room": "Veja quando a descrição for alterada nesta sala", + "Change the topic of this room": "Alterar a descrição desta sala", + "Change which room you're viewing": "Alterar a sala que você está vendo", + "Send stickers into your active room": "Enviar figurinhas nesta sala ativa", + "Send stickers into this room": "Enviar figurinhas nesta sala", + "No other application is using the webcam": "Nenhum outro aplicativo está usando a câmera", + "Permission is granted to use the webcam": "Permissão concedida para usar a câmera", + "A microphone and webcam are plugged in and set up correctly": "Um microfone e uma câmera estão conectados e configurados corretamente", + "Call failed because no webcam or microphone could not be accessed. Check that:": "A chamada falhou porque não foi possível acessar alguma câmera ou microfone. Verifique se:", + "Unable to access webcam / microphone": "Não é possível acessar a câmera/microfone", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "A chamada falhou porque não foi possível acessar algum microfone. Verifique se o microfone está conectado e configurado corretamente.", + "Unable to access microphone": "Não é possível acessar o microfone" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 8bf1ee15d30..3c5ead3aa1a 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -2558,5 +2558,52 @@ "Rate %(brand)s": "Оценить %(brand)s", "Feedback sent": "Отзыв отправлен", "%(senderName)s ended the call": "%(senderName)s завершил(а) звонок", - "You ended the call": "Вы закончили звонок" + "You ended the call": "Вы закончили звонок", + "Send stickers into this room": "Отправить стикеры в эту комнату", + "Use Ctrl + Enter to send a message": "Используйте Ctrl + Enter, чтобы отправить сообщение", + "Use Command + Enter to send a message": "Используйте Command + Enter, чтобы отправить сообщение", + "Go to Home View": "Перейти на главную страницу", + "Filter rooms and people": "Фильтровать комнаты и людей", + "Open the link in the email to continue registration.": "Откройте ссылку в письме, чтобы продолжить регистрацию.", + "A confirmation email has been sent to %(emailAddress)s": "Письмо с подтверждением отправлено на %(emailAddress)s", + "Start a new chat": "Начать новый чат", + "Role": "Роль", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Сообщения в этой комнате полностью зашифрованы. Когда люди присоединяются, вы можете проверить их в их профиле, просто нажмите на их аватар.", + "This is the start of .": "Это начало .", + "Add a photo, so people can easily spot your room.": "Добавьте фото, чтобы люди могли легко заметить вашу комнату.", + "%(displayName)s created this room.": "%(displayName)s создал(а) эту комнату.", + "You created this room.": "Вы создали эту комнату.", + "Add a topic to help people know what it is about.": "Добавьте тему, чтобы люди знали, о чём комната.", + "Topic: %(topic)s ": "Тема: %(topic)s ", + "Topic: %(topic)s (edit)": "Тема: %(topic)s (изменить)", + "This is the beginning of your direct message history with .": "Это начало вашей истории прямых сообщений с .", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "В этом разговоре только вы двое, если только кто-нибудь из вас не пригласит кого-нибудь присоединиться.", + "Takes the call in the current room off hold": "Прекратить удержание вызова в текущей комнате", + "Places the call in the current room on hold": "Перевести вызов в текущей комнате на удержание", + "Now, let's help you get started": "Теперь давайте поможем вам начать", + "Invite someone using their name, email address, username (like ) or share this room.": "Пригласите кого-нибудь, используя его имя, адрес электронной почты, имя пользователя (например, ) или поделитесь этой комнатой.", + "Start a conversation with someone using their name, email address or username (like ).": "Начните разговор с кем-нибудь, используя его имя, адрес электронной почты или имя пользователя (например, ).", + "Invite by email": "Пригласить по электронной почте", + "Welcome %(name)s": "Добро пожаловать, %(name)s", + "Add a photo so people know it's you.": "Добавьте фото, чтобы люди знали, что это вы.", + "Great, that'll help people know it's you": "Отлично, это поможет людям узнать, что это ты", + "Use the + to make a new room or explore existing ones below": "Используйте +, чтобы создать новую комнату или изучить существующие ниже", + "New version of %(brand)s is available": "Доступна новая версия %(brand)s!", + "Update %(brand)s": "Обновление %(brand)s", + "Enable desktop notifications": "Включить уведомления на рабочем столе", + "Don't miss a reply": "Не пропустите ответ", + "No other application is using the webcam": "Никакое другое приложение не использует веб-камеру", + "Permission is granted to use the webcam": "Разрешение на использование еб-камеры предоставлено", + "A microphone and webcam are plugged in and set up correctly": "Микрофон и веб-камера подключены и правильно настроены", + "Call failed because no webcam or microphone could not be accessed. Check that:": "Вызов не удался, потому что не удалось получить доступ к веб-камере или микрофону. Проверьте это:", + "Unable to access webcam / microphone": "Невозможно получить доступ к веб-камере / микрофону", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Вызов не удался, потому что нет доступа к микрофону. Убедитесь, что микрофон подключен и правильно настроен.", + "Unable to access microphone": "Нет доступа к микрофону", + "Video Call": "Видеовызов", + "Voice Call": "Голосовой вызов", + "Fill Screen": "Заполнить экран", + "Return to call": "Вернуться к звонку", + "Got an account? Sign in": "Есть учётная запись? Войти", + "New here? Create an account": "Впервые здесь? Создать учётную запись", + "Render LaTeX maths in messages": "Отображать математику LaTeX в сообщениях" } diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index 82644d85a3a..74a85031ac4 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -2834,5 +2834,90 @@ "Mauritania": "Mauritani", "Bangladesh": "Bangladesh", "Falkland Islands": "Ishujt Falkland", - "Sweden": "Suedi" + "Sweden": "Suedi", + "Filter rooms and people": "Filtroni dhoma dhe njerëz", + "Open the link in the email to continue registration.": "Që të vazhdohet regjistrimi, hapni lidhjen te email-i.", + "A confirmation email has been sent to %(emailAddress)s": "Te %(emailAddress)s u dërgua një email ripohimi", + "Role": "Rol", + "Start a new chat": "Nisni një fjalosje të re", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Ruajini lokalisht në fshehtinë në mënyrë të sigurt mesazhet e fshehtëzuar, që të shfaqen në përfundime kërkimi, duke përdorur %(size)s që të depozitoni mesazhe nga %(rooms)s dhomë.", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Ruajini lokalisht në fshehtinë në mënyrë të sigurt mesazhet e fshehtëzuar, që të shfaqen në përfundime kërkimi, duke përdorur %(size)s që të depozitoni mesazhe nga %(rooms)s dhoma.", + "See emotes posted to your active room": "Shihni emotikonë postuar në dhomën tuaj aktive", + "See emotes posted to this room": "Shihni emotikone postuar në këtë dhomë", + "Send emotes as you in your active room": "Dërgoni emotikone si ju në këtë dhomë", + "Send emotes as you in this room": "Dërgoni emotikone si ju në këtë dhomë", + "See text messages posted to your active room": "Shihni mesazhe tekst postuar në dhomën tuaj aktive", + "See text messages posted to this room": "Shihni mesazhe tekst postuar në këtë dhomë", + "Send text messages as you in your active room": "Dërgoni mesazhe tekst si ju në dhomën tuaj aktive", + "Send text messages as you in this room": "Dërgoni mesazhe tekst si ju në këtë dhomë", + "See messages posted to your active room": "Shihni mesazhe të postuar në dhomën tuaj aktive", + "See messages posted to this room": "Shihni mesazhe të postuar në këtë dhomë", + "Send messages as you in your active room": "Dërgoni mesazhe si ju në dhomën tuaj aktive", + "Send messages as you in this room": "Dërgoni mesazhi si ju në këtë dhomë", + "The %(capability)s capability": "Aftësia %(capability)s", + "See %(eventType)s events posted to your active room": "Shihni akte %(eventType)s postuar në dhomën tuaj aktive", + "Send %(eventType)s events as you in your active room": "Shihni akte %(eventType)s si ju në këtë dhomë", + "See %(eventType)s events posted to this room": "Shihni akte %(eventType)s postuar në këtë dhomë", + "Send %(eventType)s events as you in this room": "Dërgoni akte %(eventType)s në këtë dhomë si ju", + "with state key %(stateKey)s": "me kyç gjendjeje %(stateKey)s", + "with an empty state key": "me një kyç të zbrazët gjendjeje", + "See when anyone posts a sticker to your active room": "Shihni kur dikush poston një ngjitës në dhomën tuaj aktive", + "Send stickers to your active room as you": "Dërgoni ngjitës në dhomën tuaj aktive si ju", + "See when a sticker is posted in this room": "Shihni kur postohet një ngjitës në këtë dhomë", + "Send stickers to this room as you": "Dërgoni ngjitës në këtë dhomë si ju", + "See when the avatar changes in your active room": "Shihni kur ndryshon avatari në dhomën tuaj aktive", + "Change the avatar of your active room": "Ndryshoni avatarin në dhomën tuaj aktive", + "See when the avatar changes in this room": "Shihni kur ndryshon avatari në këtë dhomë", + "Change the avatar of this room": "Ndryshoni avatarin e kësaj dhome", + "See when the name changes in your active room": "Shihni kur ndryshon emri në dhomën tuaj aktive", + "Change the name of your active room": "Ndryshoni emrin e dhomës tuaj aktive", + "See when the name changes in this room": "Shihni kur ndryshohet emri në këtë dhomë", + "Change the name of this room": "Ndryshoni emrin e kësaj dhome", + "See when the topic changes in your active room": "Shihni kur ndryshon tema në dhomën tuaj aktive", + "Change the topic of your active room": "Ndryshoni temën në dhomën tuaj aktive", + "See when the topic changes in this room": "Shihni kur ndryshohet tema në këtë dhomë", + "Change the topic of this room": "Ndryshoni temën e kësaj dhome", + "Change which room you're viewing": "Ndryshoni cilën dhomë shihni", + "Send stickers into your active room": "Dërgoni ngjitës në dhomën tuaj aktive", + "Send stickers into this room": "Dërgoni ngjitës në këtë dhomë", + "Go to Home View": "Kaloni te Pamja Kreu", + "

HTML for your community's page

\n

\n Use the long description to introduce new members to the community, or distribute\n some important links\n

\n

\n You can even add images with Matrix URLs \n

\n": "

HTML për faqen e bashkësisë tuaj

\n

\n Përdoreni përshkrimin e gjatë që t’i prezantoni bashkësisë anëtarë të rinj, ose për t’u dhënë lidhje të rëndësishme\n

\n

\n Mundeni madje të shtoni figura me URL-ra Matrix \n

\n", + "Enter phone number": "Jepni numër telefoni", + "Enter email address": "Jepni adresë email-i", + "Decline All": "Hidhi Krejt Poshtë", + "Approve": "Miratojeni", + "This widget would like to:": "Ky widget do të donte të:", + "Approve widget permissions": "Miratoni leje widget-i", + "Use Ctrl + Enter to send a message": "Që të dërgoni një mesazh përdorni tastet Ctrl + Enter", + "Use Command + Enter to send a message": "Që të dërgoni një mesazh, përdorni tastet Command + Enter", + "See %(msgtype)s messages posted to your active room": "Shihni mesazhe %(msgtype)s postuar në dhomën tuaj aktive", + "See %(msgtype)s messages posted to this room": "Shihni mesazhe %(msgtype)s postuar në këtë dhomë", + "Send %(msgtype)s messages as you in your active room": "Dërgoni mesazhe %(msgtype)s si ju në dhomën tuaj aktive", + "Send %(msgtype)s messages as you in this room": "Dërgoni mesazhe %(msgtype)s si ju në këtë dhomë", + "See general files posted to your active room": "Shihni kartela të përgjithshme postuar në dhomën tuaj aktive", + "See general files posted to this room": "Shihni kartela të përgjithshme postuar në këtë dhomë", + "Send general files as you in your active room": "Dërgoni kartela të përgjithshme si ju në dhomën tuaj aktive", + "Send general files as you in this room": "Dërgoni kartela të përgjithshme si ju në këtë dhomë", + "See videos posted to your active room": "Shihni video të postuara në dhomën tuaj aktive", + "See videos posted to this room": "Shihni video të postuara në këtë dhomë", + "Send videos as you in your active room": "Dërgoni video si ju në dhomën tuaj aktive", + "Send videos as you in this room": "Dërgoni video si ju në këtë dhomë", + "See images posted to your active room": "Shihni figura postuar te dhoma juaj aktive", + "See images posted to this room": "Shihni figura postuar në këtë dhomë", + "Send images as you in your active room": "Dërgoni figura si ju në dhomën tuaj aktive", + "New here? Create an account": "I sapoardhur? Krijoni një llogari", + "Got an account? Sign in": "Keni një llogari? Hyni", + "Return to call": "Kthehu te thirrja", + "Fill Screen": "Mbushe Ekranin", + "Voice Call": "Thirrje Zanore", + "Video Call": "Thirrje Video", + "Render LaTeX maths in messages": "Formo formula LaTeX në mesazhe", + "Send images as you in this room": "Dërgoni figura si ju, në këtë dhomë", + "No other application is using the webcam": "Kamerën s’po e përdor aplikacion tjetër", + "Permission is granted to use the webcam": "Është dhënë leje për përdorimin e kamerës", + "A microphone and webcam are plugged in and set up correctly": "Një mikrofon dhe një kamerë janë futur dhe ujdisur si duhet", + "Call failed because no webcam or microphone could not be accessed. Check that:": "Thirrja dështoi, ngaqë s’u bë dot hyrje në kamerë ose mikrofon. Kontrolloni që:", + "Unable to access webcam / microphone": "S’arrihet të përdoret kamerë / mikrofon", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Thirrja dështoi, ngaqë s’u hap dot ndonjë mikrofon. Shihni që të jetë futur një mikrofon dhe ujdiseni saktë.", + "Unable to access microphone": "S’arrihet të përdoret mikrofoni" } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index f1bf9f08cd3..93ff8808cbd 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -2774,5 +2774,14 @@ "Central African Republic": "Centralafrikanska republiken", "Cayman Islands": "Caymanöarna", "Caribbean Netherlands": "Karibiska Nederländerna", - "Cape Verde": "Kap Verde" + "Cape Verde": "Kap Verde", + "Change which room you're viewing": "Ändra vilket rum du visar", + "Send stickers into your active room": "Skicka dekaler in i ditt aktiva rum", + "Send stickers into this room": "Skicka dekaler in i det här rummet", + "Remain on your screen while running": "Stanna kvar på skärmen när det körs", + "Remain on your screen when viewing another room, when running": "Stanna kvar på skärmen när ett annat rum visas, när det körs", + "See when the topic changes in this room": "Se när ämnet ändras i det här rummet", + "Change the topic of this room": "Ändra ämnet för det här rummet", + "See when the topic changes in your active room": "Se när ämnet ändras i ditt aktiva rum", + "Change the topic of your active room": "Ändra ämnet för ditt aktiva rum" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 4151a3f7553..eaf8fdbe882 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -1264,5 +1264,264 @@ "The other party cancelled the verification.": "Друга сторона скасувала звірення.", "Verified!": "Звірено!", "You've successfully verified this user.": "Ви успішно звірили цього користувача.", - "Got It": "Зрозуміло" + "Got It": "Зрозуміло", + "Comoros": "Коморські Острови", + "Colombia": "Колумбія", + "Cocos (Keeling) Islands": "Кокосові (Кілінг) Острови", + "Christmas Island": "Острів Різдва", + "China": "Китай", + "Chile": "Чилі", + "Chad": "Чад", + "Central African Republic": "Центральноафриканська Республіка", + "Cayman Islands": "Кайманові Острови", + "Caribbean Netherlands": "Бонайре, Сінт-Естатіус і Саба", + "Cape Verde": "Кабо-Верде", + "Canada": "Канада", + "Cameroon": "Камерун", + "Cambodia": "Камбоджа", + "Burundi": "Бурунді", + "Burkina Faso": "Буркіна-Фасо", + "Bulgaria": "Болгарія", + "Brunei": "Бруней", + "British Virgin Islands": "Британські Віргінські Острови", + "British Indian Ocean Territory": "Британська територія в Індійському океані", + "Brazil": "Бразилія", + "Bouvet Island": "Острів Буве", + "Botswana": "Ботсвана", + "Bosnia": "Боснія і Герцеговина", + "Bolivia": "Болівія", + "Bhutan": "Бутан", + "Bermuda": "Бермудські Острови", + "Benin": "Бенін", + "Belize": "Беліз", + "Belgium": "Бельгія", + "Belarus": "Білорусь", + "Barbados": "Барбадос", + "Bangladesh": "Бангладеш", + "Bahrain": "Бахрейн", + "Bahamas": "Багамські Острови", + "Azerbaijan": "Азербайджан", + "Austria": "Австрія", + "Australia": "Австралія", + "Aruba": "Аруба", + "Armenia": "Вірменія", + "Argentina": "Аргентина", + "Antigua & Barbuda": "Антигуа і Барбуда", + "Antarctica": "Антарктика", + "Anguilla": "Ангілья", + "Angola": "Ангола", + "Andorra": "Андорра", + "American Samoa": "Американське Самоа", + "Algeria": "Алжир", + "Albania": "Албанія", + "Åland Islands": "Аландські Острови", + "Afghanistan": "Афганістан", + "United States": "Сполучені Штати Америки", + "United Kingdom": "Велика Британія", + "The call was answered on another device.": "На дзвінок відповіли на іншому пристрої.", + "Answered Elsewhere": "Відповіли деінде", + "The call could not be established": "Не вдалося встановити зв'язок", + "The other party declined the call.": "Інша сторона відхилила дзвінок.", + "Call Declined": "Дзвінок відхилено", + "Falkland Islands": "Фолклендські (Мальвінські) Острови", + "Ethiopia": "Ефіопія", + "Estonia": "Естонія", + "Eritrea": "Еритрея", + "Equatorial Guinea": "Екваторіальна Гвінея", + "El Salvador": "Сальвадор", + "Egypt": "Єгипет", + "Ecuador": "Еквадор", + "Dominican Republic": "Домініканська Республіка", + "Dominica": "Домініка", + "Djibouti": "Джибуті", + "Denmark": "Данія", + "Côte d’Ivoire": "Кот-Д'Івуар", + "Czech Republic": "Чехія", + "Cyprus": "Кіпр", + "Curaçao": "Кюрасао", + "Cuba": "Куба", + "Croatia": "Хорватія", + "Costa Rica": "Коста-Рика", + "Cook Islands": "Острови Кука", + "Congo - Kinshasa": "Демократична Республіка Конго", + "Congo - Brazzaville": "Конго", + "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s змінив серверні права доступу для цієї кімнати.", + "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s встановив серверні права доступу для цієї кімнати", + "Takes the call in the current room off hold": "Зніміть дзвінок у поточній кімнаті з утримання", + "Places the call in the current room on hold": "Переведіть дзвінок у поточній кімнаті на утримання", + "Zimbabwe": "Зімбабве", + "Zambia": "Замбія", + "Yemen": "Ємен", + "Western Sahara": "Західна Сахара", + "Wallis & Futuna": "Волліс і Футуна", + "Vietnam": "В'єтнам", + "Venezuela": "Венесуела", + "Vatican City": "Ватикан", + "Vanuatu": "Вануату", + "Uzbekistan": "Узбекистан", + "Uruguay": "Уругвай", + "United Arab Emirates": "Об'єднані Арабські Емірати", + "Ukraine": "Україна", + "Uganda": "Уганда", + "U.S. Virgin Islands": "Віргінські Острови (США)", + "Tuvalu": "Тувалу", + "Turks & Caicos Islands": "Острови Теркс і Кайкос", + "Turkmenistan": "Туркменистан", + "Turkey": "Туреччина", + "Tunisia": "Туніс", + "Trinidad & Tobago": "Тринідад і Тобаго", + "Tonga": "Тонга", + "Tokelau": "Токелау", + "Togo": "Того", + "Timor-Leste": "Тимор-Лешті", + "Thailand": "Таїланд", + "Tanzania": "Танзанія", + "Tajikistan": "Таджикистан", + "Taiwan": "Тайвань", + "São Tomé & Príncipe": "Сан-Томе і Принсіпі", + "Syria": "Сирія", + "Switzerland": "Швейцарія", + "Sweden": "Швеція", + "Swaziland": "Есватіні", + "Svalbard & Jan Mayen": "Свальбард і Ян-Маєн", + "Suriname": "Суринам", + "Sudan": "Судан", + "St. Vincent & Grenadines": "Сент-Вінсент і Гренадини", + "St. Pierre & Miquelon": "Сен-П'єр і Мікелон", + "St. Martin": "Сен-Мартен", + "St. Lucia": "Сент-Люсія", + "St. Kitts & Nevis": "Сент-Кітс і Невіс", + "St. Helena": "Острів Святої Єлени", + "St. Barthélemy": "Сен-Бартелемі", + "Sri Lanka": "Шрі-Ланка", + "Spain": "Іспанія", + "South Sudan": "Південний Судан", + "South Korea": "Південна Корея", + "South Georgia & South Sandwich Islands": "Південна Джорджія та Південні Сандвічеві Острови", + "South Africa": "Південна Африка", + "Somalia": "Сомалі", + "Solomon Islands": "Соломонові Острови", + "Slovenia": "Словенія", + "Slovakia": "Словаччина", + "Sint Maarten": "Сінт-Мартен", + "Singapore": "Сингапур", + "Sierra Leone": "Сьєрра-Леоне", + "Seychelles": "Сейшельські Острови", + "Serbia": "Сербія", + "Senegal": "Сенегал", + "Saudi Arabia": "Саудівська Аравія", + "San Marino": "Сан-Марино", + "Samoa": "Самоа", + "Réunion": "Реюньйон", + "Rwanda": "Руанда", + "Russia": "Російська Федерація", + "Romania": "Румунія", + "Qatar": "Катар", + "Puerto Rico": "Пуерто-Рико", + "Portugal": "Португалія", + "Poland": "Польща", + "Pitcairn Islands": "Піткерн", + "Philippines": "Філіппіни", + "Peru": "Перу", + "Paraguay": "Парагвай", + "Papua New Guinea": "Папуа-Нова Гвінея", + "Panama": "Панама", + "Palestine": "Палестина", + "Palau": "Палау", + "Pakistan": "Пакистан", + "Oman": "Оман", + "Norway": "Норвегія", + "Northern Mariana Islands": "Північні Маріанські Острови", + "North Korea": "Північна Корея", + "Norfolk Island": "Острів Норфолк", + "Niue": "Ніуе", + "Nigeria": "Нігерія", + "Niger": "Нігер", + "Nicaragua": "Нікарагуа", + "New Zealand": "Нова Зеландія", + "New Caledonia": "Нова Каледонія", + "Netherlands": "Нідерланди", + "Nepal": "Непал", + "Nauru": "Науру", + "Namibia": "Намібія", + "Myanmar": "М'янма", + "Mozambique": "Мозамбік", + "Morocco": "Марокко", + "Montserrat": "Монтсеррат", + "Montenegro": "Чорногорія", + "Mongolia": "Монголія", + "Monaco": "Монако", + "Moldova": "Молдова", + "Micronesia": "Мікронезія", + "Mexico": "Мексика", + "Mayotte": "Майотта", + "Mauritius": "Маврикій", + "Mauritania": "Мавританія", + "Martinique": "Мартиніка", + "Marshall Islands": "Маршаллові Острови", + "Malta": "Мальта", + "Mali": "Малі", + "Maldives": "Мальдіви", + "Malaysia": "Малайзія", + "Malawi": "Малаві", + "Madagascar": "Мадагаскар", + "Macedonia": "Північна Македонія", + "Macau": "Макао", + "Luxembourg": "Люксембург", + "Lithuania": "Литва", + "Liechtenstein": "Ліхтенштейн", + "Libya": "Лівія", + "Liberia": "Ліберія", + "Lesotho": "Лесото", + "Lebanon": "Ліван", + "Latvia": "Латвія", + "Laos": "Лаоська Народно-Демократична Республіка", + "Kyrgyzstan": "Киргизстан", + "Kuwait": "Кувейт", + "Kosovo": "Косово", + "Kiribati": "Кірибаті", + "Kenya": "Кенія", + "Kazakhstan": "Казахстан", + "Jordan": "Йорданія", + "Jersey": "Джерсі", + "Japan": "Японія", + "Jamaica": "Ямайка", + "Italy": "Італія", + "Israel": "Ізраїль", + "Isle of Man": "Острів Мен", + "Ireland": "Ірландія", + "Iraq": "Ірак", + "Iran": "Іран", + "Indonesia": "Індонезія", + "India": "Індія", + "Iceland": "Ісландія", + "Hungary": "Угорщина", + "Hong Kong": "Гонконг", + "Honduras": "Гондурас", + "Heard & McDonald Islands": "Острів Герд і Острови Макдоналд", + "Haiti": "Гаїті", + "Guyana": "Гаяна", + "Guinea-Bissau": "Гвінея-Бісау", + "Guinea": "Гвінея", + "Guernsey": "Гернсі", + "Guatemala": "Гватемала", + "Guam": "Гуам", + "Guadeloupe": "Гваделупа", + "Grenada": "Гренада", + "Greenland": "Гренландія", + "Greece": "Греція", + "Gibraltar": "Гібралтар", + "Ghana": "Гана", + "Germany": "Німеччина", + "Georgia": "Грузія", + "Gambia": "Гамбія", + "Gabon": "Габон", + "French Southern Territories": "Французькі Південні Території", + "French Polynesia": "Французька Полінезія", + "French Guiana": "Французька Гвіана", + "France": "Франція", + "Finland": "Фінляндія", + "Fiji": "Фіджі", + "Faroe Islands": "Фарерські Острови", + "Unable to access microphone": "Неможливо доступитись до мікрофона" } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index 809be893838..672b1befd1e 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -2398,5 +2398,16 @@ "Privacy": "隐私", "Explore community rooms": "探索社区聊天室", "%(count)s results|one": "%(count)s 个结果", - "Room Info": "聊天室信息" + "Room Info": "聊天室信息", + "No other application is using the webcam": "没有其他应用程序正在使用摄像头", + "Permission is granted to use the webcam": "授予使用网络摄像头的权限", + "A microphone and webcam are plugged in and set up correctly": "麦克风和摄像头已插入并正确设置", + "Call failed because no webcam or microphone could not be accessed. Check that:": "通话失败,因为无法访问摄像头或麦克风。 检查:", + "Unable to access webcam / microphone": "无法访问摄像头/麦克风", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法访问任何麦克风。 检查是否已插入麦克风并正确设置。", + "Unable to access microphone": "无法使用麦克风", + "The call was answered on another device.": "在另一台设备上应答了该通话。", + "The call could not be established": "无法建立通话", + "The other party declined the call.": "对方拒绝了通话。", + "Call Declined": "通话被拒绝" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index d02ac268bde..3355a7d383c 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -2844,5 +2844,91 @@ "Places the call in the current room on hold": "在目前的聊天室撥打通話並等候接聽", "Role": "角色", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|one": "使用 %(size)s 儲存來自 %(count)s 個聊天室的訊息,在本機安全地快取已加密的訊息以讓它們可以在搜尋結果中出現。", - "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|other": "使用 %(size)s 儲存來自 %(count)s 個聊天室的訊息,在本機安全地快取已加密的訊息以讓它們可以在搜尋結果中出現。" + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(count)s rooms.|other": "使用 %(size)s 儲存來自 %(count)s 個聊天室的訊息,在本機安全地快取已加密的訊息以讓它們可以在搜尋結果中出現。", + "Go to Home View": "轉到主視窗", + "Filter rooms and people": "過濾聊天室與人們", + "Open the link in the email to continue registration.": "開啟電子郵件中的連結以繼續註冊。", + "A confirmation email has been sent to %(emailAddress)s": "確認電子郵件已寄送至 %(emailAddress)s", + "Start a new chat": "開始新聊天", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "使用 %(size)s 來儲存來自 %(rooms)s 個聊天室的訊息,在本機安全地快取已加密的訊息以使其出現在搜尋結果中。", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "使用 %(size)s 來儲存來自 %(rooms)s 個聊天室的訊息,在本機安全地快取已加密的訊息以使其出現在搜尋結果中。", + "

HTML for your community's page

\n

\n Use the long description to introduce new members to the community, or distribute\n some important links\n

\n

\n You can even add images with Matrix URLs \n

\n": "

您社群頁面的 HTML

\n

\n 使用詳細說明向社群介紹新成員,或散佈\n 一些重要的連結\n

\n

\n 您甚至可以使用 Matrix URL 新增圖片\n

\n", + "Decline All": "全部拒絕", + "Approve": "批准", + "This widget would like to:": "這個小工具想要:", + "Approve widget permissions": "批准小工具權限", + "Use Ctrl + Enter to send a message": "使用 Ctrl + Enter 來傳送訊息", + "Use Command + Enter to send a message": "使用 Command + Enter 來傳送訊息", + "See %(msgtype)s messages posted to your active room": "檢視發佈到您的活躍聊天室的 %(msgtype)s 訊息", + "See %(msgtype)s messages posted to this room": "檢視發佈到此聊天室的 %(msgtype)s 訊息", + "Send %(msgtype)s messages as you in your active room": "在您的活躍聊天室中以您的身份傳送 %(msgtype)s 訊息", + "Send %(msgtype)s messages as you in this room": "在此聊天室中以您的身份傳送 %(msgtype)s 訊息", + "See general files posted to your active room": "檢視在您的活躍聊天室中發佈的一般檔案", + "See general files posted to this room": "檢視在此聊天室中發佈的一般檔案", + "Send general files as you in your active room": "在您的活躍聊天室中以您的身份傳送一般檔案", + "Send general files as you in this room": "在此聊天室中以您的身份傳送一般檔案", + "See videos posted to your active room": "檢視發佈到您的活躍聊天室的影片", + "See videos posted to this room": "檢視發佈到此聊天室的影片", + "Send videos as you in your active room": "在您的活躍聊天室中以您的身份傳送影片", + "Send videos as you in this room": "在此聊天室中以您的身份傳送影片", + "See images posted to your active room": "檢視發佈到您的活躍聊天室的圖片", + "See images posted to this room": "檢視發佈到此聊天室的圖片", + "Send images as you in your active room": "在您活躍的聊天室以您的身份傳送圖片", + "Send images as you in this room": "在此聊天室以您的身份傳送圖片", + "See emotes posted to your active room": "檢視發佈到您的活躍聊天室的表情符號", + "See emotes posted to this room": "檢視發佈到此聊天室的表情符號", + "Send emotes as you in your active room": "在您的活躍聊天室中以您的身份傳送表情符號", + "Send emotes as you in this room": "在此聊天室中以您的身份傳送表情符號", + "See text messages posted to your active room": "檢視發佈到您的活躍聊天室的文字訊息", + "See text messages posted to this room": "檢視發佈到此聊天室的文字訊息", + "Send text messages as you in your active room": "在您的活躍聊天室以您的身份傳送文字訊息", + "Send text messages as you in this room": "在此聊天室以您的身份傳送文字訊息", + "See messages posted to your active room": "檢視發佈到您的活躍聊天室的訊息", + "See messages posted to this room": "檢視發佈到此聊天室的訊息", + "Send messages as you in your active room": "在您的活躍聊天室以您的身份傳送訊息", + "Send messages as you in this room": "在此聊天室以您的身份傳送訊息", + "The %(capability)s capability": "%(capability)s 能力", + "See %(eventType)s events posted to your active room": "檢視發佈到您的活躍聊天室的 %(eventType)s 活動", + "Send %(eventType)s events as you in your active room": "以您的身份在您的活躍聊天是傳送 %(eventType)s 活動", + "See %(eventType)s events posted to this room": "檢視發佈到此聊天室的 %(eventType)s 活動", + "Send %(eventType)s events as you in this room": "以您的身份在此聊天室傳送 %(eventType)s 活動", + "with state key %(stateKey)s": "帶有狀態金鑰 %(stateKey)s", + "with an empty state key": "帶有空的狀態金鑰", + "See when anyone posts a sticker to your active room": "檢視何時有人將貼圖貼到您活躍的聊天室", + "Send stickers to your active room as you": "以您的身份傳送貼圖到您活躍的聊天室", + "See when a sticker is posted in this room": "檢視貼圖在此聊天室中何時貼出", + "Send stickers to this room as you": "以您的身份傳送貼圖到此聊天室", + "See when the avatar changes in your active room": "檢視您活躍聊天是的大頭照何時變更", + "Change the avatar of your active room": "變更您活躍聊天是的大頭照", + "See when the avatar changes in this room": "檢視此聊天是的大頭照何時變更", + "Change the avatar of this room": "變更此聊天室的大頭照", + "See when the name changes in your active room": "檢視您活躍聊天室的名稱何時變更", + "Change the name of your active room": "變更您活躍聊天室的名稱", + "See when the name changes in this room": "檢視此聊天是的名稱何時變更", + "Change the name of this room": "變更此聊天室的名稱", + "See when the topic changes in your active room": "檢視您活躍的聊天是的主題何時變更", + "Change the topic of your active room": "變更您活躍聊天是的主題", + "See when the topic changes in this room": "檢視此聊天是的主題何時變更", + "Change the topic of this room": "變更此聊天室的主題", + "Change which room you're viewing": "變更您正在檢視的聊天室", + "Send stickers into your active room": "傳送貼圖到您活躍的聊天室", + "Send stickers into this room": "傳送貼圖到此聊天室", + "Remain on your screen while running": "在執行時保留在您的畫面上", + "Remain on your screen when viewing another room, when running": "在執行與檢視其他聊天室時仍保留在您的畫面上", + "Enter phone number": "輸入電話號碼", + "Enter email address": "輸入電子郵件地址", + "Return to call": "回到通話", + "Fill Screen": "全螢幕", + "Voice Call": "音訊通話", + "Video Call": "視訊通話", + "New here? Create an account": "新手?建立帳號", + "Got an account? Sign in": "有帳號了嗎?登入", + "Render LaTeX maths in messages": "在訊息中彩現 LaTeX 數學", + "No other application is using the webcam": "無其他應用程式正在使用網路攝影機", + "Permission is granted to use the webcam": "授予使用網路攝影機的權限", + "A microphone and webcam are plugged in and set up correctly": "麥克風與網路攝影機已插入並正確設定", + "Call failed because no webcam or microphone could not be accessed. Check that:": "因為無法存取網路攝影機或麥克風,所以通話失敗。請檢查:", + "Unable to access webcam / microphone": "無法存取網路攝影機/麥克風", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "因為無法存取麥克風,所以通話失敗。請檢查是否已插入麥克風並正確設定。", + "Unable to access microphone": "無法存取麥克風" } diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 0921b65137b..b61f57d4b3c 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -103,6 +103,8 @@ export interface IVariables { type Tags = Record React.ReactNode>; +export type TranslatedString = string | React.ReactNode; + /* * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * @param {string} text The untranslated text, e.g "click here now to %(foo)s". @@ -121,7 +123,7 @@ type Tags = Record React.ReactNode>; */ export function _t(text: string, variables?: IVariables): string; export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode; -export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { +export function _t(text: string, variables?: IVariables, tags?: Tags): TranslatedString { // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // However, still pass the variables to counterpart so that it can choose the correct plural if count is given // It is enough to pass the count variable, but in the future counterpart might make use of other information too diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 6f4357973aa..31e133be72b 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -32,6 +32,7 @@ import UseSystemFontController from './controllers/UseSystemFontController'; import { SettingLevel } from "./SettingLevel"; import SettingController from "./controllers/SettingController"; import { RightPanelPhases } from "../stores/RightPanelStorePhases"; +import { isMac } from '../Keyboard'; import UIFeatureController from "./controllers/UIFeatureController"; import { UIFeature } from "./UIFeature"; import { OrderedMultiController } from "./controllers/OrderedMultiController"; @@ -116,6 +117,12 @@ export interface ISetting { } export const SETTINGS: {[setting: string]: ISetting} = { + "feature_latex_maths": { + isFeature: true, + displayName: _td("Render LaTeX maths in messages"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_communities_v2_prototypes": { isFeature: true, displayName: _td( @@ -324,6 +331,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Show typing notifications"), default: true, }, + "MessageComposerInput.ctrlEnterToSend": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: isMac ? _td("Use Command + Enter to send a message") : _td("Use Ctrl + Enter to send a message"), + default: false, + }, "MessageComposerInput.autoReplaceEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Automatically replace plain text Emoji'), diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts index 0485afd1062..c0b64d76fe4 100644 --- a/src/stores/ModalWidgetStore.ts +++ b/src/stores/ModalWidgetStore.ts @@ -64,7 +64,7 @@ export class ModalWidgetStore extends AsyncStoreWithClient { this.openSourceWidgetId = null; this.modalInstance = null; }, - }); + }, null, /* priority = */ false, /* static = */ true); }; public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => { diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index a8040f57de0..8e08fc016ca 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -39,9 +39,11 @@ export interface IApp extends IWidget { avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 } +type PinnedWidgets = Record; + interface IRoomWidgets { widgets: IApp[]; - pinned: Record; + pinned: PinnedWidgets; } export const MAX_PINNED = 3; @@ -51,8 +53,9 @@ export const MAX_PINNED = 3; export default class WidgetStore extends AsyncStoreWithClient { private static internalInstance = new WidgetStore(); - private widgetMap = new Map(); - private roomMap = new Map(); + // TODO: Come up with a unique key for widgets as their IDs are not globally unique, but can exist anywhere + private widgetMap = new Map(); // Key is widget ID + private roomMap = new Map(); // Key is room ID private constructor() { super(defaultDispatcher, {}); @@ -132,6 +135,15 @@ export default class WidgetStore extends AsyncStoreWithClient { }); this.generateApps(room).forEach(app => { + // Sanity check for https://github.com/vector-im/element-web/issues/15705 + const existingApp = this.widgetMap.get(app.id); + if (existingApp) { + console.warn( + `Possible widget ID conflict for ${app.id} - wants to store in room ${app.roomId} ` + + `but is currently stored as ${existingApp.roomId} - letting the want win`, + ); + } + this.widgetMap.set(app.id, app); roomInfo.widgets.push(app); }); @@ -149,6 +161,13 @@ export default class WidgetStore extends AsyncStoreWithClient { public getRoomId = (widgetId: string) => { const app = this.widgetMap.get(widgetId); if (!app) return null; + + // Sanity check for https://github.com/vector-im/element-web/issues/15705 + const roomInfo = this.getRoom(app.roomId); + if (!roomInfo.widgets?.some(w => w.id === app.id)) { + throw new Error(`Widget ${app.id} says it is in ${app.roomId} but was not found there`); + } + return app.roomId; } @@ -158,49 +177,62 @@ export default class WidgetStore extends AsyncStoreWithClient { private onPinnedWidgetsChange = (settingName: string, roomId: string) => { this.initRoom(roomId); - this.getRoom(roomId).pinned = SettingsStore.getValue(settingName, roomId); + + const pinned: PinnedWidgets = SettingsStore.getValue(settingName, roomId); + + // Sanity check for https://github.com/vector-im/element-web/issues/15705 + const roomInfo = this.getRoom(roomId); + const remappedPinned: PinnedWidgets = {}; + for (const widgetId of Object.keys(pinned)) { + const isPinned = pinned[widgetId]; + if (!roomInfo.widgets?.some(w => w.id === widgetId)) { + console.warn(`Skipping pinned widget update for ${widgetId} in ${roomId} -- wrong room`); + } else { + remappedPinned[widgetId] = isPinned; + } + } + roomInfo.pinned = remappedPinned; + this.emit(roomId); this.emit(UPDATE_EVENT); }; - public isPinned(widgetId: string) { - const roomId = this.getRoomId(widgetId); + public isPinned(roomId: string, widgetId: string) { return !!this.getPinnedApps(roomId).find(w => w.id === widgetId); } - public canPin(widgetId: string) { - const roomId = this.getRoomId(widgetId); + // dev note: we don't need the widgetId on this function, but the contract makes more sense + // when we require it. + public canPin(roomId: string, widgetId: string) { return this.getPinnedApps(roomId).length < MAX_PINNED; } - public pinWidget(widgetId: string) { - const roomId = this.getRoomId(widgetId); + public pinWidget(roomId: string, widgetId: string) { const roomInfo = this.getRoom(roomId); if (!roomInfo) return; // When pinning, first confirm all the widgets (Jitsi) which were autopinned so that the order is correct const autoPinned = this.getPinnedApps(roomId).filter(app => !roomInfo.pinned[app.id]); autoPinned.forEach(app => { - this.setPinned(app.id, true); + this.setPinned(roomId, app.id, true); }); - this.setPinned(widgetId, true); + this.setPinned(roomId, widgetId, true); // Show the apps drawer upon the user pinning a widget if (RoomViewStore.getRoomId() === this.getRoomId(widgetId)) { defaultDispatcher.dispatch({ action: "appsDrawer", show: true, - }) + }); } } - public unpinWidget(widgetId: string) { - this.setPinned(widgetId, false); + public unpinWidget(roomId: string, widgetId: string) { + this.setPinned(roomId, widgetId, false); } - private setPinned(widgetId: string, value: boolean) { - const roomId = this.getRoomId(widgetId); + private setPinned(roomId: string, widgetId: string, value: boolean) { const roomInfo = this.getRoom(roomId); if (!roomInfo) return; if (roomInfo.pinned[widgetId] === false && value) { @@ -221,9 +253,8 @@ export default class WidgetStore extends AsyncStoreWithClient { this.emit(UPDATE_EVENT); } - public movePinnedWidget(widgetId: string, delta: 1 | -1) { + public movePinnedWidget(roomId: string, widgetId: string, delta: 1 | -1) { // TODO simplify this by changing the storage medium of pinned to an array once the Jitsi default-on goes away - const roomId = this.getRoomId(widgetId); const roomInfo = this.getRoom(roomId); if (!roomInfo || roomInfo.pinned[widgetId] === false) return; diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 0f3138fe9ef..b2fe630760c 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -34,6 +34,7 @@ import { MarkedExecution } from "../../utils/MarkedExecution"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { NameFilterCondition } from "./filters/NameFilterCondition"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; +import { VisibilityProvider } from "./filters/VisibilityProvider"; interface IState { tagsEnabled?: boolean; @@ -401,6 +402,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { + if (!VisibilityProvider.instance.isRoomVisible(room)) { + return; // don't do anything on rooms that aren't visible + } + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { if (SettingsStore.getValue("advancedRoomListLogging")) { @@ -544,7 +549,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient { public async regenerateAllLists({trigger = true}) { console.warn("Regenerating all room lists"); - const rooms = this.matrixClient.getVisibleRooms(); + const rooms = this.matrixClient.getVisibleRooms() + .filter(r => VisibilityProvider.instance.isRoomVisible(r)); const customTags = new Set(); if (this.state.tagsEnabled) { for (const room of rooms) { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 439141edb45..25059aabe76 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; +import { VisibilityProvider } from "../filters/VisibilityProvider"; /** * Fired when the Algorithm has determined a list has been updated. @@ -188,6 +189,10 @@ export class Algorithm extends EventEmitter { // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // otherwise we risk duplicating rooms. + if (val && !VisibilityProvider.instance.isRoomVisible(val)) { + val = null; // the room isn't visible - lie to the rest of this function + } + // Set the last sticky room to indicate that we're in a change. The code throughout the // class can safely handle a null room, so this should be safe to do as a backup. this._lastStickyRoom = this._stickyRoom || {}; diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts new file mode 100644 index 00000000000..553dd33ce00 --- /dev/null +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -0,0 +1,54 @@ +/* + * 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 {Room} from "matrix-js-sdk/src/models/room"; +import { RoomListCustomisations } from "../../../customisations/RoomList"; + +export class VisibilityProvider { + private static internalInstance: VisibilityProvider; + + private constructor() { + } + + public static get instance(): VisibilityProvider { + if (!VisibilityProvider.internalInstance) { + VisibilityProvider.internalInstance = new VisibilityProvider(); + } + return VisibilityProvider.internalInstance; + } + + public isRoomVisible(room: Room): boolean { + /* eslint-disable prefer-const */ + let isVisible = true; // Returned at the end of this function + let forced = false; // When true, this function won't bother calling the customisation points + /* eslint-enable prefer-const */ + + // ------ + // TODO: The `if` statements to control visibility of custom room types + // would go here. The remainder of this function assumes that the statements + // will be here. + // + // When removing this comment block, please remove the lint disable lines in the area. + // ------ + + const isVisibleFn = RoomListCustomisations.isRoomVisible; + if (!forced && isVisibleFn) { + isVisible = isVisibleFn(room); + } + + return isVisible; + } +} diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index b101a119a4d..76390086ab6 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -14,8 +14,17 @@ * limitations under the License. */ +import { IWidgetApiRequest } from "matrix-widget-api"; + export enum ElementWidgetActions { ClientReady = "im.vector.ready", HangupCall = "im.vector.hangup", OpenIntegrationManager = "integration_manager_open", + ViewRoom = "io.element.view_room", +} + +export interface IViewRoomApiRequest extends IWidgetApiRequest { + data: { + room_id: string; // eslint-disable-line camelcase + }; } diff --git a/src/stores/widgets/ElementWidgetCapabilities.ts b/src/stores/widgets/ElementWidgetCapabilities.ts new file mode 100644 index 00000000000..3f17d27909f --- /dev/null +++ b/src/stores/widgets/ElementWidgetCapabilities.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export enum ElementWidgetCapabilities { + CanChangeViewedRoom = "io.element.view_room", +} diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index eb37ad8cbf3..cc2934aec1e 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -17,8 +17,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { ClientWidgetApi, - IGetOpenIDActionRequest, - IGetOpenIDActionResponseData, IStickerActionRequest, IStickyActionRequest, ITemplateParams, @@ -27,12 +25,12 @@ import { IWidgetApiRequestEmptyData, IWidgetData, MatrixCapabilities, - OpenIDRequestState, runTemplate, Widget, - WidgetApiToWidgetAction, WidgetApiFromWidgetAction, IModalWidgetOpenRequest, + IWidgetApiErrorResponseData, + WidgetKind, } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; @@ -47,13 +45,13 @@ import { WidgetType } from "../../widgets/WidgetType"; import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ElementWidgetActions } from "./ElementWidgetActions"; -import Modal from "../../Modal"; -import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; +import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions"; import {ModalWidgetStore} from "../ModalWidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; +import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; // TODO: Destroy all of this code @@ -70,9 +68,9 @@ interface IAppTileProps { } // TODO: Don't use this because it's wrong -class ElementWidget extends Widget { - constructor(w) { - super(w); +export class ElementWidget extends Widget { + constructor(private rawDefinition: IWidget) { + super(rawDefinition); } public get templateUrl(): string { @@ -133,12 +131,7 @@ class ElementWidget extends Widget { public getCompleteUrl(params: ITemplateParams, asPopout=false): string { return runTemplate(asPopout ? this.popoutTemplateUrl : this.templateUrl, { - // we need to supply a whole widget to the template, but don't have - // easy access to the definition the superclass is using, so be sad - // and gutwrench it. - // This isn't a problem when the widget architecture is fixed and this - // subclass gets deleted. - ...super['definition'], // XXX: Private member access + ...this.rawDefinition, data: this.rawData, }, params); } @@ -148,6 +141,8 @@ export class StopGapWidget extends EventEmitter { private messaging: ClientWidgetApi; private mockWidget: ElementWidget; private scalarToken: string; + private roomId?: string; + private kind: WidgetKind; constructor(private appTileProps: IAppTileProps) { super(); @@ -160,6 +155,19 @@ export class StopGapWidget extends EventEmitter { } this.mockWidget = new ElementWidget(app); + this.roomId = appTileProps.room?.roomId; + this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably + } + + private get eventListenerRoomId(): string { + // When widgets are listening to events, we need to make sure they're only + // receiving events for the right room. In particular, room widgets get locked + // to the room they were added in while account widgets listen to the currently + // active room. + + if (this.roomId) return this.roomId; + + return RoomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -221,55 +229,6 @@ export class StopGapWidget extends EventEmitter { return this.messaging.widget.id; } - private onOpenIdReq = async (ev: CustomEvent) => { - ev.preventDefault(); - - const rawUrl = this.appTileProps.app.url; - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, rawUrl, this.appTileProps.userWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.messaging.transport.reply(ev.detail, { - state: OpenIDRequestState.Blocked, - }); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - this.messaging.transport.reply(ev.detail, { - state: OpenIDRequestState.Allowed, - ...credentials, - }); - return; - } - - // Confirm that we received the request - this.messaging.transport.reply(ev.detail, { - state: OpenIDRequestState.PendingUserConfirmation, - }); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, { - widgetUrl: rawUrl, - widgetId: this.widgetId, - isUserWidget: this.appTileProps.userWidget, - - onFinished: async (confirm) => { - const responseBody: IGetOpenIDActionResponseData = { - state: confirm ? OpenIDRequestState.Allowed : OpenIDRequestState.Blocked, - original_request_id: ev.detail.requestId, // eslint-disable-line camelcase - }; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messaging.transport.send(WidgetApiToWidgetAction.OpenIDCredentials, responseBody).catch(error => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }); - }; - private onOpenModal = async (ev: CustomEvent) => { ev.preventDefault(); if (ModalWidgetStore.instance.canOpenModalWidget()) { @@ -286,11 +245,11 @@ export class StopGapWidget extends EventEmitter { public start(iframe: HTMLIFrameElement) { if (this.started) return; - const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []); + const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; + const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId); this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); - this.messaging.on(`action:${WidgetApiFromWidgetAction.GetOpenIDCredentials}`, this.onOpenIdReq); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); @@ -298,18 +257,72 @@ export class StopGapWidget extends EventEmitter { ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId); } - if (WidgetType.JITSI.matches(this.mockWidget.type)) { - this.messaging.on("action:set_always_on_screen", - (ev: CustomEvent) => { - if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + // Always attach a handler for ViewRoom, but permission check it internally + this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { + ev.preventDefault(); // stop the widget API from auto-rejecting this + + // Check up front if this is even a valid request + const targetRoomId = (ev.detail.data || {}).room_id; + if (!targetRoomId) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "Room ID not supplied."}, + }); + } + + // Check the widget's permission + if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "This widget does not have permission for this action (denied)."}, + }); + } + + // at this point we can change rooms, so do that + defaultDispatcher.dispatch({ + action: 'view_room', + room_id: targetRoomId, + }); + + // acknowledge so the widget doesn't freak out + this.messaging.transport.reply(ev.detail, {}); + }); + + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + MatrixClientPeg.get().on('event', this.onEvent); + MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); + + this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, + (ev: CustomEvent) => { + if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + if (WidgetType.JITSI.matches(this.mockWidget.type)) { CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true); - ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); - ev.preventDefault(); - this.messaging.transport.reply(ev.detail, {}); // ack } - }, - ); - } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { + ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, {}); // ack + } + }, + ); + + // TODO: Replace this event listener with appropriate driver functionality once the API + // establishes a sane way to send events back and forth. + this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`, + (ev: CustomEvent) => { + if (this.messaging.hasCapability(MatrixCapabilities.StickerSending)) { + // Acknowledge first + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, {}); + + // Send the sticker + defaultDispatcher.dispatch({ + action: 'm.sticker', + data: ev.detail.data, + widgetId: this.mockWidget.id, + }); + } + }, + ); + + if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { this.messaging.on(`action:${ElementWidgetActions.OpenIntegrationManager}`, (ev: CustomEvent) => { // Acknowledge first @@ -341,23 +354,6 @@ export class StopGapWidget extends EventEmitter { } }, ); - - // TODO: Replace this event listener with appropriate driver functionality once the API - // establishes a sane way to send events back and forth. - this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`, - (ev: CustomEvent) => { - // Acknowledge first - ev.preventDefault(); - this.messaging.transport.reply(ev.detail, {}); - - // Send the sticker - defaultDispatcher.dispatch({ - action: 'm.sticker', - data: ev.detail.data, - widgetId: this.mockWidget.id, - }); - }, - ); } } @@ -391,5 +387,31 @@ export class StopGapWidget extends EventEmitter { if (!this.started) return; WidgetMessagingStore.instance.stopMessaging(this.mockWidget); ActiveWidgetStore.delRoomId(this.mockWidget.id); + + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().off('event', this.onEvent); + MatrixClientPeg.get().off('Event.decrypted', this.onEventDecrypted); + } + } + + private onEvent = (ev: MatrixEvent) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; + this.feedEvent(ev); + }; + + private onEventDecrypted = (ev: MatrixEvent) => { + if (ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; + this.feedEvent(ev); + }; + + private feedEvent(ev: MatrixEvent) { + if (!this.messaging) return; + + const raw = ev.event; + this.messaging.feedEvent(raw).catch(e => { + console.error("Error sending event to widget: ", e); + }); } } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index b54e4a5f7d2..60988040d36 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,17 +14,150 @@ * limitations under the License. */ -import { Capability, WidgetDriver } from "matrix-widget-api"; -import { iterableUnion } from "../../utils/iterables"; +import { + Capability, + EventDirection, + IOpenIDCredentials, + IOpenIDUpdate, + ISendEventDetails, + MatrixCapabilities, + OpenIDRequestState, + SimpleObservable, + Widget, + WidgetDriver, + WidgetEventCapability, + WidgetKind, +} from "matrix-widget-api"; +import { iterableDiff, iterableUnion } from "../../utils/iterables"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import ActiveRoomObserver from "../../ActiveRoomObserver"; +import Modal from "../../Modal"; +import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; +import WidgetCapabilitiesPromptDialog, { + getRememberedCapabilitiesForWidget, +} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; +import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; +import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; +import { WidgetType } from "../../widgets/WidgetType"; +import { EventType } from "matrix-js-sdk/src/@types/event"; // TODO: Purge this from the universe export class StopGapWidgetDriver extends WidgetDriver { - constructor(private allowedCapabilities: Capability[]) { + private allowedCapabilities: Set; + + // TODO: Refactor widgetKind into the Widget class + constructor( + allowedCapabilities: Capability[], + private forWidget: Widget, + private forWidgetKind: WidgetKind, + private inRoomId?: string, + ) { super(); + + // Always allow screenshots to be taken because it's a client-induced flow. The widget can't + // spew screenshots at us and can't request screenshots of us, so it's up to us to provide the + // button if the widget says it supports screenshots. + this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]); + + // Grant the permissions that are specific to given widget types + if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) { + this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen); + } else if (WidgetType.STICKERPICKER.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Account) { + const stickerSendingCap = WidgetEventCapability.forRoomEvent(EventDirection.Send, EventType.Sticker).raw; + this.allowedCapabilities.add(MatrixCapabilities.StickerSending); // legacy as far as MSC2762 is concerned + this.allowedCapabilities.add(stickerSendingCap); + } } public async validateCapabilities(requested: Set): Promise> { - return new Set(iterableUnion(requested, this.allowedCapabilities)); + // Check to see if any capabilities aren't automatically accepted (such as sticker pickers + // allowing stickers to be sent). If there are excess capabilities to be approved, the user + // will be prompted to accept them. + const diff = iterableDiff(requested, this.allowedCapabilities); + const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)" + const allowedSoFar = new Set(this.allowedCapabilities); + getRememberedCapabilitiesForWidget(this.forWidget).forEach(cap => { + allowedSoFar.add(cap); + missing.delete(cap); + }); + if (WidgetPermissionCustomisations.preapproveCapabilities) { + const approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested); + if (approved) { + approved.forEach(cap => { + allowedSoFar.add(cap); + missing.delete(cap); + }); + } + } + // TODO: Do something when the widget requests new capabilities not yet asked for + if (missing.size > 0) { + try { + const [result] = await Modal.createTrackedDialog( + 'Approve Widget Caps', '', + WidgetCapabilitiesPromptDialog, + { + requestedCapabilities: missing, + widget: this.forWidget, + widgetKind: this.forWidgetKind, + }).finished; + (result.approved || []).forEach(cap => allowedSoFar.add(cap)); + } catch (e) { + console.error("Non-fatal error getting capabilities: ", e); + } + } + + return new Set(iterableUnion(allowedSoFar, requested)); + } + + public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + + if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + + let r: { event_id: string } = null; // eslint-disable-line camelcase + if (stateKey !== null) { + // state event + r = await client.sendStateEvent(roomId, eventType, content, stateKey); + } else { + // message event + r = await client.sendEvent(roomId, eventType, content); + } + + return {roomId, eventId: r.event_id}; + } + + public async askOpenID(observer: SimpleObservable) { + const oidcState = WidgetPermissionStore.instance.getOIDCState( + this.forWidget, this.forWidgetKind, this.inRoomId, + ); + + const getToken = (): Promise => { + return MatrixClientPeg.get().getOpenIdToken(); + }; + + if (oidcState === OIDCState.Denied) { + return observer.update({state: OpenIDRequestState.Blocked}); + } + if (oidcState === OIDCState.Allowed) { + return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()}); + } + + observer.update({state: OpenIDRequestState.PendingUserConfirmation}); + + Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, { + widget: this.forWidget, + widgetKind: this.forWidgetKind, + inRoomId: this.inRoomId, + + onFinished: async (confirm) => { + if (!confirm) { + return observer.update({state: OpenIDRequestState.Blocked}); + } + + return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()}); + }, + }); } } diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts new file mode 100644 index 00000000000..41e8bc6652c --- /dev/null +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -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. + */ + +import SettingsStore from "../../settings/SettingsStore"; +import { Widget, WidgetKind } from "matrix-widget-api"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { SettingLevel } from "../../settings/SettingLevel"; + +export enum OIDCState { + Allowed, // user has set the remembered value as allowed + Denied, // user has set the remembered value as disallowed + Unknown, // user has not set a remembered value +} + +export class WidgetPermissionStore { + private static internalInstance: WidgetPermissionStore; + + private constructor() { + } + + public static get instance(): WidgetPermissionStore { + if (!WidgetPermissionStore.internalInstance) { + WidgetPermissionStore.internalInstance = new WidgetPermissionStore(); + } + return WidgetPermissionStore.internalInstance; + } + + // TODO (all functions here): Merge widgetKind with the widget definition + + private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string { + let location = roomId; + if (kind !== WidgetKind.Room) { + location = MatrixClientPeg.get().getUserId(); + } + if (kind === WidgetKind.Modal) { + location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it + } + if (!location) { + throw new Error("Failed to determine a location to check the widget's OIDC state with"); + } + + return encodeURIComponent(`${location}::${widget.templateUrl}`); + } + + public getOIDCState(widget: Widget, kind: WidgetKind, roomId?: string): OIDCState { + const settingsKey = this.packSettingKey(widget, kind, roomId); + const settings = SettingsStore.getValue("widgetOpenIDPermissions"); + if (settings?.deny?.includes(settingsKey)) { + return OIDCState.Denied; + } + if (settings?.allow?.includes(settingsKey)) { + return OIDCState.Allowed; + } + return OIDCState.Unknown; + } + + public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) { + const settingsKey = this.packSettingKey(widget, kind, roomId); + + const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + if (!currentValues.allow) currentValues.allow = []; + if (!currentValues.deny) currentValues.deny = []; + + if (newState === OIDCState.Allowed) { + currentValues.allow.push(settingsKey); + } else if (newState === OIDCState.Denied) { + currentValues.deny.push(settingsKey); + } else { + currentValues.allow = currentValues.allow.filter(c => c !== settingsKey); + currentValues.deny = currentValues.deny.filter(c => c !== settingsKey); + } + + SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues); + } +} diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 526c2d5ce75..986c68342c7 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -22,7 +22,6 @@ import SdkConfig from "../SdkConfig"; import dis from '../dispatcher/dispatcher'; import WidgetEchoStore from '../stores/WidgetEchoStore'; import SettingsStore from "../settings/SettingsStore"; -import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {Room} from "matrix-js-sdk/src/models/room"; import {WidgetType} from "../widgets/WidgetType"; @@ -457,27 +456,6 @@ export default class WidgetUtils { return capWhitelist; } - static getWidgetSecurityKey(widgetId: string, widgetUrl: string, isUserWidget: boolean): string { - let widgetLocation = ActiveWidgetStore.getRoomId(widgetId); - - if (isUserWidget) { - const userWidget = WidgetUtils.getUserWidgetsArray() - .find((w) => w.id === widgetId && w.content && w.content.url === widgetUrl); - - if (!userWidget) { - throw new Error("No matching user widget to form security key"); - } - - widgetLocation = userWidget.sender; - } - - if (!widgetLocation) { - throw new Error("Failed to locate where the widget resides"); - } - - return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); - } - static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) { // NB. we can't just encodeURIComponent all of these because the $ signs need to be there const queryStringParts = [ diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts index 56e0bca1b75..7883b2257a1 100644 --- a/src/utils/iterables.ts +++ b/src/utils/iterables.ts @@ -14,8 +14,12 @@ * limitations under the License. */ -import { arrayUnion } from "./arrays"; +import { arrayDiff, arrayUnion } from "./arrays"; export function iterableUnion(a: Iterable, b: Iterable): Iterable { return arrayUnion(Array.from(a), Array.from(b)); } + +export function iterableDiff(a: Iterable, b: Iterable): { added: Iterable, removed: Iterable } { + return arrayDiff(Array.from(a), Array.from(b)); +} diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx new file mode 100644 index 00000000000..834ea3ec375 --- /dev/null +++ b/src/widgets/CapabilityText.tsx @@ -0,0 +1,342 @@ +/* +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 { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api"; +import { _t, _td, TranslatedString } from "../languageHandler"; +import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; +import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities"; +import React from "react"; + +type GENERIC_WIDGET_KIND = "generic"; +const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic"; + +interface ISendRecvStaticCapText { + // @ts-ignore - TS wants the key to be a string, but we know better + [eventType: EventType]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [widgetKind: WidgetKind | GENERIC_WIDGET_KIND]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [direction: EventDirection]: string; + }; + }; +} + +interface IStaticCapText { + // @ts-ignore - TS wants the key to be a string, but we know better + [capability: Capability]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [widgetKind: WidgetKind | GENERIC_WIDGET_KIND]: string; + }; +} + +export interface TranslatedCapabilityText { + primary: TranslatedString; + byline?: TranslatedString; +} + +export class CapabilityText { + private static simpleCaps: IStaticCapText = { + [MatrixCapabilities.AlwaysOnScreen]: { + [WidgetKind.Room]: _td("Remain on your screen when viewing another room, when running"), + [GENERIC_WIDGET_KIND]: _td("Remain on your screen while running"), + }, + [MatrixCapabilities.StickerSending]: { + [WidgetKind.Room]: _td("Send stickers into this room"), + [GENERIC_WIDGET_KIND]: _td("Send stickers into your active room"), + }, + [ElementWidgetCapabilities.CanChangeViewedRoom]: { + [GENERIC_WIDGET_KIND]: _td("Change which room you're viewing"), + }, + }; + + private static stateSendRecvCaps: ISendRecvStaticCapText = { + [EventType.RoomTopic]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the topic of this room"), + [EventDirection.Receive]: _td("See when the topic changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the topic of your active room"), + [EventDirection.Receive]: _td("See when the topic changes in your active room"), + }, + }, + [EventType.RoomName]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the name of this room"), + [EventDirection.Receive]: _td("See when the name changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the name of your active room"), + [EventDirection.Receive]: _td("See when the name changes in your active room"), + }, + }, + [EventType.RoomAvatar]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the avatar of this room"), + [EventDirection.Receive]: _td("See when the avatar changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the avatar of your active room"), + [EventDirection.Receive]: _td("See when the avatar changes in your active room"), + }, + }, + }; + + private static nonStateSendRecvCaps: ISendRecvStaticCapText = { + [EventType.Sticker]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Send stickers to this room as you"), + [EventDirection.Receive]: _td("See when a sticker is posted in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Send stickers to your active room as you"), + [EventDirection.Receive]: _td("See when anyone posts a sticker to your active room"), + }, + }, + }; + + private static bylineFor(eventCap: WidgetEventCapability): TranslatedString { + if (eventCap.isState) { + return !eventCap.keyStr + ? _t("with an empty state key") + : _t("with state key %(stateKey)s", {stateKey: eventCap.keyStr}); + } + return null; // room messages are handled specially + } + + public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText { + // First see if we have a super simple line of text to provide back + if (CapabilityText.simpleCaps[capability]) { + const textForKind = CapabilityText.simpleCaps[capability]; + if (textForKind[kind]) return {primary: _t(textForKind[kind])}; + if (textForKind[GENERIC_WIDGET_KIND]) return {primary: _t(textForKind[GENERIC_WIDGET_KIND])}; + + // ... we'll fall through to the generic capability processing at the end of this + // function if we fail to locate a simple string and the capability isn't for an + // event. + } + + // We didn't have a super simple line of text, so try processing the capability as the + // more complex event send/receive permission type. + const [eventCap] = WidgetEventCapability.findEventCapabilities([capability]); + if (eventCap) { + // Special case room messages so they show up a bit cleaner to the user. Result is + // effectively "Send images" instead of "Send messages... of type images" if we were + // to handle the msgtype nuances in this function. + if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) { + return CapabilityText.forRoomMessageCap(eventCap, kind); + } + + // See if we have a static line of text to provide for the given event type and + // direction. The hope is that we do for common event types for friendlier copy. + const evSendRecv = eventCap.isState + ? CapabilityText.stateSendRecvCaps + : CapabilityText.nonStateSendRecvCaps; + if (evSendRecv[eventCap.eventType]) { + const textForKind = evSendRecv[eventCap.eventType]; + const textForDirection = textForKind[kind] || textForKind[GENERIC_WIDGET_KIND]; + if (textForDirection && textForDirection[eventCap.direction]) { + return { + primary: _t(textForDirection[eventCap.direction]), + // no byline because we would have already represented the event properly + }; + } + } + + // We don't have anything simple, so just return a generic string for the event cap + if (kind === WidgetKind.Room) { + if (eventCap.direction === EventDirection.Send) { + return { + primary: _t("Send %(eventType)s events as you in this room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } else { + return { + primary: _t("See %(eventType)s events posted to this room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } + } else { // assume generic + if (eventCap.direction === EventDirection.Send) { + return { + primary: _t("Send %(eventType)s events as you in your active room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } else { + return { + primary: _t("See %(eventType)s events posted to your active room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } + } + } + + // We don't have enough context to render this capability specially, so we'll present it as-is + return { + primary: _t("The %(capability)s capability", {capability}, { + b: sub => {sub}, + }), + }; + } + + private static forRoomMessageCap(eventCap: WidgetEventCapability, kind: WidgetKind): TranslatedCapabilityText { + // First handle the case of "all messages" to make the switch later on a bit clearer + if (!eventCap.keyStr) { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send messages as you in this room") + : _t("Send messages as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See messages posted to this room") + : _t("See messages posted to your active room"), + }; + } + } + + // Now handle all the message types we care about. There are more message types available, however + // they are not as common so we don't bother rendering them. They'll fall into the generic case. + switch (eventCap.keyStr) { + case MsgType.Text: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send text messages as you in this room") + : _t("Send text messages as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See text messages posted to this room") + : _t("See text messages posted to your active room"), + }; + } + } + case MsgType.Emote: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send emotes as you in this room") + : _t("Send emotes as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See emotes posted to this room") + : _t("See emotes posted to your active room"), + }; + } + } + case MsgType.Image: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send images as you in this room") + : _t("Send images as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See images posted to this room") + : _t("See images posted to your active room"), + }; + } + } + case MsgType.Video: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send videos as you in this room") + : _t("Send videos as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See videos posted to this room") + : _t("See videos posted to your active room"), + }; + } + } + case MsgType.File: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send general files as you in this room") + : _t("Send general files as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See general files posted to this room") + : _t("See general files posted to your active room"), + }; + } + } + default: { + let primary: TranslatedString; + if (eventCap.direction === EventDirection.Send) { + if (kind === WidgetKind.Room) { + primary = _t("Send %(msgtype)s messages as you in this room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } else { + primary = _t("Send %(msgtype)s messages as you in your active room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } + } else { + if (kind === WidgetKind.Room) { + primary = _t("See %(msgtype)s messages posted to this room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } else { + primary = _t("See %(msgtype)s messages posted to your active room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } + } + return {primary}; + } + } + } +} diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index 07cd51edbdd..bf55e9c4306 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -36,6 +36,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; const ev = mkEvent({ @@ -59,6 +60,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; const ev = mkEvent({ @@ -83,6 +85,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; }); @@ -135,6 +138,7 @@ describe("", () => { getHomeserverUrl: () => "https://my_server/", on: () => undefined, removeListener: () => undefined, + isGuest: () => false, }; }); diff --git a/yarn.lock b/yarn.lock index b8b3dea93e2..7cc852cdf7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1256,10 +1256,10 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.11.2": - version "7.11.2" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== +"@babel/runtime@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" @@ -6206,6 +6206,13 @@ jsx-ast-utils@^2.4.1: array-includes "^3.1.1" object.assign "^4.1.0" +katex@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9" + integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg== + dependencies: + commander "^2.19.0" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -6383,10 +6390,10 @@ log-symbols@^2.0.0, log-symbols@^2.2.0: dependencies: chalk "^2.0.1" -loglevel@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0" - integrity sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ== +loglevel@^1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" + integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== lolex@^5.0.0, lolex@^5.1.2: version "5.1.2" @@ -6505,17 +6512,17 @@ mathml-tag-names@^2.0.1: resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== -matrix-js-sdk@9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-9.2.0.tgz#4b01dbb170266c4fe37be9397065d870561ad220" - integrity sha512-3lPgCB2in+AHDd+tLT8HbJ9elqDeJjYCE8i8Ti+NO2Myua62HIsf3pE/C/FE/QCDTuZBTjN0vgjym22M+GO65g== +matrix-js-sdk@9.3.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-9.3.0.tgz#e5fa3f6cb5a56e5c5386ecf3110dc35072170dbb" + integrity sha512-rzvYJS5mMP42iQVfGokX8DgmJpTUH+k15vATyB5JyBq/3r/kP22tN78RgoNxYzrIP/R4rB4OHUFNtgGzBH2u8g== dependencies: - "@babel/runtime" "^7.11.2" + "@babel/runtime" "^7.12.5" another-json "^0.2.0" browser-request "^0.3.3" bs58 "^4.0.1" content-type "^1.0.4" - loglevel "^1.7.0" + loglevel "^1.7.1" qs "^6.9.4" request "^2.88.2" unhomoglyph "^1.0.6" @@ -6533,10 +6540,10 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== -matrix-widget-api@^0.1.0-beta.8: - version "0.1.0-beta.8" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.8.tgz#17e85c03c46353373890b869b1fd46162bdb0026" - integrity sha512-sWqyWs0RQqny/BimZUOxUd9BTJBzQmJlJ1i3lsSh1JBygV+aK5xQsONL97fc4i6/nwQPK72uCVDF+HwTtkpAbQ== +matrix-widget-api@^0.1.0-beta.10: + version "0.1.0-beta.10" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.10.tgz#2e4d658d90ff3152c5567089b4ddd21fb44ec1dd" + integrity sha512-yX2UURjM1zVp7snPiOFcH9+FDBdHfAdt5HEAyDUHGJ7w/F2zOtcK/y0dMlZ1+XhxY7Wv0IBZH0US8X/ioJRX1A== dependencies: events "^3.2.0"