diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000..8351c19397 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +14 diff --git a/package.json b/package.json index 9744aa7685..cccb92d5d9 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", + "posthog-js": "1.12.2", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", @@ -123,6 +124,7 @@ "@babel/traverse": "^7.12.12", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", + "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", @@ -147,13 +149,14 @@ "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", + "allchange": "github:matrix-org/allchange", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", "enzyme": "^3.11.0", "eslint": "7.18.0", "eslint-config-google": "^0.14.0", - "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#main", + "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "glob": "^7.1.6", @@ -166,6 +169,7 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", + "rrweb-snapshot": "1.1.7", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", diff --git a/release_config.yaml b/release_config.yaml new file mode 100644 index 0000000000..12e857cbdd --- /dev/null +++ b/release_config.yaml @@ -0,0 +1,4 @@ +subprojects: + matrix-js-sdk: + includeByDefault: false + diff --git a/res/css/_components.scss b/res/css/_components.scss index dd1b0d220b..652b317655 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -267,6 +267,7 @@ @import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; +@import "./views/toasts/_IncomingCallToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index d248568740..2c3f1c705c 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -28,7 +28,7 @@ limitations under the License. margin: 0 4px; grid-row: 2 / 4; grid-column: 1; - background-color: $dark-panel-bg-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; } @@ -37,7 +37,7 @@ limitations under the License. grid-row: 1 / 3; grid-column: 1; color: $primary-fg-color; - background-color: $dark-panel-bg-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; overflow: hidden; diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 4cbcb8e708..63a5fa7edf 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_ProfileSettings_controls_topic { & > textarea { + font-family: inherit; resize: vertical; } } diff --git a/res/css/views/toasts/_IncomingCallToast.scss b/res/css/views/toasts/_IncomingCallToast.scss new file mode 100644 index 0000000000..975628f948 --- /dev/null +++ b/res/css/views/toasts/_IncomingCallToast.scss @@ -0,0 +1,149 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. +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. +*/ + +.mx_IncomingCallToast { + display: flex; + flex-direction: row; + pointer-events: initial; // restore pointer events so the user can accept/decline + + .mx_IncomingCallToast_content { + display: flex; + flex-direction: column; + margin-left: 8px; + + .mx_CallEvent_caller { + font-weight: bold; + font-size: $font-15px; + line-height: $font-18px; + + margin-top: 2px; + } + + .mx_CallEvent_type { + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-fg-color; + + margin-top: 4px; + margin-bottom: 6px; + + display: flex; + flex-direction: row; + align-items: center; + + .mx_CallEvent_type_icon { + height: 16px; + width: 16px; + margin-right: 6px; + + &::before { + content: ''; + position: absolute; + height: inherit; + width: inherit; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + + &.mx_IncomingCallToast_content_voice { + .mx_CallEvent_type .mx_CallEvent_type_icon::before, + .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_IncomingCallToast_content_video { + .mx_CallEvent_type .mx_CallEvent_type_icon::before, + .mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + .mx_IncomingCallToast_buttons { + margin-top: 8px; + display: flex; + flex-direction: row; + gap: 12px; + + .mx_IncomingCallToast_button { + height: 24px; + padding: 0px 8px; + flex-shrink: 0; + flex-grow: 1; + margin-right: 0; + font-size: $font-15px; + line-height: $font-24px; + + span { + padding: 8px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + margin-right: 8px; + } + } + + &.mx_IncomingCallToast_button_accept span::before { + mask-size: 13px; + width: 13px; + height: 13px; + } + + &.mx_IncomingCallToast_button_decline span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + } + } + } + } + + .mx_IncomingCallToast_iconButton { + display: flex; + height: 20px; + width: 20px; + + &::before { + content: ''; + + height: inherit; + width: inherit; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + + .mx_IncomingCallToast_silence::before { + mask-image: url('$(res)/img/voip/silence.svg'); + } + + .mx_IncomingCallToast_unSilence::before { + mask-image: url('$(res)/img/voip/un-silence.svg'); + } +} diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 0c09070334..181a5ee0a3 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -43,84 +43,4 @@ limitations under the License. .mx_AppTile_persistedWrapper div { min-width: 350px; } - - .mx_IncomingCallBox { - min-width: 250px; - background-color: $voipcall-plinth-color; - padding: 8px; - box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); - border-radius: 8px; - - pointer-events: initial; // restore pointer events so the user can accept/decline - cursor: pointer; - - .mx_IncomingCallBox_CallerInfo { - display: flex; - direction: row; - - img, .mx_BaseAvatar_initial { - margin: 8px; - } - - > div { - display: flex; - flex-direction: column; - - justify-content: center; - } - - h1, p { - margin: 0px; - padding: 0px; - font-size: $font-14px; - line-height: $font-16px; - } - - h1 { - font-weight: bold; - } - } - - .mx_IncomingCallBox_buttons { - padding: 8px; - display: flex; - flex-direction: row; - - > .mx_IncomingCallBox_spacer { - width: 8px; - } - - > * { - flex-shrink: 0; - flex-grow: 1; - margin-right: 0; - font-size: $font-15px; - line-height: $font-24px; - } - } - - .mx_IncomingCallBox_iconButton { - position: absolute; - right: 8px; - - &::before { - content: ''; - - height: 20px; - width: 20px; - background-color: $icon-button-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - } - - .mx_IncomingCallBox_silence::before { - mask-image: url('$(res)/img/voip/silence.svg'); - } - - .mx_IncomingCallBox_unSilence::before { - mask-image: url('$(res)/img/voip/un-silence.svg'); - } - } } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 104e2993d8..60b7aa69ea 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -39,7 +39,7 @@ limitations under the License. .mx_CallView_pip { width: 320px; padding-bottom: 8px; - background-color: $voipcall-plinth-color; + background-color: $toast-bg-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; @@ -76,16 +76,22 @@ 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; + margin-bottom: 52px; background-color: $inverted-bg-color; display: flex; justify-content: center; align-items: center; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + height: 100%; background-color: #000; } + + .mx_VideoFeed_mic { + left: 10px; + bottom: 10px; + } } } diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 79bf3cbf09..892a137a32 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -35,12 +35,23 @@ limitations under the License. width: 100%; &.mx_VideoFeed_voice { + border-radius: 4px; + display: flex; align-items: center; justify-content: center; aspect-ratio: 16 / 9; } + + .mx_VideoFeed_video { + border-radius: 4px; + } + + .mx_VideoFeed_mic { + left: 6px; + bottom: 6px; + } } &.mx_CallViewSidebar_pipMode { diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 07a4a0e530..3a0f62636e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -15,18 +15,52 @@ limitations under the License. */ .mx_VideoFeed { - border-radius: 4px; - + overflow: hidden; + position: relative; &.mx_VideoFeed_voice { background-color: $inverted-bg-color; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + width: 100%; background-color: transparent; + + &.mx_VideoFeed_video_mirror { + transform: scale(-1, 1); + } + } + + .mx_VideoFeed_mic { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + + background-color: rgba(0, 0, 0, 0.5); // Same on both themes + border-radius: 100%; + + &::before { + position: absolute; + content: ""; + width: 16px; + height: 16px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + background-color: white; // Same on both themes + border-radius: 7px; + } + + &.mx_VideoFeed_mic_muted::before { + mask-image: url('$(res)/img/voip/mic-muted.svg'); + } + + &.mx_VideoFeed_mic_unmuted::before { + mask-image: url('$(res)/img/voip/mic-unmuted.svg'); + } } } - -.mx_VideoFeed_mirror { - transform: scale(-1, 1); -} diff --git a/res/img/voip/mic-muted.svg b/res/img/voip/mic-muted.svg new file mode 100644 index 0000000000..0cb7ad1c9e --- /dev/null +++ b/res/img/voip/mic-muted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/voip/mic-unmuted.svg b/res/img/voip/mic-unmuted.svg new file mode 100644 index 0000000000..8334cafa0a --- /dev/null +++ b/res/img/voip/mic-unmuted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 655492661c..e4ea2bb57e 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -1,3 +1,6 @@ +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 +$system-dark: #21262C; + // unified palette // try to use these colors when possible $bg-color: #15191E; @@ -47,7 +50,7 @@ $inverted-bg-color: $base-color; $selected-color: $room-highlight-color; // selected for hoverover & selected event tiles -$event-selected-color: #21262c; +$event-selected-color: $system-dark; // used for the hairline dividers in RoomView $primary-hairline-color: transparent; @@ -91,7 +94,7 @@ $lightbox-background-bg-color: #000; $lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #21262c; +$settings-profile-placeholder-bg-color: $system-dark; $settings-profile-overlay-placeholder-fg-color: #454545; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -112,8 +115,8 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #394049; +$quinary-content-color: #394049; +$toast-bg-color: $quinary-content-color; // ******************** @@ -175,7 +178,7 @@ $button-link-bg-color: transparent; $togglesw-off-color: $room-highlight-color; $progressbar-fg-color: $accent-color; -$progressbar-bg-color: #21262c; +$progressbar-bg-color: $system-dark; $visual-bell-bg-color: #800; @@ -210,7 +213,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #394049; // "Dark Tile" $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: #21262C; // "System Dark" +$message-body-panel-icon-bg-color: $system-dark; // "System Dark" $voice-record-stop-border-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; @@ -228,9 +231,9 @@ $groupFilterPanel-background-blur-amount: 30px; $composer-shadow-color: rgba(0, 0, 0, 0.28); // Bubble tiles -$eventbubble-self-bg: #143A34; -$eventbubble-others-bg: #394049; -$eventbubble-bg-hover: #433C23; +$eventbubble-self-bg: #14322E; +$eventbubble-others-bg: $event-selected-color; +$eventbubble-bg-hover: #1C2026; $eventbubble-avatar-outline: $bg-color; $eventbubble-reply-color: #C1C6CD; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 0c0197cfb0..064b532bb0 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -111,8 +111,8 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #394049; +$quinary-content-color: #394049; +$toast-bg-color: $quinary-content-color; // ******************** diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index b7d45452ff..e945efb219 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -8,9 +8,12 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; +$font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', 'Sans-Serif', 'Noto Color Emoji'; -$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji'; +$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', 'monospace', 'Noto Color Emoji'; + +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 +$system-light: #F4F6FA; // unified palette // try to use these colors when possible @@ -178,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #F4F6FA; +$toast-bg-color: $system-light; +$voipcall-plinth-color: $system-light; // ******************** @@ -331,7 +334,7 @@ $user-tile-hover-bg-color: $header-panel-bg-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: #F4F6FA; +$message-body-panel-icon-bg-color: $system-light; // See non-legacy _light for variable information $voice-record-stop-symbol-color: #ff4b55; @@ -348,9 +351,9 @@ $appearance-tab-border-color: $input-darker-bg-color; $composer-shadow-color: tranparent; // Bubble tiles -$eventbubble-self-bg: #F8FDFC; -$eventbubble-others-bg: #F7F8F9; -$eventbubble-bg-hover: rgb(242, 242, 242); +$eventbubble-self-bg: #F0FBF8; +$eventbubble-others-bg: $system-light; +$eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: #fff; $eventbubble-reply-color: #C1C6CD; @@ -390,7 +393,7 @@ $eventbubble-reply-color: #C1C6CD; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 32722515d8..aa17dddc56 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -8,9 +8,12 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: Inter, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; +$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', 'Sans-Serif', 'Noto Color Emoji'; -$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji'; +$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', 'monospace', 'Noto Color Emoji'; + +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 +$system-light: #F4F6FA; // unified palette // try to use these colors when possible @@ -138,7 +141,7 @@ $blockquote-bar-color: #ddd; $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: #f4f6fa; +$settings-profile-placeholder-bg-color: $system-light; $settings-profile-overlay-placeholder-fg-color: #2e2f32; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -167,8 +170,8 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; -// this probably shouldn't have it's own colour -$voipcall-plinth-color: #F4F6FA; +$toast-bg-color: $system-light; +$voipcall-plinth-color: $system-light; // ******************** @@ -327,7 +330,7 @@ $user-tile-hover-bg-color: $header-panel-bg-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: #F4F6FA; +$message-body-panel-icon-bg-color: $system-light; // These two don't change between themes. They are the $warning-color, but we don't // want custom themes to affect them by accident. @@ -350,9 +353,9 @@ $groupFilterPanel-background-blur-amount: 20px; $composer-shadow-color: rgba(0, 0, 0, 0.04); // Bubble tiles -$eventbubble-self-bg: #F8FDFC; -$eventbubble-others-bg: #F7F8F9; -$eventbubble-bg-hover: #FEFCF5; +$eventbubble-self-bg: #F0FBF8; +$eventbubble-others-bg: $system-light; +$eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: $primary-bg-color; $eventbubble-reply-color: #C1C6CD; @@ -392,7 +395,7 @@ $eventbubble-reply-color: #C1C6CD; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index e7c1dda54f..30fcf46790 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -86,6 +86,9 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/ import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; +import { getIncomingCallToastKey } from './toasts/IncomingCallToast'; +import ToastStore from './stores/ToastStore'; +import IncomingCallToast from "./toasts/IncomingCallToast"; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -624,6 +627,19 @@ export default class CallHandler extends EventEmitter { `Call state in ${mappedRoomId} changed to ${status}`, ); + const toastKey = getIncomingCallToastKey(call.callId); + if (status === CallState.Ringing) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { call }, + }); + } else { + ToastStore.sharedInstance().dismissToast(toastKey); + } + dis.dispatch({ action: 'call_state', room_id: mappedRoomId, @@ -914,6 +930,8 @@ export default class CallHandler extends EventEmitter { action: 'view_room', room_id: roomId, }); + + await this.placeCall(roomId, PlaceCallType.Voice, null); } private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index af5d2b3019..2eee5214af 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix']; +export const PERMITTED_URL_SCHEMES = [ + "bitcoin", + "ftp", + "geo", + "http", + "https", + "im", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "openpgp4fpr", + "sip", + "sftp", + "sms", + "smsto", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp", +]; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index e91e1d72cf..ffece510de 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -146,23 +146,23 @@ export default class IdentityAuthClient { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', QuestionDialog, { - title: _t("Identity server has no terms of service"), - description: ( -
-

{ _t( - "This action requires accessing the default identity server " + + title: _t("Identity server has no terms of service"), + description: ( +

+

{ _t( + "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.", {}, - { - server: () => { abbreviateUrl(identityServerUrl) }, - }, - ) }

-

{ _t( - "Only continue if you trust the owner of the server.", - ) }

-
- ), - button: _t("Trust"), + { + server: () => { abbreviateUrl(identityServerUrl) }, + }, + ) }

+

{ _t( + "Only continue if you trust the owner of the server.", + ) }

+
+ ), + button: _t("Trust"), }); const [confirmed] = await finished; if (confirmed) { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 410124a637..e48fd52cb1 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -573,6 +574,8 @@ async function doSetLoggedIn( await abortLogin(); } + PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId); + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); MatrixClientPeg.replaceUsingCreds(credentials); @@ -700,6 +703,8 @@ export function logout(): void { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + PosthogAnalytics.instance.logout(); + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session if we abort the login. diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 0000000000..860a155aff --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,355 @@ +/* +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 posthog, { PostHog } from 'posthog-js'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; +import SettingsStore from './settings/SettingsStore'; + +/* Posthog analytics tracking. + * + * Anonymity behaviour is as follows: + * + * - If Posthog isn't configured in `config.json`, events are not sent. + * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is + * enabled, events are not sent (this detection is built into posthog and turned on via the + * `respect_dnt` flag being passed to `posthog.init`). + * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. + * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. + * redact all matrix identifiers in tracking events. + * - If both flags are false or not set, events are not sent. + */ + +interface IEvent { + // The event name that will be used by PostHog. Event names should use snake_case. + eventName: string; + + // The properties of the event that will be stored in PostHog. This is just a placeholder, + // extending interfaces must override this with a concrete definition to do type validation. + properties: {}; +} + +export enum Anonymity { + Disabled, + Anonymous, + Pseudonymous +} + +// If an event extends IPseudonymousEvent, the event contains pseudonymous data +// that won't be sent unless the user has explicitly consented to pseudonymous tracking. +// For example, it might contain hashed user IDs or room IDs. +// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous. +export interface IPseudonymousEvent extends IEvent {} + +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data; +// i.e. no identifiers that can be associated with the user. +export interface IAnonymousEvent extends IEvent {} + +export interface IRoomEvent extends IPseudonymousEvent { + hashedRoomId: string; +} + +interface IPageView extends IAnonymousEvent { + eventName: "$pageview"; + properties: { + durationMs?: number; + screen?: string; + }; +} + +const hashHex = async (input: string): Promise => { + const buf = new TextEncoder().encode(input); + const digestBuf = await window.crypto.subtle.digest("sha-256", buf); + return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); +}; + +const whitelistedScreens = new Set([ + "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", + "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", +]); + +export async function getRedactedCurrentLocation( + origin: string, + hash: string, + pathname: string, + anonymity: Anonymity, +): Promise { + // Redact PII from the current location. + // If anonymous is true, redact entirely, if false, substitute it with a hash. + // For known screens, assumes a URL structure of //might/be/pii + if (origin.startsWith('file://')) { + pathname = "//"; + } + + let hashStr; + if (hash == "") { + hashStr = ""; + } else { + let [beforeFirstSlash, screen, ...parts] = hash.split("/"); + + if (!whitelistedScreens.has(screen)) { + screen = ""; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); + } + + hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`; + } + return origin + pathname + hashStr; +} + +interface PlatformProperties { + appVersion: string; + appPlatform: string; +} + +export class PosthogAnalytics { + /* Wrapper for Posthog analytics. + * 3 modes of anonymity are supported, governed by this.anonymity + * - Anonymity.Disabled means *no data* is passed to posthog + * - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog + * - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed + * to Posthog + * + * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). + * + * To pass an event to Posthog: + * + * 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. + * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is + * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. + */ + + private anonymity = Anonymity.Disabled; + // set true during the constructor if posthog config is present, otherwise false + private enabled = false; + private static _instance = null; + private platformSuperProperties = {}; + + public static get instance(): PosthogAnalytics { + if (!this._instance) { + this._instance = new PosthogAnalytics(posthog); + } + return this._instance; + } + + constructor(private readonly posthog: PostHog) { + const posthogConfig = SdkConfig.get()["posthog"]; + if (posthogConfig) { + this.posthog.init(posthogConfig.projectApiKey, { + api_host: posthogConfig.apiHost, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + // This only triggers on page load, which for our SPA isn't particularly useful. + // Plus, the .capture call originating from somewhere in posthog makes it hard + // to redact URLs, which requires async code. + // + // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. + capture_pageview: false, + sanitize_properties: this.sanitizeProperties, + respect_dnt: true, + }); + this.enabled = true; + } else { + this.enabled = false; + } + } + + private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => { + // Callback from posthog to sanitize properties before sending them to the server. + // + // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. + // See utils.js _.info.properties in posthog-js. + + // Replace the $current_url with a redacted version. + // $redacted_current_url is injected by this class earlier in capture(), as its generation + // is async and can't be done in this non-async callback. + if (!properties['$redacted_current_url']) { + console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely"); + } + properties['$current_url'] = properties['$redacted_current_url']; + delete properties['$redacted_current_url']; + + if (this.anonymity == Anonymity.Anonymous) { + // drop referrer information for anonymous users + properties['$referrer'] = null; + properties['$referring_domain'] = null; + properties['$initial_referrer'] = null; + properties['$initial_referring_domain'] = null; + + // drop device ID, which is a UUID persisted in local storage + properties['$device_id'] = null; + } + + return properties; + }; + + private static getAnonymityFromSettings(): Anonymity { + // determine the current anonymity level based on current user settings + + // "Send anonymous usage data which helps us improve Element. This will use a cookie." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); + + // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // + // TODO: Currently, this is only a labs flag, for testing purposes. + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; + } + + private registerSuperProperties(properties: posthog.Properties) { + if (this.enabled) { + this.posthog.register(properties); + } + } + + private static async getPlatformProperties(): Promise { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; + } + + private async capture(eventName: string, properties: posthog.Properties) { + if (!this.enabled) { + return; + } + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); + this.posthog.capture(eventName, properties); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public setAnonymity(anonymity: Anonymity): void { + // Update this.anonymity. + // This is public for testing purposes, typically you want to call updateAnonymityFromSettings + // to ensure this value is in step with the user's settings. + if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) { + // when transitioning to Disabled or Anonymous ensure we clear out any prior state + // set in posthog e.g. distinct ID + this.posthog.reset(); + // Restore any previously set platform super properties + this.registerSuperProperties(this.platformSuperProperties); + } + this.anonymity = anonymity; + } + + public async identifyUser(userId: string): Promise { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); + } + } + + public getAnonymity(): Anonymity { + return this.anonymity; + } + + public logout(): void { + if (this.enabled) { + this.posthog.reset(); + } + this.setAnonymity(Anonymity.Anonymous); + } + + public async trackPseudonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ) { + if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackAnonymousEvent( + eventName: E["eventName"], + properties: E["properties"] = {}, + ): Promise { + if (this.anonymity == Anonymity.Disabled) return; + await this.capture(eventName, properties); + } + + public async trackRoomEvent( + eventName: E["eventName"], + roomId: string, + properties: Omit, + ): Promise { + const updatedProperties = { + ...properties, + hashedRoomId: roomId ? await hashHex(roomId) : null, + }; + await this.trackPseudonymousEvent(eventName, updatedProperties); + } + + public async trackPageView(durationMs: number): Promise { + const hash = window.location.hash; + + let screen = null; + const split = hash.split("/"); + if (split.length >= 2) { + screen = split[1]; + } + + await this.trackAnonymousEvent("$pageview", { + durationMs, + screen, + }); + } + + public async updatePlatformSuperProperties(): Promise { + // Update super properties in posthog with our platform (app version, platform). + // These properties will be subsequently passed in every event. + // + // This only needs to be done once per page lifetime. Note that getPlatformProperties + // is async and can involve a network request if we are running in a browser. + this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties(this.platformSuperProperties); + } + + public async updateAnonymityFromSettings(userId?: string): Promise { + // Update this.anonymity based on the user's analytics opt-in settings + // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous + this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); + if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + await this.identifyUser(userId); + } + } +} diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 9cc7b60c99..c66984191f 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -163,7 +163,7 @@ const shortcuts: Record = { modifiers: [Modifiers.SHIFT], key: Key.PAGE_UP, }], - description: _td("Jump to oldest unread message"), + description: _td("Jump to oldest unread message"), }, { keybinds: [{ modifiers: [CMD_OR_CTRL, Modifiers.SHIFT], diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8cfe35c4cf..60c78b5f9e 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; +import { PosthogAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + + PosthogAnalytics.instance.updateAnonymityFromSettings(); + PosthogAnalytics.instance.updatePlatformSuperProperties(); + CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs); + PosthogAnalytics.instance.trackPageView(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusSendMessageComposer); diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index b7b0b7c652..0b0e871975 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> { let containerClasses; if (totalCount !== 0) { const topToast = this.state.toasts[0]; - const { title, icon, key, component, className, props } = topToast; - const toastClasses = classNames("mx_Toast_toast", { + const { title, icon, key, component, className, bodyClassName, props } = topToast; + const bodyClasses = classNames("mx_Toast_body", bodyClassName); + const toastClasses = classNames("mx_Toast_toast", className, { "mx_Toast_hasIcon": icon, [`mx_Toast_icon_${icon}`]: icon, - }, className); - - let countIndicator; - if (isStacked || this.state.countSeen > 0) { - countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`; - } - + }); const toastProps = Object.assign({}, props, { key, toastKey: key, }); - toast = (
-
-

{ title }

- { countIndicator } + const content = React.createElement(component, toastProps); + + let countIndicator; + if (title && isStacked || this.state.countSeen > 0) { + countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`; + } + + let titleElement; + if (title) { + titleElement = ( +
+

{ title }

+ { countIndicator } +
+ ); + } + + toast = ( +
+ { titleElement } +
{ content }
-
{ React.createElement(component, toastProps) }
-
); + ); containerClasses = classNames("mx_ToastContainer", { "mx_ToastContainer_stacked": isStacked, diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 8ae9d0654f..30e6c70f0e 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -490,9 +490,9 @@ class RoomStateExplorer extends React.PureComponent; } diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js index 9fa9fc1373..6b36c19977 100644 --- a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js +++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js @@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; export default function KeySignatureUploadFailedDialog({ - failures, - source, - continuation, - onFinished, + failures, + source, + continuation, + onFinished, }) { const RETRIES = 2; const BaseDialog = sdk.getComponent('dialogs.BaseDialog'); diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.tsx similarity index 82% rename from src/components/views/dialogs/LogoutDialog.js rename to src/components/views/dialogs/LogoutDialog.tsx index 469cd48093..8c035dcbba 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import Modal from '../../../Modal'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; @@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + shouldLoadBackupStatus: boolean; + loading: boolean; + backupInfo: IKeyBackupInfo; + error?: string; +} + @replaceableComponent("views.dialogs.LogoutDialog") -export default class LogoutDialog extends React.Component { - defaultProps = { +export default class LogoutDialog extends React.Component { + static defaultProps = { onFinished: function() {}, }; - constructor() { - super(); - this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this); - this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this); - this._onFinished = this._onFinished.bind(this); - this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this); - this._onLogoutConfirm = this._onLogoutConfirm.bind(this); + constructor(props) { + super(props); const cli = MatrixClientPeg.get(); const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled(); @@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component { }; if (shouldLoadBackupStatus) { - this._loadBackupStatus(); + this.loadBackupStatus(); } } - async _loadBackupStatus() { + private async loadBackupStatus() { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); this.setState({ @@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component { } } - _onSettingsLinkClick() { + private onSettingsLinkClick = (): void => { // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; - _onExportE2eKeysClicked() { + private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', '', import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), { matrixClient: MatrixClientPeg.get(), }, ); - } + }; - _onFinished(confirmed) { + private onFinished = (confirmed: boolean): void => { if (confirmed) { dis.dispatch({ action: 'logout' }); } // close dialog - this.props.onFinished(); - } + this.props.onFinished(confirmed); + }; - _onSetRecoveryMethodClick() { + private onSetRecoveryMethodClick = (): void => { if (this.state.backupInfo) { // A key backup exists for this account, but the creating device is not // verified, so restore the backup which will give us the keys from it and @@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component { } // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; - _onLogoutConfirm() { + private onLogoutConfirm = (): void => { dis.dispatch({ action: 'logout' }); // close dialog - this.props.onFinished(); - } + this.props.onFinished(true); + }; render() { if (this.state.shouldLoadBackupStatus) { @@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
-
{ _t("Advanced") } -

@@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component { title={_t("You'll lose access to your encrypted messages")} contentId='mx_Dialog_content' hasCancel={true} - onFinished={this._onFinished} + onFinished={this.onFinished} > { dialogContent } ); @@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component { "Are you sure you want to sign out?", )} button={_t("Sign out")} - onFinished={this._onFinished} + onFinished={this.onFinished} />); } } diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index 19ffe81ac1..c82e5a3f80 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -152,7 +152,7 @@ const PinnedMessagesCard = ({ room, onClose }: IProps) => {

{ _t("Nothing pinned, yet") }

{ _t("If you have permissions, open the menu on any message and select " + "Pin to stick them here.", {}, { - b: sub => { sub }, + b: sub => { sub }, }) } ; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 088c59d724..138f5bf9fe 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1381,8 +1381,8 @@ const BasicUserInfo: React.FC<{ className="mx_UserInfo_field" onClick={() => { dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Security, + action: Action.ViewUserSettings, + initialTabId: UserTab.Security, }); }} > diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index cdca9ab75d..674bcdaec2 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -64,9 +64,9 @@ const NewRoomIntro = () => { height={AVATAR_SIZE} onClick={() => { defaultDispatcher.dispatch({ - action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the receiver wants. - member: member || { userId: dmPartner } as User, + action: Action.ViewUser, + // XXX: We should be using a real member object and not assuming what the receiver wants. + member: member || { userId: dmPartner } as User, }); }} /> diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index 4ea16f69a3..c9688b4d29 100644 --- a/src/components/views/rooms/ReadReceiptMarker.js +++ b/src/components/views/rooms/ReadReceiptMarker.js @@ -145,7 +145,7 @@ export default class ReadReceiptMarker extends React.PureComponent { if (oldInfo && oldInfo.left) { // start at the old height and in the old h pos startStyles.push({ top: startTopOffset+"px", - left: toPx(oldInfo.left) }); + left: toPx(oldInfo.left) }); } startStyles.push({ top: startTopOffset+'px', left: '0' }); @@ -174,14 +174,14 @@ export default class ReadReceiptMarker extends React.PureComponent { title = _t( "Seen by %(userName)s at %(dateTime)s", { userName: this.props.fallbackUserId, - dateTime: dateString }, + dateTime: dateString }, ); } else { title = _t( "Seen by %(displayName)s (%(userName)s) at %(dateTime)s", { displayName: this.props.member.rawDisplayName, - userName: this.props.fallbackUserId, - dateTime: dateString }, + userName: this.props.fallbackUserId, + dateTime: dateString }, ); } } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 79d501e712..25b0b86cb1 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,6 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -106,6 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); + PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); }; _onExportE2eKeysClicked = () => { diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/CallContainer.tsx index fa963e4e28..41046b9952 100644 --- a/src/components/views/voip/CallContainer.tsx +++ b/src/components/views/voip/CallContainer.tsx @@ -1,5 +1,6 @@ /* Copyright 2020 The Matrix.org Foundation C.I.C. +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. @@ -15,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import IncomingCallBox from './IncomingCallBox'; import CallPreview from './CallPreview'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -31,7 +31,6 @@ interface IState { export default class CallContainer extends React.PureComponent { public render() { return
-
; } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx deleted file mode 100644 index 95e97f1080..0000000000 --- a/src/components/views/voip/IncomingCallBox.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import dis from '../../../dispatcher/dispatcher'; -import { _t } from '../../../languageHandler'; -import { ActionPayload } from '../../../dispatcher/payloads'; -import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; -import RoomAvatar from '../avatars/RoomAvatar'; -import AccessibleButton from '../elements/AccessibleButton'; -import { CallState } from 'matrix-js-sdk/src/webrtc/call'; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; -import classNames from 'classnames'; - -interface IProps { -} - -interface IState { - incomingCall: any; - silenced: boolean; -} - -@replaceableComponent("views.voip.IncomingCallBox") -export default class IncomingCallBox extends React.Component { - private dispatcherRef: string; - - constructor(props: IProps) { - super(props); - - this.dispatcherRef = dis.register(this.onAction); - this.state = { - incomingCall: null, - silenced: false, - }; - } - - componentDidMount = () => { - CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); - }; - - public componentWillUnmount() { - dis.unregister(this.dispatcherRef); - CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); - } - - private onAction = (payload: ActionPayload) => { - switch (payload.action) { - case 'call_state': { - const call = CallHandler.sharedInstance().getCallForRoom(payload.room_id); - if (call && call.state === CallState.Ringing) { - this.setState({ - incomingCall: call, - silenced: false, // Reset silenced state for new call - }); - } else { - this.setState({ - incomingCall: null, - }); - } - } - } - }; - - private onSilencedCallsChanged = () => { - const callId = this.state.incomingCall?.callId; - if (!callId) return; - this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(callId) }); - }; - - private onAnswerClick: React.MouseEventHandler = (e) => { - e.stopPropagation(); - dis.dispatch({ - action: 'answer', - room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), - }); - }; - - private onRejectClick: React.MouseEventHandler = (e) => { - e.stopPropagation(); - dis.dispatch({ - action: 'reject', - room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), - }); - }; - - private onSilenceClick: React.MouseEventHandler = (e) => { - e.stopPropagation(); - const callId = this.state.incomingCall.callId; - this.state.silenced ? - CallHandler.sharedInstance().unSilenceCall(callId): - CallHandler.sharedInstance().silenceCall(callId); - }; - - public render() { - if (!this.state.incomingCall) { - return null; - } - - let room = null; - if (this.state.incomingCall) { - room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall)); - } - - const caller = room ? room.name : _t("Unknown caller"); - - let incomingCallText = null; - if (this.state.incomingCall) { - if (this.state.incomingCall.type === "voice") { - incomingCallText = _t("Incoming voice call"); - } else if (this.state.incomingCall.type === "video") { - incomingCallText = _t("Incoming video call"); - } else { - incomingCallText = _t("Incoming call"); - } - } - - const silenceClass = classNames({ - "mx_IncomingCallBox_iconButton": true, - "mx_IncomingCallBox_unSilence": this.state.silenced, - "mx_IncomingCallBox_silence": !this.state.silenced, - }); - - return
-
- -
-

{ caller }

-

{ incomingCallText }

-
- -
-
- - { _t("Decline") } - -
- - { _t("Accept") } - -
-
; - } -} diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 51d2adb845..1430a7974a 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -22,6 +22,7 @@ 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"; +import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; interface IProps { call: MatrixCall; @@ -47,7 +48,7 @@ interface IState { } @replaceableComponent("views.voip.VideoFeed") -export default class VideoFeed extends React.Component { +export default class VideoFeed extends React.PureComponent { private element: HTMLVideoElement; constructor(props: IProps) { @@ -68,8 +69,15 @@ export default class VideoFeed extends React.Component { this.updateFeed(this.props.feed, null); } - componentDidUpdate(prevProps: IProps) { + componentDidUpdate(prevProps: IProps, prevState: IState) { this.updateFeed(prevProps.feed, this.props.feed); + // If the mutes state has changed, we try to playMedia() + if ( + prevState.videoMuted !== this.state.videoMuted || + prevProps.feed.stream !== this.props.feed.stream + ) { + this.playMedia(); + } } static getDerivedStateFromProps(props: IProps) { @@ -94,10 +102,12 @@ export default class VideoFeed extends React.Component { if (oldFeed) { this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged); this.stopMedia(); } if (newFeed) { this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged); this.playMedia(); } } @@ -143,7 +153,13 @@ export default class VideoFeed extends React.Component { audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); - this.playMedia(); + }; + + private onMuteStateChanged = () => { + this.setState({ + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }); }; private onResize = (e) => { @@ -153,39 +169,59 @@ export default class VideoFeed extends React.Component { }; render() { - const videoClasses = { - mx_VideoFeed: true, + const { pipMode, primary, feed } = this.props; + + const wrapperClasses = classnames("mx_VideoFeed", { mx_VideoFeed_voice: this.state.videoMuted, - mx_VideoFeed_video: !this.state.videoMuted, - mx_VideoFeed_mirror: ( - this.props.feed.isLocal() && - SettingsStore.getValue('VideoView.flipVideoHorizontally') - ), - }; + }); + const micIconClasses = classnames("mx_VideoFeed_mic", { + mx_VideoFeed_mic_muted: this.state.audioMuted, + mx_VideoFeed_mic_unmuted: !this.state.audioMuted, + }); - const { pipMode, primary } = this.props; + let micIcon; + if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) { + micIcon = ( +
+ ); + } + let content; if (this.state.videoMuted) { const member = this.props.feed.getMember(); + let avatarSize; if (pipMode && primary) avatarSize = 76; else if (pipMode && !primary) avatarSize = 16; else if (!pipMode && primary) avatarSize = 160; else; // TBD - return ( -
- -
+ content =( + ); } else { - return ( -