diff --git a/CHANGELOG.md b/CHANGELOG.md index d459b4e94a..58d23e3413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,115 @@ +Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0) + + * Upgrade to JS SDK 10.1.0 + * [Release] Don't use the event's metadata to calc the scale of an image + [\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004) + +Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1) + + * Upgrade to JS SDK 10.1.0-rc.1 + * Translations update from Weblate + [\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966) + * Fix more space panel layout and hover behaviour issues + [\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965) + * Fix edge case with space panel alignment with subspaces on ff + [\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964) + * Fix saving room pill part to history + [\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951) + * Generate room preview even when minimized + [\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948) + * Another change from recovery passphrase to Security Phrase + [\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934) + * Sort rooms in the add existing to space dialog based on recency + [\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943) + * Inhibit sending RR when context switching to a room + [\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944) + * Prevent room list keyboard handling from landing focus on hidden nodes + [\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950) + * Make the text filter search all spaces instead of just the selected one + [\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942) + * Enable indent rule and fix indent + [\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931) + * Prevent peeking members from reacting + [\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946) + * Disallow inline display maths + [\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939) + * Space creation prompt user to add existing rooms for "Just Me" spaces + [\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923) + * Add test coverage collection script + [\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937) + * Fix joining room using via servers regression + [\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936) + * Revert "Fixes the two Todays problem in Redaction" + [\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938) + * Handle encoded matrix URLs + [\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903) + * Render ignored users setting regardless of if there are any + [\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860) + * Fix inserting trailing colon after mention/pill + [\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830) + * Fixes the two Todays problem in Redaction + [\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917) + * Fix page up/down scrolling only half a page + [\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920) + * Voice messages: Composer controls + [\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935) + * Support MSC3086 asserted identity + [\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886) + * Handle possible edge case with getting stuck in "unsent messages" bar + [\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930) + * Fix suggested rooms not showing up regression from room list optimisation + [\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932) + * Broadcast language change to ElectronPlatform + [\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913) + * Fix VoIP PIP frame color + [\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701) + * Convert some Flow-typed files to TypeScript + [\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912) + * Initial SpaceStore tests work + [\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906) + * Fix issues with space hierarchy in layout and with incompatible servers + [\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926) + * Scale all mxc thumbs using device pixel ratio for hidpi + [\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928) + * Fix add existing to space dialog no longer showing rooms for public spaces + [\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918) + * Disable spaces context switching for when exploring a space + [\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924) + * Autofocus search box in the add existing to space dialog + [\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921) + * Use label element in add existing to space dialog for easier hit target + [\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922) + * Dynamic max and min zoom in the new ImageView + [\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916) + * Improve message error states + [\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897) + * Check for null room in `VisibilityProvider` + [\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914) + * Add unit tests for various collection-based utility functions + [\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910) + * Spaces visual fixes + [\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909) + * Remove reliance on DOM API to generated message preview + [\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908) + * Expand upon voice message event & include overall waveform + [\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888) + * Use floats for image background opacity + [\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905) + * Show invites to spaces at the top of the space panel + [\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902) + * Improve edge cases with spaces context switching + [\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899) + * Fix spaces notification dots wrongly including upgraded (hidden) rooms + [\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900) + * Iterate the spaces face pile design + [\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898) + * Fix alignment issue with nested spaces being cut off wrong + [\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890) + Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) diff --git a/README.md b/README.md index 73afe34df0..b3e96ef001 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Platform Targets: * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * Mobile Web is not currently a target platform - instead please use the native iOS (https://github.com/matrix-org/matrix-ios-kit) and Android - (https://github.com/matrix-org/matrix-android-sdk) SDKs. + (https://github.com/matrix-org/matrix-android-sdk2) SDKs. All code lands on the `develop` branch - `master` is only used for stable releases. **Please file PRs against `develop`!!** diff --git a/__mocks__/empty.js b/__mocks__/empty.js new file mode 100644 index 0000000000..51fb4fe937 --- /dev/null +++ b/__mocks__/empty.js @@ -0,0 +1,2 @@ +// Yes, this is empty. +module.exports = {}; diff --git a/package.json b/package.json index e54de8a96c..5a32cf2c5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.19.0", + "version": "3.20.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -58,7 +58,7 @@ "blueimp-canvas-to-blob": "^3.28.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", - "cheerio": "^1.0.0-rc.5", + "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", "counterpart": "^0.18.6", @@ -97,7 +97,7 @@ "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", + "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", @@ -132,11 +132,12 @@ "@types/modernizr": "^3.5.3", "@types/node": "^14.14.22", "@types/pako": "^1.0.1", + "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", "@types/react": "^16.9", "@types/react-dom": "^16.9.10", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "^1.27.0", + "@types/sanitize-html": "^2.3.1", "@types/zxcvbn": "^4.4.0", "@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/parser": "^4.14.0", @@ -185,7 +186,10 @@ ], "moduleNameMapper": { "\\.(gif|png|svg|ttf|woff2)$": "/__mocks__/imageMock.js", - "\\$webapp/i18n/languages.json": "/__mocks__/languages.json" + "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", + "decoderWorker\\.min\\.js": "/__mocks__/empty.js", + "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", + "waveWorker\\.min\\.js": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/res/css/_components.scss b/res/css/_components.scss index 0057f8a8fc..c8985cbb51 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -54,6 +54,7 @@ @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; +@import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @@ -62,6 +63,7 @@ @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; +@import "./views/dialogs/_BetaFeedbackDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @@ -96,6 +98,7 @@ @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; +@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @@ -161,6 +164,7 @@ @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; +@import "./views/messages/_MVoiceMessageBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -236,6 +240,7 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_LabsUserSettingsTab.scss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index e5a8ef6df2..444435dd57 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -56,6 +56,12 @@ limitations under the License. .mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; + + .mx_BetaDot { + position: absolute; + right: -13px; + top: -11px; + } } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { diff --git a/res/css/structures/_MyGroups.scss b/res/css/structures/_MyGroups.scss index 73f1332cd0..9c0062b72d 100644 --- a/res/css/structures/_MyGroups.scss +++ b/res/css/structures/_MyGroups.scss @@ -17,6 +17,11 @@ limitations under the License. .mx_MyGroups { display: flex; flex-direction: column; + + .mx_BetaCard { + margin: 0 72px; + max-width: 760px; + } } .mx_MyGroups .mx_RoomHeader_simpleHeader { @@ -30,7 +35,7 @@ limitations under the License. flex-wrap: wrap; } -.mx_MyGroups > :not(.mx_RoomHeader) { +.mx_MyGroups > :not(.mx_RoomHeader):not(.mx_BetaCard) { max-width: 960px; margin: 40px; } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 59f2ea947c..c433ccf275 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -79,6 +79,10 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceItem { display: inline-flex; flex-flow: wrap; + + &.mx_SpaceItem_narrow { + align-self: baseline; + } } .mx_SpaceItem.collapsed { @@ -233,7 +237,6 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_badgeContainer { position: absolute; - height: 16px; // Create a flexbox to make aligning dot badges easier display: flex; @@ -245,23 +248,37 @@ $activeBorderColor: $secondary-fg-color; .mx_NotificationBadge_dot { // make the smaller dot occupy the same width for centering - margin-left: 7px; - margin-right: 7px; + margin: 0 7px; } } &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { - right: -3px; - top: -3px; + right: 0; + top: 0; + + .mx_NotificationBadge { + background-clip: padding-box; + } + + .mx_NotificationBadge_dot { + margin: 0 -1px 0 0; + border: 3px solid $groupFilterPanel-bg-color; + } + + .mx_NotificationBadge_2char, + .mx_NotificationBadge_3char { + margin: -5px -5px 0 0; + border: 2px solid $groupFilterPanel-bg-color; + } } &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { // when we draw the selection border we move the relative bounds of our parent // so update our position within the bounds of the parent to maintain position overall - right: -6px; - top: -6px; + right: -3px; + top: -3px; } } } @@ -275,7 +292,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home) { + &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { width: 0; diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index c7d087d8e0..7925686bf1 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -86,7 +86,7 @@ limitations under the License. color: $primary-fg-color; .mx_AccessibleButton { - padding: 2px 8px; + padding: 4px 12px; font-weight: normal; & + .mx_AccessibleButton { @@ -94,6 +94,11 @@ limitations under the License. } } + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 12px; // to account for the 1px border + } + > span { margin-left: auto; } @@ -246,11 +251,17 @@ limitations under the License. grid-row: 1/3; .mx_AccessibleButton { - padding: 8px 18px; + line-height: $font-24px; + padding: 4px 16px; display: inline-block; visibility: hidden; } + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 16px; // to account for the 1px border + } + .mx_Checkbox { display: inline-flex; vertical-align: middle; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 553919d862..503fe72414 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -93,6 +93,10 @@ $SpaceRoomViewInnerWidth: 428px; } } + &:not(.mx_SpaceRoomView_landing) .mx_SpaceFeedbackPrompt { + width: $SpaceRoomViewInnerWidth; + } + .mx_SpaceRoomView_buttons { display: block; margin-top: 44px; @@ -103,6 +107,10 @@ $SpaceRoomViewInnerWidth: 428px; padding: 8px 22px; margin-left: 16px; } + + input.mx_AccessibleButton { + border: none; // override default styles + } } .mx_Field { @@ -133,6 +141,44 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; box-shadow: 2px 15px 30px $dialog-shadow-color; border-radius: 8px; + position: relative; + + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + right: 24px; + top: 32px; + } + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_preview_spaceBetaPrompt { + font-weight: $font-semi-bold; + font-size: $font-14px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + position: relative; + padding-left: 24px; + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-fg-color; + } + } .mx_SpaceRoomView_preview_inviter { display: flex; @@ -238,7 +284,8 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_landing_inviteButton { position: relative; - padding-left: 40px; + padding: 4px 18px 4px 40px; + line-height: $font-24px; height: min-content; &::before { @@ -254,6 +301,27 @@ $SpaceRoomViewInnerWidth: 428px; mask-image: url('$(res)/img/element-icons/room/invite.svg'); } } + + .mx_SpaceRoomView_landing_settingsButton { + position: relative; + margin-left: 16px; + width: 24px; + height: 24px; + + &::before { + position: absolute; + content: ""; + left: 0; + top: 0; + height: 24px; + width: 24px; + background: $tertiary-fg-color; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } } .mx_SpaceRoomView_landing_topic { @@ -268,87 +336,22 @@ $SpaceRoomViewInnerWidth: 428px; background-color: $groupFilterPanel-bg-color; } - .mx_SpaceRoomView_landing_adminButtons { - margin-top: 24px; - - .mx_AccessibleButton { - position: relative; - width: 160px; - height: 124px; - box-sizing: border-box; - padding: 72px 16px 0; - border-radius: 12px; - border: 1px solid $input-border-color; - margin-right: 28px; - margin-bottom: 20px; - font-size: $font-14px; - display: inline-block; - vertical-align: bottom; - - &:last-child { - margin-right: 0; - } - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &::before, &::after { - position: absolute; - content: ""; - left: 16px; - top: 16px; - height: 40px; - width: 40px; - border-radius: 20px; - } - - &::after { - mask-position: center; - mask-size: 30px; - mask-repeat: no-repeat; - background: #ffffff; // white icon fill - } - - &.mx_SpaceRoomView_landing_addButton { - &::before { - background-color: #ac3ba8; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_createButton { - &::before { - background-color: #368bd6; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_settingsButton { - &::before { - background-color: #5c56f5; - } - - &::after { - mask-image: url('$(res)/img/element-icons/settings.svg'); - } - } - } - } - .mx_SearchBox { margin: 0 0 20px; } + + .mx_SpaceFeedbackPrompt { + margin-bottom: 16px; + + // hide the HR as we have our own + & + hr { + display: none; + } + } } .mx_SpaceRoomView_privateScope { - .mx_AccessibleButton { + > .mx_AccessibleButton { @mixin SpacePillButton; } @@ -362,6 +365,23 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_inviteTeammates { + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { + padding: 58px 16px 16px; + position: relative; + border-radius: 8px; + background-color: $header-panel-bg-color; + max-width: $SpaceRoomViewInnerWidth; + margin: 20px 0 30px; + box-sizing: border-box; + + .mx_BetaCard_betaPill { + position: absolute; + left: 16px; + top: 16px; + } + } + .mx_SpaceRoomView_inviteTeammates_buttons { color: $secondary-fg-color; margin-top: 28px; @@ -443,3 +463,66 @@ $SpaceRoomViewInnerWidth: 428px; } } } + +.mx_SpaceFeedbackPrompt { + margin-top: 18px; + margin-bottom: 12px; + + > hr { + border: none; + border-top: 1px solid $input-border-color; + margin-bottom: 12px; + } + + > div { + display: flex; + flex-direction: row; + font-size: $font-15px; + line-height: $font-24px; + + > span { + color: $secondary-fg-color; + position: relative; + padding-left: 32px; + font-size: inherit; + line-height: inherit; + margin-right: auto; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + height: 20px; + width: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AccessibleButton_kind_link { + color: $accent-color; + position: relative; + padding: 0 0 0 24px; + margin-left: 8px; + font-size: inherit; + line-height: inherit; + + &::before { + content: ''; + position: absolute; + left: 0; + height: 16px; + width: 16px; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + mask-position: center; + } + } + } +} diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss new file mode 100644 index 0000000000..3463a653fc --- /dev/null +++ b/res/css/views/beta/_BetaCard.scss @@ -0,0 +1,114 @@ +/* +Copyright 2021 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_BetaCard { + margin-bottom: 20px; + padding: 24px; + background-color: $settings-profile-placeholder-bg-color; + border-radius: 8px; + display: flex; + box-sizing: border-box; + + > div { + .mx_BetaCard_title { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + margin: 4px 0 14px; + + .mx_BetaCard_betaPill { + margin-left: 12px; + } + } + + .mx_BetaCard_caption { + font-size: $font-15px; + line-height: $font-20px; + color: $secondary-fg-color; + margin-bottom: 20px; + } + + .mx_AccessibleButton { + display: block; + margin: 12px 0; + padding: 7px 40px; + width: auto; + } + + .mx_BetaCard_disclaimer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + margin-top: 20px; + } + } + + > img { + margin: auto 0 auto 20px; + width: 300px; + object-fit: contain; + height: 100%; + } +} + +.mx_BetaCard_betaPill { + background-color: $accent-color-alt; + padding: 4px 10px; + border-radius: 8px; + text-transform: uppercase; + font-size: 12px; + line-height: 15px; + color: #FFFFFF; + display: inline-block; + vertical-align: text-bottom; + + &.mx_BetaCard_betaPill_clickable { + cursor: pointer; + } +} + +$pulse-color: $accent-color-alt; +$dot-size: 12px; + +.mx_BetaDot { + border-radius: 50%; + margin: 10px; + height: $dot-size; + width: $dot-size; + transform: scale(1); + background: rgba($pulse-color, 1); + box-shadow: 0 0 0 0 rgba($pulse-color, 1); + animation: mx_Beta_bluePulse 2s infinite; + animation-iteration-count: 20; +} + +@keyframes mx_Beta_bluePulse { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba($pulse-color, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 84f965e6bd..2776c477fc 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -54,7 +54,8 @@ limitations under the License. display: flex; margin-top: 12px; - .mx_BaseAvatar { + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { margin-right: 12px; } @@ -75,10 +76,124 @@ limitations under the License. } .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + .mx_BaseAvatar_image { border-radius: 8px; } } + + .mx_AddExistingToSpace_section_experimental { + position: relative; + border-radius: 8px; + margin: 12px 0; + padding: 8px 8px 8px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } } .mx_AddExistingToSpaceDialog { @@ -101,7 +216,7 @@ limitations under the License. .mx_BaseAvatar { display: inline-flex; - margin: 5px 16px 5px 5px; + margin: auto 16px auto 5px; vertical-align: middle; } @@ -160,44 +275,7 @@ limitations under the License. } } - .mx_AddExistingToSpaceDialog_errorText { - font-weight: $font-semi-bold; - font-size: $font-12px; - line-height: $font-15px; - color: $notice-primary-color; - margin-bottom: 28px; - } - .mx_AddExistingToSpace { display: contents; } - - .mx_AddExistingToSpaceDialog_footer { - display: flex; - margin-top: 32px; - - > span { - flex-grow: 1; - font-size: $font-14px; - line-height: $font-15px; - font-weight: $font-semi-bold; - - .mx_AccessibleButton { - font-size: inherit; - display: inline-block; - } - - > * { - vertical-align: middle; - } - } - - .mx_AccessibleButton { - display: inline-block; - } - - .mx_AccessibleButton_kind_link { - padding: 0; - } - } } diff --git a/res/css/views/dialogs/_BetaFeedbackDialog.scss b/res/css/views/dialogs/_BetaFeedbackDialog.scss new file mode 100644 index 0000000000..9f5f6b512e --- /dev/null +++ b/res/css/views/dialogs/_BetaFeedbackDialog.scss @@ -0,0 +1,30 @@ +/* +Copyright 2021 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_BetaFeedbackDialog { + .mx_BetaFeedbackDialog_subheading { + color: $primary-fg-color; + font-size: $font-14px; + line-height: $font-20px; + margin-bottom: 24px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + line-height: inherit; + } +} diff --git a/res/css/views/dialogs/_UntrustedDeviceDialog.scss b/res/css/views/dialogs/_UntrustedDeviceDialog.scss new file mode 100644 index 0000000000..0ecd9d4f71 --- /dev/null +++ b/res/css/views/dialogs/_UntrustedDeviceDialog.scss @@ -0,0 +1,26 @@ +/* +Copyright 2021 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_UntrustedDeviceDialog { + .mx_Dialog_title { + display: flex; + align-items: center; + + .mx_E2EIcon { + margin-left: 0; + } + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 0075dcb511..2997c83cfd 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -76,12 +76,16 @@ limitations under the License. border: 1px solid $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, -.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } +.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { + color: $button-danger-disabled-bg-color; + border-color: $button-danger-disabled-bg-color; +} + .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { padding: 5px 12px; color: $button-danger-fg-color; diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index 770978e921..c075ac74ff 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -21,7 +21,7 @@ progress.mx_ProgressBar { appearance: none; border: none; - @mixin ProgressBarBorderRadius "6px"; + @mixin ProgressBarBorderRadius 6px; @mixin ProgressBarColour $progressbar-fg-color; @mixin ProgressBarBgColour $progressbar-bg-color; ::-webkit-progress-value { diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index b45126acf8..c215d69ec2 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -61,9 +61,9 @@ limitations under the License. .mx_MFileBody_info { background-color: $message-body-panel-bg-color; - border-radius: 4px; - width: 270px; - padding: 8px; + border-radius: 12px; + width: 243px; // same width as a playable voice message, accounting for padding + padding: 6px 12px; color: $message-body-panel-fg-color; .mx_MFileBody_info_icon { @@ -82,7 +82,7 @@ limitations under the License. mask-position: center; mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); - background-color: $message-body-panel-fg-color; + background-color: $message-body-panel-icon-fg-color; width: 13px; height: 15px; diff --git a/res/css/views/messages/_MVoiceMessageBody.scss b/res/css/views/messages/_MVoiceMessageBody.scss new file mode 100644 index 0000000000..3dfb98f778 --- /dev/null +++ b/res/css/views/messages/_MVoiceMessageBody.scss @@ -0,0 +1,19 @@ +/* +Copyright 2021 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_MVoiceMessageBody { + display: inline-block; // makes the playback controls magically line up +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 3ecbef0d1f..d41ac3a4ba 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -85,6 +85,7 @@ limitations under the License. left: 0; height: 100%; width: 100%; + mask-size: 18px; mask-repeat: no-repeat; mask-position: center; background-color: $message-action-bar-fg-color; diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 2f5695e1fb..244439bf74 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -17,18 +17,55 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; color: $primary-fg-color; + + .mx_ReactionsRow_addReactionButton { + position: relative; + display: none; // show on hover of the .mx_EventTile + width: 24px; + height: 24px; + vertical-align: middle; + margin-left: 4px; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + mask-size: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + + &.mx_ReactionsRow_addReactionButton_active { + display: inline-block; // keep showing whilst the context menu is shown + } + + &:hover, &.mx_ReactionsRow_addReactionButton_active { + &::before { + background-color: $primary-fg-color; + } + } + } +} + +.mx_EventTile:hover .mx_ReactionsRow_addReactionButton { + display: inline-block; } .mx_ReactionsRow_showAll { text-decoration: none; - font-size: $font-10px; - font-weight: 600; - margin-left: 6px; - vertical-align: top; + font-size: $font-12px; + line-height: $font-20px; + margin-left: 4px; + vertical-align: middle; - &:hover, - &:link, - &:visited { - color: $accent-color; + &:link, &:visited { + color: $tertiary-fg-color; + } + + &:hover { + color: $primary-fg-color; } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index c132fa5a0f..766fea2f8f 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -16,14 +16,15 @@ limitations under the License. .mx_ReactionsRowButton { display: inline-flex; - line-height: $font-21px; + line-height: $font-20px; margin-right: 6px; - padding: 0 6px; + padding: 1px 6px; border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; cursor: pointer; user-select: none; + vertical-align: middle; &:hover { border-color: $reaction-row-button-hover-border-color; diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 9d52e40819..1aafa8da0e 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -98,7 +98,7 @@ limitations under the License. position: relative; width: 24px; height: 24px; - border-radius: 32px; + border-radius: 8px; &::before { content: ''; @@ -114,6 +114,11 @@ limitations under the License. } } + .mx_RoomSublist_auxButton:hover, + .mx_RoomSublist_menuButton:hover { + background: $roomlist-button-bg-color; + } + // Hide the menu button by default .mx_RoomSublist_menuButton { visibility: hidden; diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index e99e1a00e1..a3ee104bd8 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -39,23 +39,25 @@ limitations under the License. width: 14px; // w&h are size of icon height: 18px; vertical-align: middle; - margin-right: 7px; // distance from left edge of waveform container (container has some margin too) - background-color: $muted-fg-color; + margin-right: 11px; // distance from left edge of waveform container (container has some margin too) + background-color: $voice-record-icon-color; mask-repeat: no-repeat; mask-size: contain; mask-image: url('$(res)/img/element-icons/trashcan.svg'); } -.mx_VoiceMessagePrimaryContainer { +.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { // Note: remaining class properties are in the PlayerContainer CSS. margin: 6px; // force the composer area to put a gutter around us - margin-right: 12px; // isolate from stop button + margin-right: 12px; // isolate from stop/send button position: relative; // important for the live circle &.mx_VoiceRecordComposerTile_recording { - padding-left: 16px; // +10px for the live circle, +6px for regular padding + // We are putting the circle in this padding, so we need +10px from the regular + // padding on the left side. + padding-left: 22px; &::before { animation: recording-pulse 2s infinite; @@ -65,8 +67,8 @@ limitations under the License. width: 10px; height: 10px; position: absolute; - left: 8px; - top: 16px; // vertically center + left: 12px; // 12px from the left edge for container padding + top: 18px; // vertically center (middle align with clock) border-radius: 10px; } } diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index 109edfff81..0f879d209e 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -22,3 +22,34 @@ limitations under the License. .mx_HelpUserSettingsTab span.mx_AccessibleButton { word-break: break-word; } + +.mx_HelpUserSettingsTab code { + word-break: break-all; + user-select: all; +} + +.mx_HelpUserSettingsTab_accessToken { + display: flex; + justify-content: space-between; + border-radius: 5px; + border: solid 1px $light-fg-color; + margin-bottom: 10px; + margin-top: 10px; + padding: 10px; +} + +.mx_HelpUserSettingsTab_accessToken_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; +} + +.mx_HelpUserSettingsTab_accessToken_copy > div { + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + margin-left: 5px; + width: 20px; + height: 20px; + background-repeat: no-repeat; +} diff --git a/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss new file mode 100644 index 0000000000..540db48d65 --- /dev/null +++ b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss @@ -0,0 +1,25 @@ +/* +Copyright 2021 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_LabsUserSettingsTab { + .mx_SettingsTab_section { + margin-top: 32px; + + .mx_SettingsFlag { + margin-right: 0; // remove right margin to align with beta cards + } + } +} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index ef3fea351b..88b9d8f693 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -29,6 +29,7 @@ $spacePanelWidth: 71px; width: 480px; box-sizing: border-box; background-color: $primary-bg-color; + position: relative; > div { > h2 { @@ -44,6 +45,13 @@ $spacePanelWidth: 71px; } } + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + top: 24px; + right: 24px; + } + .mx_SpaceCreateMenuType { @mixin SpacePillButton; } @@ -59,7 +67,7 @@ $spacePanelWidth: 71px; width: 28px; height: 28px; position: relative; - background-color: $theme-button-bg-color; + background-color: $roomlist-button-bg-color; border-radius: 14px; margin-bottom: 12px; @@ -70,7 +78,7 @@ $spacePanelWidth: 71px; width: 28px; top: 0; left: 0; - background-color: $muted-fg-color; + background-color: $tertiary-fg-color; transform: rotate(90deg); mask-repeat: no-repeat; mask-position: 2px 3px; diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss index c8ab162694..6caedafa29 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -19,12 +19,12 @@ limitations under the License. width: 32px; height: 32px; border-radius: 32px; - background-color: $primary-bg-color; + background-color: $voice-playback-button-bg-color; &::before { content: ''; position: absolute; // sizing varies by icon - background-color: $muted-fg-color; + background-color: $voice-playback-button-fg-color; mask-repeat: no-repeat; mask-size: contain; } diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss index 49bd81ef81..20def16d6a 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -19,8 +19,9 @@ limitations under the License. // Container for live recording and playback controls .mx_VoiceMessagePrimaryContainer { - padding: 6px; // makes us 4px taller than the send/stop button - padding-right: 5px; // there's 1px from the waveform itself, so account for that + // 7px top and bottom for visual design. 12px left & right, but the waveform (right) + // has a 1px padding on it that we want to account for. + padding: 7px 12px 7px 11px; background-color: $voice-record-waveform-bg-color; border-radius: 12px; @@ -30,11 +31,9 @@ limitations under the License. color: $voice-record-waveform-fg-color; font-size: $font-14px; + line-height: $font-24px; .mx_Waveform { - // We want the bars to be 2px shorter than the play/pause button in the waveform control - height: 28px; // default is 30px, so we're subtracting the 2px border off the bars - .mx_Waveform_bar { background-color: $voice-record-waveform-incomplete-fg-color; @@ -47,8 +46,8 @@ limitations under the License. } .mx_Clock { - padding-right: 4px; // isolate from waveform - padding-left: 8px; // isolate from live circle - width: 40px; // we're not using a monospace font, so fake it + width: $font-42px; // we're not using a monospace font, so fake it + padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. + padding-left: 8px; // isolate from recording circle / play control } } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 7292e325df..0be75be28c 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -65,14 +65,17 @@ limitations under the License. } } -.mx_CallView_voice { +.mx_CallView_content { position: relative; display: flex; - flex-direction: column; + border-radius: 8px; +} + +.mx_CallView_voice { align-items: center; justify-content: center; + flex-direction: column; background-color: $inverted-bg-color; - border-radius: 8px; } .mx_CallView_voice_avatarsContainer { @@ -109,9 +112,7 @@ limitations under the License. .mx_CallView_video { width: 100%; height: 100%; - position: relative; z-index: 30; - border-radius: 8px; overflow: hidden; } diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 8ead8bba3e..7d85ac264e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,21 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_VideoFeed_voice { + // We don't want to collide with the call controls that have 52px of height + padding-bottom: 52px; + background-color: $inverted-bg-color; +} + + .mx_VideoFeed_remote { width: 100%; height: 100%; - background-color: #000; - z-index: 50; + display: flex; + justify-content: center; + align-items: center; + + &.mx_VideoFeed_video { + background-color: #000; + } } .mx_VideoFeed_local { - width: 25%; - height: 25%; + max-width: 25%; + max-height: 25%; position: absolute; right: 10px; top: 10px; z-index: 100; border-radius: 4px; + + &.mx_VideoFeed_video { + background-color: transparent; + } } .mx_VideoFeed_mirror { diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png new file mode 100644 index 0000000000..f4cfa90b4e Binary files /dev/null and b/res/img/betas/spaces.png differ diff --git a/res/img/element-icons/room/composer/emoji.svg b/res/img/element-icons/room/composer/emoji.svg index 9613d9edd9..b02cb69364 100644 --- a/res/img/element-icons/room/composer/emoji.svg +++ b/res/img/element-icons/room/composer/emoji.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/message-bar/emoji.svg b/res/img/element-icons/room/message-bar/emoji.svg index 697f656b8a..07fee5b834 100644 --- a/res/img/element-icons/room/message-bar/emoji.svg +++ b/res/img/element-icons/room/message-bar/emoji.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index f9983dce9e..2d0e3d2a8b 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6; $header-panel-text-secondary-color: #c8c8cd; $text-primary-color: #ffffff; $text-secondary-color: #B9BEC6; +$quaternary-fg-color: #6F7882; $search-bg-color: #181b21; $search-placeholder-color: #61708b; $room-highlight-color: #343a46; @@ -205,9 +206,18 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; // "Dark Tile" +$message-body-panel-icon-fg-color: #21262C; // "Separator" +$message-body-panel-icon-bg-color: $tertiary-fg-color; + +$voice-record-stop-border-color: $quaternary-fg-color; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $quaternary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 194e89e548..a852ad94e9 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -200,9 +200,19 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; +$message-body-panel-icon-fg-color: $primary-bg-color; +$message-body-panel-icon-bg-color: $secondary-fg-color; + +// See non-legacy dark for variable information +$voice-record-stop-border-color: #6F7882; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: #6F7882; +$voice-record-icon-color: #6F7882; +$voice-playback-button-bg-color: $tertiary-fg-color; +$voice-playback-button-fg-color: #21262C; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index d7352e5684..84666bc662 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -191,14 +191,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -// See non-legacy _light for variable information -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: #ff4b55; -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-waveform-incomplete-fg-color: #C1C6CD; -$voice-record-live-circle-color: #ff4b55; - $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; @@ -331,9 +323,21 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// See non-legacy _light for variable information +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; +$voice-record-stop-border-color: #E3E8F0; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 20ccc2ee41..c889f43d0b 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16); $primary-fg-color: #2e2f32; $secondary-fg-color: #737D8C; $tertiary-fg-color: #8D99A5; +$quaternary-fg-color: #C1C6CD; $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) @@ -182,13 +183,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-waveform-incomplete-fg-color: #C1C6CD; -$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes - $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; @@ -328,9 +322,23 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; // "Separator" +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// These two don't change between themes. They are the $warning-color, but we don't +// want custom themes to affect them by accident. +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; + +$voice-record-stop-border-color: #E3E8F0; // "Separator" +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 0ab26ef943..f04a2ff237 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -52,6 +52,9 @@ declare global { init: () => Promise; }; + // Needed for Safari, unknown to TypeScript + webkitAudioContext: typeof AudioContext; + mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; @@ -118,6 +121,16 @@ declare global { interface HTMLAudioElement { type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); + } + + interface HTMLVideoElement { + type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); } interface Element { diff --git a/src/Avatar.ts b/src/Avatar.ts index d218ae8b46..a6499c688e 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room"; import DMRoomMap from './utils/DMRoomMap'; import {mediaFromMxc} from "./customisations/Media"; +import SettingsStore from "./settings/SettingsStore"; export type ResizeMethod = "crop" | "scale"; @@ -143,7 +144,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi } // space rooms cannot be DMs so skip the rest - if (room.isSpaceRoom()) return null; + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; let otherMember = null; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 91fd7e4c7d..0268ebfe46 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -85,6 +85,7 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; @@ -138,22 +139,12 @@ export enum PlaceCallType { ScreenSharing = 'screensharing', } -function getRemoteAudioElement(): HTMLAudioElement { - // this needs to be somewhere at the top of the DOM which - // always exists to avoid audio interruptions. - // Might as well just use DOM. - const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement; - if (!remoteAudioElement) { - console.error( - "Failed to find remoteAudio element - cannot play audio!" + - "You need to add an to the DOM.", - ); - return null; - } - return remoteAudioElement; +export enum CallHandlerEvent { + CallsChanged = "calls_changed", + CallChangeRoom = "call_change_room", } -export default class CallHandler { +export default class CallHandler extends EventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -514,6 +505,7 @@ export default class CallHandler { } this.calls.set(mappedRoomId, newCall); + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); @@ -546,10 +538,7 @@ export default class CallHandler { this.removeCallForRoom(mappedRoomId); mappedRoomId = newMappedRoomId; this.calls.set(mappedRoomId, call); - dis.dispatch({ - action: Action.CallChangeRoom, - call, - }); + this.emit(CallHandlerEvent.CallChangeRoom, call); } } }); @@ -598,11 +587,6 @@ export default class CallHandler { } } - private setCallAudioElement(call: MatrixCall) { - const audioElement = getRemoteAudioElement(); - if (audioElement) call.setRemoteAudioElement(audioElement); - } - private setCallState(call: MatrixCall, status: CallState) { const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); @@ -619,6 +603,7 @@ export default class CallHandler { private removeCallForRoom(roomId: string) { this.calls.delete(roomId); + this.emit(CallHandlerEvent.CallsChanged, this.calls); } private showICEFallbackPrompt() { @@ -679,11 +664,7 @@ export default class CallHandler { }, null, true); } - private async placeCall( - roomId: string, type: PlaceCallType, - localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, - transferee: MatrixCall, - ) { + private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); @@ -695,22 +676,19 @@ export default class CallHandler { const call = MatrixClientPeg.get().createCall(mappedRoomId); this.calls.set(roomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); if (transferee) { this.transferees[call.callId] = transferee; } this.setCallListeners(call); - this.setCallAudioElement(call); this.setActiveCallRoomId(roomId); if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { - call.placeVideoCall( - remoteElement, - localElement, - ); + call.placeVideoCall(); } else if (type === PlaceCallType.ScreenSharing) { const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); if (screenCapErrorString) { @@ -724,13 +702,12 @@ export default class CallHandler { } call.placeScreenSharingCall( - remoteElement, - localElement, async (): Promise => { const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; return source; - }); + }, + ); } else { console.error("Unknown conf call type: " + type); } @@ -787,17 +764,12 @@ export default class CallHandler { } else if (members.length === 2) { console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall( - payload.room_id, payload.type, payload.local_element, payload.remote_element, - payload.transferee, - ); + this.placeCall(payload.room_id, payload.type, payload.transferee); } else { // > 2 dis.dispatch({ action: "place_conference_call", room_id: payload.room_id, type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, }); } } @@ -833,6 +805,7 @@ export default class CallHandler { Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); this.calls.set(mappedRoomId, call) + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(call); // get ready to send encrypted events in the room, so if the user does answer @@ -875,7 +848,6 @@ export default class CallHandler { const call = this.calls.get(payload.room_id); call.answer(); - this.setCallAudioElement(call); this.setActiveCallRoomId(payload.room_id); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index 7c7940cab5..634f0bb336 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -16,7 +16,7 @@ import SettingsStore from "./settings/SettingsStore"; import {SettingLevel} from "./settings/SettingLevel"; -import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; +import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; export default { hasAnyLabeledDevices: async function() { @@ -50,18 +50,15 @@ export default { }, loadDevices: function() { - const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - setMatrixCallAudioOutput(audioOutDeviceId); setMatrixCallAudioInput(audioDeviceId); setMatrixCallVideoInput(videoDeviceId); }, setAudioOutput: function(deviceId) { SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallAudioOutput(deviceId); }, setAudioInput: function(deviceId) { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 6b2568d68c..ef5ac383e3 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -422,8 +422,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts safeBody = sanitizeHtml(formattedBody, sanitizeParams); if (SettingsStore.getValue("feature_latex_maths")) { - const phtml = cheerio.load(safeBody, - { _useHtmlParser2: true, decodeEntities: false }) + const phtml = cheerio.load(safeBody, { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); // @ts-ignore - The types for `replaceWith` wrongly expect // Cheerio instance to be returned. phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { @@ -431,6 +435,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), { throwOnError: false, + // @ts-ignore - `e` can be an Element, not just a Node displayMode: e.name == 'div', output: "htmlAndMathml", }); diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 6ce1439164..4a7b37b5e5 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -38,7 +38,7 @@ import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; import {inviteUsersToRoom} from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; -import { parseFragment as parseHtml } from "parse5"; +import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; @@ -856,7 +856,7 @@ export const Commands = [ // some superfast regex over the text so we don't have to. const embed = parseHtml(widgetUrl); if (embed && embed.childNodes && embed.childNodes.length === 1) { - const iframe = embed.childNodes[0]; + const iframe = embed.childNodes[0] as ChildElement; if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { const srcAttr = iframe.attrs.find(a => a.name === 'src'); console.log("Pulling URL out of iframe (embed code)"); diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..4cb537f318 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index ab4f605c83..549494b5cb 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Please enter your Security Phrase a second time to confirm.", + "Enter your Security Phrase a second time to confirm it.", )}

diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 7b7d552c43..6d5703a768 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -647,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } return

{_t( - "Enter your recovery passphrase a second time to confirm it.", + "Enter your Security Phrase a second time to confirm it.", )}

diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index a40ce7144d..2242fec914 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -93,7 +93,12 @@ export default class AutocompleteProvider { }; } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { return []; } diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 2615736e09..8618fc3a77 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -82,15 +82,24 @@ export default class Autocompleter { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { /* Note: This intentionally waits for all providers to return, otherwise, we run into a condition where new completions are displayed while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ // list of results from each provider, each being a list of completions or null if it times out - const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => { - return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { + return await timeout( + provider.getCompletions(query, selection, force, limit), + null, + PROVIDER_COMPLETION_TIMEOUT, + ); })); // map then filter to maintain the index for the map-operation, for this.providers to line up diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index c2d1290e08..9de25c0d84 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -38,7 +38,12 @@ export default class CommandProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { const {command, range} = this.getCurrentCommand(query, selection); if (!command) return []; @@ -55,10 +60,11 @@ export default class CommandProvider extends AutocompleteProvider { } else { if (query === '/') { // If they have just entered `/` show everything + // We exclude the limit on purpose to have a comprehensive list matches = Commands; } else { // otherwise fuzzy match against all of the fields - matches = this.matcher.match(command[1]); + matches = this.matcher.match(command[1], limit); } } diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index b7a4e0960e..c9358b0c61 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -50,7 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); // Disable autocompletions when composing commands because of various issues @@ -81,7 +86,7 @@ export default class CommunityProvider extends AutocompleteProvider { this.matcher.setObjects(groups); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, diff --git a/src/autocomplete/DuckDuckGoProvider.tsx b/src/autocomplete/DuckDuckGoProvider.tsx index e63f7255dc..3ef9cc2f6f 100644 --- a/src/autocomplete/DuckDuckGoProvider.tsx +++ b/src/autocomplete/DuckDuckGoProvider.tsx @@ -36,7 +36,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const {command, range} = this.getCurrentCommand(query, selection); if (!query || !command) { return []; @@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { method: 'GET', }); const json = await response.json(); - const results = json.Results.map((result) => { + const maxLength = limit > -1 ? limit : json.Results.length; + const results = json.Results.slice(0, maxLength).map((result) => { return { completion: result.Text, component: ( diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..b7c4a5120a 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -84,7 +84,12 @@ export default class EmojiProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } @@ -93,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection); if (command) { const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index ef1823c0ca..0bc7ead097 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -33,7 +33,12 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 91fbea4d6a..ea6e0882fd 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -87,7 +87,7 @@ export default class QueryMatcher { } } - match(query: string): T[] { + match(query: string, limit = -1): T[] { query = this.processQuery(query); if (this._options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); @@ -129,7 +129,10 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return uniq(matches.map((match) => match.object)); + const dedupped = uniq(matches.map((match) => match.object)); + const maxLength = limit === -1 ? dedupped.length : limit; + + return dedupped.slice(0, maxLength); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 74deacf61f..249c069080 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -58,7 +58,12 @@ export default class RoomProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); @@ -90,7 +95,7 @@ export default class RoomProvider extends AutocompleteProvider { this.matcher.setObjects(matcherObjects); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.displayedAlias), (c) => c.displayedAlias.length, diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 5f0cfc2df1..3cf43d0b84 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -102,7 +102,12 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; }; - async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + rawQuery: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); // lazy-load user list into matcher @@ -118,7 +123,7 @@ export default class UserProvider extends AutocompleteProvider { if (fullMatch && fullMatch !== '@') { // Don't include the '@' in our search query - it's only used as a way to trigger completion const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch; - completions = this.matcher.match(query).map((user) => { + completions = this.matcher.match(query, limit).map((user) => { const displayName = (user.name || user.userId || ''); return { // Length of completion should equal length of text in decorator. draft-js diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d9d57d8a6..ad0f75e162 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.js index 7c050e7433..2ff91e4976 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component { mx_GroupFilterPanel_items_selected: itemsSelected, }); + let betaDot; + if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) { + betaDot =
; + } + let createButton = ( + className="mx_TagTile mx_TagTile_plus"> + { betaDot } + ); if (SettingsStore.getValue("feature_communities_v2_prototypes")) { diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 44b404bd3a..7f9ef7516e 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -416,7 +416,7 @@ export default class LeftPanel extends React.Component { const roomList = ; } /** @@ -160,6 +164,7 @@ class LoggedInView extends React.Component { // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), usageLimitDismissed: false, + activeCalls: [], }; // stash the MatrixClient in case we log out before we are unmounted @@ -175,6 +180,7 @@ class LoggedInView extends React.Component { componentDidMount() { document.addEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._updateServerNoticeEvents(); @@ -199,6 +205,7 @@ class LoggedInView extends React.Component { componentWillUnmount() { document.removeEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); @@ -206,6 +213,12 @@ class LoggedInView extends React.Component { this.resizer.detach(); } + private onCallsChanged = () => { + this.setState({ + activeCalls: CallHandler.sharedInstance().getAllActiveCalls(), + }); + }; + // Child components assume that the client peg will not be null, so give them some // sort of assurance here by only allowing a re-render if the client is truthy. // @@ -661,6 +674,12 @@ class LoggedInView extends React.Component { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { + return ( + + ); + }); + return (
{ + {audioFeedArraysForCalls} ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 078b296295..288acc108a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -740,6 +740,8 @@ export default class MatrixChat extends React.PureComponent { this.showScreenAfterLogin(); break; case 'toggle_my_groups': + // persist that the user has interacted with this, use it to dismiss the beta dot + localStorage.setItem("mx_seenSpacesBeta", "1"); // We just dispatch the page change rather than have to worry about // what the logic is for each of these branches. if (this.state.page_type === PageTypes.MyGroups) { @@ -906,6 +908,11 @@ export default class MatrixChat extends React.PureComponent { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { + // Not all timeline events are decrypted ahead of time anymore + // Only the critical ones for a typical UI are + // This will start the decryption process for all events when a + // user views a room + room.decryptAllEvents(); const theAlias = Rooms.getDisplayAliasForRoom(room); if (theAlias) { presentedId = theAlias; @@ -1094,7 +1101,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const warnings = []; @@ -1133,7 +1140,7 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( @@ -1684,6 +1691,10 @@ export default class MatrixChat extends React.PureComponent { const type = screen === "start_sso" ? "sso" : "cas"; PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } dis.dispatch({ action: 'view_my_groups', }); @@ -1767,6 +1778,11 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } + const groupId = screen.substring(6); // TODO: Check valid group ID diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index c93f07fa0f..73a2a3c4b6 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -34,6 +34,7 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import defaultDispatcher from '../../dispatcher/dispatcher'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -471,6 +472,10 @@ export default class MessagePanel extends React.Component { return {nextEvent, nextTile}; } + get _roomHasPendingEdit() { + return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + } + _getEventTiles() { this.eventNodes = {}; @@ -544,11 +549,13 @@ export default class MessagePanel extends React.Component { } if (!grouper) { const wantTile = this._shouldShowEvent(mxEv); + const isGrouped = false; if (wantTile) { // make sure we unpack the array returned by _getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped, + nextEvent, nextTile)); prevEvent = mxEv; } @@ -557,6 +564,13 @@ export default class MessagePanel extends React.Component { } } + if (!this.props.editState && this._roomHasPendingEdit) { + defaultDispatcher.dispatch({ + action: "edit_event", + event: this.props.room.findEventById(this._roomHasPendingEdit), + }); + } + if (grouper) { ret.push(...grouper.getTiles()); } @@ -564,7 +578,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { + _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -572,7 +586,6 @@ export default class MessagePanel extends React.Component { const isEditing = this.props.editState && this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. let ts1 = mxEv.getTs(); @@ -584,7 +597,7 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -968,9 +981,9 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const panel = this.panel; const ret = []; + const isGrouped = true; const createEvent = this.createEvent; const lastShownEvent = this.lastShownEvent; @@ -984,12 +997,12 @@ class CreationGrouper { // If this m.room.create event should be shown (room upgrade) then show it before the summary if (panel._shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel._getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { ret.push(...panel._getTilesForEvent( - createEvent, ejected, createEvent === lastShownEvent, + createEvent, ejected, createEvent === lastShownEvent, isGrouped, )); } @@ -998,7 +1011,7 @@ class CreationGrouper { // of EventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; @@ -1083,7 +1096,7 @@ class RedactionGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped = true; const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; @@ -1103,7 +1116,8 @@ class RedactionGrouper { let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1182,7 +1196,7 @@ class MemberGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped = true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; @@ -1215,7 +1229,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 2ab11dad25..1fab6c4348 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import BetaCard from "../views/beta/BetaCard"; @replaceableComponent("structures.MyGroups") export default class MyGroups extends React.Component { @@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
    */}
    +
    { contentHeader } { content } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 5bcb3b2450..d8c763eabd 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import SettingsStore from "../../settings/SettingsStore"; @replaceableComponent("structures.RightPanel") export default class RightPanel extends React.Component { @@ -85,7 +86,9 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; - } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() + && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) + ) { return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 34682877e0..bda46aef07 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -27,8 +27,8 @@ import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import SpaceStore, {UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../stores/SpaceStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 5108643673..c0f3c59457 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1750,7 +1750,10 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself + if (myMembership === "invite" + // SpaceRoomView handles invites itself + && (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom()) + ) { if (this.state.joining || this.state.rejecting) { return ( @@ -1892,7 +1895,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { + if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { return (
    { previewBar } @@ -1914,7 +1917,7 @@ export default class RoomView extends React.Component { ); } - if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) { + if (this.state.room?.isSpaceRoom()) { return = ({ const cliRoom = cli.getRoom(room.room_id); const myMembership = cliRoom?.getMyMembership(); - const onPreviewClick = () => onViewRoomClick(false); - const onJoinClick = () => onViewRoomClick(true); + const onPreviewClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(false); + } + const onJoinClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(true); + } let button; if (myMembership === "join") { @@ -161,7 +173,16 @@ const Tile: React.FC = ({ { suggestedSection }
    -
    +
    e && linkifyElement(e)} + onClick={ev => { + // prevent clicks on links from bubbling up to the room tile + if ((ev.target as HTMLElement).tagName === "A") { + ev.stopPropagation(); + } + }} + > { description }
    @@ -254,7 +275,11 @@ export const HierarchyLevel = ({ const space = cli.getRoom(spaceId); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); + const children = Array.from(relations.get(spaceId)?.values() || []); + const sortedChildren = sortBy(children, ev => { + // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting + return getOrder(ev.content.order, null, ev.state_key); + }); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const roomId = ev.state_key; if (!rooms.has(roomId)) return result; @@ -350,6 +375,7 @@ export const SpaceHierarchy: React.FC = ({ initialText = "", showRoom, refreshToken, + additionalButtons, children, }) => { const cli = MatrixClientPeg.get(); @@ -415,78 +441,83 @@ export const SpaceHierarchy: React.FC = ({ countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); } - let editSection; + let manageButtons; if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; }); - let buttons; - if (selectedRelations.length) { - const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { - return parentChildMap.get(parentId)?.get(childId)?.content.suggested; - }); + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return parentChildMap.get(parentId)?.get(childId)?.content.suggested; + }); - const disabled = removing || saving; + const disabled = !selectedRelations.length || removing || saving; - buttons = <> - { - setRemoving(true); - try { - for (const [parentId, childId] of selectedRelations) { - await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); - parentChildMap.get(parentId).get(childId).content = {}; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError(_t("Failed to remove some rooms. Try again later")); - } - setRemoving(false); - }} - kind="danger_outline" - disabled={disabled} - > - { removing ? _t("Removing...") : _t("Remove") } - - { - setSaving(true); - try { - for (const [parentId, childId] of selectedRelations) { - const suggested = !selectionAllSuggested; - const existingContent = parentChildMap.get(parentId)?.get(childId)?.content; - if (!existingContent || existingContent.suggested === suggested) continue; - - const content = { - ...existingContent, - suggested: !selectionAllSuggested, - }; - - await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId); - - parentChildMap.get(parentId).get(childId).content = content; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError("Failed to update some suggestions. Try again later"); - } - setSaving(false); - }} - kind="primary_outline" - disabled={disabled} - > - { saving - ? _t("Saving...") - : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested")) - } - - ; + let Button: React.ComponentType> = AccessibleButton; + let props = {}; + if (!selectedRelations.length) { + Button = AccessibleTooltipButton; + props = { + tooltip: _t("Select a room below first"), + yOffset: -40, + }; } - editSection = - { buttons } - ; + manageButtons = <> + + + ; } let results; @@ -532,7 +563,10 @@ export const SpaceHierarchy: React.FC = ({ content = <>
    { countsStr } - { editSection } + + { additionalButtons } + { manageButtons } +
    { error &&
    { error } @@ -550,7 +584,7 @@ export const SpaceHierarchy: React.FC = ({ return <> void }) => { + if (!SdkConfig.get().bug_report_endpoint_url) return null; + + return
    +
    +
    + { _t("Spaces are a beta feature.") } + { + if (onClick) onClick(); + Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { + featureId: "feature_spaces", + }); + }}> + { _t("Feedback") } + +
    +
    ; +}; + const RoomMemberCount = ({ room, children }) => { const members = useRoomMembers(room); const count = members.length; @@ -130,15 +162,39 @@ const SpaceInfo = ({ space }) => {
    }; +const onBetaClick = () => { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); +}; + const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); const [busy, setBusy] = useState(false); + const spacesEnabled = SettingsStore.getValue("feature_spaces"); + let inviterSection; let joinButtons; - if (myMembership === "invite") { + if (myMembership === "join") { + // XXX remove this when spaces leaves Beta + joinButtons = ( + { + dis.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + }} + > + { _t("Leave") } + + ); + } else if (myMembership === "invite") { const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); const inviter = inviteSender && space.getMember(inviteSender); @@ -174,6 +230,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled} > { _t("Accept") } @@ -186,10 +243,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled} > { _t("Join") } - ) + ); } if (busy) { @@ -197,6 +255,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => } return
    + { inviterSection }

    @@ -214,9 +273,84 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
    { joinButtons }
    + { !spacesEnabled &&
    + { myMembership === "join" + ? _t("To view %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + : _t("To join %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + } +
    }

    ; }; +const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { + const cli = useContext(MatrixClientContext); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const rect = handle.current.getBoundingClientRect(); + contextMenu = + + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + if (await showCreateNewRoom(cli, space)) { + onNewRoomAdded(); + } + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + const [added] = await showAddExistingRooms(cli, space); + if (added) { + onNewRoomAdded(); + } + }} + /> + + ; + } + + return <> + + { _t("Add") } + + { contextMenu } + ; +}; + const SpaceLanding = ({ space }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); @@ -241,32 +375,20 @@ const SpaceLanding = ({ space }) => { const [refreshToken, forceUpdate] = useStateToggle(false); - let addRoomButtons; + let addRoomButton; if (canAddRooms) { - addRoomButtons = - { - const [added] = await showAddExistingRooms(cli, space); - if (added) { - forceUpdate(); - } - }}> - { _t("Add existing rooms & spaces") } - - { - showCreateNewRoom(cli, space); - }}> - { _t("Create a new room") } - - ; + addRoomButton = ; } let settingsButton; if (shouldShowSpaceSettings(cli, space)) { - settingsButton = { - showSpaceSettings(cli, space); - }}> - { _t("Settings") } - ; + settingsButton = { + showSpaceSettings(cli, space); + }} + title={_t("Settings")} + />; } const onMembersClick = () => { @@ -293,17 +415,20 @@ const SpaceLanding = ({ space }) => { { inviteButton } + { settingsButton }
    +
    -
    - { addRoomButtons } - { settingsButton } -
    - +
    ; }; @@ -325,14 +450,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { value={roomNames[i]} onChange={ev => setRoomName(i, ev.target.value)} autoFocus={i === 2} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); setBusy(true); try { - await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, @@ -345,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { parentSpace: space, }); })); - onFinished(); + onFinished(filteredRoomNames.length > 0); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -353,7 +482,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(false); + }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { onClick = onNextClick; @@ -365,45 +497,26 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
    { description }
    { error &&
    { error }
    } - { fields } + + { fields } +
    - { buttonLabel } - + element="input" + type="submit" + form="mx_SpaceSetupFirstRooms" + value={buttonLabel} + />
    +
    ; }; const SpaceAddExistingRooms = ({ space, onFinished }) => { - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - let onClick = onFinished; - let buttonLabel = _t("Skip for now"); - if (selectedToAdd.size > 0) { - onClick = async () => { - // TODO rate limiting - setBusy(true); - try { - await allSettled(Array.from(selectedToAdd).map((room) => - SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); - onFinished(true); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - } - setBusy(false); - }; - buttonLabel = busy ? _t("Adding...") : _t("Add"); - } - return

    { _t("What do you want to organise?") }

    @@ -411,36 +524,28 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => { "no one will be informed. You can add more later.") }
    - { error &&
    { error }
    } - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + emptySelectionButton={ + + { _t("Skip for now") } + + } + onFinished={onFinished} />
    - - { buttonLabel } - +
    +
    ; }; -const SpaceSetupPublicShare = ({ space, onFinished }) => { +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { return
    -

    { _t("Share %(name)s", { name: space.name }) }

    +

    { _t("Share %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }

    { _t("It's just you at the moment, it will be even better with others.") }
    @@ -449,17 +554,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
    - { _t("Go to my first room") } + { createdRooms ? _t("Go to my first room") : _t("Go to my space") }
    +
    ; }; -const SpaceSetupPrivateScope = ({ space, onFinished }) => { +const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => { return

    { _t("Who are you working with?") }

    - { _t("Make sure the right people have access to %(name)s", { name: space.name }) } + { _t("Make sure the right people have access to %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }
    {

    { _t("Me and my teammates") }

    { _t("A private space for you and your teammates") }
    +
    ; }; @@ -506,10 +615,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { ref={fieldRefs[i]} onValidate={validateEmailRules} autoFocus={i === 0} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); for (let i = 0; i < fieldRefs.length; i++) { const fieldRef = fieldRefs[i]; @@ -543,7 +655,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(); + }; let buttonLabel = _t("Skip for now"); if (emailAddresses.some(name => name.trim())) { onClick = onNextClick; @@ -556,8 +671,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { { _t("Make sure the right people have access. You can invite more later.") }
    +
    + + { _t("This is an experimental feature. For now, " + + "new users receiving an invite will have to open the invite on to actually join.", {}, { + b: sub => { sub }, + link: () => + app.element.io + , + }) } +
    + { error &&
    { error }
    } - { fields } +
    + { fields } +
    {
    - - { buttonLabel } - +
    +
    ; }; @@ -693,7 +828,7 @@ export default class SpaceRoomView extends React.PureComponent { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join") { + if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { return ; } else { return { return this.setState({ phase: Phase.PublicShare })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} />; case Phase.PublicShare: - return ; + return ; case Phase.PrivateScope: return { this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); }} @@ -735,7 +876,7 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} - onFinished={() => this.setState({ phase: Phase.Landing })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} />; case Phase.PrivateExistingRooms: return { + if (event.shouldAttemptDecryption()) { + event.attemptDecryption(MatrixClientPeg.get()._crypto); + } + }); + const firstVisibleEventIndex = this._checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 5ecdd4ec5a..8ce05e0a55 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -179,7 +179,7 @@ const BaseAvatar = (props: IProps) => { width: toPx(width), height: toPx(height), }} - title={title} alt="" + title={title} alt={_t("Avatar")} inputRef={inputRef} {...otherProps} /> ); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e95022687a..f15538eabf 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; @@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { room: Room; avatarSize: number; - tag: TagID; displayBadge?: boolean; forceCount?: boolean; oobData?: object; diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx new file mode 100644 index 0000000000..821c448f4f --- /dev/null +++ b/src/components/views/beta/BetaCard.tsx @@ -0,0 +1,108 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import classNames from "classnames"; + +import {_t} from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import SettingsStore from "../../../settings/SettingsStore"; +import {SettingLevel} from "../../../settings/SettingLevel"; +import TextWithTooltip from "../elements/TextWithTooltip"; +import Modal from "../../../Modal"; +import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; +import SdkConfig from "../../../SdkConfig"; + +interface IProps { + title?: string; + featureId: string; +} + +export const BetaPill = ({ onClick }: { onClick?: () => void }) => { + if (onClick) { + return +
    + { _t("Spaces is a beta feature") } +
    +
    + { _t("Tap for more info") } +
    + } + onClick={onClick} + tooltipProps={{ yOffset: -10 }} + > + { _t("Beta") } +
    ; + } + + return + { _t("Beta") } + ; +}; + +const BetaCard = ({ title: titleOverride, featureId }: IProps) => { + const info = SettingsStore.getBetaInfo(featureId); + if (!info) return null; // Beta is invalid/disabled + + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info; + const value = SettingsStore.getValue(featureId); + + let feedbackButton; + if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + feedbackButton = { + Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId }); + }} + kind="primary" + > + { _t("Feedback") } + ; + } + + return
    +
    +

    + { titleOverride || _t(title) } + +

    + { _t(caption) } +
    + { feedbackButton } + SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} + kind={feedbackButton ? "primary_outline" : "primary"} + > + { value ? _t("Leave the beta") : _t("Join the beta") } + +
    + { disclaimer &&
    + { disclaimer(value) } +
    } +
    + +
    ; +}; + +export default BetaCard; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 35efd12c9c..365f2ab1de 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -78,8 +78,10 @@ export default class MessageContextMenu extends React.Component { // We explicitly decline to show the redact option on ACL events as it has a potential // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + // Similarly for encryption events, since redacting them "breaks everything" const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) - && this.props.mxEvent.getType() !== EventType.RoomServerAcl; + && this.props.mxEvent.getType() !== EventType.RoomServerAcl + && this.props.mxEvent.getType() !== EventType.RoomEncryption; let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 1f45a76008..af52fbce6c 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useState} from "react"; +import React, {ReactNode, useContext, useMemo, useState} from "react"; import classNames from "classnames"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -29,11 +29,15 @@ import RoomAvatar from "../avatars/RoomAvatar"; import {getDisplayAliasForRoom} from "../../../Rooms"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {allSettled} from "../../../utils/promise"; +import {sleep} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import ProgressBar from "../elements/ProgressBar"; +import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -43,20 +47,38 @@ interface IProps extends IDialogProps { const Entry = ({ room, checked, onChange }) => { return ; }; interface IAddExistingToSpaceProps { space: Room; - selected: Set; - onChange(checked: boolean, room: Room): void; + footerPrompt?: ReactNode; + emptySelectionButton?: ReactNode; + onFinished(added: boolean): void; } -export const AddExistingToSpace: React.FC = ({ space, selected, onChange }) => { +export const AddExistingToSpace: React.FC = ({ + space, + footerPrompt, + emptySelectionButton, + onFinished, +}) => { const cli = useContext(MatrixClientContext); + const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]); + + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); @@ -65,7 +87,7 @@ export const AddExistingToSpace: React.FC = ({ space, const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId)); const joinRule = space.getJoinRule(); - const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => { + const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => { if (room.getMyMembership() !== "join") return arr; if (!room.name.toLowerCase().includes(lcQuery)) return arr; @@ -84,6 +106,93 @@ export const AddExistingToSpace: React.FC = ({ space, return arr; }, [[], [], []]); + const addRooms = async () => { + setError(null); + setProgress(0); + + let error; + + for (const room of selectedToAdd) { + const via = calculateRoomVia(room); + try { + await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { + if (e.errcode === "M_LIMIT_EXCEEDED") { + await sleep(e.data.retry_after_ms); + return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry + } + + throw e; + }); + setProgress(i => i + 1); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(error = e); + break; + } + } + + if (!error) { + onFinished(true); + } + }; + + const busy = progress !== null; + + let footer; + if (error) { + footer = <> + + + +
    { _t("Not all selected were added") }
    +
    { _t("Try again") }
    +
    + + + { _t("Retry") } + + ; + } else if (busy) { + footer = + +
    + { _t("Adding rooms... (%(progress)s out of %(count)s)", { + count: selectedToAdd.size, + progress, + }) } +
    +
    ; + } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + + footer = <> + + { footerPrompt } + + + { button } + ; + } + + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + return
    = ({ space, return { + checked={selectedToAdd.has(room)} + onChange={onChange ? (checked) => { onChange(checked, room); - }} + } : null} />; }) }
    @@ -112,14 +221,18 @@ export const AddExistingToSpace: React.FC = ({ space, { spaces.length > 0 ? (

    { _t("Spaces") }

    +
    +
    { _t("Feeling experimental?") }
    +
    { _t("You can add existing spaces to a space.") }
    +
    { spaces.map(space => { return { + checked={selectedToAdd.has(space)} + onChange={onChange ? (checked) => { onChange(checked, space); - }} + } : null} />; }) }
    @@ -132,10 +245,10 @@ export const AddExistingToSpace: React.FC = ({ space, return { + checked={selectedToAdd.has(room)} + onChange={onChange ? (checked) => { onChange(checked, room); - }} + } : null} />; }) } @@ -145,16 +258,16 @@ export const AddExistingToSpace: React.FC = ({ space, { _t("No results") } : undefined } + +
    + { footer } +
    ; }; const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { const [selectedSpace, setSelectedSpace] = useState(space); const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); let spaceOptionSection; if (existingSubspaces.length > 0) { @@ -201,51 +314,20 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onFinished={onFinished} fixedWidth={false} > - { error &&
    { error }
    } - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + onFinished={onFinished} + footerPrompt={<> +
    { _t("Want to add a new room instead?") }
    + onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + } />
    -
    - -
    { _t("Don't want to add an existing room?") }
    - onCreateRoomClick(cli, space)} kind="link"> - { _t("Create a new room") } - -
    - - { - // TODO rate limiting - setBusy(true); - try { - await allSettled(Array.from(selectedToAdd).map((room) => - SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); - onFinished(true); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - } - setBusy(false); - }} - > - { busy ? _t("Adding...") : _t("Add") } - -
    + onFinished(false)} /> ; }; diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx new file mode 100644 index 0000000000..1ae50dd66f --- /dev/null +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -0,0 +1,106 @@ +/* +Copyright 2021 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, {useState} from "react"; + +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import SdkConfig from "../../../SdkConfig"; +import {IDialogProps} from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import {submitFeedback} from "../../../rageshake/submit-rageshake"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {Action} from "../../../dispatcher/actions"; +import {USER_LABS_TAB} from "./UserSettingsDialog"; + +interface IProps extends IDialogProps { + featureId: string; +} + +const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { + const info = SettingsStore.getBetaInfo(featureId); + + const [comment, setComment] = useState(""); + const [canContact, setCanContact] = useState(false); + + const sendFeedback = async (ok: boolean) => { + if (!ok) return onFinished(false); + + submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact); + onFinished(true); + + Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { + title: _t("Beta feedback"), + description: _t("Thank you for your feedback, we really appreciate it."), + button: _t("Done"), + hasCloseButton: false, + fixedWidth: false, + }); + }; + + return ( +
    + { _t(info.feedbackSubheading) } +   + { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} + + { + onFinished(false); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); + }}> + { _t("To leave the beta, visit your settings.") } + +
    + + { + setComment(ev.target.value); + }} + autoFocus={true} + /> + + setCanContact((e.target as HTMLInputElement).checked)} + > + { _t("You may contact me if you have any follow up questions") } + + } + button={_t("Send feedback")} + buttonDisabled={!comment} + onFinished={sendFeedback} + />); +}; + +export default BetaFeedbackDialog; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 2ebc84ec7c..ec9c71ccbe 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1312,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent = ({ matrixClient: cli, space, onFin { error &&
    { error }
    } + onFinished(false)} /> + diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx new file mode 100644 index 0000000000..da1492e30d --- /dev/null +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2019, 2020, 2021 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 { User } from "matrix-js-sdk/src/models/user"; + +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import E2EIcon from "../rooms/E2EIcon"; +import AccessibleButton from "../elements/AccessibleButton"; +import BaseDialog from "./BaseDialog"; +import { IDialogProps } from "./IDialogProps"; +import { IDevice } from "../right_panel/UserInfo"; + +interface IProps extends IDialogProps { + user: User; + device: IDevice; +} + +const UntrustedDeviceDialog: React.FC = ({device, user, onFinished}) => { + let askToVerifyText; + let newSessionText; + + if (MatrixClientPeg.get().getUserId() === user.userId) { + newSessionText = _t("You signed in to a new session without verifying it:"); + askToVerifyText = _t("Verify your other session using one of the options below."); + } else { + newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", + {name: user.displayName, userId: user.userId}); + askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); + } + + return + + { _t("Not Trusted")} + } + > +
    +

    {newSessionText}

    +

    {device.getDisplayName()} ({device.deviceId})

    +

    {askToVerifyText}

    +
    +
    + onFinished("legacy")}> + { _t("Manually Verify by Text") } + + onFinished("sas")}> + { _t("Interactively verify by Emoji") } + + onFinished(false)}> + { _t("Done") } + +
    +
    ; +}; + +export default UntrustedDeviceDialog; diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.tsx similarity index 72% rename from src/components/views/dialogs/UploadConfirmDialog.js rename to src/components/views/dialogs/UploadConfirmDialog.tsx index 2ff16b9440..7f6bcd27d1 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,20 +16,23 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import filesize from "filesize"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getBlobSafeMimeType } from '../../../utils/blobs'; + +interface IProps { + file: File; + currentIndex: number; + totalFiles?: number; + onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void; +} @replaceableComponent("views.dialogs.UploadConfirmDialog") -export default class UploadConfirmDialog extends React.Component { - static propTypes = { - file: PropTypes.object.isRequired, - currentIndex: PropTypes.number, - totalFiles: PropTypes.number, - onFinished: PropTypes.func.isRequired, - } +export default class UploadConfirmDialog extends React.Component { + private objectUrl: string; + private mimeType: string; static defaultProps = { totalFiles: 1, @@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component { constructor(props) { super(props); - this._objectUrl = URL.createObjectURL(props.file); + // Create a fresh `Blob` for previewing (even though `File` already is + // one) so we can adjust the MIME type if needed. + this.mimeType = getBlobSafeMimeType(props.file.type); + const blob = new Blob([props.file], { type: + this.mimeType, + }); + this.objectUrl = URL.createObjectURL(blob); } componentWillUnmount() { - if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); } - _onCancelClick = () => { + private onCancelClick = () => { this.props.onFinished(false); } - _onUploadClick = () => { + private onUploadClick = () => { this.props.onFinished(true); } - _onUploadAllClick = () => { + private onUploadAllClick = () => { this.props.onFinished(true, true); } @@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component { } let preview; - if (this.props.file.type.startsWith('image/')) { + if (this.mimeType.startsWith('image/')) { preview =
    -
    +
    {this.props.file.name} ({filesize(this.props.file.size)})
    ; @@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component { let uploadAllButton; if (this.props.currentIndex + 1 < this.props.totalFiles) { - uploadAllButton = ; } @@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component { return ( @@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component { {uploadAllButton} diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index e7f6953589..fe29b85aea 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_securityIcon", , )); - if (SdkConfig.get()['showLabsSettings']) { + // Show the Labs tab if enabled or if there are any active betas + if (SdkConfig.get()['showLabsSettings'] + || SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k)) + ) { tabs.push(new Tab( USER_LABS_TAB, _td("Labs"), diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js index 2fabda1a74..e6ff6d9791 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.js @@ -49,6 +49,18 @@ const inPlaceOf = (elementRect) => ({ }); const validServer = withValidation({ + deriveData: async ({ value }) => { + try { + // check if we can successfully load this server's room directory + await MatrixClientPeg.get().publicRooms({ + limit: 1, + server: value, + }); + return {}; + } catch (error) { + return { error }; + } + }, rules: [ { key: "required", @@ -57,21 +69,11 @@ const validServer = withValidation({ }, { key: "available", final: true, - test: async ({ value }) => { - try { - const opts = { - limit: 1, - server: value, - }; - // check if we can successfully load this server's room directory - await MatrixClientPeg.get().publicRooms(opts); - return true; - } catch (e) { - return false; - } - }, + test: async (_, { error }) => !error, valid: () => _t("Looks good"), - invalid: () => _t("Can't find this server or its room list"), + invalid: ({ error }) => error.errcode === "M_FORBIDDEN" + ? _t("You are not allowed to view this server's rooms list") + : _t("Can't find this server or its room list"), }, ], }); diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.js index 9903c631b2..5013bcec0d 100644 --- a/src/components/views/elements/ActionButton.js +++ b/src/components/views/elements/ActionButton.js @@ -32,6 +32,7 @@ export default class ActionButton extends React.Component { label: PropTypes.string.isRequired, iconPath: PropTypes.string, className: PropTypes.string, + children: PropTypes.node, }; static defaultProps = { @@ -79,7 +80,8 @@ export default class ActionButton extends React.Component { } return ( - { icon } { tooltip } + { this.props.children } ); } diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index fcacae2d39..df73e1a8cb 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -108,23 +108,20 @@ export default class ImageView extends React.Component { window.addEventListener("resize", this.calculateZoom); // After the image loads for the first time we want to calculate the zoom this.image.current.addEventListener("load", this.calculateZoom); - // Try to precalculate the zoom from width and height props - this.calculateZoom(); } componentWillUnmount() { this.focusLock.current.removeEventListener('wheel', this.onWheel); + window.removeEventListener("resize", this.calculateZoom); + this.image.current.removeEventListener("load", this.calculateZoom); } private calculateZoom = () => { const image = this.image.current; const imageWrapper = this.imageWrapper.current; - const width = this.props.width || image.naturalWidth; - const height = this.props.height || image.naturalHeight; - - const zoomX = imageWrapper.clientWidth / width; - const zoomY = imageWrapper.clientHeight / height; + const zoomX = imageWrapper.clientWidth / image.naturalWidth; + const zoomY = imageWrapper.clientHeight / image.naturalHeight; // If the image is smaller in both dimensions set its the zoom to 1 to // display it in its original size @@ -210,6 +207,7 @@ export default class ImageView extends React.Component { a.href = this.props.src; a.download = this.props.name; a.target = "_blank"; + a.rel = "noreferrer noopener"; a.click(); }; @@ -445,16 +443,16 @@ export default class ImageView extends React.Component {
    {info}
    - - + + {zoomOutButton} {zoomInButton} { + constructor(props: IProps) { + super(props); + + this.state = {}; + } + + public async componentDidMount() { + let buffer: ArrayBuffer; + const content = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + if (media.isEncrypted) { + try { + const blob = await decryptFile(content.file); + buffer = await blob.arrayBuffer(); + this.setState({decryptedBlob: blob}); + } catch (e) { + this.setState({error: e}); + console.warn("Unable to decrypt voice message", e); + return; // stop processing the audio file + } + } else { + try { + buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer()); + } catch (e) { + this.setState({error: e}); + console.warn("Unable to download voice message", e); + return; // stop processing the audio file + } + } + + const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024); + + // We should have a buffer to work with now: let's set it up + const playback = new Playback(buffer, waveform); + this.setState({playback}); + // Note: the RecordingPlayback component will handle preparing the Playback class for us. + } + + public render() { + if (this.state.error) { + // TODO: @@TR: Verify error state + return ( + + + { _t("Error processing voice message") } + + ); + } + + if (!this.state.playback) { + // TODO: @@TR: Verify loading/decrypting state + return ( + + + + ); + } + + // At this point we should have a playable state + return ( + + + + + ) + } +} diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx new file mode 100644 index 0000000000..0cebcf3440 --- /dev/null +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2021 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 {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import MAudioBody from "./MAudioBody"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SettingsStore from "../../../settings/SettingsStore"; +import MVoiceMessageBody from "./MVoiceMessageBody"; + +interface IProps { + mxEvent: MatrixEvent; +} + +@replaceableComponent("views.messages.MVoiceOrAudioBody") +export default class MVoiceOrAudioBody extends React.PureComponent { + public render() { + const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']; + const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages"); + if (isVoiceMessage && voiceMessagesEnabled) { + return ; + } else { + return ; + } + } +} diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 60f7631c8e..78e0dc422d 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -72,12 +72,8 @@ export default class MessageEvent extends React.Component { 'm.emote': sdk.getComponent('messages.TextualBody'), 'm.image': sdk.getComponent('messages.MImageBody'), 'm.file': sdk.getComponent('messages.MFileBody'), - 'm.audio': sdk.getComponent('messages.MAudioBody'), + 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), - - // TODO: @@ TravisR: Use labs flag determination. - // MSC: https://github.com/matrix-org/matrix-doc/pull/2516 - 'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.tsx similarity index 64% rename from src/components/views/messages/ReactionsRow.js rename to src/components/views/messages/ReactionsRow.tsx index d5c8ea2ac9..e1fcbe5364 100644 --- a/src/components/views/messages/ReactionsRow.js +++ b/src/components/views/messages/ReactionsRow.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,29 +14,68 @@ 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 from "react"; +import classNames from "classnames"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Relations } from "matrix-js-sdk/src/models/relations"; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { isContentActionable } from '../../../utils/EventUtils'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; +import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu"; +import ReactionPicker from "../emojipicker/ReactionPicker"; +import ReactionsRowButton from "./ReactionsRowButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; // The maximum number of reactions to initially show on a message. const MAX_ITEMS_WHEN_LIMITED = 8; -@replaceableComponent("views.messages.ReactionsRow") -export default class ReactionsRow extends React.PureComponent { - static propTypes = { - // The event we're displaying reactions for - mxEvent: PropTypes.object.isRequired, - // The Relations model from the JS SDK for reactions to `mxEvent` - reactions: PropTypes.object, +const ReactButton = ({ mxEvent, reactions }: IProps) => { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const buttonRect = button.current.getBoundingClientRect(); + contextMenu = + + ; } - constructor(props) { - super(props); + return + + + { contextMenu } + ; +}; + +interface IProps { + // The event we're displaying reactions for + mxEvent: MatrixEvent; + // The Relations model from the JS SDK for reactions to `mxEvent` + reactions?: Relations; +} + +interface IState { + myReactions: MatrixEvent[]; + showAll: boolean; +} + +@replaceableComponent("views.messages.ReactionsRow") +export default class ReactionsRow extends React.PureComponent { + static contextType = MatrixClientContext; + + constructor(props, context) { + super(props, context); if (props.reactions) { props.reactions.on("Relations.add", this.onReactionsChange); @@ -92,7 +131,7 @@ export default class ReactionsRow extends React.PureComponent { if (!reactions) { return null; } - const userId = MatrixClientPeg.get().getUserId(); + const userId = this.context.getUserId(); const myReactions = reactions.getAnnotationsBySender()[userId]; if (!myReactions) { return null; @@ -114,7 +153,6 @@ export default class ReactionsRow extends React.PureComponent { return null; } - const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton'); let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => { const count = events.size; if (!count) { @@ -151,13 +189,21 @@ export default class ReactionsRow extends React.PureComponent { ; } + const cli = this.context; + + let addReactionButton; + if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) { + addReactionButton = ; + } + return
    - {items} - {showAllButton} + { items } + { showAllButton } + { addReactionButton }
    ; } } diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.tsx similarity index 73% rename from src/components/views/messages/ReactionsRowButton.js rename to src/components/views/messages/ReactionsRowButton.tsx index b37a949e57..d163f1ad30 100644 --- a/src/components/views/messages/ReactionsRowButton.js +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,49 +14,54 @@ 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 React from "react"; +import classNames from "classnames"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import dis from "../../../dispatcher/dispatcher"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip"; +import AccessibleButton from "../elements/AccessibleButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +interface IProps { + // The event we're displaying reactions for + mxEvent: MatrixEvent; + // The reaction content / key / emoji + content: string; + // The count of votes for this key + count: number; + // A Set of Matrix reaction events for this key + reactionEvents: Set; + // A possible Matrix event if the current user has voted for this type + myReactionEvent?: MatrixEvent; +} + +interface IState { + tooltipRendered: boolean; + tooltipVisible: boolean; +} @replaceableComponent("views.messages.ReactionsRowButton") -export default class ReactionsRowButton extends React.PureComponent { - static propTypes = { - // The event we're displaying reactions for - mxEvent: PropTypes.object.isRequired, - // The reaction content / key / emoji - content: PropTypes.string.isRequired, - // The count of votes for this key - count: PropTypes.number.isRequired, - // A Set of Martix reaction events for this key - reactionEvents: PropTypes.object.isRequired, - // A possible Matrix event if the current user has voted for this type - myReactionEvent: PropTypes.object, - } +export default class ReactionsRowButton extends React.PureComponent { + static contextType = MatrixClientContext; - constructor(props) { - super(props); + state = { + tooltipRendered: false, + tooltipVisible: false, + }; - this.state = { - tooltipVisible: false, - }; - } - - onClick = (ev) => { + onClick = () => { const { mxEvent, myReactionEvent, content } = this.props; if (myReactionEvent) { - MatrixClientPeg.get().redactEvent( + this.context.redactEvent( mxEvent.getRoomId(), myReactionEvent.getId(), ); } else { - MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", { + this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", { "m.relates_to": { "rel_type": "m.annotation", "event_id": mxEvent.getId(), @@ -83,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent { } render() { - const ReactionsRowButtonTooltip = - sdk.getComponent('messages.ReactionsRowButtonTooltip'); const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; const classes = classNames({ @@ -102,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent { />; } - const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); + const room = this.context.getRoom(mxEvent.getRoomId()); let label; if (room) { const senders = []; @@ -130,7 +133,6 @@ export default class ReactionsRowButton extends React.PureComponent { ); } const isPeeking = room.getMyMembership() !== "join"; - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ; + visible: boolean; +} @replaceableComponent("views.messages.ReactionsRowButtonTooltip") -export default class ReactionsRowButtonTooltip extends React.PureComponent { - static propTypes = { - // The event we're displaying reactions for - mxEvent: PropTypes.object.isRequired, - // The reaction content / key / emoji - content: PropTypes.string.isRequired, - // A Set of Martix reaction events for this key - reactionEvents: PropTypes.object.isRequired, - visible: PropTypes.bool.isRequired, - } +export default class ReactionsRowButtonTooltip extends React.PureComponent { + static contextType = MatrixClientContext; render() { - const Tooltip = sdk.getComponent('elements.Tooltip'); const { content, reactionEvents, mxEvent, visible } = this.props; - const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); + const room = this.context.getRoom(mxEvent.getRoomId()); let tooltipLabel; if (room) { const senders = []; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index be152d91bd..d5f67623a2 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -67,7 +67,7 @@ import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import {mediaFromMxc} from "../../../customisations/Media"; -interface IDevice { +export interface IDevice { deviceId: string; ambiguous?: boolean; getDisplayName(): string; @@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) { verifyDevice(cli.getUser(userId), device); }; - const deviceName = device.ambiguous ? - (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : - device.getDisplayName(); + let deviceName; + if (!device.getDisplayName()?.trim()) { + deviceName = device.deviceId; + } else { + deviceName = device.ambiguous ? + device.getDisplayName() + " (" + device.deviceId + ")" : + device.getDisplayName(); + } + let trustedLabel = null; if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); @@ -440,7 +446,7 @@ const UserOptionsSection: React.FC<{ ); }; -const warnSelfDemote = async (isSpace) => { +const warnSelfDemote = async (isSpace: boolean) => { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { title: _t("Demote yourself?"), description: @@ -727,7 +733,7 @@ const MuteToggleButton: React.FC = ({member, room, powerLevels, // if muting self, warn as it may be irreversible if (target === cli.getUserId()) { try { - if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; + if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); return; @@ -816,7 +822,7 @@ const RoomAdminToolsContainer: React.FC = ({ if (canAffectUser && me.powerLevel >= kickPowerLevel) { kickButton = ; } - if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) { + if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { redactButton = ( ); @@ -1095,7 +1101,7 @@ const PowerLevelEditor: React.FC<{ } else if (myUserId === target) { // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. try { - if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; + if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); } @@ -1325,10 +1331,10 @@ const BasicUserInfo: React.FC<{ if (!isRoomEncrypted) { if (!cryptoEnabled) { text = _t("This client does not support end-to-end encryption."); - } else if (room && !room.isSpaceRoom()) { + } else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { text = _t("Messages in this room are not end-to-end encrypted."); } - } else if (!room.isSpaceRoom()) { + } else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) { text = _t("Messages in this room are end-to-end encrypted."); } @@ -1405,7 +1411,7 @@ const BasicUserInfo: React.FC<{ canInvite={roomPermissions.canInvite} isIgnored={isIgnored} member={member} - isSpace={room?.isSpaceRoom()} + isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()} /> { adminToolsContainer } @@ -1567,7 +1573,7 @@ const UserInfo: React.FC = ({ previousPhase = RightPanelPhases.RoomMemberInfo; refireParams = {member: member}; } else if (room) { - previousPhase = previousPhase = room.isSpaceRoom() + previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList; } @@ -1616,7 +1622,7 @@ const UserInfo: React.FC = ({ } let scopeHeader; - if (room?.isSpaceRoom()) { + if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { scopeHeader =
    diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index a4dcba11a3..7054741da4 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -26,6 +26,7 @@ import Autocompleter from '../../../autocomplete/Autocompleter'; import {replaceableComponent} from "../../../utils/replaceableComponent"; const COMPOSER_SELECTED = 0; +const MAX_PROVIDER_MATCHES = 20; export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`; @@ -136,7 +137,7 @@ export default class Autocomplete extends React.PureComponent { processQuery(query: string, selection: ISelectionRange) { return this.autocompleter.getCompletions( - query, selection, this.state.forceComplete, + query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES, ).then((completions) => { // Only ever process the completions for the most recent query being processed if (query !== this.queryRequested) { diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index b006fe8c8d..897388d5d2 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; import * as sdk from '../../../index'; -import {_t} from '../../../languageHandler'; +import {_t, _td} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; @@ -24,16 +24,19 @@ import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; -import {PartCreator} from '../../../editor/parts'; +import {CommandPartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk/src/models/event'; import BasicMessageComposer from "./BasicMessageComposer"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {CommandCategories, getCommand} from '../../../SlashCommands'; import {Action} from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SendHistoryManager from '../../../SendHistoryManager'; +import Modal from '../../../Modal'; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -120,6 +123,7 @@ export default class EditMessageComposer extends React.Component { saveDisabled: true, }; this._createEditorModel(); + window.addEventListener("beforeunload", this._saveStoredEditorState); } _setEditorRef = ref => { @@ -173,11 +177,71 @@ export default class EditMessageComposer extends React.Component { } } + get _editorRoomKey() { + return `mx_edit_room_${this._getRoom().roomId}`; + } + + get _editorStateKey() { + return `mx_edit_state_${this.props.editState.getEvent().getId()}`; + } + _cancelEdit = () => { + this._clearStoredEditorState(); dis.dispatch({action: "edit_event", event: null}); dis.fire(Action.FocusComposer); } + get _shouldSaveStoredEditorState() { + return localStorage.getItem(this._editorRoomKey) !== null; + } + + _restoreStoredEditorState(partCreator) { + const json = localStorage.getItem(this._editorStateKey); + if (json) { + try { + const {parts: serializedParts} = JSON.parse(json); + const parts = serializedParts.map(p => partCreator.deserializePart(p)); + return parts; + } catch (e) { + console.error("Error parsing editing state: ", e); + } + } + } + + _clearStoredEditorState() { + localStorage.removeItem(this._editorRoomKey); + localStorage.removeItem(this._editorStateKey); + } + + _clearPreviousEdit() { + if (localStorage.getItem(this._editorRoomKey)) { + localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`); + } + } + + _saveStoredEditorState() { + const item = SendHistoryManager.createItem(this.model); + this._clearPreviousEdit(); + localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId()); + localStorage.setItem(this._editorStateKey, JSON.stringify(item)); + } + + _isSlashCommand() { + const parts = this.model.parts; + const firstPart = parts[0]; + if (firstPart) { + if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) { + return true; + } + + if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") + && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { + return true; + } + } + return false; + } + _isContentModified(newContent) { // if nothing has changed then bail const oldContent = this.props.editState.getEvent().getContent(); @@ -190,19 +254,114 @@ export default class EditMessageComposer extends React.Component { return true; } - _sendEdit = () => { + _getSlashCommand() { + const commandText = this.model.parts.reduce((text, part) => { + // use mxid to textify user pills in a command + if (part.type === "user-pill") { + return text + part.resourceId; + } + return text + part.text; + }, ""); + const {cmd, args} = getCommand(commandText); + return [cmd, args, commandText]; + } + + async _runSlashCommand(cmd, args, roomId) { + const result = cmd.run(roomId, args); + let messageContent; + let error = result.error; + if (result.promise) { + try { + if (cmd.category === CommandCategories.messages) { + messageContent = await result.promise; + } else { + await result.promise; + } + } catch (err) { + error = err; + } + } + if (error) { + console.error("Command failure: %s", error); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // assume the error is a server error when the command is async + const isServerError = !!result.promise; + const title = isServerError ? _td("Server error") : _td("Command error"); + + let errText; + if (typeof error === 'string') { + errText = error; + } else if (error.message) { + errText = error.message; + } else { + errText = _t("Server unavailable, overloaded, or something else went wrong."); + } + + Modal.createTrackedDialog(title, '', ErrorDialog, { + title: _t(title), + description: errText, + }); + } else { + console.log("Command success."); + if (messageContent) return messageContent; + } + } + + _sendEdit = async () => { const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); const newContent = editContent["m.new_content"]; + let shouldSend = true; + // If content is modified then send an updated event into the room if (this._isContentModified(newContent)) { const roomId = editedEvent.getRoomId(); - this._cancelPreviousPendingEdit(); - const prom = this.context.sendMessage(roomId, editContent); - dis.dispatch({action: "message_sent"}); - CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); + if (!containsEmote(this.model) && this._isSlashCommand()) { + const [cmd, args, commandText] = this._getSlashCommand(); + if (cmd) { + if (cmd.category === CommandCategories.messages) { + editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId); + } else { + this._runSlashCommand(cmd, args, roomId); + shouldSend = false; + } + } else { + // ask the user if their unknown command should be sent as a message + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { + title: _t("Unknown Command"), + description:
    +

    + { _t("Unrecognised command: %(commandText)s", {commandText}) } +

    +

    + { _t("You can use /help to list available commands. " + + "Did you mean to send this as a message?", {}, { + code: t => { t }, + }) } +

    +

    + { _t("Hint: Begin your message with // to start it with a slash.", {}, { + code: t => { t }, + }) } +

    +
    , + button: _t('Send as message'), + }); + const [sendAnyway] = await finished; + // if !sendAnyway bail to let the user edit the composer and try again + if (!sendAnyway) return; + } + } + if (shouldSend) { + this._cancelPreviousPendingEdit(); + const prom = this.context.sendMessage(roomId, editContent); + this._clearStoredEditorState(); + dis.dispatch({action: "message_sent"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); + } } // close the event editing and focus composer @@ -235,22 +394,27 @@ export default class EditMessageComposer extends React.Component { // then when mounting the editor again with the same editor state, // it will set the cursor at the end. this.props.editState.setEditorState(caret, parts); + window.removeEventListener("beforeunload", this._saveStoredEditorState); + if (this._shouldSaveStoredEditorState) { + this._saveStoredEditorState(); + } } _createEditorModel() { const {editState} = this.props; const room = this._getRoom(); - const partCreator = new PartCreator(room, this.context); + const partCreator = new CommandPartCreator(room, this.context); let parts; if (editState.hasEditorState()) { // if restoring state from a previous editor, // restore serialized parts from the state parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); } else { - // otherwise, parse the body of the event - parts = parseEvent(editState.getEvent(), partCreator); + //otherwise, either restore serialized parts from localStorage or parse the body of the event + parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); } this.model = new EditorModel(parts, partCreator); + this._saveStoredEditorState(); } _getInitialCaretPosition() { diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 593132a283..fbc0e477a1 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SettingsStore from "../../../settings/SettingsStore"; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -460,7 +461,7 @@ export default class MemberList extends React.Component { const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); if (chat && chat.roomId === this.props.roomId) { inviteButtonText = _t("Invite to this community"); - } else if (room.isSpaceRoom()) { + } else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { inviteButtonText = _t("Invite to this space"); } @@ -492,7 +493,7 @@ export default class MemberList extends React.Component { let previousPhase = RightPanelPhases.RoomSummary; // We have no previousPhase for when viewing a MemberList from a Space let scopeHeader; - if (room?.isSpaceRoom()) { + if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { previousPhase = undefined; scopeHeader =
    diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx index ea0ff233da..00f13209a2 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs.tsx @@ -23,11 +23,9 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import Analytics from "../../../Analytics"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { CSSTransition } from "react-transition-group"; -import RoomListStore from "../../../stores/room-list/RoomListStore"; -import { DefaultTagID } from "../../../stores/room-list/models"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import Toolbar from "../../../accessibility/Toolbar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { } @@ -84,8 +82,6 @@ export default class RoomBreadcrumbs extends React.PureComponent public render(): React.ReactElement { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { - const roomTags = RoomListStore.instance.getTagsForRoom(r); - const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; return ( diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index f856f7f6ef..6d3b50c10d 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import {DefaultTagID} from "../../../stores/room-list/models"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; @@ -177,7 +176,6 @@ export default class RoomHeader extends React.Component { roomAvatar = ; diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index a155e1b5cd..fdf38784cf 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -539,6 +539,7 @@ export default class RoomList extends React.PureComponent { onResize={this.props.onResize} showSkeleton={showSkeleton} extraTiles={extraTiles} + resizeNotifier={this.props.resizeNotifier} alwaysVisible={ALWAYS_VISIBLE_TAGS.includes(orderedTagId)} /> }); diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index fb367349a2..dd80e9d40a 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -44,6 +44,7 @@ import { ActionPayload } from "../../../dispatcher/payloads"; import { Enable, Resizable } from "re-resizable"; import { Direction } from "re-resizable/lib/resizer"; import { polyfillTouchEvent } from "../../../@types/polyfill"; +import { ResizeNotifier } from "../../../utils/ResizeNotifier"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore"; import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays"; @@ -75,7 +76,7 @@ interface IProps { onResize: () => void; showSkeleton?: boolean; alwaysVisible?: boolean; - + resizeNotifier: ResizeNotifier; extraTiles?: ReactComponentElement[]; // TODO: Account for https://github.com/vector-im/element-web/issues/14179 @@ -528,6 +529,7 @@ export default class RoomSublist extends React.Component { tiles.push(; @@ -102,6 +104,9 @@ export default class RoomTile extends React.PureComponent { }; this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("middlePanelResized", this.onResize); + } } private countUnsentEvents(): number { @@ -116,6 +121,12 @@ export default class RoomTile extends React.PureComponent { this.forceUpdate(); // notification state changed - update }; + private onResize = () => { + if (this.showMessagePreview && !this.state.messagePreview) { + this.setState({messagePreview: this.generatePreview()}); + } + }; + private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => { if (!room?.roomId === this.props.room.roomId) return; this.setState({hasUnsentEvents: this.countUnsentEvents() > 0}); @@ -195,6 +206,9 @@ export default class RoomTile extends React.PureComponent { ); this.props.room.off("Room.name", this.onRoomNameUpdate); } + if (this.props.resizeNotifier) { + this.props.resizeNotifier.off("middlePanelResized", this.onResize); + } ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); defaultDispatcher.unregister(this.dispatcherRef); this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); @@ -562,7 +576,6 @@ export default class RoomTile extends React.PureComponent { const roomAvatar = ; diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.tsx b/src/components/views/rooms/ThirdPartyMemberInfo.tsx index 78fdc8b404..792e6ac8f7 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.tsx +++ b/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -26,6 +26,7 @@ import {isValid3pidInvite} from "../../../RoomInvite"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { event: MatrixEvent; @@ -135,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index bab95291ba..2102071bf3 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -27,6 +27,10 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import RecordingPlayback from "../voice_messages/RecordingPlayback"; +import {MsgType} from "matrix-js-sdk/src/@types/event"; +import Modal from "../../../Modal"; +import ErrorDialog from "../dialogs/ErrorDialog"; +import CallMediaHandler from "../../../CallMediaHandler"; interface IProps { room: Room; @@ -64,8 +68,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), }, + "org.matrix.msc2516.voice": {}, // No content, this is a rendering hint }); await this.disposeRecording(); } @@ -115,16 +116,59 @@ export default class VoiceRecordComposerTile extends React.PureComponent { - if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here - this.setState({recordingPhase: ev}); - }); + // The "microphone access error" dialogs are used a lot, so let's functionify them + const accessError = () => { + Modal.createTrackedDialog('Microphone Access Error', '', ErrorDialog, { + title: _t("Unable to access your microphone"), + description: <> +

    {_t( + "We were unable to access your microphone. Please check your browser settings and try again.", + )}

    + , + }); + }; - this.setState({recorder, recordingPhase: RecordingState.Started}); + // Do a sanity test to ensure we're about to grab a valid microphone reference. Things might + // change between this and recording, but at least we will have tried. + try { + const devices = await CallMediaHandler.getDevices(); + if (!devices?.['audioinput']?.length) { + Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, { + title: _t("No microphone found"), + description: <> +

    {_t( + "We didn't find a microphone on your device. Please check your settings and try again.", + )}

    + , + }); + return; + } + // else we probably have a device that is good enough + } catch (e) { + console.error("Error getting devices: ", e); + accessError(); + return; + } + + try { + const recorder = VoiceRecordingStore.instance.startRecording(); + await recorder.start(); + + // We don't need to remove the listener: the recorder will clean that up for us. + recorder.on(UPDATE_EVENT, (ev: RecordingState) => { + if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here + this.setState({recordingPhase: ev}); + }); + + this.setState({recorder, recordingPhase: RecordingState.Started}); + } catch (e) { + console.error("Error starting recording: ", e); + accessError(); + + // noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack + VoiceRecordingStore.instance.disposeRecording(); + } }; private renderWaveformArea(): ReactNode { diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index fa84063ee8..e693f45c5f 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -232,7 +232,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {

    {this.state.enabling ? - : _t("Message search initilisation failed") + : _t("Message search initialisation failed") }

    {EventIndexPeg.error && ( diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index a009ead064..3fa0be478c 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -18,6 +18,7 @@ import React from 'react'; import {_t, getCurrentLanguage} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; +import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton'; import SdkConfig from "../../../../../SdkConfig"; import createRoom from "../../../../../createRoom"; import Modal from "../../../../../Modal"; @@ -26,6 +27,9 @@ import PlatformPeg from "../../../../../PlatformPeg"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import UpdateCheckButton from "../../UpdateCheckButton"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import { copyPlaintext } from "../../../../../utils/strings"; +import * as ContextMenu from "../../../../structures/ContextMenu"; +import { toRightOf } from "../../../../structures/ContextMenu"; interface IProps { closeSettingsFn: () => {}; @@ -38,6 +42,8 @@ interface IState { @replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab") export default class HelpUserSettingsTab extends React.Component { + protected closeCopiedTooltip: () => void; + constructor(props) { super(props); @@ -56,6 +62,12 @@ export default class HelpUserSettingsTab extends React.Component }); } + componentWillUnmount() { + // if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close + // the tooltip otherwise, such as pressing Escape + if (this.closeCopiedTooltip) this.closeCopiedTooltip(); + } + private onClearCacheAndReload = (e) => { if (!PlatformPeg.get()) return; @@ -153,6 +165,20 @@ export default class HelpUserSettingsTab extends React.Component ); } + onAccessTokenCopyClick = async (e) => { + e.preventDefault(); + const target = e.target; // copy target before we go async and React throws it away + + const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken()); + const buttonRect = target.getBoundingClientRect(); + const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); + const {close} = ContextMenu.createMenu(GenericTextContextMenu, { + ...toRightOf(buttonRect, 2), + message: successful ? _t('Copied!') : _t('Failed to copy'), + }); + this.closeCopiedTooltip = target.onmouseleave = close; + } + render() { const brand = SdkConfig.get().brand; @@ -269,12 +295,20 @@ export default class HelpUserSettingsTab extends React.Component
    {_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
    {_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
    - {_t("Access Token:") + ' '} - - <{ _t("click to reveal") }> - +
    +
    + {_t("Access Token")}
    + {_t("Your access token gives full access to your account." + + " Do not share it with anyone." )} +
    + {MatrixClientPeg.get().getAccessToken()} + +
    +

    {_t("Clear cache and reload")} diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js index f515f1862b..98148b19e0 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js @@ -22,6 +22,8 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import * as sdk from "../../../../../index"; import {SettingLevel} from "../../../../../settings/SettingLevel"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import SdkConfig from "../../../../../SdkConfig"; +import BetaCard from "../../../beta/BetaCard"; export class LabsSettingToggle extends React.Component { static propTypes = { @@ -48,14 +50,40 @@ export default class LabsUserSettingsTab extends React.Component { } render() { - const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); - const flags = SettingsStore.getFeatureSettingNames().map(f => ); + const features = SettingsStore.getFeatureSettingNames(); + const [labs, betas] = features.reduce((arr, f) => { + arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f); + return arr; + }, [[], []]); + + let betaSection; + if (betas.length) { + betaSection =
    + { betas.map(f => ) } +
    ; + } + + let labsSection; + if (SdkConfig.get()['showLabsSettings']) { + const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); + const flags = labs.map(f => ); + + labsSection =
    + {flags} + + + + +
    ; + } + return ( -
    +
    {_t("Labs")}
    { - _t('Customise your experience with experimental labs features. ' + + _t('Feeling experimental? Labs are the best way to get things early, ' + + 'test out new features and help shape them before they actually launch. ' + 'Learn more.', {}, { 'a': (sub) => { return -
    - {flags} - - - - -
    + { betaSection } + { labsSection }
    ); } diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index bc378ab956..ec40f7bed8 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -32,17 +32,11 @@ interface IProps { setTopic(topic: string): void; } -const SpaceBasicSettings = ({ +export const SpaceAvatar = ({ avatarUrl, avatarDisabled = false, setAvatar, - name = "", - nameDisabled = false, - setName, - topic = "", - topicDisabled = false, - setTopic, -}: IProps) => { +}: Pick) => { const avatarUploadRef = useRef(); const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache @@ -81,20 +75,34 @@ const SpaceBasicSettings = ({ } } + return
    + { avatarSection } + { + if (!e.target.files?.length) return; + const file = e.target.files[0]; + setAvatar(file); + const reader = new FileReader(); + reader.onload = (ev) => { + setAvatarDataUrl(ev.target.result as string); + }; + reader.readAsDataURL(file); + }} accept="image/*" /> +
    ; +}; + +const SpaceBasicSettings = ({ + avatarUrl, + avatarDisabled = false, + setAvatar, + name = "", + nameDisabled = false, + setName, + topic = "", + topicDisabled = false, + setTopic, +}: IProps) => { return
    -
    - { avatarSection } - { - if (!e.target.files?.length) return; - const file = e.target.files[0]; - setAvatar(file); - const reader = new FileReader(); - reader.onload = (ev) => { - setAvatarDataUrl(ev.target.result as string); - }; - reader.readAsDataURL(file); - }} accept="image/*" /> -
    + { return ( @@ -41,17 +48,39 @@ enum Visibility { Private, } +const spaceNameValidator = withValidation({ + rules: [ + { + key: "required", + test: async ({ value }) => !!value, + invalid: () => _t("Please enter a name for the space"), + }, + ], +}); + const SpaceCreateMenu = ({ onFinished }) => { const cli = useContext(MatrixClientContext); const [visibility, setVisibility] = useState(null); - const [name, setName] = useState(""); - const [avatar, setAvatar] = useState(null); - const [topic, setTopic] = useState(""); const [busy, setBusy] = useState(false); - const onSpaceCreateClick = async () => { + const [name, setName] = useState(""); + const spaceNameField = useRef(); + const [avatar, setAvatar] = useState(null); + const [topic, setTopic] = useState(""); + + const onSpaceCreateClick = async (e) => { + e.preventDefault(); if (busy) return; + setBusy(true); + // require & validate the space name field + if (!await spaceNameField.current.validate({ allowEmpty: false })) { + spaceNameField.current.focus(); + spaceNameField.current.validate({ allowEmpty: false, focused: true }); + setBusy(false); + return; + } + const initialState: IStateEvent[] = [ { type: EventType.RoomHistoryVisibility, @@ -107,7 +136,7 @@ const SpaceCreateMenu = ({ onFinished }) => { if (visibility === null) { body =

    { _t("Create a space") }

    -

    { _t("Spaces are new ways to group rooms and people. " + +

    { _t("Spaces are a new way to group rooms and people. " + "To join an existing space you'll need an invite.") }

    { />

    { _t("You can change this later") }

    + +
    ; } else { body = @@ -146,9 +177,32 @@ const SpaceCreateMenu = ({ onFinished }) => { }

    - +
    + - + setName(ev.target.value)} + ref={spaceNameField} + onValidate={spaceNameValidator} + disabled={busy} + /> + + setTopic(ev.target.value)} + rows={3} + disabled={busy} + /> + + + { busy ? _t("Creating...") : _t("Create") }
    ; @@ -164,6 +218,13 @@ const SpaceCreateMenu = ({ onFinished }) => { managed={false} > + { + onFinished(); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); + }} /> { body } ; diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 36ab423885..411b0f9b5e 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -26,13 +26,11 @@ import {SpaceItem} from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import SpaceStore, { - HOME_SPACE, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; import { RovingAccessibleButton, @@ -40,13 +38,15 @@ import { RovingTabIndexProvider, } from "../../../accessibility/RovingTabIndex"; import {Key} from "../../../Keyboard"; +import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore"; +import {NotificationState} from "../../../stores/notifications/NotificationState"; interface IButtonProps { space?: Room; className?: string; selected?: boolean; tooltip?: string; - notificationState?: SpaceNotificationState; + notificationState?: NotificationState; isNarrow?: boolean; onClick(): void; } @@ -212,8 +212,8 @@ const SpacePanel = () => { className="mx_SpaceButton_home" onClick={() => SpaceStore.instance.setActiveSpace(null)} selected={!activeSpace} - tooltip={_t("Home")} - notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} + tooltip={_t("All rooms")} + notificationState={RoomNotificationStateStore.instance.globalState} isNarrow={isPanelCollapsed} /> { invites.map(s => {

    { _t("Share invite link") }

    { copiedText } - { showRoomInviteDialog(space.roomId); @@ -59,7 +60,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { >

    { _t("Invite people") }

    { _t("Invite with email or username") } -
    + : null }
    ; }; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 6825d84013..f34baf256b 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room"; import RoomAvatar from "../avatars/RoomAvatar"; import SpaceStore from "../../../stores/SpaceStore"; +import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import NotificationBadge from "../rooms/NotificationBadge"; import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton"; import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton"; @@ -68,8 +69,14 @@ export class SpaceItem extends React.PureComponent { constructor(props) { super(props); + const collapsed = SpaceTreeLevelLayoutStore.instance.getSpaceCollapsedState( + props.space.roomId, + this.props.parents, + !props.isNested, // default to collapsed for root items + ); + this.state = { - collapsed: !props.isNested, // default to collapsed for root items + collapsed: collapsed, contextMenuPosition: null, }; } @@ -78,7 +85,14 @@ export class SpaceItem extends React.PureComponent { if (this.props.onExpand && this.state.collapsed) { this.props.onExpand(); } - this.setState({collapsed: !this.state.collapsed}); + const newCollapsedState = !this.state.collapsed; + + SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState( + this.props.space.roomId, + this.props.parents, + newCollapsedState, + ); + this.setState({collapsed: newCollapsedState}); // don't bubble up so encapsulating button for space // doesn't get triggered evt.stopPropagation(); @@ -195,7 +209,7 @@ export class SpaceItem extends React.PureComponent { const userId = this.context.getUserId(); let inviteOption; - if (this.props.space.canInvite(userId)) { + if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { inviteOption = ( { const isActive = activeSpaces.includes(space); const itemClasses = classNames({ "mx_SpaceItem": true, + "mx_SpaceItem_narrow": isNarrow, "collapsed": collapsed, "hasSubSpaces": childSpaces && childSpaces.length, }); + + const isInvite = space.getMyMembership() === "invite"; const classes = classNames("mx_SpaceButton", { mx_SpaceButton_active: isActive, mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition, mx_SpaceButton_narrow: isNarrow, + mx_SpaceButton_invite: isInvite, }); - const notificationState = space.getMyMembership() === "invite" + const notificationState = isInvite ? StaticNotificationState.forSymbol("!", NotificationColor.Red) : SpaceStore.instance.getNotificationState(space.roomId); diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx index e7c34c9177..aab89f6ab1 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx @@ -15,12 +15,11 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; +import {IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording} from "../../../voice/VoiceRecording"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {percentageOf} from "../../../utils/numbers"; import Waveform from "./Waveform"; -import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; interface IProps { recorder: VoiceRecording; @@ -38,14 +37,14 @@ export default class LiveRecordingWaveform extends React.PureComponent { // The waveform and the downsample target are pretty close, so we should be fine to // do this, despite the docs on arrayFastResample. - const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES); + const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); this.setState({ // The incoming data is between zero and one, but typically even screaming into a // microphone won't send you over 0.6, so we artificially adjust the gain for the diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx index 123af5dfa5..2e9f163f5e 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -57,8 +57,8 @@ export default class PlaybackWaveform extends React.PureComponent { - // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar. - const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); this.setState({progress}); }; diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx new file mode 100644 index 0000000000..c78f0c0fc8 --- /dev/null +++ b/src/components/views/voip/AudioFeed.tsx @@ -0,0 +1,97 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {createRef} from 'react'; +import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; +import { logger } from 'matrix-js-sdk/src/logger'; +import CallMediaHandler from "../../../CallMediaHandler"; + +interface IProps { + feed: CallFeed, +} + +export default class AudioFeed extends React.Component { + private element = createRef(); + + componentDidMount() { + this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.playMedia(); + } + + componentWillUnmount() { + this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.stopMedia(); + } + + private playMedia() { + const element = this.element.current; + const audioOutput = CallMediaHandler.getAudioOutput(); + + if (audioOutput) { + try { + // This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where + // it fails. + // It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID + // back to the default after the call is over - Dave + element.setSinkId(audioOutput); + } catch (e) { + console.error("Couldn't set requested audio output device: using default", e); + logger.warn("Couldn't set requested audio output device: using default", e); + } + } + + element.muted = false; + element.srcObject = this.props.feed.stream; + element.autoplay = true; + + try { + // A note on calling methods on media elements: + // We used to have queues per media element to serialise all calls on those elements. + // The reason given for this was that load() and play() were racing. However, we now + // never call load() explicitly so this seems unnecessary. However, serialising every + // operation was causing bugs where video would not resume because some play command + // had got stuck and all media operations were queued up behind it. If necessary, we + // should serialise the ones that need to be serialised but then be able to interrupt + // them with another load() which will cancel the pending one, but since we don't call + // load() explicitly, it shouldn't be a problem. - Dave + element.play() + } catch (e) { + logger.info("Failed to play media element with feed", this.props.feed, e); + } + } + + private stopMedia() { + const element = this.element.current; + + element.pause(); + element.src = null; + + // As per comment in componentDidMount, setting the sink ID back to the + // default once the call is over makes setSinkId work reliably. - Dave + // Since we are not using the same element anymore, the above doesn't + // seem to be necessary - Šimon + } + + private onNewStream = () => { + this.playMedia(); + }; + + render() { + return ( +
    ; + const avatarSize = this.props.pipMode ? 76 : 160; + // The 'content' for the call, ie. the videos for a video call and profile picture // for voice calls (fills the bg) let contentView: React.ReactNode; @@ -524,41 +534,85 @@ export default class CallView extends React.Component {
    ; } - if (this.props.call.type === CallType.Video) { - let localVideoFeed = null; - let onHoldBackground = null; - const backgroundStyle: CSSProperties = {}; - const containerClasses = classNames({ - mx_CallView_video: true, - mx_CallView_video_hold: isOnHold, - }); - if (isOnHold) { + // This is a bit messy. I can't see a reason to have two onHold/transfer screens + if (isOnHold || transfereeCall) { + if (this.props.call.type === CallType.Video) { + const containerClasses = classNames({ + mx_CallView_content: true, + mx_CallView_video: true, + mx_CallView_video_hold: isOnHold, + }); + let onHoldBackground = null; + const backgroundStyle: CSSProperties = {}; const backgroundAvatarUrl = avatarUrlForMember( - // is it worth getting the size of the div to pass here? + // is it worth getting the size of the div to pass here? this.props.call.getOpponentMember(), 1024, 1024, 'crop', ); backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')'; onHoldBackground =
    ; - } - if (!this.state.vidMuted) { - localVideoFeed = ; - } - contentView =
    - {onHoldBackground} - - {localVideoFeed} - {holdTransferContent} - {callControls} -
    ; - } else { - const avatarSize = this.props.pipMode ? 76 : 160; + contentView = ( +
    + {onHoldBackground} + {holdTransferContent} + {callControls} +
    + ); + } else { + const classes = classNames({ + mx_CallView_content: true, + mx_CallView_voice: true, + mx_CallView_voice_hold: isOnHold, + }); + + contentView =( +
    +
    +
    + +
    +
    + {holdTransferContent} + {callControls} +
    + ); + } + } else if (this.props.call.noIncomingFeeds()) { + // Here we're reusing the css classes from voice on hold, because + // I am lazy. If this gets merged, the CallView might be subject + // to change anyway - I might take an axe to this file in order to + // try to get other things working const classes = classNames({ + mx_CallView_content: true, mx_CallView_voice: true, - mx_CallView_voice_hold: isOnHold, }); + const feeds = this.props.call.getLocalFeeds().map((feed, i) => { + // Here we check to hide local audio feeds to achieve the same UI/UX + // as before. But once again this might be subject to change + if (feed.isVideoMuted()) return; + return ( + + ); + }); + + // Saying "Connecting" here isn't really true, but the best thing + // I can come up with, but this might be subject to change as well contentView =
    + {feeds}
    { />
    - {holdTransferContent} +
    {_t("Connecting")}
    + {callControls} +
    ; + } else { + const containerClasses = classNames({ + mx_CallView_content: true, + mx_CallView_video: true, + }); + + // TODO: Later the CallView should probably be reworked to support + // any number of feeds but now we can always expect there to be two + // feeds. This is because the js-sdk ignores any new incoming streams + const feeds = this.state.feeds.map((feed, i) => { + // Here we check to hide local audio feeds to achieve the same UI/UX + // as before. But once again this might be subject to change + if (feed.isVideoMuted() && feed.isLocal()) return; + return ( + + ); + }); + + contentView =
    + {feeds} {callControls}
    ; } diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 7540dbc8d9..0c785f758d 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -16,13 +16,12 @@ limitations under the License. import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import React from 'react'; -import CallHandler from '../../../CallHandler'; +import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; import CallView from './CallView'; import dis from '../../../dispatcher/dispatcher'; import {Resizable} from "re-resizable"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import {replaceableComponent} from "../../../utils/replaceableComponent"; -import { Action } from '../../../dispatcher/actions'; interface IProps { // What room we should display the call for @@ -55,25 +54,30 @@ export default class CallViewForRoom extends React.Component { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCall); } public componentWillUnmount() { dis.unregister(this.dispatcherRef); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall); } private onAction = (payload) => { switch (payload.action) { - case Action.CallChangeRoom: case 'call_state': { - const newCall = this.getCall(); - if (newCall !== this.state.call) { - this.setState({call: newCall}); - } + this.updateCall(); break; } } }; + private updateCall = () => { + const newCall = this.getCall(); + if (newCall !== this.state.call) { + this.setState({call: newCall}); + } + }; + private getCall(): MatrixCall { const call = CallHandler.sharedInstance().getCallForRoom(this.props.roomId); diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 2981fb6c04..d22fa055ce 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -18,52 +18,102 @@ import classnames from 'classnames'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import React, {createRef} from 'react'; import SettingsStore from "../../../settings/SettingsStore"; +import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; +import { logger } from 'matrix-js-sdk/src/logger'; +import MemberAvatar from "../avatars/MemberAvatar" import {replaceableComponent} from "../../../utils/replaceableComponent"; -export enum VideoFeedType { - Local, - Remote, -} - interface IProps { call: MatrixCall, - type: VideoFeedType, + feed: CallFeed, + + // Whether this call view is for picture-in-picture mode + // otherwise, it's the larger call view when viewing the room the call is in. + // This is sort of a proxy for a number of things but we currently have no + // need to control those things separately, so this is simpler. + pipMode?: boolean; // a callback which is called when the video element is resized // due to a change in video metadata onResize?: (e: Event) => void, } -@replaceableComponent("views.voip.VideoFeed") -export default class VideoFeed extends React.Component { - private vid = createRef(); +interface IState { + audioMuted: boolean; + videoMuted: boolean; +} - componentDidMount() { - this.vid.current.addEventListener('resize', this.onResize); - this.setVideoElement(); +@replaceableComponent("views.voip.VideoFeed") +export default class VideoFeed extends React.Component { + private element = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }; } - componentDidUpdate(prevProps) { - if (this.props.call !== prevProps.call) { - this.setVideoElement(); - } + componentDidMount() { + this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.playMedia(); } componentWillUnmount() { - this.vid.current.removeEventListener('resize', this.onResize); + this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.element.current?.removeEventListener('resize', this.onResize); + this.stopMedia(); } - private setVideoElement() { - if (this.props.type === VideoFeedType.Local) { - this.props.call.setLocalVideoElement(this.vid.current); - } else { - this.props.call.setRemoteVideoElement(this.vid.current); + private playMedia() { + const element = this.element.current; + if (!element) return; + // We play audio in AudioFeed, not here + element.muted = true; + element.srcObject = this.props.feed.stream; + element.autoplay = true; + try { + // A note on calling methods on media elements: + // We used to have queues per media element to serialise all calls on those elements. + // The reason given for this was that load() and play() were racing. However, we now + // never call load() explicitly so this seems unnecessary. However, serialising every + // operation was causing bugs where video would not resume because some play command + // had got stuck and all media operations were queued up behind it. If necessary, we + // should serialise the ones that need to be serialised but then be able to interrupt + // them with another load() which will cancel the pending one, but since we don't call + // load() explicitly, it shouldn't be a problem. - Dave + element.play() + } catch (e) { + logger.info("Failed to play media element with feed", this.props.feed, e); } } - onResize = (e) => { - if (this.props.onResize) { + private stopMedia() { + const element = this.element.current; + if (!element) return; + + element.pause(); + element.src = null; + + // As per comment in componentDidMount, setting the sink ID back to the + // default once the call is over makes setSinkId work reliably. - Dave + // Since we are not using the same element anymore, the above doesn't + // seem to be necessary - Šimon + } + + private onNewStream = () => { + this.setState({ + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }); + this.playMedia(); + }; + + private onResize = (e) => { + if (this.props.onResize && !this.props.feed.isLocal()) { this.props.onResize(e); } }; @@ -71,14 +121,33 @@ export default class VideoFeed extends React.Component { render() { const videoClasses = { mx_VideoFeed: true, - mx_VideoFeed_local: this.props.type === VideoFeedType.Local, - mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote, + mx_VideoFeed_local: this.props.feed.isLocal(), + mx_VideoFeed_remote: !this.props.feed.isLocal(), + mx_VideoFeed_voice: this.state.videoMuted, + mx_VideoFeed_video: !this.state.videoMuted, mx_VideoFeed_mirror: ( - this.props.type === VideoFeedType.Local && + this.props.feed.isLocal() && SettingsStore.getValue('VideoView.flipVideoHorizontally') ), }; - return
    Learn more about encryption.": "Po zapnutí už nelze šifrování v této místnosti vypnout. Zprávy v šifrovaných místnostech mohou číst jenom členové místnosti, server se k obsahu nedostane. Šifrování místností nepodporuje většina botů a propojení. Více informací o šifrování.", + "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Po zapnutí již nelze šifrování v této místnosti vypnout. Zprávy v šifrovaných místnostech mohou číst jen členové místnosti, server se k obsahu nedostane. Šifrování místností nepodporuje většina botů a propojení. Více informací o šifrování.", "Error updating main address": "Nepovedlo se změnit hlavní adresu", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "Nastala chyba při pokusu o nastavení hlavní adresy místnosti. Mohl to zakázat server, nebo to může být dočasná chyba.", "Power level": "Úroveň oprávnění", @@ -1219,7 +1219,7 @@ "Your Matrix account on %(serverName)s": "Váš účet Matrix na serveru %(serverName)s", "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Zda používáte funkci „breadcrumb“ (ikony nad seznamem místností)", "Replying With Files": "Odpovídání souborem", - "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Aktuálně nelze odpovědět souborem. Chcete soubor nahrát a poslat bez odpovídání?", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "V tuto chvíli není možné odpovědět souborem. Chcete tento soubor nahrát bez odpovědi?", "The file '%(fileName)s' failed to upload.": "Soubor '%(fileName)s' se nepodařilo nahrát.", "The server does not support the room version specified.": "Server nepodporuje určenou verzi místnosti.", "Name or Matrix ID": "Jméno nebo Matrix ID", @@ -1354,12 +1354,12 @@ "Unexpected error resolving identity server configuration": "Chyba při hledání konfigurace serveru identity", "Use lowercase letters, numbers, dashes and underscores only": "Používejte pouze malá písmena, čísla, pomlčky a podtržítka", "Cannot reach identity server": "Nelze se připojit k serveru identity", - "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se zaregistrovat, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje pořád, tak zkontrolujte svojí konfiguraci a nebo kontaktujte správce serveru.", - "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete si změnit heslo, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje pořád, tak zkontrolujte svojí konfiguraci a nebo kontaktujte správce serveru.", - "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se přihlásit, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje pořád, tak zkontrolujte svojí konfiguraci a nebo kontaktujte správce serveru.", + "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se zaregistrovat, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", + "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete si změnit heslo, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", + "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se přihlásit, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", "Call failed due to misconfigured server": "Volání selhalo, protože je rozbitá konfigurace serveru", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Zeptejte se správce (%(homeserverDomain)s) jestli by nemohl nakonfigurovat server TURN, aby začalo fungoval volání.", - "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Případně můžete zkusit použít veřejný server turn.matrix.org, což nemusí fungovat tak spolehlivě a řekne to tomu cizímu serveru vaší IP adresu. Můžete to udělat v Nastavení.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Požádejte správce svého homeserveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Můžete také zkusit použít veřejný server na adrese turn.matrix.org, ale ten nebude tak spolehlivý a bude sdílet vaši IP adresu s tímto serverem. To můžete spravovat také v Nastavení.", "Try using turn.matrix.org": "Zkuste použít turn.matrix.org", "Messages": "Zprávy", "Actions": "Akce", @@ -1368,7 +1368,7 @@ "Changes the avatar of the current room": "Změní avatar této místnosti", "Changes your avatar in all rooms": "Změní váš avatar pro všechny místnosti", "Use an identity server": "Používat server identit", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Použít server identit k odeslání e-mailové pozvánky. Pokračováním použijete výchozí server identit (%(defaultIdentityServerName)s) nebo ho můžete změnit v Nastavení.", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "K pozvání e-mailem použijte server identit. Pokračováním použijete výchozí server identit (%(defaultIdentityServerName)s) nebo ho můžete změnit v Nastavení.", "Use an identity server to invite by email. Manage in Settings.": "Použít server identit na odeslání e-mailové pozvánky. Můžete spravovat v Nastavení.", "Displays list of commands with usages and descriptions": "Zobrazuje seznam příkazu s popiskem", "%(senderName)s made no change.": "%(senderName)s neudělal žádnou změnu.", @@ -1708,7 +1708,7 @@ "Backup has a invalid signature from this user": "Záloha má neplatný podpis od tohoto uživatele", "Backup has a signature from unknown user with ID %(deviceId)s": "Záloha je podepsaná neznámým uživatelem %(deviceId)s", "Close preview": "Zavřít náhled", - "Hide verified sessions": "Schovat ověřené relace", + "Hide verified sessions": "Skrýt ověřené relace", "%(count)s verified sessions|other": "%(count)s ověřených relací", "%(count)s verified sessions|one": "1 ověřená relace", "Language Dropdown": "Menu jazyků", @@ -1721,8 +1721,8 @@ "Unknown (user, session) pair:": "Neznámý pár (uživatel, relace):", "Session already verified!": "Relace je už ověřená!", "WARNING: Session already verified, but keys do NOT MATCH!": "VAROVÁNÍ: Relace je už ověřená, ale klíče NEODPOVÍDAJÍ!", - "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "VAROVÁNÍ: OVĚŘENÍ KLÍČŮ SELHALO! Podpisový klíč pro uživatele %(userId)s a relaci %(deviceId)s je „%(fprint)s“, což neodpovídá klíči „%(fingerprint)s“. Může to znamenat, že je vaše komunikace rušena!", - "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "Zadaný podpisový klíč odpovídá klíči relace %(deviceId)s od uživatele %(userId)s. Relace byla označena za platnou.", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "VAROVÁNÍ: OVĚŘENÍ KLÍČE SE NEZDAŘILO! Podpisový klíč pro uživatele %(userId)s a relaci %(deviceId)s je „%(fprint)s“, což neodpovídá klíči „%(fingerprint)s“. To by mohlo znamenat, že vaše komunikace je zachycována!", + "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "Zadaný podpisový klíč odpovídá klíči relace %(deviceId)s od uživatele %(userId)s. Relace byla označena jako ověřená.", "a few seconds ago": "před pár vteřinami", "about a minute ago": "před minutou", "%(num)s minutes ago": "před %(num)s minutami", @@ -1917,8 +1917,8 @@ "Compare unique emoji": "Porovnejte jedinečnou kombinaci emoji", "Compare a unique set of emoji if you don't have a camera on either device": "Pokud na žádném zařízení nemáte kameru, porovnejte jedinečnou kombinaci emoji", "Not Trusted": "Nedůvěryhodné", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil do nové relace a neověřil ji:", - "Ask this user to verify their session, or manually verify it below.": "Poproste tohoto uživatele aby svojí relaci ověřil a nebo jí níže můžete ověřit manuálně.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil do nové relace bez ověření:", + "Ask this user to verify their session, or manually verify it below.": "Požádejte tohoto uživatele, aby ověřil svou relaci, nebo jí níže můžete ověřit manuálně.", "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "Relace, kterou se snažíte ověřit, neumožňuje ověření QR kódem ani pomocí emoji, což je to, co %(brand)s podporuje. Zkuste použít jiného klienta.", "Verify by scanning": "Ověřte naskenováním", "You declined": "Odmítli jste", @@ -1936,7 +1936,7 @@ "If disabled, messages from encrypted rooms won't appear in search results.": "Když je to zakázané, zprávy v šifrovaných místnostech se nebudou objevovat ve výsledcích vyhledávání.", "Disable": "Zakázat", "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s si bezpečně uchovává šifrované zprávy lokálně, aby v nich mohl vyhledávat:", - "Space used:": "Použitý prostor:", + "Space used:": "Použité místo:", "Indexed messages:": "Indexované zprávy:", "Indexed rooms:": "Indexované místnosti:", "Message downloading sleep time(ms)": "Čas na stažení zprávy (ms)", @@ -2032,8 +2032,8 @@ "Could not find user in room": "Nepovedlo se najít uživatele v místnosti", "Please supply a widget URL or embed code": "Zadejte prosím URL widgetu nebo jeho kód", "Send a bug report with logs": "Zaslat hlášení o chybě", - "You signed in to a new session without verifying it:": "Přihlásili jste se do nové relace, ale neoveřili jste ji:", - "Verify your other session using one of the options below.": "Ověřte ostatní relací jedním z následujících způsobů.", + "You signed in to a new session without verifying it:": "Přihlásili jste se do nové relace, aniž byste ji ověřili:", + "Verify your other session using one of the options below.": "Ověřte další relaci jedním z následujících způsobů.", "Click the button below to confirm deleting these sessions.|other": "Zmáčknutím tlačítka potvrdíte smazání těchto relací.", "Click the button below to confirm deleting these sessions.|one": "Zmáčknutím tlačítka potvrdíte smazání této relace.", "Delete sessions|other": "Smazat relace", @@ -2093,7 +2093,7 @@ "Please verify the room ID or address and try again.": "Ověřte prosím, že ID místnosti je správné a zkuste to znovu.", "Room ID or address of ban list": "ID nebo adresa seznamu zablokovaných", "Help us improve %(brand)s": "Pomozte nám zlepšovat %(brand)s", - "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Zasílat anonymní data o použití aplikace, která nám pomáhají %(brand)s zlepšovat. Bedeme na to používat soubory cookie.", + "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Zasílat anonymní údaje o použití aplikace, která nám pomáhají %(brand)s zlepšovat. K tomu se použije soubor cookie.", "I want to help": "Chci pomoci", "Your homeserver has exceeded its user limit.": "Na vašem domovském serveru byl překročen limit počtu uživatelů.", "Your homeserver has exceeded one of its resource limits.": "Na vašem domovském serveru byl překročen limit systémových požadavků.", @@ -2166,8 +2166,8 @@ "Signature upload failed": "Podpis se nepodařilo nahrát", "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Je-li jiná verze programu %(brand)s stále otevřená na jiné kartě, tak ji prosím zavřete, neboť užívání programu %(brand)s stejným hostitelem se zpožděným nahráváním současně povoleným i zakázaným bude působit problémy.", "Unexpected server error trying to leave the room": "Neočekávaná chyba serveru při odcházení z místnosti", - "The person who invited you already left the room.": "Uživatel který vás pozval už místnosti není.", - "The person who invited you already left the room, or their server is offline.": "Uživatel který vás pozvat už odešel z místnosti a nebo je jeho server offline.", + "The person who invited you already left the room.": "Uživatel, který vás pozval, již opustil místnost.", + "The person who invited you already left the room, or their server is offline.": "Uživatel, který vás pozval, již opustil místnost nebo je jeho server offline.", "You left the call": "Odešli jste z hovoru", "%(senderName)s left the call": "%(senderName)s opustil/a hovor", "Call ended": "Hovor skončil", @@ -2954,13 +2954,13 @@ "Mobile experience": "Zážitek na mobilních zařízeních", "Element Web is currently experimental on mobile. The native apps are recommended for most people.": "Element Web je v současné době experimentální na mobilních zařízeních. Nativní aplikace se doporučují pro většinu lidí.", "Use app for a better experience": "Pro lepší zážitek použijte aplikaci", - "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web je experimentální na mobilních zařízeních. Pro lepší zážitek a nejnovější funkce použijte naši bezplatnou nativní aplikaci.", + "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web je v mobilní verzi experimentální. Chcete-li získat lepší zážitek a nejnovější funkce, použijte naši bezplatnou nativní aplikaci.", "Use app": "Použijte aplikaci", "Something went wrong in confirming your identity. Cancel and try again.": "Při ověřování vaší identity se něco pokazilo. Zrušte to a zkuste to znovu.", - "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server odmítl váš pokus o přihlášení. Může to být způsobeno tím, že věci trvají příliš dlouho. Prosím zkuste to znovu. Pokud to bude pokračovat, obraťte se na správce domovského serveru.", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server odmítl váš pokus o přihlášení. To může to být způsobeno tím, že vše trvá příliš dlouho. Zkuste to prosím znovu. Pokud tento problém přetrvává, obraťte se na správce domovského serveru.", "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server nebyl dosažitelný a nemohl vás přihlásit. Zkuste to prosím znovu. Pokud to bude pokračovat, obraťte se na správce domovského serveru.", "Try again": "Zkuste to znovu", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Požádali jsme prohlížeč, aby si pamatoval, který domovský server používáte k přihlášení, ale váš prohlížeč to bohužel zapomněl. Přejděte na přihlašovací stránku a zkuste to znovu.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Požádali jsme prohlížeč, aby si zapamatoval, který domovský server používáte k přihlášení, ale váš prohlížeč to bohužel zapomněl. Přejděte na přihlašovací stránku a zkuste to znovu.", "We couldn't log you in": "Nemohli jsme vás přihlásit", "Show stickers button": "Tlačítko Zobrazit nálepky", "Windows": "Okna", @@ -3012,8 +3012,8 @@ "Inviting...": "Pozvání...", "Invite by username": "Pozvat podle uživatelského jména", "Invite your teammates": "Pozvěte své spolupracovníky", - "Failed to invite the following users to your space: %(csvUsers)s": "Nepodařilo se pozvat následující uživatele do vašeho space: %(csvUsers)s", - "A private space for you and your teammates": "Soukromý space pro Vás a vaše spolupracovníky", + "Failed to invite the following users to your space: %(csvUsers)s": "Nepodařilo se pozvat následující uživatele do vašeho prostoru: %(csvUsers)s", + "A private space for you and your teammates": "Soukromý prostor pro Vás a vaše spolupracovníky", "Me and my teammates": "Já a moji spolupracovníci", "A private space just for you": "Soukromý space právě pro vás", "Just Me": "Pouze já", @@ -3022,7 +3022,7 @@ "At the moment only you can see it.": "V tuto chvíli to vidíte jen Vy.", "Creating rooms...": "Vytváření místností...", "Skip for now": "Prozatím přeskočit", - "Failed to create initial space rooms": "Vytvoření počátečních místností ve space se nezdařilo", + "Failed to create initial space rooms": "Vytvoření počátečních místností v prostoru se nezdařilo", "Random": "Náhodný", "Your private space ": "Váš soukromý space ", "Your public space ": "Váš veřejný space ", @@ -3030,9 +3030,9 @@ " invited you to ": " vás pozval do ", "%(count)s members|one": "%(count)s člen", "%(count)s members|other": "%(count)s členů", - "Your server does not support showing space hierarchies.": "Váš server nepodporuje zobrazování hierarchií spaces.", + "Your server does not support showing space hierarchies.": "Váš server nepodporuje zobrazování hierarchií prostorů.", "Default Rooms": "Výchozí místnosti", - "Add existing rooms & spaces": "Přidat stávající místnosti a spaces", + "Add existing rooms & spaces": "Přidat stávající místnosti a prostory", "Accept Invite": "Přijmout pozvání", "Find a room...": "Najít místnost...", "Manage rooms": "Spravovat místnosti", @@ -3044,68 +3044,68 @@ "Remove from Space": "Odebrat ze space", "Undo": "Vrátit", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Vaše zpráva nebyla odeslána, protože tento domovský server byl zablokován jeho správcem. Kontaktujte svého správce služby, abyste mohli službu nadále používat.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Opravdu chcete opustit space '%(spaceName)s'?", - "This space is not public. You will not be able to rejoin without an invite.": "Tento space není veřejný. Bez pozvánky se nebudete moci znovu připojit.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Opravdu chcete opustit prostor '%(spaceName)s'?", + "This space is not public. You will not be able to rejoin without an invite.": "Tento prostor není veřejný. Bez pozvánky se nebudete moci znovu připojit.", "Start audio stream": "Zahájit audio přenos", "Failed to start livestream": "Nepodařilo spustit živý přenos", "Unable to start audio streaming.": "Nelze spustit streamování zvuku.", "View dev tools": "Zobrazit nástroje pro vývojáře", - "Leave Space": "Opustit space", - "Make this space private": "Nastavit tento space jako soukromý", - "Edit settings relating to your space.": "Upravte nastavení týkající se vašeho space.", - "Space settings": "Nastavení space", - "Failed to save space settings.": "Nastavení space se nepodařilo uložit.", - "Invite someone using their name, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, uživatelského jména (například ) nebo sdílejte tento space.", - "Invite someone using their name, email address, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, e-mailové adresy, uživatelského jména (například ) nebo sdílejte tento space.", - "Unnamed Space": "Nejmenovaný space", + "Leave Space": "Opustit prostor", + "Make this space private": "Nastavit tento prostor jako soukromý", + "Edit settings relating to your space.": "Upravte nastavení týkající se vašeho prostoru.", + "Space settings": "Nastavení prostoru", + "Failed to save space settings.": "Nastavení prostoru se nepodařilo uložit.", + "Invite someone using their name, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, uživatelského jména (například ) nebo sdílejte tento prostor.", + "Invite someone using their name, email address, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, e-mailové adresy, uživatelského jména (například ) nebo sdílejte tento prostor.", + "Unnamed Space": "Nejmenovaný prostor", "Invite to %(spaceName)s": "Pozvat do %(spaceName)s", - "Failed to add rooms to space": "Nepodařilo se přidat místnosti do space", + "Failed to add rooms to space": "Nepodařilo se přidat místnosti do prostoru", "Applying...": "Potvrzuji...", "Apply": "Použít", "Create a new room": "Vytvořit novou místnost", "Don't want to add an existing room?": "Nechcete přidat existující místnost?", - "Spaces": "Spaces", - "Filter your rooms and spaces": "Filtrujte své místnosti a spaces", + "Spaces": "Prostory", + "Filter your rooms and spaces": "Filtrujte své místnosti a prostory", "Add existing spaces/rooms": "Přidat existující space/místnost", - "Space selection": "Výběr space", - "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Tuto změnu nebudete moci vrátit zpět, protože budete degradováni, pokud jste posledním privilegovaným uživatelem v daném space, nebude možné znovu získat oprávnění.", + "Space selection": "Výběr prostoru", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Tuto změnu nebudete moci vrátit zpět, protože budete degradováni, pokud jste posledním privilegovaným uživatelem v daném prostoru, nebude možné znovu získat oprávnění.", "Empty room": "Prázdná místnost", "Suggested Rooms": "Doporučené místnosti", "Explore space rooms": "Prozkoumat místnosti space", - "You do not have permissions to add rooms to this space": "Nemáte oprávnění k přidávání místností do tohoto space", + "You do not have permissions to add rooms to this space": "Nemáte oprávnění k přidávání místností do tohoto prostoru", "Add existing room": "Přidat existující místnost", - "You do not have permissions to create new rooms in this space": "Nemáte oprávnění k vytváření nových místností v tomto space", + "You do not have permissions to create new rooms in this space": "Nemáte oprávnění k vytváření nových místností v tomto prostoru", "Send message": "Poslat zprávu", - "Invite to this space": "Pozvat do tohoto space", + "Invite to this space": "Pozvat do tohoto prostoru", "Your message was sent": "Zpráva byla odeslána", "Encrypting your message...": "Šifrování zprávy...", "Sending your message...": "Odesílání zprávy...", "Spell check dictionaries": "Slovníky pro kontrolu pravopisu", - "Space options": "Nastavení space", + "Space options": "Nastavení prostoru", "Space Home": "Domov space", "New room": "Nová místnost", - "Leave space": "Opusit space", + "Leave space": "Opusit prostor", "Invite people": "Pozvat lidi", - "Share your public space": "Sdílejte svůj veřejný space", + "Share your public space": "Sdílejte svůj veřejný prostor", "Invite members": "Pozvat členy", "Invite by email or username": "Pozvěte e-mailem nebo uživatelským jménem", "Share invite link": "Sdílet odkaz na pozvánku", "Click to copy": "Kliknutím zkopírujte", - "Expand space panel": "Rozbalit space panel", - "Collapse space panel": "Sbalit space panel", + "Expand space panel": "Rozbalit panel prostoru", + "Collapse space panel": "Sbalit panel prostoru", "Creating...": "Vytváření...", "You can change these at any point.": "Můžete je kdykoli změnit.", "Give it a photo, name and description to help you identify it.": "Přiřaďte mu obrázek, jméno a popis, abyste jej mohli lépe identifikovat.", - "Your private space": "Váš soukromý space", - "Your public space": "Váš veřejný space", + "Your private space": "Váš soukromý prostor", + "Your public space": "Váš veřejný prostor", "You can change this later": "Toto můžete změnit později", "Invite only, best for yourself or teams": "Pouze pozvat, nejlepší pro sebe nebo pro týmy", "Private": "Soukromý", - "Open space for anyone, best for communities": "Otevřený space pro kohokoli, nejlepší pro komunity", + "Open space for anyone, best for communities": "Otevřený prostor pro kohokoli, nejlepší pro komunity", "Public": "Veřejný", - "Create a space": "Vytvořit space", + "Create a space": "Vytvořit prostor", "Jump to the bottom of the timeline when you send a message": "Při odesílání zprávy přeskočit na konec časové osy", - "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototyp. Nejsou kompatibilní se skupinami, skupinami v2 a vlastními štítky. Pro některé funkce je vyžadován kompatibilní domovský server.", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototyp prostorů. Nejsou kompatibilní se skupinami, skupinami v2 a vlastními štítky. Pro některé funkce je vyžadován kompatibilní domovský server.", "This homeserver has been blocked by its administrator.": "Tento domovský server byl zablokován jeho správcem.", "You're already in a call with this person.": "S touto osobou již telefonujete.", "Already in call": "Již máte hovor", @@ -3123,13 +3123,13 @@ "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Pro každého z nich vytvoříme místnost. Později můžete přidat další, včetně již existujících.", "Let's create a room for each of them. You can add more later too, including already existing ones.": "Vytvořme místnost pro každého z nich. Později můžete přidat i další, včetně již existujících.", "Make sure the right people have access. You can invite more later.": "Zajistěte přístup pro správné lidi. Další můžete pozvat později.", - "A private space to organise your rooms": "Soukromý space pro uspořádání vašich místností", + "A private space to organise your rooms": "Soukromý prostor pro uspořádání vašich místností", "Make sure the right people have access to %(name)s": "Zajistěte, aby do %(name)s měli přístup správní lidé", "Go to my first room": "Jít do mé první místnosti", "It's just you at the moment, it will be even better with others.": "V tuto chvíli to jste jen vy, s ostatními to bude ještě lepší.", "Share %(name)s": "Sdílet %(name)s", - "Private space": "Soukromý space", - "Public space": "Veřejný space", + "Private space": "Soukromý prostor", + "Public space": "Veřejný prostor", " invites you": " vás zve", "Search names and description": "Prohledat jména a popisy", "Create room": "Vytvořit místnost", @@ -3139,10 +3139,10 @@ "Mark as not suggested": "Označit jako nedoporučené", "Removing...": "Odebírání...", "Failed to remove some rooms. Try again later": "Odebrání některých místností se nezdařilo. Zkuste to později znovu", - "%(count)s rooms and 1 space|one": "%(count)s místnost a 1 space", - "%(count)s rooms and 1 space|other": "%(count)s místností a 1 space", - "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s místnost a %(numSpaces)s space", - "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s místností a %(numSpaces)s spaces", + "%(count)s rooms and 1 space|one": "%(count)s místnost a 1 prostor", + "%(count)s rooms and 1 space|other": "%(count)s místností a 1 prostor", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s místnost a %(numSpaces)s prostorů", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s místností a %(numSpaces)s prostorů", "If you can't find the room you're looking for, ask for an invite or create a new room.": "Pokud nemůžete najít místnost, kterou hledáte, požádejte o pozvánku nebo vytvořte novou místnost.", "This room is suggested as a good one to join": "Tato místnost je doporučena jako dobrá pro připojení", "Suggested": "Doporučeno", @@ -3155,8 +3155,8 @@ "Invite People": "Pozvat lidi", "Invite with email or username": "Pozvěte e-mailem nebo uživatelským jménem", "You can change these anytime.": "Tyto údaje můžete kdykoli změnit.", - "Add some details to help people recognise it.": "Přidejte několik podrobností, aby to lidé lépe rozpoznali.", - "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces jsou nový způsob, jak seskupovat místnosti a lidi. Chcete-li se připojit ke stávajícímu space, budete potřebovat pozvánku.", + "Add some details to help people recognise it.": "Přidejte nějaké podrobnosti, aby ho lidé lépe rozpoznali.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Prostory jsou nový způsob, jak seskupovat místnosti a lidi. Chcete-li se připojit ke stávajícímu prostoru, budete potřebovat pozvánku.", "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "K vašemu účtu přistupuje nové přihlášení: %(name)s (%(deviceID)s) pomocí %(ip)s", "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Z %(deviceName)s (%(deviceId)s) pomocí %(ip)s", "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Ověřte toto přihlášení, abyste získali přístup k šifrovaným zprávám a dokázali ostatním, že jste to opravdu vy.", @@ -3201,6 +3201,40 @@ "Please choose a strong password": "Vyberte silné heslo", "What are some things you want to discuss in %(spaceName)s?": "O kterých tématech chcete diskutovat v %(spaceName)s?", "Let's create a room for each of them.": "Vytvořme pro každé z nich místnost.", - "Use another login": "Použijte jiné přihlašovací jméno", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Bez ověření nebudete mít přístup ke všem svým zprávám a ostatním se můžete zobrazit jako nedůvěryhodný." + "Use another login": "Použít jinou relaci", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Bez ověření nebudete mít přístup ke všem svým zprávám a ostatním se můžete zobrazit jako nedůvěryhodný.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jste zde jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Pokud vše resetujete, začnete bez důvěryhodných relací, bez důvěryhodných uživatelů a možná nebudete moci zobrazit minulé zprávy.", + "Only do this if you have no other device to complete verification with.": "Udělejte to, pouze pokud nemáte žádné jiné zařízení, se kterým byste mohli dokončit ověření.", + "Reset everything": "Resetovat vše", + "Forgotten or lost all recovery methods? Reset all": "Zapomněli nebo ztratili jste všechny metody obnovy? Obnovit vše", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Pokud tak učiníte, nezapomeňte, že žádná z vašich zpráv nebude smazána, ale vyhledávání může být na několik okamžiků zpomaleno během opětovného vytvoření indexu", + "View message": "Zobrazit zprávu", + "Zoom in": "Přiblížit", + "Zoom out": "Oddálit", + "%(seconds)ss left": "Zbývá %(seconds)ss", + "Change server ACLs": "Změnit seznamy přístupů serveru", + "Show options to enable 'Do not disturb' mode": "Zobrazit možnosti pro povolení režimu „Nerušit“", + "You can select all or individual messages to retry or delete": "Můžete vybrat všechny nebo jednotlivé zprávy, které chcete zkusit poslat znovu nebo odstranit", + "Sending": "Odesílání", + "Retry all": "Zkusit všechny znovu", + "Delete all": "Smazat všechny", + "Some of your messages have not been sent": "Některé z vašich zpráv nebyly odeslány", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s členů včetně %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Včetně %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Zobrazit jednoho člena", + "View all %(count)s members|other": "Zobrazit všech %(count)s členů", + "Failed to send": "Odeslání se nezdařilo", + "What do you want to organise?": "Co si přejete organizovat?", + "Filter all spaces": "Filtrovat všechny prostory", + "Delete recording": "Smazat zvukovou zprávu", + "Stop the recording": "Zastavit nahrávání", + "%(count)s results in all spaces|one": "%(count)s výsledek ve všech prostorech", + "%(count)s results in all spaces|other": "%(count)s výsledků ve všech prostorech", + "Play": "Přehrát", + "Pause": "Pozastavit", + "Enter your Security Phrase a second time to confirm it.": "Zadejte bezpečnostní frázi podruhé a potvrďte ji.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Vyberte místnosti nebo konverzace, které chcete přidat. Toto je prostor pouze pro vás, nikdo nebude informován. Později můžete přidat další.", + "You have no ignored users.": "Nemáte žádné ignorované uživatele." } diff --git a/src/i18n/strings/da.json b/src/i18n/strings/da.json index ca28295c4f..15ce2986da 100644 --- a/src/i18n/strings/da.json +++ b/src/i18n/strings/da.json @@ -71,7 +71,7 @@ "No rooms to show": "Ingen rum at vise", "This email address is already in use": "Denne email adresse er allerede i brug", "This phone number is already in use": "Dette telefonnummer er allerede i brug", - "Failed to verify email address: make sure you clicked the link in the email": "Kunne ikke bekræfte emailaddressen: vær sikker på at klikke på linket i emailen", + "Failed to verify email address: make sure you clicked the link in the email": "Kunne ikke bekræfte emailaddressen: vær sikker på at klikke på linket i e-mailen", "Call Timeout": "Opkalds Timeout", "The remote side failed to pick up": "Den anden side tog den ikke", "Unable to capture screen": "Kunne ikke optage skærm", @@ -473,9 +473,9 @@ "Show a placeholder for removed messages": "Vis en pladsholder for fjernede beskeder", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Hvorvidt du benytter %(brand)s på en enhed, hvor touch er den primære input-grænseflade", "Your user agent": "Din user agent", - "Use Single Sign On to continue": "Brug Single Sign On til at fortsætte", + "Use Single Sign On to continue": "Brug engangs login for at fortsætte", "Confirm adding this email address by using Single Sign On to prove your identity.": "Bekræft tilføjelsen af denne email adresse ved at bruge Single Sign On til at bevise din identitet.", - "Single Sign On": "Single Sign On", + "Single Sign On": "Engangs login", "Confirm adding email": "Bekræft tilføjelse af email", "Click the button below to confirm adding this email address.": "Klik på knappen herunder for at bekræfte tilføjelsen af denne email adresse.", "Confirm": "Bekræft", diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 9551f00e55..a3d6027fba 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -47,7 +47,7 @@ "For security, this session has been signed out. Please sign in again.": "Aus Sicherheitsgründen wurde diese Sitzung beendet. Bitte melde dich erneut an.", "Guests cannot join this room even if explicitly invited.": "Gäste können diesem Raum nicht beitreten, auch wenn sie explizit eingeladen wurden.", "Hangup": "Auflegen", - "Homeserver is": "Der Heimserver ist", + "Homeserver is": "Dein Heimserver ist", "Identity Server is": "Der Identitätsserver ist", "I have verified my email address": "Ich habe meine E-Mail-Adresse verifiziert", "Import E2E room keys": "E2E-Raumschlüssel importieren", @@ -78,7 +78,7 @@ "Someone": "Jemand", "Success": "Erfolg", "This doesn't appear to be a valid email address": "Dies scheint keine gültige E-Mail-Adresse zu sein", - "This room is not accessible by remote Matrix servers": "Ferngesteuerte Matrixserver können auf diesen Raum nicht zugreifen", + "This room is not accessible by remote Matrix servers": "Dieser Raum ist von Personen auf anderen Matrix-Servern nicht betretbar", "Admin": "Administrator", "Server may be unavailable, overloaded, or you hit a bug.": "Server ist nicht verfügbar, überlastet oder du bist auf einen Softwarefehler gestoßen.", "Labs": "Labor", @@ -88,7 +88,7 @@ "Unban": "Verbannung aufheben", "unknown error code": "Unbekannter Fehlercode", "Upload avatar": "Profilbild hochladen", - "Upload file": "Datei hochladen", + "Upload file": "Datei senden", "Users": "Benutzer", "Verification Pending": "Verifizierung ausstehend", "Video call": "Videoanruf", @@ -114,7 +114,7 @@ "VoIP is unsupported": "VoIP wird nicht unterstützt", "You are already in a call.": "Du bist bereits in einem Gespräch.", "You cannot place a call with yourself.": "Du kannst keinen Anruf mit dir selbst starten.", - "You cannot place VoIP calls in this browser.": "VoIP-Gespräche werden von diesem Browser nicht unterstützt.", + "You cannot place VoIP calls in this browser.": "Anrufe werden von diesem Browser nicht unterstützt.", "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Deine E-Mail-Adresse scheint nicht mit einer Matrix-ID auf diesem Heimserver verbunden zu sein.", "Sun": "So", "Mon": "Mo", @@ -125,7 +125,7 @@ "Sat": "Sa", "Jan": "Jan", "Feb": "Feb", - "Mar": "Mrz", + "Mar": "Mär", "Apr": "Apr", "May": "Mai", "Jun": "Jun", @@ -207,7 +207,7 @@ "Failed to kick": "Rauswurf fehlgeschlagen", "Failed to mute user": "Stummschalten des Nutzers fehlgeschlagen", "Failed to reject invite": "Ablehnen der Einladung ist fehlgeschlagen", - "Failed to set display name": "Anzeigename konnte nicht gesetzt werden", + "Failed to set display name": "Anzeigename konnte nicht geändert werden", "Fill screen": "Fülle Bildschirm", "Incorrect verification code": "Falscher Verifizierungscode", "Join Room": "Raum beitreten", @@ -215,7 +215,7 @@ "not specified": "nicht angegeben", "No more results": "Keine weiteren Ergebnisse", "No results": "Keine Ergebnisse", - "OK": "OK", + "OK": "Ok", "Search": "Suchen", "Search failed": "Suche ist fehlgeschlagen", "Server error": "Serverfehler", @@ -349,7 +349,7 @@ "If you already have a Matrix account you can log in instead.": "Wenn du bereits ein Matrix-Benutzerkonto hast, kannst du dich stattdessen auch direkt anmelden.", "Home": "Startseite", "Username invalid: %(errMessage)s": "Ungültiger Benutzername: %(errMessage)s", - "Accept": "Akzeptieren", + "Accept": "Annehmen", "Active call (%(roomName)s)": "Aktiver Anruf (%(roomName)s)", "Admin Tools": "Administratorwerkzeuge", "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.": "Verbindung zum Heimserver fehlgeschlagen - bitte überprüfe die Internetverbindung und stelle sicher, dass dem SSL-Zertifikat deines Heimservers vertraut wird und dass Anfragen nicht durch eine Browser-Erweiterung blockiert werden.", @@ -362,7 +362,7 @@ "Incoming video call from %(name)s": "Eingehender Videoanruf von %(name)s", "Incoming voice call from %(name)s": "Eingehender Sprachanruf von %(name)s", "Join as voice or video.": "Per Sprachanruf oder Videoanruf beitreten.", - "Last seen": "Zuletzt gesehen", + "Last seen": "Zuletzt gesehen um", "No display name": "Kein Anzeigename", "Private Chat": "Privater Chat", "Public Chat": "Öffentlicher Chat", @@ -430,7 +430,7 @@ "Banned by %(displayName)s": "Verbannt von %(displayName)s", "Description": "Beschreibung", "Unable to accept invite": "Einladung kann nicht angenommen werden", - "Failed to invite users to %(groupId)s": "Benutzer konnten nicht in %(groupId)s eingeladen werden", + "Failed to invite users to %(groupId)s": "Einige Leute konnten nicht in %(groupId)s eingeladen werden", "Unable to reject invite": "Einladung konnte nicht abgelehnt werden", "Who would you like to add to this summary?": "Wen möchtest zu dieser Übersicht hinzufügen?", "Add to summary": "Zur Übersicht hinzufügen", @@ -469,7 +469,7 @@ "Which rooms would you like to add to this community?": "Welche Räume sollen zu dieser Community hinzugefügt werden?", "Add rooms to the community": "Räume zur Community hinzufügen", "Add to community": "Zur Community hinzufügen", - "Failed to invite users to community": "Benutzer konnten nicht in die Community eingeladen werden", + "Failed to invite users to community": "Einige Leute konnten nicht in die Community eingeladen werden", "Communities": "Communities", "Invalid community ID": "Ungültige Community-ID", "'%(groupId)s' is not a valid community ID": "'%(groupId)s' ist keine gültige Community-ID", @@ -504,7 +504,7 @@ "Delete Widget": "Widget löschen", "Mention": "Erwähnen", "Invite": "Einladen", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Das Löschen eines Widgets entfernt es für alle Nutzer in diesem Raum. Möchtest du es wirklich löschen?", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Das Löschen des Widgets entfernt es für alle in diesem Raum. Wirklich löschen?", "Mirror local video feed": "Lokalen Video-Feed spiegeln", "Failed to withdraw invitation": "Die Einladung konnte nicht zurückgezogen werden", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community-IDs dürfen nur die folgenden Zeichen enthalten: a-z, 0-9, or '=_-./'", @@ -582,13 +582,13 @@ "Notify the whole room": "Alle im Raum benachrichtigen", "Room Notification": "Raum-Benachrichtigung", "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "Diese Räume werden Community-Mitgliedern auf der Community-Seite angezeigt. Community-Mitglieder können diesen Räumen beitreten, indem sie diese anklicken.", - "Show these rooms to non-members on the community page and room list?": "Sollen diese Räume öffentlich auf der Community-Seite und in der Raum-Liste angezeigt werden?", + "Show these rooms to non-members on the community page and room list?": "Sollen diese Räume öffentlich auf der Communityseite und in der Raumliste angezeigt werden?", "

    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 use 'img' tags\n

    \n": "

    HTML für deine Community-Seite

    \n

    \n Nutze die ausführliche Beschreibung, um neuen Mitgliedern diese Community vorzustellen\n oder um wichtige Links bereitzustellen.\n

    \n

    \n Du kannst sogar 'img'-Tags (HTML) verwenden\n

    \n", "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "Deine Community hat noch keine ausführliche Beschreibung, d. h. eine HTML-Seite, die Community-Mitgliedern angezeigt wird.
    Hier klicken, um die Einstellungen zu öffnen und eine Beschreibung zu erstellen!", "Enable inline URL previews by default": "URL-Vorschau standardmäßig aktivieren", "Enable URL previews for this room (only affects you)": "URL-Vorschau für dich in diesem Raum", "Enable URL previews by default for participants in this room": "URL-Vorschau für Raummitglieder", - "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade am %(hs)s-Server an, nicht auf matrix.org.", + "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade am Server von %(hs)s an, nicht auf matrix.org.", "There's no one else here! Would you like to invite others or stop warning about the empty room?": "Sonst ist hier aktuell niemand. Möchtest du Benutzer einladen oder die Warnmeldung bezüglich des leeren Raums deaktivieren?", "URL previews are disabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig deaktiviert.", "URL previews are enabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig aktiviert.", @@ -647,14 +647,14 @@ "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Wenn du einen Fehler via GitHub meldest, können Fehlerberichte uns helfen um das Problem zu finden. Sie enthalten Anwendungsdaten wie deinen Nutzernamen, Raum- und Gruppen-IDs und Aliase, die du besucht hast sowie Nutzernamen anderer Nutzer mit denen du schreibst. Sie enthalten keine Nachrichten.", "Submit debug logs": "Fehlerberichte einreichen", "Code": "Code", - "Opens the Developer Tools dialog": "Öffnet die Entwicklerwerkzeuge", + "Opens the Developer Tools dialog": "Entwickler-Werkzeuge öffnen", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Von %(displayName)s (%(userName)s) um %(dateTime)s gesehen", "Unable to join community": "Community konnte nicht betreten werden", "Unable to leave community": "Community konnte nicht verlassen werden", "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "Änderungen am Namen und Bild deiner Community werden evtl. erst nach 30 Minuten von anderen Nutzern gesehen werden.", "Join this community": "Community beitreten", "Leave this community": "Community verlassen", - "You don't currently have any stickerpacks enabled": "Du hast aktuell keine Stickerpakete aktiviert", + "You don't currently have any stickerpacks enabled": "Keine Stickerpakete aktiviert", "Hide Stickers": "Sticker ausblenden", "Show Stickers": "Sticker anzeigen", "Who can join this community?": "Wer kann dieser Community beitreten?", @@ -708,7 +708,7 @@ "Messages containing keywords": "Nachrichten mit Schlüsselwörtern", "Error saving email notification preferences": "Fehler beim Speichern der E-Mail-Benachrichtigungseinstellungen", "Tuesday": "Dienstag", - "Enter keywords separated by a comma:": "Schlüsselwörter kommagetrennt eingeben:", + "Enter keywords separated by a comma:": "Gib die Schlüsselwörter durch einen Beistrich getrennt ein:", "Forward Message": "Nachricht weiterleiten", "You have successfully set a password and an email address!": "Du hast erfolgreich ein Passwort und eine E-Mail-Adresse gesetzt!", "Remove %(name)s from the directory?": "Soll der Raum %(name)s aus dem Verzeichnis entfernt werden?", @@ -781,7 +781,7 @@ "Thank you!": "Danke!", "Uploaded on %(date)s by %(user)s": "Hochgeladen: %(date)s von %(user)s", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "In deinem aktuell verwendeten Browser können Aussehen und Handhabung der Anwendung unter Umständen noch komplett fehlerhaft sein, so dass einige bzw. im Extremfall alle Funktionen nicht zur Verfügung stehen. Du kannst es trotzdem versuchen und fortfahren, bist dabei aber bezüglich aller auftretenden Probleme auf dich allein gestellt!", - "Checking for an update...": "Nach Aktualisierung suchen...", + "Checking for an update...": "Nach Aktualisierungen suchen...", "Missing roomId.": "Fehlende Raum-ID.", "Every page you use in the app": "Jede Seite, die du in der App benutzt", "e.g. ": "z. B. ", @@ -903,7 +903,7 @@ "Avoid repeated words and characters": "Vermeide wiederholte Worte und Zeichen", "Avoid sequences": "Vermeide Sätze", "Avoid recent years": "Vermeide die letzten Jahre", - "Avoid years that are associated with you": "Vermeide Jahre, die mit dir zusammenhängen", + "Avoid years that are associated with you": "Vermeide Jahreszahlen, die mit dir zu tun haben", "Avoid dates and years that are associated with you": "Vermeide Daten und Jahre, die mit dir in Verbindung stehen", "Capitalization doesn't help very much": "Großschreibung hilft nicht viel", "All-uppercase is almost as easy to guess as all-lowercase": "Alles groß zu schreiben ist genauso einfach zu erraten, wie alles klein zu schreiben", @@ -978,7 +978,7 @@ "%(names)s and %(lastPerson)s are typing …": "%(names)s und %(lastPerson)s tippen…", "Render simple counters in room header": "Einfache Zähler in Raumkopfzeile anzeigen", "Enable Emoji suggestions while typing": "Emojivorschläge während Eingabe", - "Show a placeholder for removed messages": "Zeigt einen Platzhalter für gelöschte Nachrichten an", + "Show a placeholder for removed messages": "Platzhalter für gelöschte Nachrichten", "Show join/leave messages (invites/kicks/bans unaffected)": "Betreten oder Verlassen von Benutzern (ausgen. Einladungen/Rauswürfe/Banne)", "Show avatar changes": "Avataränderungen anzeigen", "Show display name changes": "Änderungen von Anzeigenamen", @@ -1036,7 +1036,7 @@ "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s erlaubte Gäste diesem Raum beizutreten.", "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s hat Gästen verboten, diesem Raum beizutreten.", "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s änderte den Gastzugriff auf '%(rule)s'", - "Group & filter rooms by custom tags (refresh to apply changes)": "Gruppiere und filtere Räume nach eigenen Tags (neu laden um Änderungen zu übernehmen)", + "Group & filter rooms by custom tags (refresh to apply changes)": "[Veraltet] Gruppiere und filtere Räume nach eigenen Tags (neu laden um Änderungen zu übernehmen)", "Unable to find a supported verification method.": "Konnte keine unterstützte Verifikationsmethode finden.", "Dog": "Hund", "Cat": "Katze", @@ -1101,12 +1101,12 @@ "Folder": "Ordner", "Pin": "Anheften", "Timeline": "Chatverlauf", - "Autocomplete delay (ms)": "Verzögerung zur Autovervollständigung (ms)", + "Autocomplete delay (ms)": "Verzögerung vor Autovervollständigung (ms)", "Roles & Permissions": "Rollen und Berechtigungen", "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Änderungen an der Sichtbarkeit des Chatverlaufs gelten nur für zukünftige Nachrichten. Die Sichtbarkeit des existierenden Verlaufs bleibt unverändert.", "Security & Privacy": "Sicherheit", "Encryption": "Verschlüsselung", - "Once enabled, encryption cannot be disabled.": "Sobald aktiviert, kann die Verschlüsselung nicht mehr deaktiviert werden.", + "Once enabled, encryption cannot be disabled.": "Sobald du die Verschlüsselung aktivierst, kann du sie nicht mehr deaktivieren.", "Encrypted": "Verschlüsselt", "Ignored users": "Blockierte Benutzer", "Key backup": "Schlüsselsicherung", @@ -1190,7 +1190,7 @@ "Bulk options": "Sammeloptionen", "Join millions for free on the largest public server": "Schließe dich kostenlos auf dem größten öffentlichen Server Millionen von Menschen an", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Stellt ¯\\_(ツ)_/¯ einer Klartextnachricht voran", - "Changes your display nickname in the current room only": "Ändert den Anzeigenamen ausschließlich für den aktuellen Raum", + "Changes your display nickname in the current room only": "Ändert deinen Anzeigenamen nur für den aktuellen Raum", "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s hat Abzeichen der Gruppen %(groups)s für diesen Raum aktiviert.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s hat Abzeichen der Gruppen %(groups)s in diesem Raum deaktiviert.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s hat Abzeichen von %(newGroups)s aktiviert und von %(oldGroups)s deaktiviert.", @@ -1203,9 +1203,9 @@ "Change room avatar": "Ändere Raumbild", "Change room name": "Ändere Raumname", "Change main address for the room": "Ändere Hauptadresse für den Raum", - "Change history visibility": "Ändere Sichtbarkeit der Historie", + "Change history visibility": "Sichtbarkeit des Verlaufs ändern", "Change permissions": "Ändere Berechtigungen", - "Change topic": "Ändere das Thema", + "Change topic": "Thema ändern", "Modify widgets": "Widgets bearbeiten", "Default role": "Standard-Rolle", "Send messages": "Nachrichten senden", @@ -1249,7 +1249,7 @@ "Please supply a https:// or http:// widget URL": "Bitte gib eine mit https:// oder http:// beginnende Widget-URL an", "Sends the given emote coloured as a rainbow": "Zeigt Aktionen in Regenbogenfarben", "%(senderName)s made no change.": "%(senderName)s hat keine Änderung vorgenommen.", - "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s hat die Einladung zum Raumbeitritt für %(targetDisplayName)s zurückgezogen.", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s hat die Einladung für %(targetDisplayName)s zurückgezogen.", "Cannot reach homeserver": "Der Heimserver ist nicht erreichbar", "Ensure you have a stable internet connection, or get in touch with the server admin": "Stelle sicher, dass du eine stabile Internetverbindung hast oder wende dich an deinen Serveradministrator", "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.", @@ -1446,7 +1446,7 @@ "Go": "Los", "Command Help": "Befehl Hilfe", "To help us prevent this in future, please send us logs.": "Um uns zu helfen, dies in Zukunft zu vermeiden, sende uns bitte die Protokolldateien.", - "Notification settings": "Benachrichtigungseinstellungen", + "Notification settings": "Benachrichtigungen", "Help": "Hilf uns", "Filter": "Filtern", "Filter rooms…": "Räume filtern…", @@ -1459,7 +1459,7 @@ "Your key share request has been sent - please check your other sessions for key share requests.": "Deine Schlüsselanfrage wurde gesendet - sieh in deinen anderen Sitzungen nach der Schlüsselanfrage.", "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Schlüsselanfragen werden automatisch an deine anderen Sitzungen gesendet. Wenn du sie abgelehnt oder ignoriert hast, klicke hier, um die Schlüssel erneut anzufordern.", "If your other sessions do not have the key for this message you will not be able to decrypt them.": "Wenn deine anderen Sitzungen nicht über den Schlüssel für diese Nachricht verfügen, kannst du die Nachricht nicht entschlüsseln.", - "Re-request encryption keys from your other sessions.": "Fordere die Schlüssel aus deinen anderen Sitzungen erneut an.", + "Re-request encryption keys from your other sessions.": "Schlüssel aus deinen anderen Sitzungen erneut anfordern.", "Room %(name)s": "Raum %(name)s", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Ein Aktualisierung dieses Raums deaktiviert die aktuelle Instanz des Raums und erstellt einen aktualisierten Raum mit demselben Namen.", "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) hat sich zu einer neuen Sitzung angemeldet, ohne sie zu verifizieren:", @@ -1634,7 +1634,7 @@ "Show rooms with unread notifications first": "Räume mit ungelesenen Benachrichtigungen zuerst zeigen", "Show shortcuts to recently viewed rooms above the room list": "Kürzlich besuchte Räume anzeigen", "Use Single Sign On to continue": "Einmalanmeldung zum Fortfahren nutzen", - "Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte E-Mail-Adresse mit der Einmalanmeldung, um deine Identität nachzuweisen.", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige das Hinzufügen dieser E-Mail-Adresse durch Single Sign-on, um deine Identität nachzuweisen.", "Single Sign On": "Einmalanmeldung", "Confirm adding email": "Hinzugefügte E-Mail-Addresse bestätigen", "Confirm adding this phone number by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte Telefonnummer, indem du deine Identität mittels der Einmalanmeldung nachweist.", @@ -2028,7 +2028,7 @@ "Doesn't look like a valid email address": "Das sieht nicht nach einer gültigen E-Mail-Adresse aus", "Enter phone number (required on this homeserver)": "Telefonnummer eingeben (auf diesem Heimserver erforderlich)", "Doesn't look like a valid phone number": "Das sieht nicht nach einer gültigen Telefonnummer aus", - "Sign in with SSO": "Mit Single-Sign-On anmelden", + "Sign in with SSO": "Einmalanmeldung verwenden", "Welcome to %(appName)s": "Willkommen bei %(appName)s", "Send a Direct Message": "Direktnachricht senden", "Create a Group Chat": "Gruppenchat erstellen", @@ -2107,7 +2107,7 @@ "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems im Heimserver fehlgeschlagen", "Failed to re-authenticate": "Erneute Authentifizierung fehlgeschlagen", "Command Autocomplete": "Autovervollständigung aktivieren", - "Community Autocomplete": "Community-Auto-Vervollständigung", + "Community Autocomplete": "Community-Autovervollständigung", "DuckDuckGo Results": "DuckDuckGo Ergebnisse", "Great! This recovery passphrase looks strong enough.": "Super! Diese Wiederherstellungspassphrase sieht stark genug aus.", "Enter a recovery passphrase": "Gib eine Wiederherstellungspassphrase ein", @@ -2129,12 +2129,12 @@ "Navigation": "Navigation", "Calls": "Anrufe", "Room List": "Raumliste", - "Autocomplete": "Auto-Vervollständigung", + "Autocomplete": "Autovervollständigung", "Alt": "Alt", - "Toggle microphone mute": "Schalte Mikrofon stumm/an", - "Toggle video on/off": "Schalte Video an/aus", + "Toggle microphone mute": "Mikrofon an-/ausschalten", + "Toggle video on/off": "Video an-/ausschalten", "Jump to room search": "Zur Raumsuche springen", - "Close dialog or context menu": "Schließe Dialog oder Kontextmenü", + "Close dialog or context menu": "Dialog oder Kontextmenü schließen", "Cancel autocomplete": "Autovervollständigung deaktivieren", "Unable to revoke sharing for email address": "Dem Teilen der E-Mail-Adresse kann nicht widerrufen werden", "Unable to validate homeserver/identity server": "Heimserver/Identitätsserver nicht validierbar", @@ -2172,13 +2172,13 @@ "Activate selected button": "Ausgewählten Button aktivieren", "Toggle right panel": "Rechtes Panel ein-/ausblenden", "Toggle this dialog": "Diesen Dialog ein-/ausblenden", - "Move autocomplete selection up/down": "Auto-Vervollständigung nach oben/unten verschieben", + "Move autocomplete selection up/down": "Autovervollständigung nach oben/unten verschieben", "Opens chat with the given user": "Öffnet einen Chat mit diesem Benutzer", "Sends a message to the given user": "Sendet diesem Benutzer eine Nachricht", "Waiting for your other session to verify…": "Warte auf die Verifikation deiner anderen Sitzungen…", "You've successfully verified your device!": "Du hast dein Gerät erfolgreich verifiziert!", "QR Code": "QR-Code", - "To continue, use Single Sign On to prove your identity.": "Zum Fortfahren, nutze Single Sign-On um deine Identität zu bestätigen.", + "To continue, use Single Sign On to prove your identity.": "Zum Fortfahren nutze die Einmalanmeldung um deine Identität zu bestätigen.", "Confirm to continue": "Bestätige um fortzufahren", "Click the button below to confirm your identity.": "Klicke den Button unten um deine Identität zu bestätigen.", "Confirm encryption setup": "Bestätige die Einrichtung der Verschlüsselung", @@ -2257,8 +2257,8 @@ "Light": "Hell", "Dark": "Dunkel", "Use the improved room list (will refresh to apply changes)": "Verwende die verbesserte Raumliste (lädt die Anwendung neu)", - "Use custom size": "Verwende individuelle Größe", - "Hey you. You're the best!": "Hey du. Du bist großartig.", + "Use custom size": "Andere Schriftgröße verwenden", + "Hey you. You're the best!": "Hey du. Du bist großartig!", "Message layout": "Nachrichtenlayout", "Compact": "Kompakt", "Modern": "Modern", @@ -2421,7 +2421,7 @@ "Download logs": "Protokolle herunterladen", "Unexpected server error trying to leave the room": "Unerwarteter Serverfehler beim Versuch den Raum zu verlassen", "Error leaving room": "Fehler beim Verlassen des Raums", - "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 Prototyp. Benötigt einen kompatiblen Heimserver. Höchst experimentell - mit Vorsicht verwenden.", + "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "[Veraltet] Communities v2 Prototyp. Benötigt einen kompatiblen Heimserver. Höchst experimentell - mit Vorsicht verwenden.", "Explore rooms in %(communityName)s": "Räume in %(communityName)s erkunden", "Set up Secure Backup": "Schlüsselsicherung einrichten", "Information": "Information", @@ -2570,7 +2570,7 @@ "Change the avatar of this room": "Icon von diesem Raum ändern", "See when the avatar changes in this room": "Sehen, wenn sich das Icon des Raums ändert", "Change the avatar of your active room": "Den Avatar deines aktiven Raums ändern", - "See when the avatar changes in your active room": "Sehen wenn ein Avatar in deinem aktiven Raum geändert wird", + "See when the avatar changes in your active room": "Sehen, wenn das Icon in deinem aktiven Raum geändert wird", "Send stickers to this room as you": "Einen Sticker in diesen Raum senden", "See when a sticker is posted in this room": "Sehe wenn ein Sticker in diesen Raum gesendet wird", "Send stickers to your active room as you": "Einen Sticker als du in deinen aktiven Raum senden", @@ -2597,7 +2597,7 @@ "See videos posted to your active room": "In deinen aktiven Raum gesendete Videos anzeigen", "See videos posted to this room": "In diesen Raum gesendete Videos anzeigen", "Send images as you in this room": "Bilder als du in diesen Raum senden", - "Send images as you in your active room": "Bilder als du in deinem aktiven Raum senden", + "Send images as you in your active room": "Sende Bilder als deine Person in den aktiven Raum.", "See images posted to this room": "In diesen Raum gesendete Bilder anzeigen", "See images posted to your active room": "In deinen aktiven Raum gesendete Bilder anzeigen", "Send videos as you in this room": "Videos als du in diesen Raum senden", @@ -2621,7 +2621,7 @@ "Call Paused": "Anruf pausiert", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Verschlüsselte Nachrichten sicher lokal zwischenspeichern, um sie in Suchergebnissen finden zu können. Es werden %(size)s benötigt, um die Nachrichten von %(rooms)s Räumen zu speichern.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Verschlüsselte Nachrichten sicher lokal zwischenspeichern, um sie in Suchergebnissen finden zu können. Es werden %(size)s benötigt, um die Nachrichten vom Raum %(rooms)s zu speichern.", - "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Nur ihr zwei seid in dieser Konversation, außer ihr ladet jemanden Neues ein.", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Nur ihr beide nehmt an dieser Konversation teil, es sei denn, ihr ladet jemanden ein.", "This is the beginning of your direct message history with .": "Dies ist der Beginn deiner Direktnachrichten mit .", "Topic: %(topic)s (edit)": "Thema: %(topic)s (ändern)", "Topic: %(topic)s ": "Thema: %(topic)s ", @@ -2945,16 +2945,16 @@ "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Du kannst in den benutzerdefinierten Serveroptionen eine andere Heimserver-URL angeben, um dich bei anderen Matrixservern anzumelden.", "Server Options": "Servereinstellungen", "No other application is using the webcam": "keine andere Anwendung auf die Webcam zugreift", - "Permission is granted to use the webcam": "auf die Webcam zugegriffen werden darf", + "Permission is granted to use the webcam": "Zugriff auf die Webcam ist gestattet.", "A microphone and webcam are plugged in and set up correctly": "Mikrofon und Webcam eingesteckt und richtig eingerichtet sind", "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Der Anruf ist fehlgeschlagen weil nicht auf das Mikrofon zugegriffen werden konnte. Stelle sicher, dass das Mikrofon richtig eingesteckt und eingerichtet ist.", "Call failed because no webcam or microphone could not be accessed. Check that:": "Der Anruf ist fehlgeschlagen weil nicht auf das Mikrofon oder die Webcam zugegriffen werden konnte. Stelle sicher, dass:", "Unable to access webcam / microphone": "Auf Webcam / Mikrofon konnte nicht zugegriffen werden", "Unable to access microphone": "Es konnte nicht auf das Mikrofon zugegriffen werden", - "Host account on": "Benutzer*innenkonto betreiben an", + "Host account on": "Konto betreiben auf", "Hold": "Halten", "Resume": "Fortsetzen", - "We call the places where you can host your account ‘homeservers’.": "Den Ort, an dem du dein Benutzer*innenkonto betreibst, nennen wir „Heimserver“.", + "We call the places where you can host your account ‘homeservers’.": "Den Ort, an dem du dein Konto betreibst, nennen wir „Heimserver“.", "Invalid URL": "Ungültiger Link", "Unable to validate homeserver": "Überprüfung des Heimservers nicht möglich", "%(name)s paused": "%(name)s hat pausiert", @@ -3003,7 +3003,7 @@ "Workspace: ": "Arbeitsraum: ", "Dial pad": "Wähltastatur", "There was an error looking up the phone number": "Beim Suchen der Telefonnummer ist ein Fehler aufgetreten", - "Change which room, message, or user you're viewing": "Ändere welchen Raum, Nachricht oder Nutzer du siehst", + "Change which room, message, or user you're viewing": "Ändere den sichtbaren Raum, Nachricht oder Nutzer", "Unable to look up phone number": "Telefonnummer konnte nicht gefunden werden", "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "In dieser Sitzung wurde festgestellt, dass deine Sicherheitsphrase und dein Schlüssel für sichere Nachrichten entfernt wurden.", "A new Security Phrase and key for Secure Messages have been detected.": "Eine neue Sicherheitsphrase und ein neuer Schlüssel für sichere Nachrichten wurden erkannt.", @@ -3025,7 +3025,7 @@ "Security Key mismatch": "Nicht übereinstimmende Sicherheitsschlüssel", "Set my room layout for everyone": "Diese Raumgestaltung für alle setzen", "%(senderName)s has updated the widget layout": "%(senderName)s hat das Widget-Layout aktualisiert", - "Search (must be enabled)": "Suche (muss aktiviert sein)", + "Search (must be enabled)": "Suchen (muss in den Einstellungen aktiviert sein)", "Remember this": "Dies merken", "The widget will verify your user ID, but won't be able to perform actions for you:": "Das Widget überprüft deine Nutzer-ID, kann jedoch keine Aktionen für dich ausführen:", "Allow this widget to verify your identity": "Erlaube diesem Widget deine Identität zu überprüfen", @@ -3085,7 +3085,7 @@ "Save changes": "Änderungen speichern", "Undo": "Rückgängig", "Save Changes": "Änderungen Speichern", - "View dev tools": "Entwicklerwerkzeuge anzeigen", + "View dev tools": "Entwicklerwerkzeuge", "Apply": "Anwenden", "Create a new room": "Neuen Raum erstellen", "Suggested Rooms": "Vorgeschlagene Räume", @@ -3094,7 +3094,7 @@ "New room": "Neuer Raum", "Share invite link": "Einladungslink teilen", "Click to copy": "Klicken um zu kopieren", - "Collapse space panel": "Space-Feld zuklappen", + "Collapse space panel": "Space-Feld einklappen", "Expand space panel": "Space-Feld aufklappen", "Creating...": "Erstelle...", "You can change these at any point.": "Du kannst diese jederzeit ändern.", @@ -3161,7 +3161,7 @@ "Invite to %(spaceName)s": "Leute zu %(spaceName)s einladen", "Spaces": "Spaces", "Invite People": "Personen einladen", - "Invite with email or username": "Personen mit E-Mail oder Benutzername einladen", + "Invite with email or username": "Personen mit E-Mail oder Benutzernamen einladen", "You can change these anytime.": "Du kannst diese jederzeit ändern.", "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Von %(deviceName)s (%(deviceId)s) mit der Adresse %(ip)s", "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Neue Anmeldung: %(name)s (%(deviceID)s) mit der IP-Adresse %(ip)s", @@ -3222,7 +3222,7 @@ "Manage & explore rooms": "Räume entdecken und verwalten", "unknown person": "unbekannte Person", "Send and receive voice messages (in development)": "Sprachnachrichten senden und empfangen (in der Entwicklung)", - "Check your devices": "Überprüfe dein Gerät", + "Check your devices": "Überprüfe deine Sitzungen", "%(deviceId)s from %(ip)s": "%(deviceId)s von %(ip)s", "This homeserver has been blocked by it's administrator.": "Dieser Heimserver wurde von seiner Administration blockiert.", "You have unverified logins": "Du hast nicht-bestätigte Anmeldungen", @@ -3243,8 +3243,55 @@ "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Soll die Host-Erstellung wirklich abgebrochen werden? Dieser Prozess kann nicht wieder fortgesetzt werden.", "Invite to just this room": "Nur für diesen Raum einladen", "Consult first": "Konsultiere zuerst", - "Reset event store?": "Ereigniss-Speicher zurück setzen?", - "You most likely do not want to reset your event index store": "Es ist wahrscheinlich, dass du den Ereigniss-Index-Speicher nicht zurück setzen möchtest", + "Reset event store?": "Ereignisspeicher zurück setzen?", + "You most likely do not want to reset your event index store": "Es ist wahrscheinlich, dass du den Ereignis-Indexspeicher nicht zurück setzen möchtest", "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Falls du dies tust, werden keine deiner Nachrichten gelöscht. Allerdings wird die Such-Funktion eine Weile lang schlecht funktionieren, bis der Index wieder hergestellt ist", - "Reset event store": "Ereignis-Speicher zurück setzen" + "Reset event store": "Ereignisspeicher zurück setzen", + "Show options to enable 'Do not disturb' mode": "Optionen für den \"Nicht-Stören-Modus\" anzeigen", + "You can add more later too, including already existing ones.": "Natürlich kannst du jederzeit weitere Räume hinzufügen.", + "Let's create a room for each of them.": "Wir erstellen dir für jedes Thema einen Raum.", + "What are some things you want to discuss in %(spaceName)s?": "Welche Themen willst du in %(spaceName)s besprechen?", + "Inviting...": "Einladen...", + "Failed to create initial space rooms": "Fehler beim Initialisieren des Space", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du bist hier noch alleine. Wenn du den Space verlässt, ist er für immer verloren (eine lange Zeit).", + "Edit settings relating to your space.": "Einstellungen vom Space bearbeiten.", + "Please choose a strong password": "Bitte gib ein sicheres Passwort ein", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Wenn du alles zurücksetzt, gehen alle verifizierten Anmeldungen, Benutzer und verschlüsselte Nachrichten verloren.", + "Only do this if you have no other device to complete verification with.": "Verwende es nur, wenn du kein Gerät, mit dem du dich verifizieren, kannst bei dir hast.", + "Reset everything": "Alles zurücksetzen", + "Forgotten or lost all recovery methods? Reset all": "Hast du alle Wiederherstellungsmethoden vergessen? Setze sie hier zurück", + "View message": "Nachricht anzeigen", + "Zoom in": "Vergrößern", + "Zoom out": "Verkleinern", + "%(seconds)ss left": "%(seconds)s vergangen", + "Change server ACLs": "ACLs des Servers bearbeiten", + "Failed to send": "Fehler beim Senden", + "View all %(count)s members|other": "Alle %(count)s Mitglieder anzeigen", + "View all %(count)s members|one": "Mitglied anzeigen", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "Some of your messages have not been sent": "Einige Nachrichten konnten nicht gesendet werden", + "Original event source": "Ursprüngliche Rohdaten", + "Decrypted event source": "Entschlüsselte Rohdaten", + "Sending": "Senden", + "You can select all or individual messages to retry or delete": "Du kannst einzelne oder alle Nachrichten erneut senden oder löschen", + "Delete all": "Alle löschen", + "Retry all": "Alle erneut senden", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Ohne deine Verifikation kannst du keine deiner verschlüsselten Nachrichten lesen und andere vertrauen dir möglicherweise nicht.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifiziere diese Anmeldung, um auf verschlüsselte Nachrichten zuzugreifen und dich anderen gegenüber zu identifizieren.", + "Use another login": "Mit anderem Gerät verifizeren", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Falls du es wirklich willst: Es werden keine Nachrichten gelöscht. Außerdem wird die Suche, während der Index erstellt wird, etwas langsamer sein", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s Mitglieder inklusive %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inklusive%(commaSeparatedMembers)s", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Beratung mit %(transferTarget)s. Übertragung zu %(transferee)s", + "Play": "Abspielen", + "Pause": "Pause", + "What do you want to organise?": "Was willst du organisieren?", + "Enter your Security Phrase a second time to confirm it.": "Gib dein Kennwort ein zweites Mal zur Bestätigung ein.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Wähle Räume oder Konversationen die Du hinzufügen möchtest. Dieser Bereich ist nur für Dich, niemand wird informiert. Du kannst später mehr hinzufügen.", + "Filter all spaces": "Alle Bereiche filtern", + "Delete recording": "Aufnahme löschen", + "Stop the recording": "Aufnahme stoppen", + "%(count)s results in all spaces|one": "%(count)s Ergebnis in allen Bereichen", + "%(count)s results in all spaces|other": "%(count)s Ergebnisse in allen Bereichen", + "You have no ignored users.": "Du ignorierst keine Benutzer." } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8a240264c0..5126065031 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -578,14 +578,6 @@ "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "Light": "Light", "Dark": "Dark", - "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", - "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", - "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", - "Not Trusted": "Not Trusted", - "Manually Verify by Text": "Manually Verify by Text", - "Interactively verify by Emoji": "Interactively verify by Emoji", - "Done": "Done", "%(displayName)s is typing …": "%(displayName)s is typing …", "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", @@ -798,8 +790,16 @@ "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "Change notification settings": "Change notification settings", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.", + "Spaces": "Spaces", + "Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta available for web, desktop and Android. Thank you for trying the beta.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", - "Send and receive voice messages (in development)": "Send and receive voice messages (in development)", + "Send and receive voice messages": "Send and receive voice messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "New spinner design": "New spinner design", @@ -846,7 +846,7 @@ "Match system theme": "Match system theme", "Use a system font": "Use a system font", "System font name": "System font name", - "Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)", "Send analytics data": "Send analytics data", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", @@ -898,6 +898,7 @@ "You held the call Switch": "You held the call Switch", "You held the call Resume": "You held the call Resume", "%(peerName)s held the call": "%(peerName)s held the call", + "Connecting": "Connecting", "Video Call": "Video Call", "Voice Call": "Voice Call", "Fill Screen": "Fill Screen", @@ -1008,8 +1009,9 @@ "Upload": "Upload", "Name": "Name", "Description": "Description", + "Please enter a name for the space": "Please enter a name for the space", "Create a space": "Create a space", - "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.", "Public": "Public", "Open space for anyone, best for communities": "Open space for anyone, best for communities", "Private": "Private", @@ -1024,7 +1026,7 @@ "Create": "Create", "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", - "Home": "Home", + "All rooms": "All rooms", "Click to copy": "Click to copy", "Copied!": "Copied!", "Failed to copy": "Failed to copy", @@ -1100,7 +1102,7 @@ "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", - "Message search initilisation failed": "Message search initilisation failed", + "Message search initialisation failed": "Message search initialisation failed", "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", @@ -1265,11 +1267,12 @@ "olm version:": "olm version:", "Homeserver is": "Homeserver is", "Identity Server is": "Identity Server is", - "Access Token:": "Access Token:", - "click to reveal": "click to reveal", + "Access Token": "Access Token", + "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", + "Copy": "Copy", "Clear cache and reload": "Clear cache and reload", "Labs": "Labs", - "Customise your experience with experimental labs features. Learn more.": "Customise your experience with experimental labs features. Learn more.", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.", "Ignored/Blocked": "Ignored/Blocked", "Error adding ignored user/server": "Error adding ignored user/server", "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", @@ -1452,6 +1455,13 @@ "Someone is using an unknown session": "Someone is using an unknown session", "This room is end-to-end encrypted": "This room is end-to-end encrypted", "Everyone in this room is verified": "Everyone in this room is verified", + "Server error": "Server error", + "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", + "Unknown Command": "Unknown Command", + "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", + "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", "Edit message": "Edit message", "Mod": "Mod", "This event could not be displayed": "This event could not be displayed", @@ -1642,13 +1652,6 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", - "Server error": "Server error", - "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", - "Unknown Command": "Unknown Command", - "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", - "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", - "Send as message": "Send as message", "Failed to connect to integration manager": "Failed to connect to integration manager", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", @@ -1662,6 +1665,10 @@ "Invited by %(sender)s": "Invited by %(sender)s", "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", + "Unable to access your microphone": "Unable to access your microphone", + "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", + "No microphone found": "No microphone found", + "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", "Record a voice message": "Record a voice message", "Stop the recording": "Stop the recording", "Delete recording": "Delete recording", @@ -1861,6 +1868,8 @@ "%(name)s wants to verify": "%(name)s wants to verify", "You sent a verification request": "You sent a verification request", "Error decrypting video": "Error decrypting video", + "Error processing voice message": "Error processing voice message", + "Add reaction": "Add reaction", "Show all": "Show all", "Reactions": "Reactions", " reacted with %(content)s": " reacted with %(content)s", @@ -1945,8 +1954,8 @@ "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "Zoom out": "Zoom out", "Zoom in": "Zoom in", - "Rotate Right": "Rotate Right", "Rotate Left": "Rotate Left", + "Rotate Right": "Rotate Right", "Download": "Download", "Information": "Information", "View message": "View message", @@ -2022,10 +2031,11 @@ "Continue with %(provider)s": "Continue with %(provider)s", "Sign in with single sign-on": "Sign in with single sign-on", "And %(count)s more...|other": "And %(count)s more...", + "Home": "Home", "Enter a server name": "Enter a server name", "Looks good": "Looks good", + "You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list", "Can't find this server or its room list": "Can't find this server or its room list", - "All rooms": "All rooms", "Your server": "Your server", "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", "Remove server": "Remove server", @@ -2036,15 +2046,17 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", + "Not all selected were added": "Not all selected were added", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Filter your rooms and spaces": "Filter your rooms and spaces", - "Spaces": "Spaces", + "Feeling experimental?": "Feeling experimental?", + "You can add existing spaces to a space.": "You can add existing spaces to a space.", "Direct Messages": "Direct Messages", "Space selection": "Space selection", "Add existing rooms": "Add existing rooms", - "Don't want to add an existing room?": "Don't want to add an existing room?", + "Want to add a new room instead?": "Want to add a new room instead?", "Create a new room": "Create a new room", - "Failed to add rooms to space": "Failed to add rooms to space", - "Adding...": "Adding...", "Matrix ID": "Matrix ID", "Matrix Room ID": "Matrix Room ID", "email address": "email address", @@ -2058,6 +2070,15 @@ "Invite anyway and never warn me again": "Invite anyway and never warn me again", "Invite anyway": "Invite anyway", "Close dialog": "Close dialog", + "Beta feedback": "Beta feedback", + "Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.", + "Done": "Done", + "%(featureName)s beta feedback": "%(featureName)s beta feedback", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.", + "To leave the beta, visit your settings.": "To leave the beta, visit your settings.", + "Feedback": "Feedback", + "You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions", + "Send feedback": "Send feedback", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.", "Preparing to send logs": "Preparing to send logs", "Logs sent": "Logs sent", @@ -2188,10 +2209,8 @@ "Comment": "Comment", "There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.", "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", - "Feedback": "Feedback", "Report a bug": "Report a bug", "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", - "Send feedback": "Send feedback", "Confirm abort of host creation": "Confirm abort of host creation", "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.", "Abort": "Abort", @@ -2355,7 +2374,6 @@ "Share Community": "Share Community", "Share Room Message": "Share Room Message", "Link to selected message": "Link to selected message", - "Copy": "Copy", "Command Help": "Command Help", "Failed to save space settings.": "Failed to save space settings.", "Space settings": "Space settings", @@ -2378,6 +2396,13 @@ "Summary": "Summary", "Document": "Document", "Next": "Next", + "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", + "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", + "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", + "Not Trusted": "Not Trusted", + "Manually Verify by Text": "Manually Verify by Text", + "Interactively verify by Emoji": "Interactively verify by Emoji", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload all": "Upload all", @@ -2470,6 +2495,11 @@ "Revoke permissions": "Revoke permissions", "Move left": "Move left", "Move right": "Move right", + "Spaces is a beta feature": "Spaces is a beta feature", + "Tap for more info": "Tap for more info", + "Beta": "Beta", + "Leave the beta": "Leave the beta", + "Join the beta": "Join the beta", "Avatar": "Avatar", "This room is public": "This room is public", "Away": "Away", @@ -2603,6 +2633,7 @@ "Error whilst fetching joined communities": "Error whilst fetching joined communities", "Create a new community": "Create a new community", "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.", + "Communities are changing to Spaces": "Communities are changing to Spaces", "You’re all caught up": "You’re all caught up", "You have no visible notifications.": "You have no visible notifications.", "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.", @@ -2663,19 +2694,22 @@ "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces", "%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space", "%(count)s rooms and 1 space|one": "%(count)s room and 1 space", + "Select a room below first": "Select a room below first", "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", "Removing...": "Removing...", "Mark as not suggested": "Mark as not suggested", "Mark as suggested": "Mark as suggested", "No results found": "No results found", "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", - "Search names and description": "Search names and description", + "Search names and descriptions": "Search names and descriptions", "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", "Create room": "Create room", + "Spaces are a beta feature.": "Spaces are a beta feature.", "Public space": "Public space", "Private space": "Private space", " invites you": " invites you", - "Add existing rooms & spaces": "Add existing rooms & spaces", + "To view %(spaceName)s, turn on the Spaces beta": "To view %(spaceName)s, turn on the Spaces beta", + "To join %(spaceName)s, turn on the Spaces beta": "To join %(spaceName)s, turn on the Spaces beta", "Welcome to ": "Welcome to ", "Random": "Random", "Support": "Support", @@ -2688,6 +2722,7 @@ "Share %(name)s": "Share %(name)s", "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", "Go to my first room": "Go to my first room", + "Go to my space": "Go to my space", "Who are you working with?": "Who are you working with?", "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", "Just me": "Just me", @@ -2698,6 +2733,7 @@ "Inviting...": "Inviting...", "Invite your teammates": "Invite your teammates", "Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.", "Invite by username": "Invite by username", "What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?", "Let's create a room for each of them.": "Let's create a room for each of them.", @@ -2821,7 +2857,7 @@ "Use a different passphrase?": "Use a different passphrase?", "That doesn't match.": "That doesn't match.", "Go back to set it again.": "Go back to set it again.", - "Please enter your Security Phrase a second time to confirm.": "Please enter your Security Phrase a second time to confirm.", + "Enter your Security Phrase a second time to confirm it.": "Enter your Security Phrase a second time to confirm it.", "Repeat your Security Phrase...": "Repeat your Security Phrase...", "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", @@ -2851,8 +2887,6 @@ "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", "Enter a security phrase only you know, as it’s used to safeguard your data. To be secure, you shouldn’t re-use your account password.": "Enter a security phrase only you know, as it’s used to safeguard your data. To be secure, you shouldn’t re-use your account password.", - "Enter your recovery passphrase a second time to confirm it.": "Enter your recovery passphrase a second time to confirm it.", - "Confirm your recovery passphrase": "Confirm your recovery passphrase", "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.", "Unable to query secret storage status": "Unable to query secret storage status", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json index 003da7ef8f..4a97b60a2e 100644 --- a/src/i18n/strings/en_US.json +++ b/src/i18n/strings/en_US.json @@ -657,5 +657,13 @@ "Confirm adding email": "Confirm adding email", "Single Sign On": "Single Sign On", "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirm adding this email address by using Single Sign On to prove your identity.", - "Use Single Sign On to continue": "Use Single Sign On to continue" + "Use Single Sign On to continue": "Use Single Sign On to continue", + "Message search initilisation failed": "Message search initialization failed", + "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait while we resynchronize with the server!", + "Customise your experience with experimental labs features. Learn more.": "Customize your experience with experimental labs features. Learn more.", + "Customise your appearance": "Customize your appearance", + "Unrecognised command: %(commandText)s": "Unrecognized command: %(commandText)s", + "Add some details to help people recognise it.": "Add some details to help people recognize it.", + "Unrecognised room address:": "Unrecognized room address:", + "A private space to organise your rooms": "A private space to organize your rooms" } diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json index 62e8e1f98a..f4d30b40b7 100644 --- a/src/i18n/strings/eo.json +++ b/src/i18n/strings/eo.json @@ -3188,5 +3188,35 @@ "Space options": "Agordoj de aro", "Space Home": "Hejmo de aro", "with state key %(stateKey)s": "kun statŝlosilo %(stateKey)s", - "with an empty state key": "kun malplena statŝlosilo" + "with an empty state key": "kun malplena statŝlosilo", + "Invited people will be able to read old messages.": "Invititoj povos legi malnovajn mesaĝojn.", + "Adding...": "Aldonante…", + "Add existing rooms": "Aldoni jamajn ĉambrojn", + "View message": "Montri mesaĝon", + "Zoom in": "Zomi", + "Zoom out": "Malzomi", + "%(count)s people you know have already joined|one": "%(count)s persono, kiun vi konas, jam aliĝis", + "%(count)s people you know have already joined|other": "%(count)s personoj, kiujn vi konas, jam aliĝis", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s anoj inkluzive je %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inkluzive je %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Montri 1 anon", + "View all %(count)s members|other": "Montri ĉiujn %(count)s anojn", + "Accept on your other login…": "Akceptu per via alia saluto…", + "Stop & send recording": "Ĉesi kaj sendi registrajon", + "Record a voice message": "Registri voĉmesaĝon", + "Quick actions": "Rapidaj agoj", + "Invite to just this room": "Inviti nur al ĉi tiu ĉambro", + "%(seconds)ss left": "%(seconds)s sekundoj restas", + "Failed to send": "Malsukcesis sendi", + "Change server ACLs": "Ŝanĝi servilblokajn listojn", + "Warn before quitting": "Averti antaŭ ĉesigo", + "Workspace: ": "Laborspaco: ", + "Manage & explore rooms": "Administri kaj esplori ĉambrojn", + "unknown person": "nekonata persono", + "Send and receive voice messages (in development)": "Sendi kaj ricevi voĉmesaĝojn (evoluigate)", + "Show options to enable 'Do not disturb' mode": "Montri elekteblojn por ŝalti sendistran reĝimon", + "%(deviceId)s from %(ip)s": "%(deviceId)s de %(ip)s", + "Review to ensure your account is safe": "Kontrolu por certigi sekurecon de via konto", + "Sends the given message as a spoiler": "Sendas la donitan mesaĝon kiel malkaŝon de intrigo" } diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index d396cc318f..3e4b7b52ce 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -3225,5 +3225,39 @@ "Use another login": "Usar otro inicio de sesión", "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica tu identidad para acceder a mensajes cifrados y probar tu identidad a otros.", "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Si no verificas no tendrás acceso a todos tus mensajes y puede que aparezcas como no confiable para otros usuarios.", - "Invite messages are hidden by default. Click to show the message.": "Los mensajes de invitación no se muestran por defecto. Haz clic para mostrarlo." + "Invite messages are hidden by default. Click to show the message.": "Los mensajes de invitación no se muestran por defecto. Haz clic para mostrarlo.", + "You can select all or individual messages to retry or delete": "Puedes seleccionar uno o todos los mensajes para reintentar o eliminar", + "Sending": "Enviando", + "Retry all": "Reintentar todo", + "Delete all": "Borrar todo", + "Some of your messages have not been sent": "Algunos de tus mensajes no se han enviado", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Eres la única persona aquí. Si te vas, no podrá unirse nadie en el futuro, incluyéndote a ti.", + "Forgotten or lost all recovery methods? Reset all": "¿Has olvidado o perdido todos los métodos de recuperación? Restablecer todo", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Si restableces todo, volverás a empezar sin sesiones ni usuarios de confianza, y puede que no puedas ver mensajes anteriores.", + "Only do this if you have no other device to complete verification with.": "Solo haz esto si no tienes ningún otro dispositivo con el que completar la verificación.", + "Reset everything": "Restablecer todo", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Si lo haces, ten en cuenta que ninguno de tus mensajes serán eliminados, pero la experiencia de búsqueda será peor durante unos momentos mientras recreamos el índice", + "View message": "Ver mensaje", + "Zoom in": "Acercar", + "Zoom out": "Alejar", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s miembros, incluyendo a %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Incluyendo %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Ver 1 miembro", + "View all %(count)s members|other": "Ver los %(count)s miembros", + "%(seconds)ss left": "%(seconds)ss restantes", + "Failed to send": "No se ha podido mandar", + "Change server ACLs": "Cambiar los ACLs del servidor", + "Show options to enable 'Do not disturb' mode": "Mostrar opciones para activar el modo «no molestar»", + "Stop the recording": "Parar grabación", + "Delete recording": "Borrar grabación", + "Enter your Security Phrase a second time to confirm it.": "Escribe tu frase de seguridad de nuevo para confirmarla.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elige salas o conversaciones para añadirlas. Este espacio es solo para ti, no informaremos a nadie. Puedes añadir más más tarde.", + "What do you want to organise?": "¿Qué quieres organizar?", + "Filter all spaces": "Filtrar todos los espacios", + "%(count)s results in all spaces|one": "%(count)s resultado en todos los espacios", + "%(count)s results in all spaces|other": "%(count)s resultados en todos los espacios", + "You have no ignored users.": "No has ignorado a nadie.", + "Pause": "Pausar", + "Play": "Reproducir" } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index 444475deea..fc42ad73dd 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -3263,5 +3263,39 @@ "Use another login": "Pruugi muud kasutajakontot", "Verify your identity to access encrypted messages and prove your identity to others.": "Tagamaks ligipääsu oma krüptitud sõnumitele ja tõestamaks oma isikut teistele kasutajatale, verifitseeri end.", "Let's create a room for each of them.": "Teeme siis iga teema jaoks oma jututoa.", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Ilma verifitseerimiseta sul puudub ligipääs kõikidele oma sõnumitele ning teised ei näe sinu kasutajakontot usaldusväärsena." + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Ilma verifitseerimiseta sul puudub ligipääs kõikidele oma sõnumitele ning teised ei näe sinu kasutajakontot usaldusväärsena.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Sa oled siin viimane osaleja. Kui sa nüüd lahkud, siis mitte keegi, kaasa arvatud sa ise, ei saa hiljem enam liituda.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Kui sa kõik krüptoseosed lähtestad, siis sul esimese hooga pole ühtegi usaldusväärseks tunnistatud sessiooni ega kasutajat ning ilmselt ei saa sa lugeda vanu sõnumeid.", + "Only do this if you have no other device to complete verification with.": "Toimi nii vaid siis, kui sul pole jäänud ühtegi seadet, millega verifitseerimist lõpuni teha.", + "Reset everything": "Alusta kõigega algusest", + "Forgotten or lost all recovery methods? Reset all": "Unustasid või oled kaotanud kõik võimalused ligipääsu taastamiseks? Lähtesta kõik ühe korraga", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Kui sa siiski soovid seda teha, siis sinu sõnumeid me ei kustuta, aga seniks kuni sõnumite indeks taustal uuesti luuakse, toimib otsing aeglaselt ja ebatõhusalt", + "View message": "Vaata sõnumit", + "Zoom in": "Suumi sisse", + "Zoom out": "Suumi välja", + "%(seconds)ss left": "jäänud %(seconds)s sekundit", + "Change server ACLs": "Muuda serveri ligipääsuõigusi", + "Show options to enable 'Do not disturb' mode": "Näita valikuid „Ära sega“ režiimi sisse lülitamiseks", + "You can select all or individual messages to retry or delete": "Sa võid valida kas kõik või mõned sõnumid kas kustutamiseks või uuesti saatmiseks", + "Sending": "Saadan", + "Retry all": "Proovi kõikidega uuesti", + "Delete all": "Kustuta kõik", + "Some of your messages have not been sent": "Mõned sinu sõnumid on saatmata", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s liiget, sealhulgas %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Sealhulgas %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Vaata üht liiget", + "View all %(count)s members|other": "Vaata kõiki %(count)s liiget", + "Failed to send": "Saatmine ei õnnestunud", + "Enter your Security Phrase a second time to confirm it.": "Kinnitamiseks palun sisesta turvafraas teist korda.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Lisamiseks vali vestlusi ja jututubasid. Hetkel on see kogukonnakeskus vaid sinu jaoks ja esialgu keegi ei saa sellest teada. Teisi saad liituma kutsuda hiljem.", + "What do you want to organise?": "Mida sa soovid ette võtta?", + "Filter all spaces": "Otsi kõikides kogukonnakeskustest", + "Delete recording": "Kustuta salvestus", + "Stop the recording": "Lõpeta salvestamine", + "%(count)s results in all spaces|one": "%(count)s tulemus kõikides kogukonnakeskustes", + "%(count)s results in all spaces|other": "%(count)s tulemust kõikides kogukonnakeskustes", + "You have no ignored users.": "Sa ei ole veel kedagi eiranud.", + "Play": "Esita", + "Pause": "Peata" } diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json index d036a55c23..5948048561 100644 --- a/src/i18n/strings/fa.json +++ b/src/i18n/strings/fa.json @@ -223,7 +223,7 @@ "Home": "خانه", "Hangup": "قطع", "For security, this session has been signed out. Please sign in again.": "برای امنیت، این نشست نامعتبر شده است. لطفاً دوباره وارد سیستم شوید.", - "We couldn't log you in": "ما نتوانستیم شما را وارد حسابتان کنیم", + "We couldn't log you in": "نتوانستیم شما را وارد کنیم", "Trust": "اعتماد کن", "Only continue if you trust the owner of the server.": "تنها در صورتی که به صاحب سرور اطمینان دارید، ادامه دهید.", "Identity server has no terms of service": "سرور هویت‌سنجی، شرایط استفاده از خدمت (terms of service) را مشخص نکرده‌است", @@ -314,5 +314,323 @@ "e.g. %(exampleValue)s": "برای مثال %(exampleValue)s", "Explore rooms": "کاوش اتاق", "Sign In": "ورود", - "Create Account": "ایجاد اکانت" + "Create Account": "ایجاد اکانت", + "Use an identity server": "از سرور هویت‌سنجی استفاده کنید", + "Invites user with given id to current room": "کاربر با شناسه داده شده را به اتاق فعلی دعوت کن", + "Sets the room name": "نام اتاق را تنظیم می کند", + "This room has no topic.": "این اتاق هیچ موضوعی ندارد.", + "Failed to set topic": "تنظیم موضوع موفقیت‌آمیز نبود", + "Gets or sets the room topic": "موضوع اتاق را دریافت یا تنظیم می‌کند", + "Changes your avatar in all rooms": "تصویر نمایه خود را در همه‌ی اتاق‌ها تغییر دهید", + "Changes your avatar in this current room only": "تصویر نمایه خود را تنها در این اتاق تغییر دهید", + "Changes the avatar of the current room": "تصویر نمایه اتاق فعلی را تغییر دهید", + "Changes your display nickname": "نام نمایشی خود را تغییر دهید", + "Changes your display nickname in the current room only": "نام نمایشی خود را تنها در اتاق فعلی تغییر دهید", + "Double check that your server supports the room version chosen and try again.": "بررسی کنید که کارگزار شما از نسخه اتاق انتخاب‌شده پشتیبانی کرده و دوباره امتحان کنید.", + "Error upgrading room": "خطا در ارتقاء نسخه اتاق", + "You do not have the required permissions to use this command.": "شما مجوزهای لازم را برای استفاده از این دستور ندارید.", + "Upgrades a room to a new version": "یک اتاق را به نسخه جدید ارتقا دهید", + "To use it, just wait for autocomplete results to load and tab through them.": "برای استفاده از آن ، لطفا منتظر بمانید تا نتایج تکمیل خودکار بارگیری شده و آنها را مرور کنید.", + "Searches DuckDuckGo for results": "در سایت DuckDuckGo جستجو می‌کند", + "Sends a message as html, without interpreting it as markdown": "پیام را به صورت html می فرستد ، بدون اینکه آن را به عنوان markdown تفسیر کند", + "Sends a message as plain text, without interpreting it as markdown": "پیام را به صورت متن ساده و بدون تفسیر آن به عنوان markdown ارسال می کند", + "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "(͡ ° ͜ʖ ͡ °) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "┬──┬ ノ (゜ - ゜ ノ) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "(╯ ° □ °) ╯︵ ┻━┻) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "¯ \\ _ (ツ) _ / ¯ را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Sends the given message as a spoiler": "پیام داده شده را به عنوان اسپویلر ارسال می کند", + "Usage": "استفاده", + "Other": "دیگر", + "Effects": "جلوه‌ها", + "Actions": "اقدامات", + "Messages": "پیام ها", + "Setting up keys": "تنظیم کلیدها", + "Go Back": "برگرد", + "Are you sure you want to cancel entering passphrase?": "آیا مطمئن هستید که می خواهید وارد کردن عبارت امنیتی را لغو کنید؟", + "Cancel entering passphrase?": "وارد کردن عبارت امنیتی لغو شود؟", + "Missing room_id in request": "room_id در صورت درخواست وجود ندارد", + "Missing user_id in request": "user_id در صورت درخواست وجود ندارد", + "Room %(roomId)s not visible": "اتاق %(roomId)s قابل مشاهده نیست", + "You do not have permission to do that in this room.": "شما مجاز به انجام این کار در این اتاق نیستید.", + "You are not in this room.": "شما در این اتاق نیستید.", + "Power level must be positive integer.": "سطح قدرت باید عدد صحیح مثبت باشد.", + "This room is not recognised.": "این اتاق شناخته نشده است.", + "Missing roomId.": "شناسه‌ی اتاق گم‌شده", + "Unable to create widget.": "ایجاد ابزارک امکان پذیر نیست.", + "You need to be able to invite users to do that.": "نیاز است که شما قادر به دعوت کاربران به آن باشید.", + "You need to be logged in.": "شما باید وارد شوید.", + "Failed to invite the following users to the %(roomName)s room:": "دعوت کاربران زیر به اتاق %(roomName)s موفقیت‌آمیز نبود:", + "Failed to invite users to the room:": "دعوت کاربران به اتاق موفقیت‌آمیز نبود:", + "Failed to invite": "دعوت موفقیت‌آمیز نبود", + "Custom (%(level)s)": "%(level)s دلخواه", + "Moderator": "معاون", + "Restricted": "ممنوع", + "Use your account or create a new one to continue.": "برای ادامه کار از حساب کاربری خود استفاده کرده و یا حساب کاربری جدیدی ایجاد کنید.", + "Sign In or Create Account": "وارد شوید یا حساب کاربری بسازید", + "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": "جزایر فارو", + "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": "کنگو - برازاویل", + "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": "انگلستان", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "به نظر نمی‌رسد آدرس ایمیل شما با هیچ کدام از شناسه ماتریکس در این سرور مرتبط باشد.", + "This email address was not found": "این آدرس ایمیل یافت نشد", + "Unable to enable Notifications": "فعال کردن اعلان ها امکان پذیر نیست", + "%(brand)s was not given permission to send notifications - please try again": "به %(brand)s اجازه ارسال اعلان داده نشده است - لطفاً دوباره امتحان کنید", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s اجازه ارسال اعلان به شما را ندارد - لطفاً تنظیمات مرورگر خود را بررسی کنید", + "%(name)s is requesting verification": "%(name)s درخواست تائید دارد", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "سرور درخواست ورود شما را رد کرد. این می‌تواند به خاطر طولانی شدن فرآیندها باشد. لطفا دوباره امتحان کنید. اگر این مشکل ادامه داشت، لطفا با مدیر سرور تماس بگیرید.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "سرور شما در دسترس نبود و امکان ورود شما میسر نیست. لطفا دوباره امتحان کنید. اگر مشکل ادامه داشت، لطفا با مدیر سرور تماس بگیرید.", + "Try again": "دوباره امتحان کنید", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "این که شما از %(brand)s روی دستگاهی استفاده می‌کنید که در آن لامسه مکانیزم اصلی ورودی است", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "ما از مرورگر خواستیم تا سروری را که شما برای ورود استفاده می‌کنید به خاطر بسپارد، اما متاسفانه مرورگر شما آن را فراموش کرده‌است. به صفحه‌ی ورود بروید و دوباره امتحان کنید.", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "این اقدام نیاز به دسترسی به سرور هویت‌سنجی پیش‌فرض برای تایید آدرس ایمیل یا شماره تماس دارد، اما کارگزار هیچ گونه شرایط خدماتی (terms of service) ندارد.", + "The remote side failed to pick up": "طرف دیگر قادر به پاسخ‌دادن نیست", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "هرگاه این صفحه شامل اطلاعات قابل شناسایی مانند شناسه‌ی اتاق ، کاربر یا گروه باشد ، این داده‌ها قبل از ارسال به سرور حذف می شوند.", + "Your user agent": "نماینده کاربری شما", + "Whether you're using %(brand)s as an installed Progressive Web App": "این که آیا شما از%(brand)s به عنوان یک PWA استفاده می‌کنید یا نه", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "این که آیا از ویژگی 'breadcrumbs' (نمایه‌ی کاربری بالای فهرست اتاق‌ها) استفاده می‌کنید یا خیر" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index fcc5ec9afe..4aee15691b 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -3024,7 +3024,7 @@ "Use Command + F to search": "Utilisez Commande + F pour rechercher", "Show line numbers in code blocks": "Afficher les numéros de ligne dans les blocs de code", "Expand code blocks by default": "Développer les blocs de code par défaut", - "Show stickers button": "Afficher le bouton autocollants", + "Show stickers button": "Afficher le bouton des autocollants", "Use app": "Utiliser l’application", "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web est expérimental sur téléphone. Pour une meilleure expérience et bénéficier des dernières fonctionnalités, utilisez notre application native gratuite.", "Use app for a better experience": "Utilisez une application pour une meilleure expérience", @@ -3043,7 +3043,7 @@ "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Votre serveur d’accueil n’est pas accessible, nous n’avons pas pu vous connecter. Merci de réessayer. Si cela persiste, merci de contacter l’administrateur de votre serveur d’accueil.", "Try again": "Réessayez", "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Nous avons demandé à votre navigateur de mémoriser votre serveur d’accueil, mais il semble l’avoir oublié. Rendez-vous à la page de connexion et réessayez.", - "We couldn't log you in": "Impossible de vous déconnecter", + "We couldn't log you in": "Nous n’avons pas pu vous connecter", "Upgrade to %(hostSignupBrand)s": "Mettre à jour vers %(hostSignupBrand)s", "Edit Values": "Modifier les valeurs", "Values at explicit levels in this room:": "Valeurs pour les rangs explicites de ce salon :", @@ -3071,7 +3071,7 @@ "Original event source": "Événement source original", "Decrypted event source": "Événement source déchiffré", "We'll create rooms for each of them. You can add existing rooms after setup.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez ajouter des salons après l’initialisation.", - "What projects are you working on?": "Sur quels projets travaillez vous ?", + "What projects are you working on?": "Sur quels projets travaillez-vous ?", "We'll create rooms for each topic.": "Nous allons créer un salon pour chaque sujet.", "What are some things you want to discuss?": "De quoi voulez vous discuter ?", "Inviting...": "Invitation…", @@ -3083,7 +3083,7 @@ "A private space just for you": "Un espace privé seulement pour vous", "Just Me": "Seulement moi", "Ensure the right people have access to the space.": "Vérifiez que les bonnes personnes ont accès à cet espace.", - "Who are you working with?": "Avec qui travaillez vous ?", + "Who are you working with?": "Avec qui travaillez-vous ?", "Finish": "Finir", "At the moment only you can see it.": "Pour l’instant vous seul pouvez le voir.", "Creating rooms...": "Création des salons…", @@ -3112,7 +3112,7 @@ "Remove from Space": "Supprimer de l’espace", "Undo": "Annuler", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Votre message n’a pas été envoyé car ce serveur d’accueil a été banni par son administrateur. Merci de contacter votre administrateur de service pour poursuivre l’usage de ce service.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", + "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes-vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", "This space is not public. You will not be able to rejoin without an invite.": "Cet espace n’est pas public. Vous ne pourrez pas le rejoindre sans invitation.", "Start audio stream": "Démarrer une diffusion audio", "Failed to start livestream": "Échec lors du démarrage de la diffusion en direct", @@ -3188,7 +3188,7 @@ "This room is suggested as a good one to join": "Ce salon recommandé peut être intéressant à rejoindre", "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Vérifiez cette connexion pour accéder à vos messages chiffrés et prouver aux autres qu’il s’agit bien de vous.", "Verify with another session": "Vérifier avec une autre session", - "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Nous allons créer un salon pour chaque. Vous pourrez en ajouter plus tard, y compris certains déjà existant.", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez aussi en ajouter plus tard, y compris certains déjà existant.", "Let's create a room for each of them. You can add more later too, including already existing ones.": "Créons un salon pour chacun d’entre eux. Vous pourrez en ajouter plus tard, y compris certains déjà existant.", "Make sure the right people have access. You can invite more later.": "Assurez-vous que les accès sont accordés aux bonnes personnes. Vous pourrez en inviter d’autres plus tard.", "A private space to organise your rooms": "Un espace privé pour organiser vos salons", @@ -3232,7 +3232,7 @@ "Please choose a strong password": "Merci de choisir un mot de passe fort", "You can add more later too, including already existing ones.": "Vous pourrez en ajouter plus tard, y compris certains déjà existant.", "Let's create a room for each of them.": "Créons un salon pour chacun d’entre eux.", - "What are some things you want to discuss in %(spaceName)s?": "De quoi voulez vous discuter dans %(spaceName)s ?", + "What are some things you want to discuss in %(spaceName)s?": "De quoi voulez-vous discuter dans %(spaceName)s ?", "Verification requested": "Vérification requise", "Avatar": "Avatar", "Verify other login": "Vérifier l’autre connexion", @@ -3262,5 +3262,39 @@ "Send and receive voice messages (in development)": "Envoyez et recevez des messages vocaux (en développement)", "%(deviceId)s from %(ip)s": "%(deviceId)s depuis %(ip)s", "Review to ensure your account is safe": "Vérifiez pour assurer la sécurité de votre compte", - "Sends the given message as a spoiler": "Envoie le message flouté" + "Sends the given message as a spoiler": "Envoie le message flouté", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Vous êtes la seule personne ici. Si vous partez, plus personne ne pourra rejoindre cette conversation, y compris vous.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Si vous réinitialisez tout, vous allez repartir sans session et utilisateur de confiance. Vous pourriez ne pas voir certains messages passés.", + "Only do this if you have no other device to complete verification with.": "Poursuivez seulement si vous n’avez aucun autre appareil avec lequel procéder à la vérification.", + "Reset everything": "Tout réinitialiser", + "Forgotten or lost all recovery methods? Reset all": "Vous avez perdu ou oublié tous vos moyens de récupération ? Tout réinitialiser", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Si vous le faites, notez qu’aucun de vos messages ne sera supprimé, mais la recherche pourrait être dégradée pendant quelques instants, le temps de recréer l’index", + "View message": "Afficher le message", + "Zoom in": "Zoomer", + "Zoom out": "Dé-zoomer", + "%(seconds)ss left": "%(seconds)s secondes restantes", + "Change server ACLs": "Modifier les ACL du serveur", + "Show options to enable 'Do not disturb' mode": "Afficher une option pour activer le mode « Ne pas déranger »", + "You can select all or individual messages to retry or delete": "Vous pouvez choisir de renvoyer ou supprimer tous les messages ou seulement certains", + "Sending": "Envoi", + "Retry all": "Tout renvoyer", + "Delete all": "Tout supprimer", + "Some of your messages have not been sent": "Certains de vos messages n’ont pas été envoyés", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membres dont %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Dont %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Afficher le membre", + "View all %(count)s members|other": "Afficher les %(count)s membres", + "Failed to send": "Échec de l’envoi", + "Play": "Lecture", + "Pause": "Pause", + "Enter your Security Phrase a second time to confirm it.": "Saisissez à nouveau votre phrase secrète pour la confirmer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Choisissez des salons ou conversations à ajouter. C’est un espace rien que pour vous, personne n’en sera informé. Vous pourrez en ajouter plus tard.", + "What do you want to organise?": "Que voulez-vous organiser ?", + "Filter all spaces": "Filtrer tous les espaces", + "Delete recording": "Supprimer l’enregistrement", + "Stop the recording": "Arrêter l’enregistrement", + "%(count)s results in all spaces|one": "%(count)s résultat dans tous les espaces", + "%(count)s results in all spaces|other": "%(count)s résultats dans tous les espaces", + "You have no ignored users.": "Vous n’avez ignoré personne." } diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index f6ebce684f..451be0fb7c 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -3285,5 +3285,39 @@ "What are some things you want to discuss in %(spaceName)s?": "Sobre que temas queres conversar en %(spaceName)s?", "Let's create a room for each of them.": "Crea unha sala para cada un deles.", "You can add more later too, including already existing ones.": "Podes engadir máis posteriormente, incluíndo os xa existentes.", - "Use another login": "Usar outra conexión" + "Use another login": "Usar outra conexión", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Es a única persoa aquí. Se saes, ninguén poderá unirse no futuro, incluíndote a ti.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se restableces todo, volverás a comezar sen sesións verificadas, usuarias de confianza, e poderías non poder ver as mensaxes anteriores.", + "Only do this if you have no other device to complete verification with.": "Fai isto únicamente se non tes outro dispositivo co que completar a verificación.", + "Reset everything": "Restablecer todo", + "Forgotten or lost all recovery methods? Reset all": "Perdidos ou esquecidos tódolos métodos de recuperación? Restabléceos", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se o fas, ten en conta que non se borrará ningunha das túas mensaxes, mais a experiencia de busca podería degradarse durante uns momentos ata que se recrea o índice", + "View message": "Ver mensaxe", + "Zoom in": "Achegar", + "Zoom out": "Alonxar", + "%(seconds)ss left": "%(seconds)ss restantes", + "Change server ACLs": "Cambiar ACLs do servidor", + "Show options to enable 'Do not disturb' mode": "Mostrar opcións para activar o modo 'Non molestar'", + "You can select all or individual messages to retry or delete": "Podes elexir todo ou mensaxes individuais para reintentar ou eliminar", + "Sending": "Enviando", + "Retry all": "Reintentar todo", + "Delete all": "Eliminar todo", + "Some of your messages have not been sent": "Algunha das túas mensaxes non se enviou", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membros, incluíndo a %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Incluíndo a %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Ver 1 membro", + "View all %(count)s members|other": "Ver tódolos %(count)s membros", + "Failed to send": "Fallou o envío", + "Enter your Security Phrase a second time to confirm it.": "Escribe a túa Frase de Seguridade por segunda vez para confirmala.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elixe salas ou conversas para engadilas. Este é un espazo para ti, ninguén será notificado. Podes engadir máis posteriormente.", + "What do you want to organise?": "Que queres organizar?", + "Filter all spaces": "Filtrar os espazos", + "Delete recording": "Eliminar a gravación", + "Stop the recording": "Deter a gravación", + "%(count)s results in all spaces|one": "%(count)s resultado en tódolos espazos", + "%(count)s results in all spaces|other": "%(count)s resultados en tódolos espazos", + "You have no ignored users.": "Non tes usuarias ignoradas.", + "Play": "Reproducir", + "Pause": "Deter" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index 2ec5af8a17..6b33ecb81e 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3280,5 +3280,39 @@ "Send and receive voice messages (in development)": "Hang üzenetek küldése és fogadása (fejlesztés alatt)", "%(deviceId)s from %(ip)s": "%(deviceId)s innen: %(ip)s", "Review to ensure your account is safe": "Tekintse át, hogy meggyőződjön arról, hogy a fiókja biztonságban van", - "Sends the given message as a spoiler": "A megadott üzenet szpojlerként küldése" + "Sends the given message as a spoiler": "A megadott üzenet szpojlerként küldése", + "Change server ACLs": "Kiszolgáló ACL-ek módosítása", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Csak ön van itt. Ha kilép, akkor a jövőben senki nem tud majd ide belépni, beleértve önt is.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Ha mindent alapállapotba helyez, nem lesz megbízható munkamenete, nem lesznek megbízható felhasználók és a régi üzenetekhez sem biztos, hogy hozzáfér majd.", + "Only do this if you have no other device to complete verification with.": "Csak akkor tegye meg, ha nincs egyetlen másik eszköze sem az ellenőrzés elvégzéséhez.", + "Reset everything": "Minden visszaállítása", + "Forgotten or lost all recovery methods? Reset all": "Elfelejtette vagy elveszett minden visszaállítási lehetőség? Mind alaphelyzetbe állítása", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Ha ezt teszi, tudnia kell, hogy az üzenetek nem kerülnek törlésre de keresés nem lesz tökéletes amíg az indexek nem készülnek el újra", + "View message": "Üzenet megjelenítése", + "Zoom in": "Nagyít", + "Zoom out": "Kicsinyít", + "%(seconds)ss left": "%(seconds)s mp van vissza", + "Show options to enable 'Do not disturb' mode": "Mutassa a lehetőséget a „Ne zavarjanak” módhoz", + "You can select all or individual messages to retry or delete": "Újraküldéshez vagy törléshez kiválaszthatja az üzeneteket egyenként vagy az összeset együtt", + "Retry all": "Mind újraküldése", + "Sending": "Küldés", + "Delete all": "Mind törlése", + "Some of your messages have not been sent": "Néhány üzenete nem lett elküldve", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s résztvevő beleértve: %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Beleértve: %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 résztvevő megmutatása", + "View all %(count)s members|other": "Az összes %(count)s résztvevő megmutatása", + "Failed to send": "Küldés sikertelen", + "Enter your Security Phrase a second time to confirm it.": "A megerősítéshez adja meg a biztonsági jelmondatot még egyszer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Válassz szobákat vagy beszélgetéseket amit hozzáadhat. Ez csak az ön tere, senki nem lesz értesítve. Továbbiakat később is hozzáadhat.", + "What do you want to organise?": "Mit szeretne megszervezni?", + "Filter all spaces": "Minden tér szűrése", + "Delete recording": "Felvétel törlése", + "Stop the recording": "Felvétel megállítása", + "%(count)s results in all spaces|one": "%(count)s találat van az összes térben", + "%(count)s results in all spaces|other": "%(count)s találat a terekben", + "You have no ignored users.": "Nincs figyelmen kívül hagyott felhasználó.", + "Play": "Lejátszás", + "Pause": "Szünet" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 2d0edb77e6..a393a83409 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -3285,5 +3285,39 @@ "You can add more later too, including already existing ones.": "Puoi aggiungerne anche altri in seguito, inclusi quelli già esistenti.", "Use another login": "Usa un altro accesso", "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica la tua identità per accedere ai messaggi cifrati e provare agli altri che sei tu.", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Senza la verifica, non avrai accesso a tutti i tuoi messaggi e potresti apparire agli altri come non fidato." + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Senza la verifica, non avrai accesso a tutti i tuoi messaggi e potresti apparire agli altri come non fidato.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Sei l'unica persona qui. Se esci, nessuno potrà entrare in futuro, incluso te.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se reimposti tutto, ricomincerai senza sessioni fidate, senza utenti fidati e potresti non riuscire a vedere i messaggi passati.", + "Only do this if you have no other device to complete verification with.": "Fallo solo se non hai altri dispositivi con cui completare la verifica.", + "Reset everything": "Reimposta tutto", + "Forgotten or lost all recovery methods? Reset all": "Hai dimenticato o perso tutti i metodi di recupero? Reimposta tutto", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se lo fai, ricorda che nessuno dei tuoi messaggi verrà eliminato, ma l'esperienza di ricerca potrà peggiorare per qualche momento mentre l'indice viene ricreato", + "View message": "Vedi messaggio", + "Zoom in": "Ingrandisci", + "Zoom out": "Rimpicciolisci", + "%(seconds)ss left": "%(seconds)ss rimanenti", + "Change server ACLs": "Modifica le ACL del server", + "Show options to enable 'Do not disturb' mode": "Mostra opzioni per attivare la modalità \"Non disturbare\"", + "You can select all or individual messages to retry or delete": "Puoi selezionare tutti o alcuni messaggi da riprovare o eliminare", + "Sending": "Invio in corso", + "Retry all": "Riprova tutti", + "Delete all": "Elimina tutti", + "Some of your messages have not been sent": "Alcuni tuoi messaggi non sono stati inviati", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membri inclusi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Vedi 1 membro", + "View all %(count)s members|other": "Vedi tutti i %(count)s membri", + "Failed to send": "Invio fallito", + "Enter your Security Phrase a second time to confirm it.": "Inserisci di nuovo la password di sicurezza per confermarla.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Scegli le stanze o le conversazioni da aggiungere. Questo è uno spazio solo per te, nessuno ne saprà nulla. Puoi aggiungerne altre in seguito.", + "What do you want to organise?": "Cosa vuoi organizzare?", + "Filter all spaces": "Filtra tutti gli spazi", + "Delete recording": "Elimina registrazione", + "Stop the recording": "Ferma la registrazione", + "%(count)s results in all spaces|one": "%(count)s risultato in tutti gli spazi", + "%(count)s results in all spaces|other": "%(count)s risultati in tutti gli spazi", + "You have no ignored users.": "Non hai utenti ignorati.", + "Play": "Riproduci", + "Pause": "Pausa" } diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index de98a878e8..a30c949635 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -1517,7 +1517,7 @@ "Explore rooms": "Gesprekken ontdekken", "Show previews/thumbnails for images": "Miniaturen voor afbeeldingen tonen", "Clear cache and reload": "Cache wissen en herladen", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit kan niet ongedaan gemaakt worden. Wilt u doorgaan?", "Remove %(count)s messages|one": "1 bericht verwijderen", "%(count)s unread messages including mentions.|other": "%(count)s ongelezen berichten, inclusief vermeldingen.", "%(count)s unread messages.|other": "%(count)s ongelezen berichten.", @@ -1932,7 +1932,7 @@ "Jump to first unread room.": "Ga naar het eerste ongelezen gesprek.", "Jump to first invite.": "Ga naar de eerste uitnodiging.", "Session verified": "Sessie geverifieerd", - "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze heeft nu toegang tot uw versleutelde berichten, en de sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", + "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. U heeft nu toegang tot uw versleutelde berichten, en deze sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Your new session is now verified. Other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Without completing security on this session, it won’t have access to encrypted messages.": "Als u de beveiliging van deze sessie niet vervolledigt, zal ze geen toegang hebben tot uw versleutelde berichten.", "Go Back": "Terugkeren", @@ -2366,7 +2366,7 @@ "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "De beheerder van uw server heeft eind-tot-eind-versleuteling standaard uitgeschakeld in alle privégesprekken en directe gesprekken.", "Scroll to most recent messages": "Spring naar meest recente bericht", "The authenticity of this encrypted message can't be guaranteed on this device.": "De echtheid van dit versleutelde bericht kan op dit apparaat niet worden gegarandeerd.", - "To link to this room, please add an address.": "Voeg een adres toe om naar deze kamer te verwijzen.", + "To link to this room, please add an address.": "Voeg een adres toe om naar dit gesprek te kunnen verwijzen.", "Remove messages sent by others": "Berichten van anderen verwijderen", "Privacy": "Privacy", "Keyboard Shortcuts": "Sneltoetsen", @@ -3170,6 +3170,40 @@ "Share decryption keys for room history when inviting users": "Deel ontsleutelsleutels voor de gespreksgeschiedenis wanneer u personen uitnodigd", "Send and receive voice messages (in development)": "Verstuur en ontvang audioberichten (in ontwikkeling)", "%(deviceId)s from %(ip)s": "%(deviceId)s van %(ip)s", - "Review to ensure your account is safe": "Controleer om u te verzekeren dat uw account veilig is", - "Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler" + "Review to ensure your account is safe": "Controleer ze voor de zekerheid dat uw account veilig is", + "Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "U bent de enige persoon hier. Als u weggaat, zal niemand in de toekomst kunnen toetreden, u ook niet.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Als u alles reset, zult u opnieuw opstarten zonder vertrouwde sessies, zonder vertrouwde gebruikers, en zult u misschien geen vroegere berichten meer kunnen zien.", + "Only do this if you have no other device to complete verification with.": "Doe dit alleen als u geen ander apparaat hebt om de verificatie mee uit te voeren.", + "Reset everything": "Alles opnieuw instellen", + "Forgotten or lost all recovery methods? Reset all": "Alles vergeten of alle herstelmethoden verloren? Alles opnieuw instellen", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Als u dat doet, let wel geen van uw berichten wordt verwijderd, maar de zoekresultaten zullen gedurende enkele ogenblikken verslechteren terwijl de index opnieuw wordt aangemaakt", + "View message": "Bericht bekijken", + "Zoom in": "Inzoomen", + "Zoom out": "Uitzoomen", + "%(seconds)ss left": "%(seconds)s's over", + "Change server ACLs": "Wijzig server ACL's", + "Show options to enable 'Do not disturb' mode": "Toon opties om de 'Niet storen' modus in te schakelen", + "You can select all or individual messages to retry or delete": "U kunt alles selecteren of per individueel bericht opnieuw verzenden of verwijderen", + "Sending": "Wordt verstuurd", + "Retry all": "Alles opnieuw proberen", + "Delete all": "Verwijder alles", + "Some of your messages have not been sent": "Enkele van uw berichten zijn niet verstuurd", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s leden inclusief %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusief %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 lid bekijken", + "View all %(count)s members|other": "Bekijk alle %(count)s leden", + "Failed to send": "Verzenden is mislukt", + "Enter your Security Phrase a second time to confirm it.": "Voor uw veiligheidswachtwoord een tweede keer in om het te bevestigen.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Kies een gesprek om hem toe te voegen. Dit is een space voor u, niemand zal hiervan een melding krijgen. U kan er later meer toevoegen.", + "What do you want to organise?": "Wat wilt u organiseren?", + "Filter all spaces": "Alle spaces filteren", + "Delete recording": "Opname verwijderen", + "Stop the recording": "Opname stoppen", + "%(count)s results in all spaces|one": "%(count)s resultaat in alle spaces", + "%(count)s results in all spaces|other": "%(count)s resultaten in alle spaces", + "You have no ignored users.": "U heeft geen gebruiker genegeerd.", + "Play": "Afspelen", + "Pause": "Pauze" } diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index ab9a478446..83c6c25833 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -2265,5 +2265,11 @@ "There was an error finding this widget.": "Wystąpił błąd podczas próby odnalezienia tego widżetu.", "Active Widgets": "Aktywne widżety", "Encryption not enabled": "Nie włączono szyfrowania", - "Encryption enabled": "Włączono szyfrowanie" + "Encryption enabled": "Włączono szyfrowanie", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Twój serwer domowy był nieosiągalny i nie mógł Cię zalogować. Spróbuj ponownie. Jeśli to się powtórzy, skontaktuj się z administratorem swojego serwera.", + "Try again": "Spróbuj ponownie", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Poprosiliśmy przeglądarkę o zapamiętanie, z którego serwera głównego korzystasz, aby umożliwić Ci logowanie, ale niestety Twoja przeglądarka o tym zapomniała. Przejdź do strony logowania i spróbuj ponownie.", + "We couldn't log you in": "Nie mogliśmy Cię zalogować", + "You're already in a call with this person.": "Prowadzisz już rozmowę z tą osobą.", + "Already in call": "Już dzwoni" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 7db3758fd8..da42347b49 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -3210,5 +3210,6 @@ "Removing...": "Удаление…", "Failed to remove some rooms. Try again later": "Не удалось удалить несколько комнат. Попробуйте позже", "%(count)s rooms and 1 space|one": "%(count)s комната и одно пространство", - "%(count)s rooms and 1 space|other": "%(count)s комнат и одно пространство" + "%(count)s rooms and 1 space|other": "%(count)s комнат и одно пространство", + "Sends the given message as a spoiler": "Отправить данное сообщение под спойлером" } diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index ad768b59cb..ab4d9862c8 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -3272,5 +3272,38 @@ "Share decryption keys for room history when inviting users": "Ndani me përdorues kyçe shfshehtëzimi, kur ftohen përdorues", "Send and receive voice messages (in development)": "Dërgoni dhe merrni mesazhe zanorë (në zhvillim)", "%(deviceId)s from %(ip)s": "%(deviceId)s prej %(ip)s", - "Review to ensure your account is safe": "Shqyrtojeni për t’u siguruar se llogaria është e parrezik" + "Review to ensure your account is safe": "Shqyrtojeni për t’u siguruar se llogaria është e parrezik", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jeni i vetmi person këtu. Nëse e braktisni, askush s’do të jetë në gjendje të hyjë në të ardhmen, përfshi ju.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Nëse riktheni gjithçka te parazgjedhjet, do të rifilloni pa sesione të besuara, pa përdorues të besuar, dhe mund të mos jeni në gjendje të shihni mesazhe të dikurshëm.", + "Only do this if you have no other device to complete verification with.": "Bëjeni këtë vetëm nëse s’keni pajisje tjetër me të cilën të plotësoni verifikimin.", + "Reset everything": "Kthe gjithçka te parazgjedhjet", + "Forgotten or lost all recovery methods? Reset all": "Harruat, ose humbët krejt metodat e rimarrjes? Riujdisini të gjitha", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Nëse e bëni, ju lutemi, kini parasysh se s’do të fshihet asnjë nga mesazhet tuaj, por puna me kërkimin mund degradojë për pak çaste, ndërkohë që rikrijohet treguesi", + "View message": "Shihni mesazh", + "%(seconds)ss left": "Edhe %(seconds)ss", + "Change server ACLs": "Ndryshoni ACL-ra shërbyesi", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Po kryhet këshillim me %(transferTarget)s. Shpërngule te %(transferee)s", + "Show options to enable 'Do not disturb' mode": "Shfaq mundësi për aktivizim të mënyrës “Mos më shqetësoni”", + "You can select all or individual messages to retry or delete": "Për riprovim ose fshirje mund të përzgjidhni krejt mesazhet, ose të tillë individualë", + "Sending": "Po dërgohet", + "Retry all": "Riprovoji krejt", + "Delete all": "Fshiji krejt", + "Some of your messages have not been sent": "Disa nga mesazhet tuaj s’janë dërguar", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s anëtarë, përfshi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Prfshi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Shihni 1 anëtar", + "View all %(count)s members|other": "Shihni krejt %(count)s anëtarët", + "Failed to send": "S’u arrit të dërgohet", + "Enter your Security Phrase a second time to confirm it.": "Jepni Frazën tuaj të Sigurisë edhe një herë, për ta ripohuar.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Zgjidhni dhoma ose biseda që të shtohen. Kjo është thjesht një hapësirë për ju, s’do ta dijë kush tjetër. Mund të shtoni të tjerë më vonë.", + "What do you want to organise?": "Ç’doni të sistemoni?", + "Filter all spaces": "Filtro krejt hapësirat", + "Delete recording": "Fshije regjistrimin", + "Stop the recording": "Ndale regjistrimin", + "%(count)s results in all spaces|one": "%(count)s përfundim në krejt hapësirat", + "%(count)s results in all spaces|other": "%(count)s përfundime në krejt hapësirat", + "You have no ignored users.": "S’keni përdorues të shpërfillur.", + "Play": "Luaje", + "Pause": "Ndalesë" } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index 42a7f78268..5824224f3c 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -3216,5 +3216,39 @@ "Please choose a strong password": "Vänligen välj ett starkt lösenord", "Use another login": "Använd annan inloggning", "Verify your identity to access encrypted messages and prove your identity to others.": "Verifiera din identitet för att komma åt krypterade meddelanden och bevisa din identitet för andra.", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Om du inte verifierar så kommer du inte ha åtkomst till alla dina meddelanden och kan synas som ej betrodd för andra." + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Om du inte verifierar så kommer du inte ha åtkomst till alla dina meddelanden och kan synas som ej betrodd för andra.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du är den enda personen här. Om du lämnar så kommer ingen kunna gå med igen, inklusive du.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Om du återställer allt så kommer du att börja om utan betrodda sessioner eller betrodda användare, och kommer kanske inte kunna se gamla meddelanden.", + "Only do this if you have no other device to complete verification with.": "Gör detta endast om du inte har någon annan enhet att slutföra verifikationen med.", + "Reset everything": "Återställ allt", + "Forgotten or lost all recovery methods? Reset all": "Glömt eller förlorat alla återställningsalternativ? Återställ allt", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Om du gör det, observera att inga av dina meddelanden kommer att raderas, men sökupplevelsen kan degraderas en stund medans registret byggs upp igen", + "View message": "Visa meddelande", + "Zoom in": "Zooma in", + "Zoom out": "Zooma ut", + "%(seconds)ss left": "%(seconds)ss kvar", + "Change server ACLs": "Ändra server-ACLer", + "Show options to enable 'Do not disturb' mode": "Visa alternativ för att aktivera 'Stör ej'-läget", + "Delete all": "Radera alla", + "View all %(count)s members|one": "Visa 1 medlem", + "View all %(count)s members|other": "Visa alla %(count)s medlemmar", + "You can select all or individual messages to retry or delete": "Du kan välja alla eller individuella meddelanden att försöka igen eller radera", + "Sending": "Skickar", + "Retry all": "Försök alla igen", + "Some of your messages have not been sent": "Vissa av dina meddelanden har inte skickats", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s medlemmar inklusive %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inklusive %(commaSeparatedMembers)s", + "Failed to send": "Misslyckades att skicka", + "Enter your Security Phrase a second time to confirm it.": "Ange din säkerhetsfras igen för att bekräfta den.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Välj rum eller konversationer att lägga till. Detta är bara ett utrymmer för dig, ingen kommer att informeras. Du kan lägga till fler senare.", + "What do you want to organise?": "Vad vill du organisera?", + "Filter all spaces": "Filtrera alla utrymmen", + "Delete recording": "Radera inspelningen", + "Stop the recording": "Stoppa inspelningen", + "%(count)s results in all spaces|one": "%(count)s resultat i alla utrymmen", + "%(count)s results in all spaces|other": "%(count)s resultat i alla utrymmen", + "You have no ignored users.": "Du har inga ignorerade användare.", + "Play": "Spela", + "Pause": "Pausa" } diff --git a/src/i18n/strings/tzm.json b/src/i18n/strings/tzm.json index ba63af5fb0..f9ac9cf574 100644 --- a/src/i18n/strings/tzm.json +++ b/src/i18n/strings/tzm.json @@ -4,7 +4,7 @@ "Actions": "Tugawin", "Messages": "Tuzinin", "Cancel": "Sser", - "Create Account": "Ssenflul amiḍan", + "Create Account": "senflul amiḍan", "Sign In": "Kcem", "Name or Matrix ID": "Isem neɣ ID Matrix", "Dec": "Duj", @@ -35,5 +35,122 @@ "The version of %(brand)s": "Taleqqemt n %(brand)s", "Add Phone Number": "Rnu uṭṭun n utilifun", "Add Email Address": "Rnu tasna imayl", - "Open": "Ṛẓem" + "Open": "Ṛẓem", + "Permissions": "Tisirag", + "Subscribe": "Zemmem", + "Change": "Senfel", + "Disconnect": "Kkes azday", + "exists": "illa", + "Santa": "Santa", + "Pizza": "Tapizzat", + "Corn": "Akbal", + "Cloud": "Tagut", + "Globe": "Amaḍal", + "Flower": "Ajeǧǧig", + "Butterfly": "Aferteṭṭu", + "Rooster": "Ayaẓiḍ", + "Panda": "Apanda", + "Upgrade": "Leqqem", + "Confirm": "Sentem", + "Brazil": "Brazil", + "Bolivia": "Bulivya", + "Bhutan": "Buṭan", + "Bermuda": "Birmuda", + "Benin": "Binin", + "Belize": "Biliz", + "Belgium": "Beljika", + "Belarus": "Bilarusya", + "Bahamas": "Bahamas", + "Aruba": "Aruba", + "Angola": "Angula", + "Andorra": "Andura", + "Algeria": "Dzayer", + "Albania": "Albanya", + "End": "End", + "Space": "Space", + "Shift": "Shift", + "Super": "Super", + "Ctrl": "Ctrl", + "Esc": "Esc", + "Calls": "Iɣuṛiten", + "Emoji": "Imuji", + "Afghanistan": "Afɣanistan", + "Logout": "Ffeɣ", + "Leave": "Fel", + "Phone": "Atilifun", + "Email": "Imayl", + "Go": "Ddu", + "Send": "Azen", + "example": "amedya", + "Example": "Amedya", + "Hide": "Ffer", + "Name": "Isem", + "Flags": "Icenyalen", + "Join": "Lkem", + "edited": "infel", + "Copied!": "inɣel!", + "Home": "Asnubeg", + "Reply": "Rar", + "Yes": "Yah", + "About": "Xef", + "Search…": "Arezzu…", + "A-Z": "A-Ẓ", + "Settings": "Tisɣal", + "Reject": "Agy", + "Re-join": "als-lkem", + "People": "Midden", + "Search": "Rzu", + "%(duration)sd": "%(duration)sas", + "Loading...": "Azdam...", + "Share": "Bḍu", + "Camera": "Takamiṛa", + "Microphone": "Amikṛu", + "Add": "Rnu", + "Ignore": "Nexxel", + "None": "Walu", + "Account": "Amiḍan", + "Theme": "Asgum", + "Algorithm:": "Talguritmit:", + "Save": "Ḥḍu", + "Profile": "Ifres", + "ID": "ID", + "Remove": "KKes", + "Folder": "Asdaw", + "Guitar": "Agiṭaṛ", + "Ball": "Tacama", + "Flag": "Acenyal", + "Telephone": "Atilifun", + "Key": "Tasarut", + "Book": "Adlis", + "Gift": "Timucit", + "Hat": "Tarazal", + "Robot": "Aṛubu", + "Heart": "Ul", + "Apple": "Tadeffuyt", + "Banana": "Tabanant", + "Fire": "Timessi", + "Moon": "Ayyur", + "Mushroom": "Agursel", + "Tree": "Aseklu", + "Fish": "Aselm", + "Turtle": "Ifker", + "Rabbit": "Agnin", + "Elephant": "Ilew", + "Pig": "Ilef", + "Close": "Rgel", + "Horse": "Ayyis", + "Lion": "Izem", + "Cat": "Amuc", + "Dog": "Aydi", + "or": "neɣ", + "Decline": "Agy", + "Guest": "Anebgi", + "Ok": "Wax", + "Notifications": "Tineɣmisin", + "No": "Uhu", + "Dark": "Adeɣmum", + "Usage": "Asemres", + "Feb": "Bṛa", + "Jan": "Yen", + "Continue": "Kemmel" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index c9bb9bb2d7..2dff300c18 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -3288,5 +3288,39 @@ "You can add more later too, including already existing ones.": "您稍後可以新增更多內容,包含既有的。", "Please choose a strong password": "請選擇強密碼", "Use another login": "使用其他登入", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未經驗證,您將無法存取您的所有訊息,且可能不被其他人信任。" + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未經驗證,您將無法存取您的所有訊息,且可能不被其他人信任。", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "您是這裡唯一的人。如果您離開,包含您在內的任何人都無法加入。", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "如果您重設所有東西,您將會在沒有受信任的工作階段、沒有受信任的使用者,且可能會看不到過去的訊息。", + "Only do this if you have no other device to complete verification with.": "當您沒有其他裝置可以完成驗證時,才執行此動作。", + "Reset everything": "重設所有東西", + "Forgotten or lost all recovery methods? Reset all": "忘記或遺失了所有復原方法?重設全部", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "如果這樣做,請注意,您的訊息不會被刪除,但在重新建立索引時,搜尋體驗可能會降低片刻", + "View message": "檢視訊息", + "Zoom in": "放大", + "Zoom out": "縮小", + "%(seconds)ss left": "剩%(seconds)s秒", + "Change server ACLs": "變更伺服器 ACL", + "Show options to enable 'Do not disturb' mode": "顯示啟用「勿打擾」模式的選項", + "You can select all or individual messages to retry or delete": "您可以選取全部或單獨的訊息來重試或刪除", + "Sending": "正在傳送", + "Retry all": "重試全部", + "Delete all": "刪除全部", + "Some of your messages have not been sent": "您的部份訊息未傳送", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s 個成員包含 %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "包含 %(commaSeparatedMembers)s", + "View all %(count)s members|one": "檢視 1 個成員", + "View all %(count)s members|other": "檢視全部 %(count)s 個成員", + "Failed to send": "傳送失敗", + "Enter your Security Phrase a second time to confirm it.": "再次輸入您的安全密語以進行確認。", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "挑選要新增的聊天室或對話。這是專屬於您的空間,不會有人被通知。您稍後可以再新增更多。", + "What do you want to organise?": "您想要整理什麼?", + "Filter all spaces": "過濾所有空間", + "Delete recording": "刪除錄製", + "Stop the recording": "停止錄製", + "%(count)s results in all spaces|one": "所有空間中有 %(count)s 個結果", + "%(count)s results in all spaces|other": "所有空間中有 %(count)s 個結果", + "You have no ignored users.": "您沒有忽略的使用者。", + "Play": "播放", + "Pause": "暫停" } diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index 1cb44f240d..857dc5b248 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -38,7 +38,6 @@ export default class EventIndex extends EventEmitter { this._eventsPerCrawl = 100; this._crawler = null; this._currentCheckpoint = null; - this.liveEventsForIndex = new Set(); } async init() { @@ -188,16 +187,12 @@ export default class EventIndex extends EventEmitter { return; } - // If the event is not yet decrypted mark it for the - // Event.decrypted callback. if (ev.isBeingDecrypted()) { - const eventId = ev.getId(); - this.liveEventsForIndex.add(eventId); - } else { - // If the event is decrypted or is unencrypted add it to the - // index now. - await this.addLiveEventToIndex(ev); + // XXX: Private member access + await ev._decryptionPromise; } + + await this.addLiveEventToIndex(ev); } onRoomStateEvent = async (ev, state) => { @@ -216,10 +211,7 @@ export default class EventIndex extends EventEmitter { * listener, if so queues it up to be added to the index. */ onEventDecrypted = async (ev, err) => { - const eventId = ev.getId(); - // If the event isn't in our live event set, ignore it. - if (!this.liveEventsForIndex.delete(eventId)) return; if (err) return; await this.addLiveEventToIndex(ev); } @@ -523,18 +515,23 @@ export default class EventIndex extends EventEmitter { } }); - const decryptionPromises = []; - - matrixEvents.forEach(ev => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { - // TODO the decryption promise is a private property, this - // should either be made public or we should convert the - // event that gets fired when decryption is done into a - // promise using the once event emitter method: - // https://nodejs.org/api/events.html#events_events_once_emitter_name - decryptionPromises.push(ev._decryptionPromise); - } - }); + const decryptionPromises = matrixEvents + .filter(event => event.isEncrypted()) + .map(event => { + if (event.shouldAttemptDecryption()) { + return event.attemptDecryption(client._crypto, { + isRetry: true, + emit: false, + }); + } else { + // TODO the decryption promise is a private property, this + // should either be made public or we should convert the + // event that gets fired when decryption is done into a + // promise using the once event emitter method: + // https://nodejs.org/api/events.html#events_events_once_emitter_name + return event._decryptionPromise; + } + }); // Let us wait for all the events to get decrypted. await Promise.all(decryptionPromises); diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 84a131f23a..feda257d8b 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -254,11 +254,15 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { - return null; - } else { - return '_blank'; + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { + return null; + } else { + return '_blank'; + } + } catch (e) { + // malformed URI } } return null; diff --git a/src/rageshake/rageshake.js b/src/rageshake/rageshake.js index b886f369df..9512f62e42 100644 --- a/src/rageshake/rageshake.js +++ b/src/rageshake/rageshake.js @@ -73,7 +73,9 @@ class ConsoleLogger { // Convert objects and errors to helpful things args = args.map((arg) => { - if (arg instanceof Error) { + if (arg instanceof DOMException) { + return arg.message + ` (${arg.name} | ${arg.code}) ` + (arg.stack ? `\n${arg.stack}` : ''); + } else if (arg instanceof Error) { return arg.message + (arg.stack ? `\n${arg.stack}` : ''); } else if (typeof (arg) === 'object') { try { diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts index 29856b1a86..f46dd88fba 100644 --- a/src/rageshake/submit-rageshake.ts +++ b/src/rageshake/submit-rageshake.ts @@ -28,6 +28,7 @@ import * as rageshake from './rageshake'; // polyfill textencoder if necessary import * as TextEncodingUtf8 from 'text-encoding-utf-8'; import SettingsStore from "../settings/SettingsStore"; +import SdkConfig from "../SdkConfig"; let TextEncoder = window.TextEncoder; if (!TextEncoder) { TextEncoder = TextEncodingUtf8.TextEncoder; @@ -268,6 +269,25 @@ function uint8ToString(buf: Buffer) { return out; } +export async function submitFeedback(endpoint: string, label: string, comment: string, canContact = false) { + let version = "UNKNOWN"; + try { + version = await PlatformPeg.get().getAppVersion(); + } catch (err) {} // PlatformPeg already logs this. + + const body = new FormData(); + body.append("label", label); + body.append("text", comment); + body.append("can_contact", canContact ? "yes" : "no"); + + body.append("app", "element-web"); + body.append("version", version); + body.append("platform", PlatformPeg.get().getHumanReadableName()); + body.append("user_id", MatrixClientPeg.get()?.getUserId()); + + await _submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {}); +} + function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); diff --git a/src/settings/Settings.ts b/src/settings/Settings.tsx similarity index 92% rename from src/settings/Settings.ts rename to src/settings/Settings.tsx index 2a26eeac13..577f23fa3a 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.tsx @@ -16,8 +16,9 @@ limitations under the License. */ import { MatrixClient } from 'matrix-js-sdk/src/client'; +import React, { ReactNode } from "react"; -import { _td } from '../languageHandler'; +import { _t, _td } from '../languageHandler'; import { NotificationBodyEnabledController, NotificationsEnabledController, @@ -39,6 +40,7 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController"; import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; +import SdkConfig from "../SdkConfig"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -117,6 +119,15 @@ export interface ISetting { // historical settings which we don't want existing user's values be wiped. Do // not use this for new settings. invertedSettingName?: string; + + betaInfo?: { + title: string; // _td + caption: string; // _td + disclaimer?: (enabled: boolean) => ReactNode; + image: string; // require(...) + feedbackSubheading?: string; + feedbackLabel?: string; + }; } export const SETTINGS: {[setting: string]: ISetting} = { @@ -127,6 +138,36 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, controller: new ReloadOnChangeController(), + betaInfo: { + title: _td("Spaces"), + caption: _td("Spaces are a new way to group rooms and people."), + disclaimer: (enabled) => { + if (enabled) { + return <> +

    { _t("If you leave, %(brand)s will reload with Spaces disabled. " + + "Communities and custom tags will be visible again.", { + brand: SdkConfig.get().brand, + }) }

    +

    { _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }

    + ; + } + + return <> +

    { _t("%(brand)s will reload with Spaces enabled. " + + "Communities and custom tags will be hidden.", { + brand: SdkConfig.get().brand, + }) }

    + { _t("You can leave the beta any time from settings or tapping on a beta badge, " + + "like the one above.") } +

    { _t("Beta available for web, desktop and Android. " + + "Some features may be unavailable on your homeserver.") }

    + ; + }, + image: require("../../res/img/betas/spaces.png"), + feedbackSubheading: _td("Your feedback will help make spaces better. " + + "The more detail you can go into, the better."), + feedbackLabel: "spaces-feedback", + }, }, "feature_dnd": { isFeature: true, @@ -136,7 +177,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "feature_voice_messages": { isFeature: true, - displayName: _td("Send and receive voice messages (in development)"), + displayName: _td("Send and receive voice messages"), supportedLevels: LEVELS_FEATURE, default: false, }, @@ -438,7 +479,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, - displayName: _td('Allow Peer-to-Peer for 1:1 calls'), + displayName: _td( + "Allow Peer-to-Peer for 1:1 calls " + + "(if you enable this, the other party might be able to see your IP address)", + ), default: true, invertedSettingName: 'webRtcForceTURN', }, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index c2675bd8f8..c32bbe731d 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -257,6 +257,15 @@ export default class SettingsStore { return SETTINGS[settingName].isFeature; } + public static getBetaInfo(settingName: string) { + // consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag + if (SettingsStore.isFeature(settingName) + && SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false + ) { + return SETTINGS[settingName]?.betaInfo; + } + } + /** * Determines if a setting is enabled. * If a setting is disabled then it should be hidden from the user. @@ -445,8 +454,8 @@ export default class SettingsStore { throw new Error("Setting '" + settingName + "' does not appear to be a setting."); } - // When features are specified in the config.json, we force them as enabled or disabled. - if (SettingsStore.isFeature(settingName)) { + // When non-beta features are specified in the config.json, we force them as enabled or disabled. + if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) { const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true); if (configVal === true || configVal === false) return false; } diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 393f4f27a1..1a78a1b485 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -122,7 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private async appendRoom(room: Room) { - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index ec6227e45e..b1b8199f93 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {sortBy, throttle} from "lodash"; +import {ListIteratee, Many, sortBy, throttle} from "lodash"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixEvent} from "matrix-js-sdk/src/models/event"; @@ -31,28 +31,23 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS import {DefaultTagID} from "./room-list/models"; import {EnhancedMap, mapDiff} from "../utils/maps"; import {setHasDiff} from "../utils/sets"; -import {objectDiff} from "../utils/objects"; -import {arrayHasDiff} from "../utils/arrays"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; -type SpaceKey = string | symbol; - interface IState {} const ACTIVE_SPACE_LS_KEY = "mx_active_space"; -export const HOME_SPACE = Symbol("home-space"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); -// Space Room ID/HOME_SPACE will be emitted when a Space's children change +// Space Room ID will be emitted when a Space's children change const MAX_SUGGESTED_ROOMS = 20; -const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -61,15 +56,18 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, }, [[], []]); }; -const getOrder = (ev: MatrixEvent): string | null => { - const content = ev.getContent(); - if (typeof content.order === "string" && Array.from(content.order).every((c: string) => { +// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id` +export const getOrder = (order: string, creationTs: number, roomId: string): Array>> => { + let validatedOrder: string = null; + + if (typeof order === "string" && Array.from(order).every((c: string) => { const charCode = c.charCodeAt(0); return charCode >= 0x20 && charCode <= 0x7F; })) { - return content.order; + validatedOrder = order; } - return null; + + return [validatedOrder, creationTs, roomId]; } const getRoomFn: FetchRoomFn = (room: Room) => { @@ -83,15 +81,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; - // The list of rooms not present in any currently joined spaces - private orphanedRooms = new Set(); // Map from room ID to set of spaces which list it as a child private parentMap = new EnhancedMap>(); - // Map from space key to SpaceNotificationState instance representing that space - private notificationStateMap = new Map(); + // Map from spaceId to SpaceNotificationState instance representing that space + private notificationStateMap = new Map(); // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); - // The space currently selected in the Space Panel - if null then `Home` is selected + private spaceFilteredRooms = new Map>(); + // The space currently selected in the Space Panel - if null then All Rooms is selected private _activeSpace?: Room = null; private _suggestedRooms: ISpaceSummaryRoom[] = []; private _invitedSpaces = new Set(); @@ -112,6 +108,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._suggestedRooms; } + /** + * Sets the active space, updates room list filters, + * optionally switches the user's room back to where they were when they last viewed that space. + * @param space which space to switch to. + * @param contextSwitch whether to switch the user's context, + * should not be done when the space switch is done implicitly due to another event like switching room. + */ public async setActiveSpace(space: Room | null, contextSwitch = true) { if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; @@ -193,9 +196,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private getChildren(spaceId: string): Room[] { const room = this.matrixClient?.getRoom(spaceId); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); - return sortBy(childEvents, getOrder) - .map(ev => this.matrixClient.getRoom(ev.getStateKey())) - .filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; + return sortBy(childEvents, ev => { + const roomId = ev.getStateKey(); + const childRoom = this.matrixClient?.getRoom(roomId); + const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs(); + return getOrder(ev.getContent().order, createTs, roomId); + }).map(ev => { + return this.matrixClient.getRoom(ev.getStateKey()); + }).filter(room => { + return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite"; + }) || []; } public getChildRooms(spaceId: string): Room[] { @@ -227,7 +237,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { - return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); + if (!space) { + return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); + } + return this.spaceFilteredRooms.get(space.roomId) || new Set(); }; private rebuild = throttle(() => { @@ -258,7 +271,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }); - const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); + const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren)); // somewhat algorithm to handle full-cycles const detachedNodes = new Set(spaces); @@ -299,13 +312,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // rootSpaces.push(space); // }); - this.orphanedRooms = new Set(orphanedRooms); this.rootSpaces = rootSpaces; this.parentMap = backrefs; // if the currently selected space no longer exists, remove its selection if (this._activeSpace && detachedNodes.has(this._activeSpace)) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } this.onRoomsUpdate(); // TODO only do this if a change has happened @@ -320,25 +332,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.rebuild(); } - private showInHomeSpace = (room: Room) => { - if (room.isSpaceRoom()) return false; - return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space - || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space - || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites - }; - - // Update a given room due to its tag changing (e.g DM-ness or Fav-ness) - // This can only change whether it shows up in the HOME_SPACE or not - private onRoomUpdate = (room: Room) => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId); - this.emit(HOME_SPACE); - } else if (!this.orphanedRooms.has(room.roomId)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId); - this.emit(HOME_SPACE); - } - }; - private onSpaceMembersChange = (ev: MatrixEvent) => { // skip this update if we do not have a DM with this user if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; @@ -352,16 +345,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - // put all room invites in the Home Space - const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); - - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId); - } - }); - this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. @@ -406,10 +389,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.forEach((roomIds, s) => { // Update NotificationStates - this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => roomIds.has(room.roomId))); + this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { + if (roomIds.has(room.roomId)) { + return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); + } + + return false; + })); }); }, 100, {trailing: true, leading: true}); + private switchToRelatedSpace = (roomId: string) => { + if (this.suggestedRooms.find(r => r.room_id === roomId)) return; + + let parent = this.getCanonicalParent(roomId); + if (!parent) { + parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId)); + } + if (!parent) { + const parents = Array.from(this.parentMap.get(roomId) || []); + parent = parents.find(p => this.matrixClient.getRoom(p)); + } + + // don't trigger a context switch when we are switching a space to match the chosen room + this.setActiveSpace(parent || null, false); + }; + private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { const membership = newMembership || room.getMyMembership(); @@ -424,6 +430,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (numSuggestedRooms !== this._suggestedRooms.length) { this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } + + // if the room currently being viewed was just joined then switch to its related space + if (newMembership === "join" && room.roomId === RoomViewStore.getRoomId()) { + this.switchToRelatedSpace(room.roomId); + } } return; } @@ -442,7 +453,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space - this.setActiveSpace(room); + this.setActiveSpace(room, false); } }; @@ -463,8 +474,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else { - this.onRoomUpdate(room); } this.emit(room.roomId); break; @@ -477,38 +486,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; - private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => { - if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) { - // If the room was in favourites and now isn't or the opposite then update its position in the trees - const oldTags = lastEvent?.getContent()?.tags || {}; - const newTags = ev.getContent()?.tags || {}; - if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { - this.onRoomUpdate(room); - } - } - } - - private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Direct) { - const lastContent = lastEvent.getContent(); - const content = ev.getContent(); - - const diff = objectDiff>(lastContent, content); - // filter out keys which changed by reference only by checking whether the sets differ - const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k])); - // DM tag changes, refresh relevant rooms - new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => { - const room = this.matrixClient?.getRoom(roomId); - if (room) { - this.onRoomUpdate(room); - } - }); - } - }; - protected async reset() { this.rootSpaces = []; - this.orphanedRooms = new Set(); this.parentMap = new EnhancedMap(); this.notificationStateMap = new Map(); this.spaceFilteredRooms = new Map(); @@ -523,8 +502,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); - this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); } @@ -534,18 +511,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("RoomState.events", this.onRoomState); - this.matrixClient.on("Room.accountData", this.onRoomAccountData); - this.matrixClient.on("accountData", this.onAccountData); await this.onSpaceUpdate(); // trigger an initial update // restore selected state from last session if any and still valid const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); if (lastSpaceId) { - const space = this.rootSpaces.find(s => s.roomId === lastSpaceId); - if (space) { - this.setActiveSpace(space); - } + this.setActiveSpace(this.matrixClient.getRoom(lastSpaceId)); } } @@ -553,27 +525,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!SettingsStore.getValue("feature_spaces")) return; switch (payload.action) { case "view_room": { - const room = this.matrixClient?.getRoom(payload.room_id); - // Don't auto-switch rooms when reacting to a context-switch // as this is not helpful and can create loops of rooms/space switching - if (!room || payload.context_switch) break; + if (payload.context_switch) break; - if (room.isSpaceRoom()) { + const roomId = payload.room_id; + const room = this.matrixClient?.getRoom(roomId); + if (room?.isSpaceRoom()) { // Don't context switch when navigating to the space room // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); - } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { - let parent = this.getCanonicalParent(room.roomId); - if (!parent) { - parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); - } - if (!parent) { - const parents = Array.from(this.parentMap.get(room.roomId) || []); - parent = parents.find(p => this.matrixClient.getRoom(p)); - } - // don't trigger a context switch when we are switching a space to match the chosen room - this.setActiveSpace(parent || null, false); + } else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { + this.switchToRelatedSpace(roomId); } // Persist last viewed room from a space @@ -584,13 +547,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } case "after_leave_room": if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } break; } } - public getNotificationState(key: SpaceKey): SpaceNotificationState { + public getNotificationState(key: string): SpaceNotificationState { if (this.notificationStateMap.has(key)) { return this.notificationStateMap.get(key); } diff --git a/src/stores/SpaceTreeLevelLayoutStore.ts b/src/stores/SpaceTreeLevelLayoutStore.ts new file mode 100644 index 0000000000..424e9f4012 --- /dev/null +++ b/src/stores/SpaceTreeLevelLayoutStore.ts @@ -0,0 +1,48 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const getSpaceCollapsedKey = (roomId: string, parents: Set): string => { + const separator = "/"; + let path = ""; + if (parents) { + for (const entry of parents.entries()) { + path += entry + separator; + } + } + return `mx_space_collapsed_${path + roomId}`; +}; + +export default class SpaceTreeLevelLayoutStore { + private static internalInstance: SpaceTreeLevelLayoutStore; + + public static get instance(): SpaceTreeLevelLayoutStore { + if (!SpaceTreeLevelLayoutStore.internalInstance) { + SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore(); + } + return SpaceTreeLevelLayoutStore.internalInstance; + } + + public setSpaceCollapsedState(roomId: string, parents: Set, collapsed: boolean) { + // XXX: localStorage doesn't allow booleans + localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString()); + } + + public getSpaceCollapsedState(roomId: string, parents: Set, fallback: boolean): boolean { + const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents)); + // XXX: localStorage doesn't allow booleans + return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback; + } +} diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 6e9216423a..a23401e4c9 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -426,6 +426,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { return; // don't do anything on rooms that aren't visible } + if (cause === RoomUpdateCause.NewRoom && !this.prefilterConditions.every(c => c.isVisible(room))) { + return; // don't do anything on new rooms which ought not to be shown + } + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { if (SettingsStore.getValue("advancedRoomListLogging")) { @@ -664,7 +668,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { * and thus might not cause an update to the store immediately. * @param {IFilterCondition} filter The filter condition to add. */ - public addFilter(filter: IFilterCondition): void { + public async addFilter(filter: IFilterCondition): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 console.log("Adding filter condition:", filter); @@ -676,13 +680,15 @@ export class RoomListStoreClass extends AsyncStoreWithClient { promise = this.recalculatePrefiltering(); } else { this.filterConditions.push(filter); + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below + // this way the runtime filters are only evaluated on one dataset and not both. + await this.recalculatePrefiltering(); + } if (this.algorithm) { this.algorithm.addFilterCondition(filter); } - // Runtime filters with spaces disable prefiltering for the search all spaces effect - if (SettingsStore.getValue("feature_spaces")) { - promise = this.recalculatePrefiltering(); - } } promise.then(() => this.updateFn.trigger()); } @@ -706,10 +712,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (this.algorithm) { this.algorithm.removeFilterCondition(filter); - // Runtime filters with spaces disable prefiltering for the search all spaces effect - if (SettingsStore.getValue("feature_spaces")) { - promise = this.recalculatePrefiltering(); - } + } + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + promise = this.recalculatePrefiltering(); } } idx = this.prefilterConditions.indexOf(filter); diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index 13e1d83901..0b1b78bc75 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -24,26 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter = new SpaceFilterCondition(); + private filter: SpaceFilterCondition; private activeSpace: Room = SpaceStore.instance.activeSpace; constructor(private store: RoomListStoreClass) { - this.updateFilter(); // get the filter into a consistent state - store.addFilter(this.filter); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); } - private onSelectedSpaceUpdated = (activeSpace: Room) => { + private onSelectedSpaceUpdated = (activeSpace?: Room) => { this.activeSpace = activeSpace; - this.updateFilter(); + + if (this.filter) { + if (activeSpace) { + this.updateFilter(); + } else { + this.store.removeFilter(this.filter); + this.filter = null; + } + } else if (activeSpace) { + this.filter = new SpaceFilterCondition(); + this.updateFilter(); + this.store.addFilter(this.filter); + } }; private updateFilter = () => { - if (this.activeSpace) { - SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { - this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); - }); - } + SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { + this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); + }); this.filter.updateSpace(this.activeSpace); }; } diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 83ee803115..c50db43d08 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import { MultiLock } from "../../../utils/MultiLock"; /** * Fired when the Algorithm has determined a list has been updated. @@ -77,6 +78,7 @@ export class Algorithm extends EventEmitter { } = {}; private allowedByFilter: Map = new Map(); private allowedRoomsByFilters: Set = new Set(); + private handlerLock = new MultiLock(); /** * Set to true to suspend emissions of algorithm updates. @@ -199,8 +201,10 @@ export class Algorithm extends EventEmitter { } private async doUpdateStickyRoom(val: Room) { - // no-op sticky rooms for spaces - they're effectively virtual rooms - if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null; + if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { + // no-op sticky rooms for spaces - they're effectively virtual rooms + val = null; + } // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // otherwise we risk duplicating rooms. @@ -577,9 +581,8 @@ export class Algorithm extends EventEmitter { await this.generateFreshTags(newTags); - this.cachedRooms = newTags; + this.cachedRooms = newTags; // this recalculates the filtered rooms for us this.updateTagsFromCache(); - this.recalculateFilteredRooms(); // Now that we've finished generation, we need to update the sticky room to what // it was. It's entirely possible that it changed lists though, so if it did then @@ -678,191 +681,204 @@ export class Algorithm extends EventEmitter { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); + console.log(`Acquiring lock for ${room.roomId} with cause ${cause}`); } - if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - - // Note: check the isSticky against the room ID just in case the reference is wrong - const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; - if (cause === RoomUpdateCause.NewRoom) { - const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; - const roomTags = this.roomIdsToTags[room.roomId]; - const hasTags = roomTags && roomTags.length > 0; - - // Don't change the cause if the last sticky room is being re-added. If we fail to - // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus - // lose the room. - if (hasTags && !isForLastSticky) { - console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); - cause = RoomUpdateCause.PossibleTagChange; + const release = await this.handlerLock.acquire(room.roomId); + try { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); } + if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - // Check to see if the room is known first - let knownRoomRef = this.rooms.includes(room); - if (hasTags && !knownRoomRef) { - console.warn(`${room.roomId} might be a reference change - attempting to update reference`); - this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); - knownRoomRef = this.rooms.includes(room); - if (!knownRoomRef) { - console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + // Note: check the isSticky against the room ID just in case the reference is wrong + const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; + if (cause === RoomUpdateCause.NewRoom) { + const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; + const roomTags = this.roomIdsToTags[room.roomId]; + const hasTags = roomTags && roomTags.length > 0; + + // Don't change the cause if the last sticky room is being re-added. If we fail to + // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus + // lose the room. + if (hasTags && !isForLastSticky) { + console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); + cause = RoomUpdateCause.PossibleTagChange; + } + + // Check to see if the room is known first + let knownRoomRef = this.rooms.includes(room); + if (hasTags && !knownRoomRef) { + console.warn(`${room.roomId} might be a reference change - attempting to update reference`); + this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); + knownRoomRef = this.rooms.includes(room); + if (!knownRoomRef) { + console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + } + } + + // If we have tags for a room and don't have the room referenced, something went horribly + // wrong - the reference should have been updated above. + if (hasTags && !knownRoomRef && !isSticky) { + throw new Error(`${room.roomId} is missing from room array but is known`); + } + + // Like above, update the reference to the sticky room if we need to + if (hasTags && isSticky) { + // Go directly in and set the sticky room's new reference, being careful not + // to trigger a sticky room update ourselves. + this._stickyRoom.room = room; + } + + // If after all that we're still a NewRoom update, add the room if applicable. + // We don't do this for the sticky room (because it causes duplication issues) + // or if we know about the reference (as it should be replaced). + if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { + this.rooms.push(room); } } - // If we have tags for a room and don't have the room referenced, something went horribly - // wrong - the reference should have been updated above. - if (hasTags && !knownRoomRef && !isSticky) { - throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`); - } + let didTagChange = false; + if (cause === RoomUpdateCause.PossibleTagChange) { + const oldTags = this.roomIdsToTags[room.roomId] || []; + const newTags = this.getTagsForRoom(room); + const diff = arrayDiff(oldTags, newTags); + if (diff.removed.length > 0 || diff.added.length > 0) { + for (const rmTag of diff.removed) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Removing ${room.roomId} from ${rmTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; + if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); + this._cachedRooms[rmTag] = algorithm.orderedRooms; + this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list + this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed + } + for (const addTag of diff.added) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Adding ${room.roomId} to ${addTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[addTag]; + if (!algorithm) throw new Error(`No algorithm for ${addTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); + this._cachedRooms[addTag] = algorithm.orderedRooms; + } - // Like above, update the reference to the sticky room if we need to - if (hasTags && isSticky) { - // Go directly in and set the sticky room's new reference, being careful not - // to trigger a sticky room update ourselves. - this._stickyRoom.room = room; - } + // Update the tag map so we don't regen it in a moment + this.roomIdsToTags[room.roomId] = newTags; - // If after all that we're still a NewRoom update, add the room if applicable. - // We don't do this for the sticky room (because it causes duplication issues) - // or if we know about the reference (as it should be replaced). - if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { - this.rooms.push(room); - } - } - - let didTagChange = false; - if (cause === RoomUpdateCause.PossibleTagChange) { - const oldTags = this.roomIdsToTags[room.roomId] || []; - const newTags = this.getTagsForRoom(room); - const diff = arrayDiff(oldTags, newTags); - if (diff.removed.length > 0 || diff.added.length > 0) { - for (const rmTag of diff.removed) { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Removing ${room.roomId} from ${rmTag}`); + console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); } - const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; - if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); - this._cachedRooms[rmTag] = algorithm.orderedRooms; - this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list - this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed - } - for (const addTag of diff.added) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Adding ${room.roomId} to ${addTag}`); - } - const algorithm: OrderingAlgorithm = this.algorithms[addTag]; - if (!algorithm) throw new Error(`No algorithm for ${addTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); - this._cachedRooms[addTag] = algorithm.orderedRooms; - } - - // Update the tag map so we don't regen it in a moment - this.roomIdsToTags[room.roomId] = newTags; - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); - } - cause = RoomUpdateCause.Timeline; - didTagChange = true; - } else { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); - } - cause = RoomUpdateCause.Timeline; - } - - if (didTagChange && isSticky) { - // Manually update the tag for the sticky room without triggering a sticky room - // update. The update will be handled implicitly by the sticky room handling and - // requires no changes on our part, if we're in the middle of a sticky room change. - if (this._lastStickyRoom) { - this._stickyRoom = { - room, - tag: this.roomIdsToTags[room.roomId][0], - position: 0, // right at the top as it changed tags - }; + cause = RoomUpdateCause.Timeline; + didTagChange = true; } else { - // We have to clear the lock as the sticky room change will trigger updates. - await this.setStickyRoom(room); + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); + } + cause = RoomUpdateCause.Timeline; + } + + if (didTagChange && isSticky) { + // Manually update the tag for the sticky room without triggering a sticky room + // update. The update will be handled implicitly by the sticky room handling and + // requires no changes on our part, if we're in the middle of a sticky room change. + if (this._lastStickyRoom) { + this._stickyRoom = { + room, + tag: this.roomIdsToTags[room.roomId][0], + position: 0, // right at the top as it changed tags + }; + } else { + // We have to clear the lock as the sticky room change will trigger updates. + await this.setStickyRoom(room); + } } } - } - // If the update is for a room change which might be the sticky room, prevent it. We - // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though - // as the sticky room relies on this. - if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { - if (this.stickyRoom === room) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); + // If the update is for a room change which might be the sticky room, prevent it. We + // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though + // as the sticky room relies on this. + if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { + if (this.stickyRoom === room) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn( + `[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`, + ); + } + return false; } - return false; } - } - if (!this.roomIdsToTags[room.roomId]) { - if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (!this.roomIdsToTags[room.roomId]) { + if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + } + return false; + } + if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + } + + // Get the tags for the room and populate the cache + const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + + // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), + // which means we should *always* have a tag to go off of. + if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + + this.roomIdsToTags[room.roomId] = roomTags; + + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); } - return false; } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); } - // Get the tags for the room and populate the cache - const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + const tags = this.roomIdsToTags[room.roomId]; + if (!tags) { + console.warn(`No tags known for "${room.name}" (${room.roomId})`); + return false; + } - // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), - // which means we should *always* have a tag to go off of. - if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + let changed = didTagChange; + for (const tag of tags) { + const algorithm: OrderingAlgorithm = this.algorithms[tag]; + if (!algorithm) throw new Error(`No algorithm for ${tag}`); - this.roomIdsToTags[room.roomId] = roomTags; + await algorithm.handleRoomUpdate(room, cause); + this._cachedRooms[tag] = algorithm.orderedRooms; + + // Flag that we've done something + this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list + this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed + changed = true; + } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); + console.log( + `[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`, + ); } + return changed; + } finally { + release(); } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); - } - - const tags = this.roomIdsToTags[room.roomId]; - if (!tags) { - console.warn(`No tags known for "${room.name}" (${room.roomId})`); - return false; - } - - let changed = didTagChange; - for (const tag of tags) { - const algorithm: OrderingAlgorithm = this.algorithms[tag]; - if (!algorithm) throw new Error(`No algorithm for ${tag}`); - - await algorithm.handleRoomUpdate(room, cause); - this._cachedRooms[tag] = algorithm.orderedRooms; - - // Flag that we've done something - this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list - this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed - changed = true; - } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`); - } - return changed; } } diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 7c8c879cf6..49cfd9e520 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -21,79 +21,83 @@ import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; +export const sortRooms = (rooms: Room[]): Room[] => { + // We cache the timestamp lookup to avoid iterating forever on the timeline + // of events. This cache only survives a single sort though. + // We wouldn't need this if `.sort()` didn't constantly try and compare all + // of the rooms to each other. + + // TODO: We could probably improve the sorting algorithm here by finding changes. + // See https://github.com/vector-im/element-web/issues/14459 + // For example, if we spent a little bit of time to determine which elements have + // actually changed (probably needs to be done higher up?) then we could do an + // insertion sort or similar on the limited set of changes. + + // TODO: Don't assume we're using the same client as the peg + // See https://github.com/vector-im/element-web/issues/14458 + let myUserId = ''; + if (MatrixClientPeg.get()) { + myUserId = MatrixClientPeg.get().getUserId(); + } + + const tsCache: { [roomId: string]: number } = {}; + const getLastTs = (r: Room) => { + if (tsCache[r.roomId]) { + return tsCache[r.roomId]; + } + + const ts = (() => { + // Apparently we can have rooms without timelines, at least under testing + // environments. Just return MAX_INT when this happens. + if (!r || !r.timeline) { + return Number.MAX_SAFE_INTEGER; + } + + // If the room hasn't been joined yet, it probably won't have a timeline to + // parse. We'll still fall back to the timeline if this fails, but chances + // are we'll at least have our own membership event to go off of. + const effectiveMembership = getEffectiveMembership(r.getMyMembership()); + if (effectiveMembership !== EffectiveMembership.Join) { + const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); + if (membershipEvent && !Array.isArray(membershipEvent)) { + return membershipEvent.getTs(); + } + } + + for (let i = r.timeline.length - 1; i >= 0; --i) { + const ev = r.timeline[i]; + if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) + + if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { + return ev.getTs(); + } + } + + // we might only have events that don't trigger the unread indicator, + // in which case use the oldest event even if normally it wouldn't count. + // This is better than just assuming the last event was forever ago. + if (r.timeline.length && r.timeline[0].getTs()) { + return r.timeline[0].getTs(); + } else { + return Number.MAX_SAFE_INTEGER; + } + })(); + + tsCache[r.roomId] = ts; + return ts; + }; + + return rooms.sort((a, b) => { + return getLastTs(b) - getLastTs(a); + }); +}; + /** * Sorts rooms according to the last event's timestamp in each room that seems * useful to the user. */ export class RecentAlgorithm implements IAlgorithm { public async sortRooms(rooms: Room[], tagId: TagID): Promise { - // We cache the timestamp lookup to avoid iterating forever on the timeline - // of events. This cache only survives a single sort though. - // We wouldn't need this if `.sort()` didn't constantly try and compare all - // of the rooms to each other. - - // TODO: We could probably improve the sorting algorithm here by finding changes. - // See https://github.com/vector-im/element-web/issues/14459 - // For example, if we spent a little bit of time to determine which elements have - // actually changed (probably needs to be done higher up?) then we could do an - // insertion sort or similar on the limited set of changes. - - // TODO: Don't assume we're using the same client as the peg - // See https://github.com/vector-im/element-web/issues/14458 - let myUserId = ''; - if (MatrixClientPeg.get()) { - myUserId = MatrixClientPeg.get().getUserId(); - } - - const tsCache: { [roomId: string]: number } = {}; - const getLastTs = (r: Room) => { - if (tsCache[r.roomId]) { - return tsCache[r.roomId]; - } - - const ts = (() => { - // Apparently we can have rooms without timelines, at least under testing - // environments. Just return MAX_INT when this happens. - if (!r || !r.timeline) { - return Number.MAX_SAFE_INTEGER; - } - - // If the room hasn't been joined yet, it probably won't have a timeline to - // parse. We'll still fall back to the timeline if this fails, but chances - // are we'll at least have our own membership event to go off of. - const effectiveMembership = getEffectiveMembership(r.getMyMembership()); - if (effectiveMembership !== EffectiveMembership.Join) { - const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); - if (membershipEvent && !Array.isArray(membershipEvent)) { - return membershipEvent.getTs(); - } - } - - for (let i = r.timeline.length - 1; i >= 0; --i) { - const ev = r.timeline[i]; - if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) - - if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { - return ev.getTs(); - } - } - - // we might only have events that don't trigger the unread indicator, - // in which case use the oldest event even if normally it wouldn't count. - // This is better than just assuming the last event was forever ago. - if (r.timeline.length && r.timeline[0].getTs()) { - return r.timeline[0].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; - } - })(); - - tsCache[r.roomId] = ts; - return ts; - }; - - return rooms.sort((a, b) => { - return getLastTs(b) - getLastTs(a); - }); + return sortRooms(rooms); } } diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index 43bdcb3879..6a06bee0d8 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { IDestroyable } from "../../../utils/IDestroyable"; -import SpaceStore, {HOME_SPACE} from "../../SpaceStore"; +import SpaceStore from "../../SpaceStore"; import { setHasDiff } from "../../../utils/sets"; /** @@ -55,10 +55,12 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi } }; - private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE; + private getSpaceEventKey = (space: Room) => space.roomId; public updateSpace(space: Room) { - SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + if (this.space) { + SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + } SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate); this.onStoreUpdate(); // initial update from the change to the space } diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index f212b1f9d9..c07c2b0b26 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -50,7 +50,7 @@ export class VisibilityProvider { } // hide space rooms as they'll be shown in the SpacePanel - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) { + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { return false; } diff --git a/src/utils/DecryptFile.ts b/src/utils/DecryptFile.ts index 93cedbc707..d073393170 100644 --- a/src/utils/DecryptFile.ts +++ b/src/utils/DecryptFile.ts @@ -17,63 +17,8 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; import {mediaFromContent} from "../customisations/Media"; -import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; - -// WARNING: We have to be very careful about what mime-types we allow into blobs, -// as for performance reasons these are now rendered via URL.createObjectURL() -// rather than by converting into data: URIs. -// -// This means that the content is rendered using the origin of the script which -// called createObjectURL(), and so if the content contains any scripting then it -// will pose a XSS vulnerability when the browser renders it. This is particularly -// bad if the user right-clicks the URI and pastes it into a new window or tab, -// as the blob will then execute with access to Element's full JS environment(!) -// -// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 -// for details. -// -// We mitigate this by only allowing mime-types into blobs which we know don't -// contain any scripting, and instantiate all others as application/octet-stream -// regardless of what mime-type the event claimed. Even if the payload itself -// is some malicious HTML, the fact we instantiate it with a media mimetype or -// application/octet-stream means the browser doesn't try to render it as such. -// -// One interesting edge case is image/svg+xml, which empirically *is* rendered -// correctly if the blob is set to the src attribute of an img tag (for thumbnails) -// *even if the mimetype is application/octet-stream*. However, empirically JS -// in the SVG isn't executed in this scenario, so we seem to be okay. -// -// Tested on Chrome 65 and Firefox 60 -// -// The list below is taken mainly from -// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats -// N.B. Matrix doesn't currently specify which mimetypes are valid in given -// events, so we pick the ones which HTML5 browsers should be able to display -// -// For the record, mime-types which must NEVER enter this list below include: -// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. - -const ALLOWED_BLOB_MIMETYPES = [ - 'image/jpeg', - 'image/gif', - 'image/png', - - 'video/mp4', - 'video/webm', - 'video/ogg', - - 'audio/mp4', - 'audio/webm', - 'audio/aac', - 'audio/mpeg', - 'audio/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wav', - 'audio/flac', - 'audio/x-flac', -]; +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; +import { getBlobSafeMimeType } from "./blobs"; /** * Decrypt a file attached to a matrix event. @@ -100,9 +45,7 @@ export function decryptFile(file: IEncryptedFile): Promise { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { - mimetype = 'application/octet-stream'; - } + mimetype = getBlobSafeMimeType(mimetype); return new Blob([dataArray], {type: mimetype}); }); diff --git a/src/utils/MultiLock.ts b/src/utils/MultiLock.ts new file mode 100644 index 0000000000..507a924dda --- /dev/null +++ b/src/utils/MultiLock.ts @@ -0,0 +1,30 @@ +/* +Copyright 2021 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 { EnhancedMap } from "./maps"; +import AwaitLock from "await-lock"; + +export type DoneFn = () => void; + +export class MultiLock { + private locks = new EnhancedMap(); + + public async acquire(key: string): Promise { + const lock = this.locks.getOrCreate(key, new AwaitLock()); + await lock.acquireAsync(); + return () => lock.release(); + } +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index f7e693452b..e527f43c29 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {percentageOf, percentageWithin} from "./numbers"; + /** * Quickly resample an array to have less/more data points. If an input which is larger * than the desired size is provided, it will be downsampled. Similarly, if the input @@ -27,7 +29,7 @@ export function arrayFastResample(input: number[], points: number): number[] { // Heavily inspired by matrix-media-repo (used with permission) // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10 - let samples: number[] = []; + const samples: number[] = []; if (input.length > points) { // Danger: this loop can cause out of memory conditions if the input is too small. const everyNth = Math.round(input.length / points); @@ -36,27 +38,71 @@ export function arrayFastResample(input: number[], points: number): number[] { } } else { // Smaller inputs mean we have to spread the values over the desired length. We - // end up overshooting the target length in doing this, so we'll resample down - // before returning. This recursion is risky, but mathematically should not go - // further than 1 level deep. + // end up overshooting the target length in doing this, but we're not looking to + // be super accurate so we'll let the sanity trims do their job. const spreadFactor = Math.ceil(points / input.length); for (const val of input) { samples.push(...arraySeed(val, spreadFactor)); } - samples = arrayFastResample(samples, points); } - // Sanity fill, just in case - while (samples.length < points) { - samples.push(input[input.length - 1]); - } + // Trim to size & return + return arrayTrimFill(samples, points, arraySeed(input[input.length - 1], points)); +} - // Sanity trim, just in case - if (samples.length > points) { - samples = samples.slice(0, points); - } +/** + * Attempts a smooth resample of the given array. This is functionally similar to arrayFastResample + * though can take longer due to the smoothing of data. + * @param {number[]} input The input array to resample. + * @param {number} points The number of samples to end up with. + * @returns {number[]} The resampled array. + */ +export function arraySmoothingResample(input: number[], points: number): number[] { + if (input.length === points) return input; // short-circuit a complicated call - return samples; + let samples: number[] = []; + if (input.length > points) { + // We're downsampling. To preserve the curve we'll actually reduce our sample + // selection and average some points between them. + + // All we're doing here is repeatedly averaging the waveform down to near our + // target value. We don't average down to exactly our target as the loop might + // never end, and we can over-average the data. Instead, we'll get as far as + // we can and do a followup fast resample (the neighbouring points will be close + // to the actual waveform, so we can get away with this safely). + while (samples.length > (points * 2) || samples.length === 0) { + samples = []; + for (let i = 1; i < input.length - 1; i += 2) { + const prevPoint = input[i - 1]; + const nextPoint = input[i + 1]; + const currPoint = input[i]; + const average = (prevPoint + nextPoint + currPoint) / 3; + samples.push(average); + } + input = samples; + } + + return arrayFastResample(samples, points); + } else { + // In practice there's not much purpose in burning CPU for short arrays only to + // end up with a result that can't possibly look much different than the fast + // resample, so just skip ahead to the fast resample. + return arrayFastResample(input, points); + } +} + +/** + * Rescales the input array to have values that are inclusively within the provided + * minimum and maximum. + * @param {number[]} input The array to rescale. + * @param {number} newMin The minimum value to scale to. + * @param {number} newMax The maximum value to scale to. + * @returns {number[]} The rescaled array. + */ +export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { + const min: number = Math.min(...input); + const max: number = Math.max(...input); + return input.map(v => percentageWithin(percentageOf(v, min, max), newMin, newMax)); } /** diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts new file mode 100644 index 0000000000..4e073a3936 --- /dev/null +++ b/src/utils/blobs.ts @@ -0,0 +1,78 @@ +/* +Copyright 2021 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. +*/ + +// WARNING: We have to be very careful about what mime-types we allow into blobs, +// as for performance reasons these are now rendered via URL.createObjectURL() +// rather than by converting into data: URIs. +// +// This means that the content is rendered using the origin of the script which +// called createObjectURL(), and so if the content contains any scripting then it +// will pose a XSS vulnerability when the browser renders it. This is particularly +// bad if the user right-clicks the URI and pastes it into a new window or tab, +// as the blob will then execute with access to Element's full JS environment(!) +// +// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 +// for details. +// +// We mitigate this by only allowing mime-types into blobs which we know don't +// contain any scripting, and instantiate all others as application/octet-stream +// regardless of what mime-type the event claimed. Even if the payload itself +// is some malicious HTML, the fact we instantiate it with a media mimetype or +// application/octet-stream means the browser doesn't try to render it as such. +// +// One interesting edge case is image/svg+xml, which empirically *is* rendered +// correctly if the blob is set to the src attribute of an img tag (for thumbnails) +// *even if the mimetype is application/octet-stream*. However, empirically JS +// in the SVG isn't executed in this scenario, so we seem to be okay. +// +// Tested on Chrome 65 and Firefox 60 +// +// The list below is taken mainly from +// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats +// N.B. Matrix doesn't currently specify which mimetypes are valid in given +// events, so we pick the ones which HTML5 browsers should be able to display +// +// For the record, mime-types which must NEVER enter this list below include: +// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. + +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export function getBlobSafeMimeType(mimetype: string): string { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return 'application/octet-stream'; + } + return mimetype; +} diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index 015ecca22e..d87c826cc2 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -346,9 +346,14 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); - if (m) { - return m[1]; + try { + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); + if (m) { + return m[1]; + } + } catch (e) { + // Not a valid URI + return permalink; } // A bit of a hack to convert permalinks of unknown origin to Element links diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 3f2b6f9bb4..c14dc988d2 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -83,6 +83,7 @@ export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => { if (shouldCreate) { await createRoom(opts); } + return shouldCreate; }; export const showSpaceInvite = (space: Room, initialText = "") => { diff --git a/src/verification.js b/src/verification.ts similarity index 62% rename from src/verification.js rename to src/verification.ts index 74e3897d3a..acd9f6d2b2 100644 --- a/src/verification.js +++ b/src/verification.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; import * as sdk from './index'; -import { _t } from './languageHandler'; -import {RightPanelPhases} from "./stores/RightPanelStorePhases"; -import {findDMForUser} from './createRoom'; -import {accessSecretStorage} from './SecurityManager'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; -import {Action} from './dispatcher/actions'; +import { RightPanelPhases } from "./stores/RightPanelStorePhases"; +import { findDMForUser } from './createRoom'; +import { accessSecretStorage } from './SecurityManager'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { Action } from './dispatcher/actions'; +import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; +import {IDevice} from "./components/views/right_panel/UserInfo"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); @@ -39,40 +42,7 @@ async function enable4SIfNeeded() { return true; } -function UntrustedDeviceDialog(props) { - const {device, user, onFinished} = props; - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - let askToVerifyText; - let newSessionText; - - if (MatrixClientPeg.get().getUserId() === user.userId) { - newSessionText = _t("You signed in to a new session without verifying it:"); - askToVerifyText = _t("Verify your other session using one of the options below."); - } else { - newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", - {name: user.displayName, userId: user.userId}); - askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); - } - - return -
    -

    {newSessionText}

    -

    {device.getDisplayName()} ({device.deviceId})

    -

    {askToVerifyText}

    -
    -
    - onFinished("legacy")}>{_t("Manually Verify by Text")} - onFinished("sas")}>{_t("Interactively verify by Emoji")} - onFinished()}>{_t("Done")} -
    -
    ; -} - -export async function verifyDevice(user, device) { +export async function verifyDevice(user: User, device: IDevice) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -115,7 +85,7 @@ export async function verifyDevice(user, device) { }); } -export async function legacyVerifyUser(user) { +export async function legacyVerifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -135,7 +105,7 @@ export async function legacyVerifyUser(user) { }); } -export async function verifyUser(user) { +export async function verifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -155,7 +125,7 @@ export async function verifyUser(user) { }); } -export function pendingVerificationRequestForUser(user) { +export function pendingVerificationRequestForUser(user: User) { const cli = MatrixClientPeg.get(); const dmRoom = findDMForUser(cli, user.userId); if (dmRoom) { diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 99b1f62866..61da435151 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -15,11 +15,13 @@ limitations under the License. */ import EventEmitter from "events"; -import {UPDATE_EVENT} from "../stores/AsyncStore"; -import {arrayFastResample, arraySeed} from "../utils/arrays"; -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; -import {PlaybackClock} from "./PlaybackClock"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { PlaybackClock } from "./PlaybackClock"; +import { createAudioContext, decodeOgg } from "./compat"; +import { clamp } from "../utils/numbers"; export enum PlaybackState { Decoding = "decoding", @@ -28,9 +30,26 @@ export enum PlaybackState { Playing = "playing", // active progress through timeline } -export const PLAYBACK_WAVEFORM_SAMPLES = 35; +export const PLAYBACK_WAVEFORM_SAMPLES = 39; const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); +function makePlaybackWaveform(input: number[]): number[] { + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = input.map(v => Math.abs(v)); + + // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 for the remaining function logic. + const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled + // waveform. Most speech happens below the 0.5 mark. + const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); + + // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something + // sensible. This is what we return to keep our contract of "values between zero and one". + return arrayRescale(filtered, 0, 1); +} + export class Playback extends EventEmitter implements IDestroyable { private readonly context: AudioContext; private source: AudioBufferSourceNode; @@ -48,14 +67,16 @@ export class Playback extends EventEmitter implements IDestroyable { */ constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { super(); - this.context = new AudioContext(); - this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES); + this.context = createAudioContext(); + this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); this.waveformObservable.update(this.resampledWaveform); this.clock = new PlaybackClock(this.context); - - // TODO: @@ TR: Calculate real waveform } + /** + * Stable waveform for the playback. Values are guaranteed to be between + * zero and one, inclusive. + */ public get waveform(): number[] { return this.resampledWaveform; } @@ -92,8 +113,32 @@ export class Playback extends EventEmitter implements IDestroyable { } public async prepare() { - this.audioBuf = await this.context.decodeAudioData(this.buf); + // Safari compat: promise API not supported on this function + this.audioBuf = await new Promise((resolve, reject) => { + this.context.decodeAudioData(this.buf, b => resolve(b), async e => { + // This error handler is largely for Safari as well, which doesn't support Opus/Ogg + // very well. + console.error("Error decoding recording: ", e); + console.warn("Trying to re-encode to WAV instead..."); + + const wav = await decodeOgg(this.buf); + + // noinspection ES6MissingAwait - not needed when using callbacks + this.context.decodeAudioData(wav, b => resolve(b), e => { + console.error("Still failed to decode recording: ", e); + reject(e); + }); + }); + }); + + // Update the waveform to the real waveform once we have channel data to use. We don't + // exactly trust the user-provided waveform to be accurate... + const waveform = Array.from(this.audioBuf.getChannelData(0)); + this.resampledWaveform = makePlaybackWaveform(waveform); + this.waveformObservable.update(this.resampledWaveform); + this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.durationSeconds = this.audioBuf.duration; } diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index 06d6381691..d6d36e861f 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -54,6 +54,15 @@ export class PlaybackClock implements IDestroyable { } }; + /** + * Mark the time in the audio context where the clip starts/has been loaded. + * This is to ensure the clock isn't skewed into thinking it is ~0.5s into + * a clip when the duration is set. + */ + public flagLoadTime() { + this.clipStart = this.context.currentTime; + } + public flagStart() { if (this.stopped) { this.clipStart = this.context.currentTime; diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index eb705200ca..fde5779fa2 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -19,20 +19,23 @@ import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; import {MatrixClient} from "matrix-js-sdk/src/client"; import CallMediaHandler from "../CallMediaHandler"; import {SimpleObservable} from "matrix-widget-api"; -import {clamp} from "../utils/numbers"; +import {clamp, percentageOf, percentageWithin} from "../utils/numbers"; import EventEmitter from "events"; import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; import {UPDATE_EVENT} from "../stores/AsyncStore"; import {Playback} from "./Playback"; +import {createAudioContext} from "./compat"; const CHANNELS = 1; // stereo isn't important -const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. +export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files. const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary. +export const RECORDING_PLAYBACK_SAMPLES = 44; + export interface IRecordingUpdate { waveform: number[]; // floating points between 0 (low) and 1 (high). timeSeconds: number; // float @@ -53,6 +56,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderFFT: AnalyserNode; private recorderWorklet: AudioWorkletNode; + private recorderProcessor: ScriptProcessorNode; private buffer = new Uint8Array(0); // use this.audioBuffer to access private mxc: string; private recording = false; @@ -88,78 +92,107 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } private async makeRecorder() { - this.recorderStream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: CHANNELS, - noiseSuppression: true, // browsers ignore constraints they can't honour - deviceId: CallMediaHandler.getAudioInput(), - }, - }); - this.recorderContext = new AudioContext({ - // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) - }); - this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); - this.recorderFFT = this.recorderContext.createAnalyser(); + try { + this.recorderStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: CHANNELS, + noiseSuppression: true, // browsers ignore constraints they can't honour + deviceId: CallMediaHandler.getAudioInput(), + }, + }); + this.recorderContext = createAudioContext({ + // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) + }); + this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); + this.recorderFFT = this.recorderContext.createAnalyser(); - // Bring the FFT time domain down a bit. The default is 2048, and this must be a power - // of two. We use 64 points because we happen to know down the line we need less than - // that, but 32 would be too few. Large numbers are not helpful here and do not add - // precision: they introduce higher precision outputs of the FFT (frequency data), but - // it makes the time domain less than helpful. - this.recorderFFT.fftSize = 64; + // Bring the FFT time domain down a bit. The default is 2048, and this must be a power + // of two. We use 64 points because we happen to know down the line we need less than + // that, but 32 would be too few. Large numbers are not helpful here and do not add + // precision: they introduce higher precision outputs of the FFT (frequency data), but + // it makes the time domain less than helpful. + this.recorderFFT.fftSize = 64; - // Set up our worklet. We use this for timing information and waveform analysis: the - // web audio API prefers this be done async to avoid holding the main thread with math. - const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; - if (!mxRecorderWorkletPath) { - throw new Error("Unable to create recorder: no worklet script registered"); - } - await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); - this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); - - // Connect our inputs and outputs - this.recorderSource.connect(this.recorderFFT); - this.recorderSource.connect(this.recorderWorklet); - this.recorderWorklet.connect(this.recorderContext.destination); - - // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. - this.recorderWorklet.port.onmessage = (ev) => { - switch (ev.data['ev']) { - case PayloadEvent.Timekeep: - this.processAudioUpdate(ev.data['timeSeconds']); - break; - case PayloadEvent.AmplitudeMark: - // Sanity check to make sure we're adding about one sample per second - if (ev.data['forSecond'] === this.amplitudes.length) { - this.amplitudes.push(ev.data['amplitude']); - } - break; + // Set up our worklet. We use this for timing information and waveform analysis: the + // web audio API prefers this be done async to avoid holding the main thread with math. + const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; + if (!mxRecorderWorkletPath) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Unable to create recorder: no worklet script registered"); } - }; - this.recorder = new Recorder({ - encoderPath, // magic from webpack - encoderSampleRate: SAMPLE_RATE, - encoderApplication: 2048, // voice (default is "audio") - streamPages: true, // this speeds up the encoding process by using CPU over time - encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder - numberOfChannels: CHANNELS, - sourceNode: this.recorderSource, - encoderBitRate: BITRATE, + // Connect our inputs and outputs + this.recorderSource.connect(this.recorderFFT); - // We use low values for the following to ease CPU usage - the resulting waveform - // is indistinguishable for a voice message. Note that the underlying library will - // pick defaults which prefer the highest possible quality, CPU be damned. - encoderComplexity: 3, // 0-10, 10 is slow and high quality. - resampleQuality: 3, // 0-10, 10 is slow and high quality - }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; + if (this.recorderContext.audioWorklet) { + await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); + this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); + this.recorderSource.connect(this.recorderWorklet); + this.recorderWorklet.connect(this.recorderContext.destination); + + // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. + this.recorderWorklet.port.onmessage = (ev) => { + switch (ev.data['ev']) { + case PayloadEvent.Timekeep: + this.processAudioUpdate(ev.data['timeSeconds']); + break; + case PayloadEvent.AmplitudeMark: + // Sanity check to make sure we're adding about one sample per second + if (ev.data['forSecond'] === this.amplitudes.length) { + this.amplitudes.push(ev.data['amplitude']); + } + break; + } + }; + } else { + // Safari fallback: use a processor node instead, buffered to 1024 bytes of data + // like the worklet is. + this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); + this.recorderSource.connect(this.recorderProcessor); + this.recorderProcessor.connect(this.recorderContext.destination); + this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess); + } + + this.recorder = new Recorder({ + encoderPath, // magic from webpack + encoderSampleRate: SAMPLE_RATE, + encoderApplication: 2048, // voice (default is "audio") + streamPages: true, // this speeds up the encoding process by using CPU over time + encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder + numberOfChannels: CHANNELS, + sourceNode: this.recorderSource, + encoderBitRate: BITRATE, + + // We use low values for the following to ease CPU usage - the resulting waveform + // is indistinguishable for a voice message. Note that the underlying library will + // pick defaults which prefer the highest possible quality, CPU be damned. + encoderComplexity: 3, // 0-10, 10 is slow and high quality. + resampleQuality: 3, // 0-10, 10 is slow and high quality + }); + this.recorder.ondataavailable = (a: ArrayBuffer) => { + const buf = new Uint8Array(a); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + } catch (e) { + console.error("Error starting recording: ", e); + if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely + console.error(`${e.name} (${e.code}): ${e.message}`); + } + + // Clean up as best as possible + if (this.recorderStream) this.recorderStream.getTracks().forEach(t => t.stop()); + if (this.recorderSource) this.recorderSource.disconnect(); + if (this.recorder) this.recorder.close(); + if (this.recorderContext) { + // noinspection ES6MissingAwait - not important that we wait + this.recorderContext.close(); + } + + throw e; // rethrow so upstream can handle it + } } private get audioBuffer(): Uint8Array { @@ -188,6 +221,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.mxc; } + private onAudioProcess = (ev: AudioProcessingEvent) => { + this.processAudioUpdate(ev.playbackTime); + + // We skip the functionality of the worklet regarding waveform calculations: we + // should get that information pretty quick during the playback info. + }; + private processAudioUpdate = (timeSeconds: number) => { if (!this.recording) return; @@ -195,7 +235,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // size. The time domain is also known as the audio waveform. We're ignoring the // output of the FFT here (frequency data) because we're not interested in it. const data = new Float32Array(this.recorderFFT.fftSize); - this.recorderFFT.getFloatTimeDomainData(data); + if (!this.recorderFFT.getFloatTimeDomainData) { + // Safari compat + const data2 = new Uint8Array(this.recorderFFT.fftSize); + this.recorderFFT.getByteTimeDomainData(data2); + for (let i = 0; i < data2.length; i++) { + data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1); + } + } else { + this.recorderFFT.getFloatTimeDomainData(data); + } // We can't just `Array.from()` the array because we're dealing with 32bit floats // and the built-in function won't consider that when converting between numbers. @@ -266,7 +315,11 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Disconnect the source early to start shutting down resources await this.recorder.stop(); // stop first to flush the last frame this.recorderSource.disconnect(); - this.recorderWorklet.disconnect(); + if (this.recorderWorklet) this.recorderWorklet.disconnect(); + if (this.recorderProcessor) { + this.recorderProcessor.disconnect(); + this.recorderProcessor.removeEventListener("audioprocess", this.onAudioProcess); + } // close the context after the recorder so the recorder doesn't try to // connect anything to the context (this would generate a warning) diff --git a/src/voice/compat.ts b/src/voice/compat.ts new file mode 100644 index 0000000000..316d779e28 --- /dev/null +++ b/src/voice/compat.ts @@ -0,0 +1,82 @@ +/* +Copyright 2021 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 {SAMPLE_RATE} from "./VoiceRecording"; + +// @ts-ignore - we know that this is not a module. We're looking for a path. +import decoderWasmPath from 'opus-recorder/dist/decoderWorker.min.wasm'; +import wavEncoderPath from 'opus-recorder/dist/waveWorker.min.js'; +import decoderPath from 'opus-recorder/dist/decoderWorker.min.js'; + +export function createAudioContext(opts?: AudioContextOptions): AudioContext { + if (window.AudioContext) { + return new AudioContext(opts); + } else if (window.webkitAudioContext) { + // While the linter is correct that "a constructor name should not start with + // a lowercase letter", it's also wrong to think that we have control over this. + // eslint-disable-next-line new-cap + return new window.webkitAudioContext(opts); + } else { + throw new Error("Unsupported browser"); + } +} + +export function decodeOgg(audioBuffer: ArrayBuffer): Promise { + // Condensed version of decoder example, using a promise: + // https://github.com/chris-rudmin/opus-recorder/blob/master/example/decoder.html + return new Promise((resolve) => { // no reject because the workers don't seem to have a fail path + console.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake) + const typedArray = new Uint8Array(audioBuffer); + const decoderWorker = new Worker(decoderPath); + const wavWorker = new Worker(wavEncoderPath); + + decoderWorker.postMessage({ + command: 'init', + decoderSampleRate: SAMPLE_RATE, + outputBufferSampleRate: SAMPLE_RATE, + }); + + wavWorker.postMessage({ + command: 'init', + wavBitDepth: 24, // standard for 48khz (SAMPLE_RATE) + wavSampleRate: SAMPLE_RATE, + }); + + decoderWorker.onmessage = (ev) => { + if (ev.data === null) { // null == done + wavWorker.postMessage({command: 'done'}); + return; + } + + wavWorker.postMessage({ + command: 'encode', + buffers: ev.data, + }, ev.data.map(b => b.buffer)); + }; + + wavWorker.onmessage = (ev) => { + if (ev.data.message === 'page') { + // The encoding comes through as a single page + resolve(new Blob([ev.data.page], {type: "audio/wav"}).arrayBuffer()); + } + }; + + decoderWorker.postMessage({ + command: 'decode', + pages: typedArray, + }, [typedArray.buffer]); + }); +} diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts index 754610b223..1e3f92e788 100644 --- a/test/CallHandler-test.ts +++ b/test/CallHandler-test.ts @@ -16,7 +16,7 @@ limitations under the License. import './skinned-sdk'; -import CallHandler, { PlaceCallType } from '../src/CallHandler'; +import CallHandler, { PlaceCallType, CallHandlerEvent } from '../src/CallHandler'; import { stubClient, mkStubRoom } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import dis from '../src/dispatcher/dispatcher'; @@ -172,11 +172,9 @@ describe('CallHandler', () => { let callRoomChangeEventCount = 0; const roomChangePromise = new Promise(resolve => { - dispatchHandle = dis.register(payload => { - if (payload.action === Action.CallChangeRoom) { - ++callRoomChangeEventCount; - resolve(); - } + callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => { + ++callRoomChangeEventCount; + resolve(); }); }); @@ -201,7 +199,7 @@ describe('CallHandler', () => { fakeCall.emit(CallEvent.AssertedIdentityChanged); await roomChangePromise; - dis.unregister(dispatchHandle); + callHandler.removeAllListeners(); // If everything's gone well, we should have seen only one room change // event and the call should now be in user 3's room. diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 7347ff2658..dc70e3f7f6 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -77,7 +77,7 @@ describe('MessagePanel', function() { DMRoomMap.makeShared(); }); - afterEach(function() { + afterEach(function () { clock.uninstall(); }); @@ -88,7 +88,21 @@ describe('MessagePanel', function() { events.push(test_utils.mkMessage( { event: true, room: "!room:id", user: "@user:id", - ts: ts0 + i*1000, + ts: ts0 + i * 1000, + })); + } + return events; + } + + // Just to avoid breaking Dateseparator tests that might run at 00hrs + function mkOneDayEvents() { + const events = []; + const ts0 = Date.parse('09 May 2004 00:12:00 GMT'); + for (let i = 0; i < 10; i++) { + events.push(test_utils.mkMessage( + { + event: true, room: "!room:id", user: "@user:id", + ts: ts0 + i * 1000, })); } return events; @@ -104,7 +118,7 @@ describe('MessagePanel', function() { let i = 0; events.push(test_utils.mkMessage({ event: true, room: "!room:id", user: "@user:id", - ts: ts0 + ++i*1000, + ts: ts0 + ++i * 1000, })); for (i = 0; i < 10; i++) { @@ -151,7 +165,7 @@ describe('MessagePanel', function() { }, getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, - ts: ts0 + i*1000, + ts: ts0 + i * 1000, mship: 'join', prevMship: 'join', name: 'A user', @@ -250,7 +264,6 @@ describe('MessagePanel', function() { }), ]; } - function isReadMarkerVisible(rmContainer) { return rmContainer && rmContainer.children.length > 0; } @@ -437,4 +450,17 @@ describe('MessagePanel', function() { // read marker should be hidden given props and at the last event expect(isReadMarkerVisible(rm)).toBeFalsy(); }); + + it('should render Date separators for the events', function () { + const events = mkOneDayEvents(); + const res = mount( + , + ); + const Dates = res.find(sdk.getComponent('messages.DateSeparator')); + + expect(Dates.length).toEqual(1); + }); }); diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 07b75aaae5..7c7a2f84fb 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -178,7 +178,7 @@ describe('editor/deserialize', function() { const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "Try "}); - expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld"}); + expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld", resourceId: "#room:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "?"}); }); it('@room pill', function() { diff --git a/test/end-to-end-tests/yarn.lock b/test/end-to-end-tests/yarn.lock index 7f2cefb92e..97b348fe50 100644 --- a/test/end-to-end-tests/yarn.lock +++ b/test/end-to-end-tests/yarn.lock @@ -435,9 +435,9 @@ jsprim@^1.2.2: verror "1.10.0" lodash@^4.15.0, lodash@^4.17.11: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== mime-db@~1.38.0: version "1.38.0" diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index aef788647d..20c48c29db 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -101,6 +101,7 @@ const invite1 = "!invite1:server"; const invite2 = "!invite2:server"; const room1 = "!room1:server"; const room2 = "!room2:server"; +const room3 = "!room3:server"; const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; @@ -361,8 +362,8 @@ describe("SpaceStore", () => { expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); }); - it("home space does not contain rooms/low priority from rooms within spaces", () => { - expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + it("home space does contain rooms/low priority even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); }); it("space contains child rooms", () => { @@ -614,8 +615,8 @@ describe("SpaceStore", () => { describe("space auto switching tests", () => { beforeEach(async () => { - [room1, room2, orphan1].forEach(mkRoom); - mkSpace(space1, [room1, room2]); + [room1, room2, room3, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2, room3]); mkSpace(space2, [room1, room2]); client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ @@ -641,15 +642,15 @@ describe("SpaceStore", () => { it("switch to canonical parent space for room", async () => { viewRoom(room1); - await store.setActiveSpace(null, false); + await store.setActiveSpace(client.getRoom(space2), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space2)); }); it("switch to first containing space for room", async () => { viewRoom(room2); - await store.setActiveSpace(null, false); - viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2), false); + viewRoom(room3); expect(store.activeSpace).toBe(client.getRoom(space1)); }); @@ -659,6 +660,13 @@ describe("SpaceStore", () => { viewRoom(orphan1); expect(store.activeSpace).toBeNull(); }); + + it("when switching rooms in the all rooms home space don't switch to related space", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBeNull(); + }); }); describe("traverseSpace", () => { diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index c5be59ab43..5974915965 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -21,7 +21,9 @@ import { arrayHasDiff, arrayHasOrderChange, arrayMerge, + arrayRescale, arraySeed, + arraySmoothingResample, arrayTrimFill, arrayUnion, ArrayUtil, @@ -29,9 +31,9 @@ import { } from "../../src/utils/arrays"; import {objectFromEntries} from "../../src/utils/objects"; -function expectSample(i: number, input: number[], expected: number[]) { +function expectSample(i: number, input: number[], expected: number[], smooth = false) { console.log(`Resample case index: ${i}`); // for debugging test failures - const result = arrayFastResample(input, expected.length); + const result = (smooth ? arraySmoothingResample : arrayFastResample)(input, expected.length); expect(result).toBeDefined(); expect(result).toHaveLength(expected.length); expect(result).toEqual(expected); @@ -65,6 +67,47 @@ describe('arrays', () => { }); }); + describe('arraySmoothingResample', () => { + it('should downsample', () => { + // Dev note: these aren't great samples, but they demonstrate the bare minimum. Ideally + // we'd be feeding a thousand values in and seeing what a curve of 250 values looks like, + // but that's not really feasible to manually verify accuracy. + [ + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3]}, // Odd -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3]}, // Odd -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3]}, // Even -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should upsample', () => { + [ + {input: [2, 0, 2], output: [2, 2, 0, 0, 2, 2]}, // Odd -> Even + {input: [2, 0, 2], output: [2, 2, 0, 0, 2]}, // Odd -> Odd + {input: [2, 0], output: [2, 2, 2, 0, 0]}, // Even -> Odd + {input: [2, 0], output: [2, 2, 2, 0, 0, 0]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should maintain sample', () => { + [ + {input: [2, 0, 2], output: [2, 0, 2]}, // Odd + {input: [2, 0], output: [2, 0]}, // Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + }); + + describe('arrayRescale', () => { + it('should rescale', () => { + const input = [8, 9, 1, 0, 2, 7, 10]; + const output = [80, 90, 10, 0, 20, 70, 100]; + const result = arrayRescale(input, 0, 100); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + describe('arrayTrimFill', () => { it('should shrink arrays', () => { const input = [1, 2, 3]; diff --git a/yarn.lock b/yarn.lock index acdca26e55..7712ac507a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1594,6 +1594,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/parse5@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.0.tgz#38590dc2c3cf5717154064e3ee9b6947ee21b299" + integrity sha512-oPwPSj4a1wu9rsXTEGIJz91ISU725t0BmSnUhb57sI+M8XEmvUop84lzuiYdq0Y5M6xLY8DBPg0C2xEQKLyvBA== + "@types/prettier@^2.0.0": version "2.1.6" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" @@ -1633,12 +1638,12 @@ "@types/prop-types" "*" csstype "^3.0.2" -"@types/sanitize-html@^1.27.0": - version "1.27.0" - resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.27.0.tgz#77702dc856f16efecc005014c1d2e45b1f2cbc56" - integrity sha512-j7Vnh3P7W4ZcoRsHNO2HpwA2m1d0c2+l39xqSQqH0+WlfcvKypgZp45eCC7NJ75ZyXPxNb2PSbIL6LtZ6E0Qbw== +"@types/sanitize-html@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce" + integrity sha512-+UT/XRluJuCunRftwO6OzG6WOBgJ+J3sROIoSJWX+7PB2FtTJTEJLrHCcNwzCQc0r60bej3WAbaigK+VZtZCGw== dependencies: - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" "@types/stack-utils@^1.0.1": version "1.0.1" @@ -2396,29 +2401,29 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -cheerio-select-tmp@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/cheerio-select-tmp/-/cheerio-select-tmp-0.1.1.tgz#55bbef02a4771710195ad736d5e346763ca4e646" - integrity sha512-YYs5JvbpU19VYJyj+F7oYrIE2BOll1/hRU7rEy/5+v9BzkSo3bK81iAeeQEMI92vRIxz677m72UmJUiVwwgjfQ== +cheerio-select@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.4.0.tgz#3a16f21e37a2ef0f211d6d1aa4eff054bb22cdc9" + integrity sha512-sobR3Yqz27L553Qa7cK6rtJlMDbiKPdNywtR95Sj/YgfpLfy0u6CGJuaBKe5YE/vTc23SCRKxWSdlon/w6I/Ew== dependencies: - css-select "^3.1.2" - css-what "^4.0.0" - domelementtype "^2.1.0" - domhandler "^4.0.0" - domutils "^2.4.4" + css-select "^4.1.2" + css-what "^5.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.6.0" -cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.5: - version "1.0.0-rc.5" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.5.tgz#88907e1828674e8f9fee375188b27dadd4f0fa2f" - integrity sha512-yoqps/VCaZgN4pfXtenwHROTp8NG6/Hlt4Jpz2FEP0ZJQ+ZUkVDd0hAPDNKhj3nakpfPt/CNs57yEtxD1bXQiw== +cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.9: + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.9.tgz#a3ae6b7ce7af80675302ff836f628e7cb786a67f" + integrity sha512-QF6XVdrLONO6DXRF5iaolY+odmhj2CLj+xzNod7INPWMi/x9X4SOylH0S/vaPpX+AUU6t04s34SQNh7DbkuCng== dependencies: - cheerio-select-tmp "^0.1.0" - dom-serializer "~1.2.0" - domhandler "^4.0.0" - entities "~2.1.0" - htmlparser2 "^6.0.0" - parse5 "^6.0.0" - parse5-htmlparser2-tree-adapter "^6.0.0" + cheerio-select "^1.4.0" + dom-serializer "^1.3.1" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" chokidar@^3.4.0, chokidar@^3.5.1: version "3.5.1" @@ -2700,21 +2705,21 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -css-select@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-3.1.2.tgz#d52cbdc6fee379fba97fb0d3925abbd18af2d9d8" - integrity sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA== +css-select@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.2.tgz#8b52b6714ed3a80d8221ec971c543f3b12653286" + integrity sha512-nu5ye2Hg/4ISq4XqdLY2bEatAcLIdt3OYGFc9Tm9n7VSlFBcfRv0gBNksHRgSdUDQGtN3XrZ94ztW+NfzkFSUw== dependencies: boolbase "^1.0.0" - css-what "^4.0.0" - domhandler "^4.0.0" - domutils "^2.4.3" + css-what "^5.0.0" + domhandler "^4.2.0" + domutils "^2.6.0" nth-check "^2.0.0" -css-what@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233" - integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A== +css-what@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.0.tgz#f0bf4f8bac07582722346ab243f6a35b512cfc47" + integrity sha512-qxyKHQvgKwzwDWC/rGbT821eJalfupxYW2qbSJSAtdSTimsr/MlaGONoNLllaUPZWf8QnbcKM/kPVYUQuEKAFA== cssesc@^3.0.0: version "3.0.0" @@ -2920,9 +2925,9 @@ doctrine@^3.0.0: esutils "^2.0.2" dom-helpers@^5.0.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" - integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ== + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== dependencies: "@babel/runtime" "^7.8.7" csstype "^3.0.2" @@ -2935,10 +2940,10 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@^1.0.1, dom-serializer@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" - integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== +dom-serializer@^1.0.1, dom-serializer@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" + integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== dependencies: domelementtype "^2.0.1" domhandler "^4.0.0" @@ -2949,10 +2954,10 @@ domelementtype@1, domelementtype@^1.3.1: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== -domelementtype@^2.0.1, domelementtype@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" - integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== +domelementtype@^2.0.1, domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== domexception@^2.0.1: version "2.0.1" @@ -2968,19 +2973,12 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -domhandler@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" - integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== +domhandler@^4.0.0, domhandler@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059" + integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA== dependencies: - domelementtype "^2.0.1" - -domhandler@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e" - integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA== - dependencies: - domelementtype "^2.1.0" + domelementtype "^2.2.0" domutils@^1.5.1: version "1.7.0" @@ -2990,14 +2988,14 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" -domutils@^2.0.0, domutils@^2.4.3, domutils@^2.4.4: - version "2.4.4" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" - integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA== +domutils@^2.4.4, domutils@^2.5.2, domutils@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.6.0.tgz#2e15c04185d43fb16ae7057cb76433c6edb938b7" + integrity sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA== dependencies: dom-serializer "^1.0.1" - domelementtype "^2.0.1" - domhandler "^4.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" ecc-jsbn@~0.1.1: version "0.1.2" @@ -3063,7 +3061,7 @@ entities@^1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -entities@^2.0.0, entities@~2.1.0: +entities@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== @@ -4221,9 +4219,9 @@ hoist-non-react-statics@^3.3.0: react-is "^16.7.0" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: version "3.0.7" @@ -4273,16 +4271,6 @@ htmlparser2@^3.10.0: inherits "^2.0.1" readable-stream "^3.1.1" -htmlparser2@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" - integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== - dependencies: - domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils "^2.0.0" - entities "^2.0.0" - htmlparser2@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01" @@ -4293,6 +4281,16 @@ htmlparser2@^6.0.0: domutils "^2.4.4" entities "^2.0.0" +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -5580,9 +5578,9 @@ lodash.sortby@^4.7.0: integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^4.0.0: version "4.0.0" @@ -5672,8 +5670,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "10.0.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/c8f69c0b7937b9064938c134d708c4d064b71315" + version "10.1.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2d73805ca3d8c5a140fe05e574f826696de1656a" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -6302,7 +6300,12 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5-htmlparser2-tree-adapter@^6.0.0: +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + +parse5-htmlparser2-tree-adapter@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== @@ -6314,7 +6317,7 @@ parse5@5.1.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parse5@^6.0.0, parse5@^6.0.1: +parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -7243,17 +7246,18 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -"sanitize-html@github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db": - version "2.0.0-rc.3" - resolved "https://codeload.github.com/apostrophecms/sanitize-html/tar.gz/3c7f93f2058f696f5359e3e58d464161647226db" +sanitize-html@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353" + integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA== dependencies: deepmerge "^4.2.2" escape-string-regexp "^4.0.0" - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" is-plain-object "^5.0.0" klona "^2.0.3" + parse-srcset "^1.0.2" postcss "^8.0.2" - srcset "^3.0.0" saxes@^5.0.0: version "5.0.1" @@ -7510,11 +7514,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -srcset@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/srcset/-/srcset-3.0.0.tgz#8afd8b971362dfc129ae9c1a99b3897301ce6441" - integrity sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ== - sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -7999,6 +7998,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + tsutils@^3.17.1: version "3.19.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9" @@ -8070,9 +8074,9 @@ typescript@^4.1.3: integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@^0.7.18: - version "0.7.23" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.23.tgz#704d67f951e13195fbcd3d78818577f5bc1d547b" - integrity sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA== + version "0.7.28" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" + integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== unhomoglyph@^1.0.6: version "1.0.6"