From 66b21e96ca11f0820f7af39fe24c93037a623f57 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 13:57:36 +0100 Subject: [PATCH 01/33] Resolve multiple CVEs CVE-2022-39249 CVE-2022-39250 CVE-2022-39251 CVE-2022-39236 --- src/components/views/rooms/EventTile.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index b4d022cab4..dfda481859 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -628,9 +628,11 @@ export class UnwrappedEventTile extends React.Component { } if (!userTrust.isCrossSigningVerified()) { - // user is not verified, so default to everything is normal + // If the message is unauthenticated, then display a grey + // shield, otherwise if the user isn't cross-signed then + // nothing's needed this.setState({ - verified: E2EState.Normal, + verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } From fa7acf4dfd6b26cb35bf5a87a8dbaad49128ba9c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 14:09:31 +0100 Subject: [PATCH 02/33] Upgrade matrix-js-sdk to 19.7.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 70b1657767..78730997b1 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "19.6.0", + "matrix-js-sdk": "19.7.0", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index e52ef8945f..b70868079c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6778,10 +6778,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@19.6.0: - version "19.6.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-19.6.0.tgz#8e531b4d140f98f5c3d8c2aca1e7fae2ef839f3e" - integrity sha512-VU+FTixX+NfWeUbFNR1I0+RUQlXJCoYrg+qDcYje0faalcRN2zWJmS2KWD0hXIqXQS2q44zbPc7WzpPjd0ToAQ== +matrix-js-sdk@19.7.0: + version "19.7.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz#ccadae630c56032b040c87b163475a04601409ce" + integrity sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 0f8884e697c7bb65980f543086e534497a817d23 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 14:11:18 +0100 Subject: [PATCH 03/33] Prepare changelog for v3.56.0 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a501849e..ec1af4929c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +Changes in [3.56.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.56.0) (2022-09-28) +===================================================================================================== + +## 🔒 Security +* Fix for [CVE-2022-39249](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39249) +* Fix for [CVE-2022-39250](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39250) +* Fix for [CVE-2022-39251](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39251) +* Fix for [CVE-2022-39236](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39236) + Changes in [3.55.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.55.0) (2022-09-20) =============================================================================================================== From 82795b9c86ca2e0c59903a4d30c5d3d8208df2d5 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 14:11:18 +0100 Subject: [PATCH 04/33] v3.56.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78730997b1..4951e117f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.55.0", + "version": "3.56.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { From fff065d6af7790fdbb9d158ecf4e647bd78dec93 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 14:14:49 +0100 Subject: [PATCH 05/33] Reset matrix-js-sdk back to develop branch --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 1c447bfe66..133deaa502 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "19.7.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index 1aee0c2a78..fc26aa5a97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6925,10 +6925,9 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@19.7.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "19.7.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz#ccadae630c56032b040c87b163475a04601409ce" - integrity sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/14aa7846a5ac4559dd0489726f06d6eab54f5de5" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 74b2e503acd697ba4236ca81952abd9743996942 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 15:49:16 +0100 Subject: [PATCH 06/33] Upgrade matrix-js-sdk to 20.0.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4951e117f4..66343cd1b6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "19.7.0", + "matrix-js-sdk": "20.0.0", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index b70868079c..54000e8c24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6778,10 +6778,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@19.7.0: - version "19.7.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-19.7.0.tgz#ccadae630c56032b040c87b163475a04601409ce" - integrity sha512-mFN1LBmEpYHCH6II1F8o7y8zJr0kn1yX7ga7tRXHbLJAlBS4bAXRsEoAzdv6OrV8/dS325JlVUYQLHFHQWjYxg== +matrix-js-sdk@20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-20.0.0.tgz#f88f2052b0fb868233616ca4b968885613759fc6" + integrity sha512-eOKTiWhvUxiPtl4bHFLJh/kQRdaR3ucTUTTvGXZAWh1Ljo6emU2VILPCQWyl7HTOzjCvkRHAvuGlBypMQZf+MQ== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 5e21313fe9cfc94ffb7c4b946e14679bcd6ac1ea Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 15:51:11 +0100 Subject: [PATCH 07/33] Prepare changelog for v3.57.0 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1af4929c..66cb4bd3b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [3.57.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.57.0) (2022-09-28) +===================================================================================================== + +## 🐛 Bug Fixes + * Bump IDB crypto store version ([\#2705](https://github.com/matrix-org/matrix-js-sdk/pull/2705)). + Changes in [3.56.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.56.0) (2022-09-28) ===================================================================================================== From cdb0033afcbba9c19563b62efa54fa94680d5346 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 15:51:11 +0100 Subject: [PATCH 08/33] v3.57.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66343cd1b6..67c2c5a75f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.56.0", + "version": "3.57.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { From 1a0dbbf1925d5112ddb844ed9ca3fbc49bbb85e8 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 28 Sep 2022 15:55:01 +0100 Subject: [PATCH 09/33] Reset matrix-js-sdk back to develop branch --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 4470cfd2ff..82b26a93f5 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "20.0.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index bebec104fc..965d66f09c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6925,10 +6925,9 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@20.0.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "20.0.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-20.0.0.tgz#f88f2052b0fb868233616ca4b968885613759fc6" - integrity sha512-eOKTiWhvUxiPtl4bHFLJh/kQRdaR3ucTUTTvGXZAWh1Ljo6emU2VILPCQWyl7HTOzjCvkRHAvuGlBypMQZf+MQ== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/83fca5b57d8fe1b8c18444129a2e2318129753d5" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From e15ef9f3de36df7f318c083e485f44e1de8aad17 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 28 Sep 2022 18:13:09 +0100 Subject: [PATCH 10/33] Add device notifications enabled switch (#9324) --- src/components/structures/MatrixChat.tsx | 6 +- .../views/elements/LabelledToggleSwitch.tsx | 13 ++- .../views/settings/Notifications.tsx | 87 ++++++++++++++----- src/i18n/strings/en_EN.json | 4 +- src/settings/Settings.tsx | 4 + src/utils/notifications.ts | 49 +++++++++++ .../views/settings/Notifications-test.tsx | 21 ++++- .../__snapshots__/Notifications-test.tsx.snap | 19 ++-- test/utils/notifications-test.ts | 79 +++++++++++++++++ 9 files changed, 251 insertions(+), 31 deletions(-) create mode 100644 src/utils/notifications.ts create mode 100644 test/utils/notifications-test.ts diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index cd1b3f599d..73d614a430 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015-2021 The Matrix.org Foundation C.I.C. +Copyright 2015-2022 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. @@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { createLocalNotificationSettingsIfNeeded } from '../../utils/notifications'; // legacy export export { default as Views } from "../../Views"; @@ -1257,6 +1258,9 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); StorageManager.tryPersistStorage(); + const cli = MatrixClientPeg.get(); + createLocalNotificationSettingsIfNeeded(cli); + if ( MatrixClientPeg.currentUserIsJustRegistered() && SettingsStore.getValue("FTUE.useCaseSelection") === null diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 6df972440a..90b419c735 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -18,12 +18,15 @@ import React from "react"; import classNames from "classnames"; import ToggleSwitch from "./ToggleSwitch"; +import { Caption } from "../typography/Caption"; interface IProps { // The value for the toggle switch value: boolean; // The translated label for the switch label: string; + // The translated caption for the switch + caption?: string; // Whether or not to disable the toggle switch disabled?: boolean; // True to put the toggle in front of the label @@ -38,8 +41,14 @@ interface IProps { export default class LabelledToggleSwitch extends React.PureComponent { public render() { // This is a minimal version of a SettingsFlag - - let firstPart = { this.props.label }; + const { label, caption } = this.props; + let firstPart = + { label } + { caption && <> +
+ { caption } + } +
; let secondPart = { this.state = { phase: Phase.Loading, + deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? false, desktopNotifications: SettingsStore.getValue("notificationsEnabled"), desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"), audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"), @@ -128,6 +132,9 @@ export default class Notifications extends React.PureComponent { SettingsStore.watchSetting("notificationsEnabled", null, (...[,,,, value]) => this.setState({ desktopNotifications: value as boolean }), ), + SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[,,,, value]) => { + this.setState({ deviceNotificationsEnabled: value as boolean }); + }), SettingsStore.watchSetting("notificationBodyEnabled", null, (...[,,,, value]) => this.setState({ desktopShowBody: value as boolean }), ), @@ -148,12 +155,19 @@ export default class Notifications extends React.PureComponent { public componentDidMount() { // noinspection JSIgnoredPromiseFromCall this.refreshFromServer(); + this.refreshFromAccountData(); } public componentWillUnmount() { this.settingWatchers.forEach(watcher => SettingsStore.unwatchSetting(watcher)); } + public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) { + this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled); + } + } + private async refreshFromServer() { try { const newState = (await Promise.all([ @@ -162,7 +176,9 @@ export default class Notifications extends React.PureComponent { this.refreshThreepids(), ])).reduce((p, c) => Object.assign(c, p), {}); - this.setState>({ + this.setState + >({ ...newState, phase: Phase.Ready, }); @@ -172,6 +188,22 @@ export default class Notifications extends React.PureComponent { } } + private async refreshFromAccountData() { + const cli = MatrixClientPeg.get(); + const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId)); + if (settingsEvent) { + const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced; + await this.updateDeviceNotifications(notificationsEnabled); + } + } + + private persistLocalNotificationSettings(enabled: boolean): Promise<{}> { + const cli = MatrixClientPeg.get(); + return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), { + is_silenced: !enabled, + }); + } + private async refreshRules(): Promise> { const ruleSets = await MatrixClientPeg.get().getPushRules(); const categories = { @@ -297,6 +329,10 @@ export default class Notifications extends React.PureComponent { } }; + private updateDeviceNotifications = async (checked: boolean) => { + await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked); + }; + private onEmailNotificationsChanged = async (email: string, checked: boolean) => { this.setState({ phase: Phase.Persisting }); @@ -497,7 +533,8 @@ export default class Notifications extends React.PureComponent { const masterSwitch = ; @@ -521,28 +558,36 @@ export default class Notifications extends React.PureComponent { { masterSwitch } this.updateDeviceNotifications(checked)} disabled={this.state.phase === Phase.Persisting} /> - - - + { this.state.deviceNotificationsEnabled && (<> + + + + ) } { emailSwitches } ; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 22abbc653f..b8a7361175 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1361,8 +1361,10 @@ "Messages containing keywords": "Messages containing keywords", "Error saving notification preferences": "Error saving notification preferences", "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.", - "Enable for this account": "Enable for this account", + "Enable notifications for this account": "Enable notifications for this account", + "Turn off to disable notifications on all your devices and sessions": "Turn off to disable notifications on all your devices and sessions", "Enable email notifications for %(email)s": "Enable email notifications for %(email)s", + "Enable notifications for this device": "Enable notifications for this device", "Enable desktop notifications for this session": "Enable desktop notifications for this session", "Show message in desktop notification": "Show message in desktop notification", "Enable audible notifications for this session": "Enable audible notifications for this session", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 5220f9d060..69edd0b466 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -790,6 +790,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new NotificationsEnabledController(), }, + "deviceNotificationsEnabled": { + supportedLevels: [SettingLevel.DEVICE], + default: false, + }, "notificationSound": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: false, diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 0000000000..088d4232b4 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,49 @@ +/* +Copyright 2022 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 { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import SettingsStore from "../settings/SettingsStore"; + +export const deviceNotificationSettingsKeys = [ + "notificationsEnabled", + "notificationBodyEnabled", + "audioNotificationsEnabled", +]; + +export function getLocalNotificationAccountDataEventType(deviceId: string): string { + return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; +} + +export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise { + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + const event = cli.getAccountData(eventType); + + // New sessions will create an account data event to signify they support + // remote toggling of push notifications on this device. Default `is_silenced=true` + // For backwards compat purposes, older sessions will need to check settings value + // to determine what the state of `is_silenced` + if (!event) { + // If any of the above is true, we fall in the "backwards compat" case, + // and `is_silenced` will be set to `false` + const isSilenced = !deviceNotificationSettingsKeys.some(key => SettingsStore.getValue(key)); + + await cli.setAccountData(eventType, { + is_silenced: isSilenced, + }); + } +} diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index 1cbbb13439..88deaa2c0f 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -15,7 +15,14 @@ limitations under the License. import React from 'react'; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from 'enzyme'; -import { IPushRule, IPushRules, RuleId, IPusher } from 'matrix-js-sdk/src/matrix'; +import { + IPushRule, + IPushRules, + RuleId, + IPusher, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixEvent, +} from 'matrix-js-sdk/src/matrix'; import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids'; import { act } from 'react-dom/test-utils'; @@ -67,6 +74,17 @@ describe('', () => { setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), getRooms: jest.fn().mockReturnValue([]), + getAccountData: jest.fn().mockImplementation(eventType => { + if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + return new MatrixEvent({ + type: eventType, + content: { + is_silenced: false, + }, + }); + } + }), + setAccountData: jest.fn(), }); mockClient.getPushRules.mockResolvedValue(pushRules); @@ -117,6 +135,7 @@ describe('', () => { const component = await getComponentAndWait(); expect(findByTestId(component, 'notif-master-switch').length).toBeTruthy(); + expect(findByTestId(component, 'notif-device-switch').length).toBeTruthy(); expect(findByTestId(component, 'notif-setting-notificationsEnabled').length).toBeTruthy(); expect(findByTestId(component, 'notif-setting-notificationBodyEnabled').length).toBeTruthy(); expect(findByTestId(component, 'notif-setting-audioNotificationsEnabled').length).toBeTruthy(); diff --git a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap index 432a1c9a79..f9f4bcd58a 100644 --- a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap @@ -60,9 +60,10 @@ exports[` main notification switches renders only enable notifi className="mx_UserNotifSettings" > @@ -72,10 +73,18 @@ exports[` main notification switches renders only enable notifi - Enable for this account + Enable notifications for this account +
+ + + Turn off to disable notifications on all your devices and sessions + +
<_default - aria-label="Enable for this account" + aria-label="Enable notifications for this account" checked={false} disabled={false} onChange={[Function]} @@ -83,7 +92,7 @@ exports[` main notification switches renders only enable notifi main notification switches renders only enable notifi
{ + const accountDataStore = {}; + const mockClient = getMockClientWithEventEmitter({ + isGuest: jest.fn().mockReturnValue(false), + getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), + setAccountData: jest.fn().mockImplementation((eventType, content) => { + accountDataStore[eventType] = new MatrixEvent({ + type: eventType, + content, + }); + }), + }); + + const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); + + beforeEach(() => { + mocked(SettingsStore).getValue.mockReturnValue(false); + }); + + describe('createLocalNotification', () => { + it('creates account data event', async () => { + await createLocalNotificationSettingsIfNeeded(mockClient); + const event = mockClient.getAccountData(accountDataEventKey); + expect(event?.getContent().is_silenced).toBe(true); + }); + + // Can't figure out why the mock does not override the value here + /*.each(deviceNotificationSettingsKeys) instead of skip */ + it.skip("unsilenced for existing sessions", async (/*settingKey*/) => { + mocked(SettingsStore) + .getValue + .mockImplementation((key) => { + // return key === settingKey; + }); + + await createLocalNotificationSettingsIfNeeded(mockClient); + const event = mockClient.getAccountData(accountDataEventKey); + expect(event?.getContent().is_silenced).toBe(false); + }); + + it("does not override an existing account event data", async () => { + mockClient.setAccountData(accountDataEventKey, { + is_silenced: false, + }); + + await createLocalNotificationSettingsIfNeeded(mockClient); + const event = mockClient.getAccountData(accountDataEventKey); + expect(event?.getContent().is_silenced).toBe(false); + }); + }); +}); From c3bfb6e4a9ba1579838f7a88894ddbf995415e9b Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 28 Sep 2022 18:18:10 +0100 Subject: [PATCH 11/33] Display push toggle for web sessions (MSC3890) (#9327) --- .../settings/devices/CurrentDeviceSection.tsx | 7 +++ .../views/settings/devices/DeviceDetails.tsx | 30 ++++++++--- .../settings/devices/FilteredDeviceList.tsx | 19 ++++--- .../views/settings/devices/useOwnDevices.ts | 54 +++++++++++++++---- .../settings/tabs/user/SessionManagerTab.tsx | 12 +++-- .../settings/devices/DeviceDetails-test.tsx | 25 ++++++++- .../devices/FilteredDeviceList-test.tsx | 3 +- .../tabs/user/SessionManagerTab-test.tsx | 49 +++++++++++++++-- 8 files changed, 166 insertions(+), 33 deletions(-) diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index 023d33b083..615c9c69f0 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import React, { useState } from 'react'; import { _t } from '../../../../languageHandler'; @@ -29,6 +30,8 @@ interface Props { device?: DeviceWithVerification; isLoading: boolean; isSigningOut: boolean; + localNotificationSettings?: LocalNotificationSettings | undefined; + setPushNotifications?: (deviceId: string, enabled: boolean) => Promise | undefined; onVerifyCurrentDevice: () => void; onSignOutCurrentDevice: () => void; saveDeviceName: (deviceName: string) => Promise; @@ -38,6 +41,8 @@ const CurrentDeviceSection: React.FC = ({ device, isLoading, isSigningOut, + localNotificationSettings, + setPushNotifications, onVerifyCurrentDevice, onSignOutCurrentDevice, saveDeviceName, @@ -63,6 +68,8 @@ const CurrentDeviceSection: React.FC = ({ { isExpanded && void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; - setPusherEnabled?: (deviceId: string, enabled: boolean) => Promise | undefined; + setPushNotifications?: (deviceId: string, enabled: boolean) => Promise | undefined; supportsMSC3881?: boolean | undefined; } @@ -46,11 +48,12 @@ interface MetadataTable { const DeviceDetails: React.FC = ({ device, pusher, + localNotificationSettings, isSigningOut, onVerifyDevice, onSignOutDevice, saveDeviceName, - setPusherEnabled, + setPushNotifications, supportsMSC3881, }) => { const metadata: MetadataTable[] = [ @@ -70,6 +73,21 @@ const DeviceDetails: React.FC = ({ ], }, ]; + + const showPushNotificationSection = !!pusher || !!localNotificationSettings; + + function isPushNotificationsEnabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { + if (pusher) return pusher[PUSHER_ENABLED.name]; + if (localNotificationSettings) return !localNotificationSettings.is_silenced; + return true; + } + + function isCheckboxDisabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { + if (localNotificationSettings) return false; + if (pusher && !supportsMSC3881) return true; + return false; + } + return
= ({ , ) }
- { pusher && ( + { showPushNotificationSection && (
= ({ setPusherEnabled?.(device.device_id, checked)} + checked={isPushNotificationsEnabled(pusher, localNotificationSettings)} + disabled={isCheckboxDisabled(pusher, localNotificationSettings)} + onChange={checked => setPushNotifications?.(device.device_id, checked)} aria-label={_t("Toggle push notifications on this session.")} data-testid='device-detail-push-notification-checkbox' /> diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index baee4e1d0e..7affee684c 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { ForwardedRef, forwardRef } from 'react'; import { IPusher } from 'matrix-js-sdk/src/@types/PushRules'; import { PUSHER_DEVICE_ID } from 'matrix-js-sdk/src/@types/event'; +import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import { _t } from '../../../../languageHandler'; import AccessibleButton from '../../elements/AccessibleButton'; @@ -39,6 +40,7 @@ import { DevicesState } from './useOwnDevices'; interface Props { devices: DevicesDictionary; pushers: IPusher[]; + localNotificationSettings: Map; expandedDeviceIds: DeviceWithVerification['device_id'][]; signingOutDeviceIds: DeviceWithVerification['device_id'][]; filter?: DeviceSecurityVariation; @@ -47,7 +49,7 @@ interface Props { onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void; saveDeviceName: DevicesState['saveDeviceName']; onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; - setPusherEnabled: (deviceId: string, enabled: boolean) => Promise; + setPushNotifications: (deviceId: string, enabled: boolean) => Promise; supportsMSC3881?: boolean | undefined; } @@ -141,24 +143,26 @@ const NoResults: React.FC = ({ filter, clearFilter }) => const DeviceListItem: React.FC<{ device: DeviceWithVerification; pusher?: IPusher | undefined; + localNotificationSettings?: LocalNotificationSettings | undefined; isExpanded: boolean; isSigningOut: boolean; onDeviceExpandToggle: () => void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; onRequestDeviceVerification?: () => void; - setPusherEnabled: (deviceId: string, enabled: boolean) => Promise; + setPushNotifications: (deviceId: string, enabled: boolean) => Promise; supportsMSC3881?: boolean | undefined; }> = ({ device, pusher, + localNotificationSettings, isExpanded, isSigningOut, onDeviceExpandToggle, onSignOutDevice, saveDeviceName, onRequestDeviceVerification, - setPusherEnabled, + setPushNotifications, supportsMSC3881, }) =>
  • } @@ -192,6 +197,7 @@ export const FilteredDeviceList = forwardRef(({ devices, pushers, + localNotificationSettings, filter, expandedDeviceIds, signingOutDeviceIds, @@ -200,7 +206,7 @@ export const FilteredDeviceList = saveDeviceName, onSignOutDevices, onRequestDeviceVerification, - setPusherEnabled, + setPushNotifications, supportsMSC3881, }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -258,6 +264,7 @@ export const FilteredDeviceList = key={device.device_id} device={device} pusher={getPusherForDevice(device)} + localNotificationSettings={localNotificationSettings.get(device.device_id)} isExpanded={expandedDeviceIds.includes(device.device_id)} isSigningOut={signingOutDeviceIds.includes(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} @@ -268,7 +275,7 @@ export const FilteredDeviceList = ? () => onRequestDeviceVerification(device.device_id) : undefined } - setPusherEnabled={setPusherEnabled} + setPushNotifications={setPushNotifications} supportsMSC3881={supportsMSC3881} />, ) } diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index b583d4c080..255bcc2d53 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -15,11 +15,19 @@ limitations under the License. */ import { useCallback, useContext, useEffect, useState } from "react"; -import { IMyDevice, IPusher, MatrixClient, PUSHER_DEVICE_ID, PUSHER_ENABLED } from "matrix-js-sdk/src/matrix"; +import { + IMyDevice, + IPusher, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixClient, + PUSHER_DEVICE_ID, + PUSHER_ENABLED, +} from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; @@ -77,13 +85,14 @@ export enum OwnDevicesError { export type DevicesState = { devices: DevicesDictionary; pushers: IPusher[]; + localNotificationSettings: Map; currentDeviceId: string; isLoadingDeviceList: boolean; // not provided when current session cannot request verification requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; refreshDevices: () => Promise; saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise; - setPusherEnabled: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise; + setPushNotifications: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise; error?: OwnDevicesError; supportsMSC3881?: boolean | undefined; }; @@ -95,6 +104,8 @@ export const useOwnDevices = (): DevicesState => { const [devices, setDevices] = useState({}); const [pushers, setPushers] = useState([]); + const [localNotificationSettings, setLocalNotificationSettings] + = useState(new Map()); const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true); const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes! @@ -120,6 +131,19 @@ export const useOwnDevices = (): DevicesState => { const { pushers } = await matrixClient.getPushers(); setPushers(pushers); + const notificationSettings = new Map(); + Object.keys(devices).forEach((deviceId) => { + const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; + const event = matrixClient.getAccountData(eventType); + if (event) { + notificationSettings.set( + deviceId, + event.getContent(), + ); + } + }); + setLocalNotificationSettings(notificationSettings); + setIsLoadingDeviceList(false); } catch (error) { if ((error as MatrixError).httpStatus == 404) { @@ -169,32 +193,40 @@ export const useOwnDevices = (): DevicesState => { } }, [matrixClient, devices, refreshDevices]); - const setPusherEnabled = useCallback( + const setPushNotifications = useCallback( async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise => { - const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId); try { - await matrixClient.setPusher({ - ...pusher, - [PUSHER_ENABLED.name]: enabled, - }); - await refreshDevices(); + const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId); + if (pusher) { + await matrixClient.setPusher({ + ...pusher, + [PUSHER_ENABLED.name]: enabled, + }); + } else if (localNotificationSettings.has(deviceId)) { + await matrixClient.setLocalNotificationSettings(deviceId, { + is_silenced: !enabled, + }); + } } catch (error) { logger.error("Error setting pusher state", error); throw new Error(_t("Failed to set pusher state")); + } finally { + await refreshDevices(); } - }, [matrixClient, pushers, refreshDevices], + }, [matrixClient, pushers, localNotificationSettings, refreshDevices], ); return { devices, pushers, + localNotificationSettings, currentDeviceId, isLoadingDeviceList, error, requestDeviceVerification, refreshDevices, saveDeviceName, - setPusherEnabled, + setPushNotifications, supportsMSC3881, }; }; diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index ec2b7e8a61..e87d548d57 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -88,12 +88,13 @@ const SessionManagerTab: React.FC = () => { const { devices, pushers, + localNotificationSettings, currentDeviceId, isLoadingDeviceList, requestDeviceVerification, refreshDevices, saveDeviceName, - setPusherEnabled, + setPushNotifications, supportsMSC3881, } = useOwnDevices(); const [filter, setFilter] = useState(); @@ -171,9 +172,11 @@ const SessionManagerTab: React.FC = () => { /> saveDeviceName(currentDevice?.device_id, deviceName)} + saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)} onVerifyCurrentDevice={onVerifyCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice} /> @@ -190,6 +193,7 @@ const SessionManagerTab: React.FC = () => { { onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} onSignOutDevices={onSignOutOtherDevices} saveDeviceName={saveDeviceName} - setPusherEnabled={setPusherEnabled} + setPushNotifications={setPushNotifications} ref={filteredDeviceListRef} supportsMSC3881={supportsMSC3881} /> diff --git a/test/components/views/settings/devices/DeviceDetails-test.tsx b/test/components/views/settings/devices/DeviceDetails-test.tsx index 0cec7f387b..bb088e6000 100644 --- a/test/components/views/settings/devices/DeviceDetails-test.tsx +++ b/test/components/views/settings/devices/DeviceDetails-test.tsx @@ -33,7 +33,7 @@ describe('', () => { isLoading: false, onSignOutDevice: jest.fn(), saveDeviceName: jest.fn(), - setPusherEnabled: jest.fn(), + setPushNotifications: jest.fn(), supportsMSC3881: true, }; @@ -157,6 +157,27 @@ describe('', () => { fireEvent.click(checkbox); - expect(defaultProps.setPusherEnabled).toHaveBeenCalledWith(device.device_id, !enabled); + expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled); + }); + + it('changes the local notifications settings status when clicked', () => { + const device = { + ...baseDevice, + }; + + const enabled = false; + + const { getByTestId } = render(getComponent({ + device, + localNotificationSettings: { + is_silenced: !enabled, + }, + isSigningOut: true, + })); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + fireEvent.click(checkbox); + + expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled); }); }); diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx index 181c435b60..4a57565e19 100644 --- a/test/components/views/settings/devices/FilteredDeviceList-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -45,9 +45,10 @@ describe('', () => { onDeviceExpandToggle: jest.fn(), onSignOutDevices: jest.fn(), saveDeviceName: jest.fn(), - setPusherEnabled: jest.fn(), + setPushNotifications: jest.fn(), expandedDeviceIds: [], signingOutDeviceIds: [], + localNotificationSettings: new Map(), devices: { [unverifiedNoMetadata.device_id]: unverifiedNoMetadata, [verifiedNoMetadata.device_id]: verifiedNoMetadata, diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 12af8a18e0..5c1d5586aa 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -22,7 +22,13 @@ import { logger } from 'matrix-js-sdk/src/logger'; import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; import { sleep } from 'matrix-js-sdk/src/utils'; -import { IMyDevice, PUSHER_DEVICE_ID, PUSHER_ENABLED } from 'matrix-js-sdk/src/matrix'; +import { + IMyDevice, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixEvent, + PUSHER_DEVICE_ID, + PUSHER_ENABLED, +} from 'matrix-js-sdk/src/matrix'; import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; @@ -71,6 +77,8 @@ describe('', () => { doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true), getPushers: jest.fn(), setPusher: jest.fn(), + getAccountData: jest.fn(), + setLocalNotificationSettings: jest.fn(), }); const defaultProps = {}; @@ -114,6 +122,19 @@ describe('', () => { [PUSHER_ENABLED.name]: true, })], }); + + mockClient.getAccountData + .mockReset() + .mockImplementation(eventType => { + if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + return new MatrixEvent({ + type: eventType, + content: { + is_silenced: false, + }, + }); + } + }); }); it('renders spinner while devices load', () => { @@ -333,7 +354,6 @@ describe('', () => { mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrossSigningInfo.checkDeviceTrust .mockImplementation((_userId, { deviceId }) => { - console.log('hhh', deviceId); if (deviceId === alicesDevice.device_id) { return new DeviceTrustLevel(true, true, false, false); } @@ -363,7 +383,6 @@ describe('', () => { mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrossSigningInfo.checkDeviceTrust .mockImplementation((_userId, { deviceId }) => { - console.log('hhh', deviceId); if (deviceId === alicesDevice.device_id) { return new DeviceTrustLevel(true, true, false, false); } @@ -702,4 +721,28 @@ describe('', () => { expect(mockClient.setPusher).toHaveBeenCalled(); }); + + it("lets you change the local notification settings state", async () => { + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceDetails(getByTestId, alicesDevice.device_id); + + // device details are expanded + expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy(); + expect(getByTestId('device-detail-push-notification')).toBeTruthy(); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + + expect(checkbox).toBeTruthy(); + fireEvent.click(checkbox); + + expect(mockClient.setLocalNotificationSettings).toHaveBeenCalledWith( + alicesDevice.device_id, + { is_silenced: true }, + ); + }); }); From 951cad98d3f1507440f38bb009d6c30f4ac246b0 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 29 Sep 2022 09:22:00 +0200 Subject: [PATCH 12/33] Device manager - extract filtered device list header (#9323) * extract filtered device list header * stylelint --- res/css/_components.pcss | 1 + .../settings/devices/_FilteredDeviceList.pcss | 20 --------- .../devices/_FilteredDeviceListHeader.pcss | 36 ++++++++++++++++ .../settings/devices/FilteredDeviceList.tsx | 8 ++-- .../devices/FilteredDeviceListHeader.tsx | 42 +++++++++++++++++++ src/i18n/strings/en_EN.json | 1 + .../devices/FilteredDeviceListHeader-test.tsx | 39 +++++++++++++++++ .../FilteredDeviceListHeader-test.tsx.snap | 19 +++++++++ .../tabs/user/SessionManagerTab-test.tsx | 2 +- .../SessionManagerTab-test.tsx.snap | 4 +- 10 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss create mode 100644 src/components/views/settings/devices/FilteredDeviceListHeader.tsx create mode 100644 test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx create mode 100644 test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 96e5a1a50f..6996d33cee 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -36,6 +36,7 @@ @import "./components/views/settings/devices/_DeviceTile.pcss"; @import "./components/views/settings/devices/_DeviceType.pcss"; @import "./components/views/settings/devices/_FilteredDeviceList.pcss"; +@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss"; diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index 01c8df787e..4b23271225 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -20,26 +20,6 @@ limitations under the License. } } -.mx_FilteredDeviceList_header { - display: flex; - flex-direction: row; - align-items: center; - box-sizing: border-box; - - width: 100%; - height: 48px; - padding: 0 $spacing-16; - margin-bottom: $spacing-32; - - background-color: $system; - border-radius: 8px; - color: $secondary-content; -} - -.mx_FilteredDeviceList_headerLabel { - flex: 1 1 100%; -} - .mx_FilteredDeviceList_list { list-style-type: none; display: grid; diff --git a/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss new file mode 100644 index 0000000000..2cdbcf356f --- /dev/null +++ b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss @@ -0,0 +1,36 @@ +/* +Copyright 2022 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_FilteredDeviceListHeader { + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + gap: $spacing-8; + + width: 100%; + height: 48px; + padding: 0 $spacing-16; + margin-bottom: $spacing-32; + + background-color: $system; + border-radius: 8px; + color: $secondary-content; +} + +.mx_FilteredDeviceListHeader_label { + flex: 1 1 100%; +} diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 7affee684c..5ec0a428d0 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -36,6 +36,7 @@ import { DeviceWithVerification, } from './types'; import { DevicesState } from './useOwnDevices'; +import FilteredDeviceListHeader from './FilteredDeviceListHeader'; interface Props { devices: DevicesDictionary; @@ -242,10 +243,7 @@ export const FilteredDeviceList = }; return
    -
    - - { _t('Sessions') } - + id='device-list-filter' label={_t('Filter devices')} @@ -254,7 +252,7 @@ export const FilteredDeviceList = options={options} selectedLabel={_t('Show')} /> -
    + { !!sortedDevices.length ? : onFilterChange(undefined)} /> diff --git a/src/components/views/settings/devices/FilteredDeviceListHeader.tsx b/src/components/views/settings/devices/FilteredDeviceListHeader.tsx new file mode 100644 index 0000000000..6ecee4c8d2 --- /dev/null +++ b/src/components/views/settings/devices/FilteredDeviceListHeader.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2022 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, { HTMLProps } from 'react'; + +import { _t } from '../../../../languageHandler'; + +interface Props extends Omit, 'className'> { + selectedDeviceCount: number; + children?: React.ReactNode; +} + +const FilteredDeviceListHeader: React.FC = ({ + selectedDeviceCount, + children, + ...rest +}) => { + return
    + + { selectedDeviceCount > 0 + ? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount }) + : _t('Sessions') + } + + { children } +
    ; +}; + +export default FilteredDeviceListHeader; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b8a7361175..c1b25cb2ab 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1753,6 +1753,7 @@ "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", "Filter devices": "Filter devices", "Show": "Show", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", "Security recommendations": "Security recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "View all": "View all", diff --git a/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx b/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx new file mode 100644 index 0000000000..8f7ecdc924 --- /dev/null +++ b/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2022 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 { render } from '@testing-library/react'; +import React from 'react'; + +import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader'; + +describe('', () => { + const defaultProps = { + selectedDeviceCount: 0, + children:
    test
    , + ['data-testid']: 'test123', + }; + const getComponent = (props = {}) => (); + + it('renders correctly when no devices are selected', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('renders correctly when some devices are selected', () => { + const { getByText } = render(getComponent({ selectedDeviceCount: 2 })); + expect(getByText('2 sessions selected')).toBeTruthy(); + }); +}); diff --git a/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap new file mode 100644 index 0000000000..f474cca811 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly when no devices are selected 1`] = ` +
    +
    + + Sessions + +
    + test +
    +
    +
    +`; diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 5c1d5586aa..70bb171b12 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -279,7 +279,7 @@ describe('', () => { await flushPromisesWithFakeTimers(); // unverified filter is set - expect(container.querySelector('.mx_FilteredDeviceList_header')).toMatchSnapshot(); + expect(container.querySelector('.mx_FilteredDeviceListHeader')).toMatchSnapshot(); }); describe('device detail expansion', () => { diff --git a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap index f4f120d0a5..32af1a3fa6 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap @@ -17,10 +17,10 @@ exports[` Sign out Signs out of current device 1`] = ` exports[` goes to filtered list from security recommendations 1`] = `
    Sessions From a49603b9b84a76a47b1fccee4d39198da94249b8 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 29 Sep 2022 15:23:02 +0100 Subject: [PATCH 13/33] Inhibit local notifications when local notifications are silenced (#9328) --- src/Notifier.ts | 17 +++++-- src/utils/notifications.ts | 8 ++- test/Notifier-test.ts | 85 ++++++++++++++++++++++++++++++++ test/utils/notifications-test.ts | 17 ++++++- 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 test/Notifier-test.ts diff --git a/src/Notifier.ts b/src/Notifier.ts index 80a6661055..ed2eaeee59 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -46,6 +46,7 @@ import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; +import { localNotificationsAreSilenced } from "./utils/notifications"; /* * Dispatches: @@ -90,8 +91,9 @@ export const Notifier = { return TextForEvent.textForEvent(ev); }, - _displayPopupNotification: function(ev: MatrixEvent, room: Room) { + _displayPopupNotification: function(ev: MatrixEvent, room: Room): void { const plaf = PlatformPeg.get(); + const cli = MatrixClientPeg.get(); if (!plaf) { return; } @@ -99,6 +101,10 @@ export const Notifier = { return; } + if (localNotificationsAreSilenced(cli)) { + return; + } + let msg = this.notificationMessageForEvent(ev); if (!msg) return; @@ -170,7 +176,12 @@ export const Notifier = { }; }, - _playAudioNotification: async function(ev: MatrixEvent, room: Room) { + _playAudioNotification: async function(ev: MatrixEvent, room: Room): Promise { + const cli = MatrixClientPeg.get(); + if (localNotificationsAreSilenced(cli)) { + return; + } + const sound = this.getSoundForRoom(room.roomId); logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); @@ -325,7 +336,7 @@ export const Notifier = { } const isGuest = client.isGuest(); return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() && - !this.isEnabled() && !this._isPromptHidden(); + !localNotificationsAreSilenced(client) && !this.isEnabled() && !this._isPromptHidden(); }, _isPromptHidden: function() { diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 088d4232b4..ffa346ca2e 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event"; +import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import { MatrixClient } from "matrix-js-sdk/src/client"; import SettingsStore from "../settings/SettingsStore"; @@ -32,7 +33,6 @@ export function getLocalNotificationAccountDataEventType(deviceId: string): stri export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise { const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); const event = cli.getAccountData(eventType); - // New sessions will create an account data event to signify they support // remote toggling of push notifications on this device. Default `is_silenced=true` // For backwards compat purposes, older sessions will need to check settings value @@ -47,3 +47,9 @@ export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient) }); } } + +export function localNotificationsAreSilenced(cli: MatrixClient): boolean { + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + const event = cli.getAccountData(eventType); + return event?.getContent()?.is_silenced ?? true; +} diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts new file mode 100644 index 0000000000..1178d35bec --- /dev/null +++ b/test/Notifier-test.ts @@ -0,0 +1,85 @@ +/* +Copyright 2022 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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import Notifier from "../src/Notifier"; +import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications"; +import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils"; + +describe("Notifier", () => { + let MockPlatform; + let accountDataStore = {}; + + const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue("@bob:example.org"), + isGuest: jest.fn().mockReturnValue(false), + getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), + setAccountData: jest.fn().mockImplementation((eventType, content) => { + accountDataStore[eventType] = new MatrixEvent({ + type: eventType, + content, + }); + }), + }); + const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); + const roomId = "!room1:server"; + const testEvent = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: roomId, + content: {}, + }); + const testRoom = mkRoom(mockClient, roomId); + + beforeEach(() => { + accountDataStore = {}; + MockPlatform = mockPlatformPeg({ + supportsNotifications: jest.fn().mockReturnValue(true), + maySendNotifications: jest.fn().mockReturnValue(true), + displayNotification: jest.fn(), + }); + + Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); + }); + + describe("_displayPopupNotification", () => { + it.each([ + { silenced: true, count: 0 }, + { silenced: false, count: 1 }, + ])("does not dispatch when notifications are silenced", ({ silenced, count }) => { + mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); + Notifier._displayPopupNotification(testEvent, testRoom); + expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count); + }); + }); + + describe("_playAudioNotification", () => { + it.each([ + { silenced: true, count: 0 }, + { silenced: false, count: 1 }, + ])("does not dispatch when notifications are silenced", ({ silenced, count }) => { + // It's not ideal to only look at whether this function has been called + // but avoids starting to look into DOM stuff + Notifier.getSoundForRoom = jest.fn(); + + mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); + Notifier._playAudioNotification(testEvent, testRoom); + expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); + }); + }); +}); diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 991e36f3a3..62e12e6ef8 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -18,6 +18,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { mocked } from "jest-mock"; import { + localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded, getLocalNotificationAccountDataEventType, } from "../../src/utils/notifications"; @@ -27,7 +28,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client"; jest.mock("../../src/settings/SettingsStore"); describe('notifications', () => { - const accountDataStore = {}; + let accountDataStore = {}; const mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn().mockReturnValue(false), getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), @@ -42,6 +43,7 @@ describe('notifications', () => { const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); beforeEach(() => { + accountDataStore = {}; mocked(SettingsStore).getValue.mockReturnValue(false); }); @@ -76,4 +78,17 @@ describe('notifications', () => { expect(event?.getContent().is_silenced).toBe(false); }); }); + + describe('localNotificationsAreSilenced', () => { + it('defaults to true when no setting exists', () => { + expect(localNotificationsAreSilenced(mockClient)).toBeTruthy(); + }); + it('checks the persisted value', () => { + mockClient.setAccountData(accountDataEventKey, { is_silenced: true }); + expect(localNotificationsAreSilenced(mockClient)).toBeTruthy(); + + mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); + expect(localNotificationsAreSilenced(mockClient)).toBeFalsy(); + }); + }); }); From 3c5ff301774ac17cd88b72c094a8154158b562cc Mon Sep 17 00:00:00 2001 From: owi92 <94838646+owi92@users.noreply.github.com> Date: Thu, 29 Sep 2022 17:51:50 +0200 Subject: [PATCH 14/33] Fix quote shortcut for most keyboard layouts (#9298) --- src/accessibility/KeyboardShortcuts.ts | 1 + src/components/views/rooms/BasicMessageComposer.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 1a7f45404c..6ea8432622 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -339,6 +339,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.FormatQuote]: { default: { ctrlOrCmdKey: true, + shiftKey: true, key: Key.GREATER_THAN, }, displayName: _td("Toggle Quote"), diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index b75e81aaed..d74c7b5148 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -754,7 +754,7 @@ export default class BasicMessageEditor extends React.Component [Formatting.Bold]: ctrlShortcutLabel("B"), [Formatting.Italics]: ctrlShortcutLabel("I"), [Formatting.Code]: ctrlShortcutLabel("E"), - [Formatting.Quote]: ctrlShortcutLabel(">"), + [Formatting.Quote]: ctrlShortcutLabel(">", true), [Formatting.InsertLink]: ctrlShortcutLabel("L", true), }; From 3e076c8246168aca867c5c5cef2bee8d161c1c33 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 29 Sep 2022 20:05:06 +0100 Subject: [PATCH 15/33] Fixes to ensure account data values do not go stale (#9331) * Fixes to ensure account data values do not go stale * Remove unneeded cli.off * fix test --- src/components/structures/MatrixChat.tsx | 13 +++++-- .../views/settings/devices/useOwnDevices.ts | 13 +++++++ .../tabs/user/SessionManagerTab-test.tsx | 34 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 73d614a430..e05c451255 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -401,6 +401,8 @@ export default class MatrixChat extends React.PureComponent { } } + private get cli(): MatrixClient { return MatrixClientPeg.get(); } + public componentDidMount(): void { window.addEventListener("resize", this.onWindowResized); } @@ -1258,8 +1260,7 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); StorageManager.tryPersistStorage(); - const cli = MatrixClientPeg.get(); - createLocalNotificationSettingsIfNeeded(cli); + this.cli.on(ClientEvent.Sync, this.onInitialSync); if ( MatrixClientPeg.currentUserIsJustRegistered() && @@ -1287,6 +1288,14 @@ export default class MatrixChat extends React.PureComponent { } } + private onInitialSync = (): void => { + if (this.cli.isInitialSyncComplete()) { + this.cli.off(ClientEvent.Sync, this.onInitialSync); + } + + createLocalNotificationSettingsIfNeeded(this.cli); + }; + private async onShowPostLoginScreen(useCase?: UseCase) { if (useCase) { PosthogAnalytics.instance.setProperty("ftueUseCaseSelection", useCase); diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 255bcc2d53..94fe560553 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -16,10 +16,12 @@ limitations under the License. import { useCallback, useContext, useEffect, useState } from "react"; import { + ClientEvent, IMyDevice, IPusher, LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixClient, + MatrixEvent, PUSHER_DEVICE_ID, PUSHER_ENABLED, } from "matrix-js-sdk/src/matrix"; @@ -32,6 +34,7 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifi import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; import { DevicesDictionary, DeviceWithVerification } from "./types"; +import { useEventEmitter } from "../../../../hooks/useEventEmitter"; const isDeviceVerified = ( matrixClient: MatrixClient, @@ -161,6 +164,16 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { + const type = event.getType(); + if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + const newSettings = new Map(localNotificationSettings); + const deviceId = type.slice(type.lastIndexOf(".") + 1); + newSettings.set(deviceId, event.getContent()); + setLocalNotificationSettings(newSettings); + } + }); + const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; const requestDeviceVerification = isCurrentDeviceVerified && userId diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 70bb171b12..2cc5a32b79 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -23,6 +23,7 @@ import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; import { sleep } from 'matrix-js-sdk/src/utils'; import { + ClientEvent, IMyDevice, LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixEvent, @@ -745,4 +746,37 @@ describe('', () => { { is_silenced: true }, ); }); + + it("updates the UI when another session changes the local notifications", async () => { + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceDetails(getByTestId, alicesDevice.device_id); + + // device details are expanded + expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy(); + expect(getByTestId('device-detail-push-notification')).toBeTruthy(); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + + expect(checkbox).toBeTruthy(); + + expect(checkbox.getAttribute('aria-checked')).toEqual("true"); + + const evt = new MatrixEvent({ + type: LOCAL_NOTIFICATION_SETTINGS_PREFIX.name + "." + alicesDevice.device_id, + content: { + is_silenced: true, + }, + }); + + await act(async () => { + mockClient.emit(ClientEvent.AccountData, evt); + }); + + expect(checkbox.getAttribute('aria-checked')).toEqual("false"); + }); }); From 7a33818bd7ec89c21054691afcb6db2fb2631e14 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 29 Sep 2022 21:06:49 +0200 Subject: [PATCH 16/33] Extract createVoiceMessageContent (#9322) --- .../views/rooms/VoiceRecordComposerTile.tsx | 40 +++--------- src/utils/createVoiceMessageContent.ts | 64 +++++++++++++++++++ .../rooms/VoiceRecordComposerTile-test.tsx | 8 ++- .../createVoiceMessageContent-test.ts.snap | 32 ++++++++++ test/utils/createVoiceMessageContent-test.ts | 32 ++++++++++ 5 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 src/utils/createVoiceMessageContent.ts create mode 100644 test/utils/__snapshots__/createVoiceMessageContent-test.ts.snap create mode 100644 test/utils/createVoiceMessageContent-test.ts diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 782cda9f4c..c3fe0762c7 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -16,7 +16,6 @@ limitations under the License. import React, { ReactNode } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -45,6 +44,7 @@ import { addReplyToMessageContent } from "../../../utils/Reply"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext from "../../../contexts/RoomContext"; import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; +import { createVoiceMessageContent } from "../../../utils/createVoiceMessageContent"; interface IProps { room: Room; @@ -122,36 +122,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), - }, - "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint - }; + const content = createVoiceMessageContent( + upload.mxc, + this.state.recorder.contentType, + Math.round(this.state.recorder.durationSeconds * 1000), + this.state.recorder.contentLength, + upload.encrypted, + this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)), + ); attachRelation(content, relation); if (replyToEvent) { diff --git a/src/utils/createVoiceMessageContent.ts b/src/utils/createVoiceMessageContent.ts new file mode 100644 index 0000000000..f406304c8c --- /dev/null +++ b/src/utils/createVoiceMessageContent.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 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 { IEncryptedFile, MsgType } from "matrix-js-sdk/src/matrix"; + +/** + * @param {string} mxc MXC URL of the file + * @param {string} mimetype + * @param {number} duration Duration in milliseconds + * @param {number} size + * @param {number[]} [waveform] + * @param {IEncryptedFile} [file] Encrypted file + */ +export const createVoiceMessageContent = ( + mxc: string, + mimetype: string, + duration: number, + size: number, + file?: IEncryptedFile, + waveform?: number[], +) => { + return { + "body": "Voice message", + //"msgtype": "org.matrix.msc2516.voice", + "msgtype": MsgType.Audio, + "url": mxc, + "file": file, + "info": { + duration, + mimetype, + size, + }, + + // MSC1767 + Ideals of MSC2516 as MSC3245 + // https://github.com/matrix-org/matrix-doc/pull/3245 + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc1767.file": { + url: mxc, + file, + name: "Voice message.ogg", + mimetype, + size, + }, + "org.matrix.msc1767.audio": { + duration, + // https://github.com/matrix-org/matrix-doc/pull/3246 + waveform, + }, + "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint + }; +}; diff --git a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx index 77df519a8d..fa4363adb8 100644 --- a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx +++ b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx @@ -57,7 +57,7 @@ describe("", () => { durationSeconds: 1337, contentType: "audio/ogg", getPlayback: () => ({ - thumbnailWaveform: [], + thumbnailWaveform: [1.4, 2.5, 3.6], }), } as unknown as VoiceRecording; voiceRecordComposerTile = mount(); @@ -88,7 +88,11 @@ describe("", () => { "msgtype": MsgType.Audio, "org.matrix.msc1767.audio": { "duration": 1337000, - "waveform": [], + "waveform": [ + 1434, + 2560, + 3686, + ], }, "org.matrix.msc1767.file": { "file": undefined, diff --git a/test/utils/__snapshots__/createVoiceMessageContent-test.ts.snap b/test/utils/__snapshots__/createVoiceMessageContent-test.ts.snap new file mode 100644 index 0000000000..bf1c949456 --- /dev/null +++ b/test/utils/__snapshots__/createVoiceMessageContent-test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createVoiceMessageContent should create a voice message content 1`] = ` +Object { + "body": "Voice message", + "file": Object {}, + "info": Object { + "duration": 23000, + "mimetype": "ogg/opus", + "size": 42000, + }, + "msgtype": "m.audio", + "org.matrix.msc1767.audio": Object { + "duration": 23000, + "waveform": Array [ + 1, + 2, + 3, + ], + }, + "org.matrix.msc1767.file": Object { + "file": Object {}, + "mimetype": "ogg/opus", + "name": "Voice message.ogg", + "size": 42000, + "url": "mxc://example.com/file", + }, + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc3245.voice": Object {}, + "url": "mxc://example.com/file", +} +`; diff --git a/test/utils/createVoiceMessageContent-test.ts b/test/utils/createVoiceMessageContent-test.ts new file mode 100644 index 0000000000..0043cf292a --- /dev/null +++ b/test/utils/createVoiceMessageContent-test.ts @@ -0,0 +1,32 @@ +/* +Copyright 2022 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 { IEncryptedFile } from "matrix-js-sdk/src/matrix"; + +import { createVoiceMessageContent } from "../../src/utils/createVoiceMessageContent"; + +describe("createVoiceMessageContent", () => { + it("should create a voice message content", () => { + expect(createVoiceMessageContent( + "mxc://example.com/file", + "ogg/opus", + 23000, + 42000, + {} as unknown as IEncryptedFile, + [1, 2, 3], + )).toMatchSnapshot(); + }); +}); From 772df3021201d9c73835a626df8dcb6334ad9a3e Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 30 Sep 2022 09:07:50 +0200 Subject: [PATCH 17/33] Device manager - sign out of multiple sessions (#9325) * add device selection that does nothing * multi select and sign out of sessions * test multiple selection * fix type after rebase --- .../settings/devices/_FilteredDeviceList.pcss | 4 + .../devices/_FilteredDeviceListHeader.pcss | 2 +- res/css/views/elements/_AccessibleButton.pcss | 10 +- .../views/elements/AccessibleButton.tsx | 1 + .../views/settings/devices/DeviceTile.tsx | 10 +- .../settings/devices/FilteredDeviceList.tsx | 70 +++++++++-- .../settings/devices/SelectableDeviceTile.tsx | 3 +- .../settings/tabs/user/SessionManagerTab.tsx | 35 ++++-- src/i18n/strings/en_EN.json | 2 +- .../__snapshots__/DevicesPanel-test.tsx.snap | 2 + .../devices/FilteredDeviceList-test.tsx | 5 +- .../SelectableDeviceTile-test.tsx.snap | 2 + .../tabs/user/SessionManagerTab-test.tsx | 111 ++++++++++++++++++ 13 files changed, 224 insertions(+), 33 deletions(-) diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index 4b23271225..a871b08049 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -42,3 +42,7 @@ limitations under the License. text-align: center; margin-bottom: $spacing-32; } + +.mx_FilteredDeviceList_headerButton { + flex-shrink: 0; +} diff --git a/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss index 2cdbcf356f..3bba9d90b3 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss @@ -19,7 +19,7 @@ limitations under the License. flex-direction: row; align-items: center; box-sizing: border-box; - gap: $spacing-8; + gap: $spacing-16; width: 100%; height: 48px; diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index bb4d492481..f891e810be 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -139,7 +139,8 @@ limitations under the License. &.mx_AccessibleButton_kind_link, &.mx_AccessibleButton_kind_link_inline, - &.mx_AccessibleButton_kind_danger_inline { + &.mx_AccessibleButton_kind_danger_inline, + &.mx_AccessibleButton_kind_content_inline { font-size: inherit; font-weight: normal; line-height: inherit; @@ -155,8 +156,13 @@ limitations under the License. color: $alert; } + &.mx_AccessibleButton_kind_content_inline { + color: $primary-content; + } + &.mx_AccessibleButton_kind_link_inline, - &.mx_AccessibleButton_kind_danger_inline { + &.mx_AccessibleButton_kind_danger_inline, + &.mx_AccessibleButton_kind_content_inline { display: inline; } diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index c90293aff4..9e63b6cf54 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -26,6 +26,7 @@ type AccessibleButtonKind = | 'primary' | 'primary_outline' | 'primary_sm' | 'secondary' + | 'content_inline' | 'danger' | 'danger_outline' | 'danger_sm' diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index e48070ddbc..bfeabfabb3 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -25,6 +25,7 @@ import { DeviceWithVerification } from "./types"; import { DeviceType } from "./DeviceType"; export interface DeviceTileProps { device: DeviceWithVerification; + isSelected?: boolean; children?: React.ReactNode; onClick?: () => void; } @@ -68,7 +69,12 @@ const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> value ? { value } : null ); -const DeviceTile: React.FC = ({ device, children, onClick }) => { +const DeviceTile: React.FC = ({ + device, + children, + isSelected, + onClick, +}) => { const inactive = getInactiveMetadata(device); const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`; const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified'); @@ -83,7 +89,7 @@ const DeviceTile: React.FC = ({ device, children, onClick }) => ]; return
    - +
    diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 5ec0a428d0..6d6668a854 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -25,11 +25,11 @@ import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropd import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceSecurityCard from './DeviceSecurityCard'; -import DeviceTile from './DeviceTile'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS, } from './filter'; +import SelectableDeviceTile from './SelectableDeviceTile'; import { DevicesDictionary, DeviceSecurityVariation, @@ -44,6 +44,7 @@ interface Props { localNotificationSettings: Map; expandedDeviceIds: DeviceWithVerification['device_id'][]; signingOutDeviceIds: DeviceWithVerification['device_id'][]; + selectedDeviceIds: DeviceWithVerification['device_id'][]; filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; @@ -51,9 +52,15 @@ interface Props { saveDeviceName: DevicesState['saveDeviceName']; onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; setPushNotifications: (deviceId: string, enabled: boolean) => Promise; + setSelectedDeviceIds: (deviceIds: DeviceWithVerification['device_id'][]) => void; supportsMSC3881?: boolean | undefined; } +const isDeviceSelected = ( + deviceId: DeviceWithVerification['device_id'], + selectedDeviceIds: DeviceWithVerification['device_id'][], +) => selectedDeviceIds.includes(deviceId); + // devices without timestamp metadata should be sorted last const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => (right.last_seen_ts || 0) - (left.last_seen_ts || 0); @@ -147,10 +154,12 @@ const DeviceListItem: React.FC<{ localNotificationSettings?: LocalNotificationSettings | undefined; isExpanded: boolean; isSigningOut: boolean; + isSelected: boolean; onDeviceExpandToggle: () => void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; onRequestDeviceVerification?: () => void; + toggleSelected: () => void; setPushNotifications: (deviceId: string, enabled: boolean) => Promise; supportsMSC3881?: boolean | undefined; }> = ({ @@ -159,21 +168,25 @@ const DeviceListItem: React.FC<{ localNotificationSettings, isExpanded, isSigningOut, + isSelected, onDeviceExpandToggle, onSignOutDevice, saveDeviceName, onRequestDeviceVerification, setPushNotifications, + toggleSelected, supportsMSC3881, }) =>
  • - - + { isExpanded && ) => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -216,6 +231,15 @@ export const FilteredDeviceList = return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id); } + const toggleSelection = (deviceId: DeviceWithVerification['device_id']): void => { + if (isDeviceSelected(deviceId, selectedDeviceIds)) { + // remove from selection + setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId)); + } else { + setSelectedDeviceIds([...selectedDeviceIds, deviceId]); + } + }; + const options: FilterDropdownOption[] = [ { id: ALL_FILTER_ID, label: _t('All') }, { @@ -243,15 +267,35 @@ export const FilteredDeviceList = }; return
    - - - id='device-list-filter' - label={_t('Filter devices')} - value={filter || ALL_FILTER_ID} - onOptionChange={onFilterOptionChange} - options={options} - selectedLabel={_t('Show')} - /> + + { selectedDeviceIds.length + ? <> + onSignOutDevices(selectedDeviceIds)} + className='mx_FilteredDeviceList_headerButton' + > + { _t('Sign out') } + + setSelectedDeviceIds([])} + className='mx_FilteredDeviceList_headerButton' + > + { _t('Cancel') } + + + : + id='device-list-filter' + label={_t('Filter devices')} + value={filter || ALL_FILTER_ID} + onOptionChange={onFilterOptionChange} + options={options} + selectedLabel={_t('Show')} + /> + } { !!sortedDevices.length ? @@ -265,6 +309,7 @@ export const FilteredDeviceList = localNotificationSettings={localNotificationSettings.get(device.device_id)} isExpanded={expandedDeviceIds.includes(device.device_id)} isSigningOut={signingOutDeviceIds.includes(device.device_id)} + isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} onSignOutDevice={() => onSignOutDevices([device.device_id])} saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)} @@ -274,6 +319,7 @@ export const FilteredDeviceList = : undefined } setPushNotifications={setPushNotifications} + toggleSelected={() => toggleSelection(device.device_id)} supportsMSC3881={supportsMSC3881} />, ) } diff --git a/src/components/views/settings/devices/SelectableDeviceTile.tsx b/src/components/views/settings/devices/SelectableDeviceTile.tsx index e232e5ff50..d784472a84 100644 --- a/src/components/views/settings/devices/SelectableDeviceTile.tsx +++ b/src/components/views/settings/devices/SelectableDeviceTile.tsx @@ -32,8 +32,9 @@ const SelectableDeviceTile: React.FC = ({ children, device, isSelected, o onChange={onClick} className='mx_SelectableDeviceTile_checkbox' id={`device-tile-checkbox-${device.device_id}`} + data-testid={`device-tile-checkbox-${device.device_id}`} /> - + { children }
    ; diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index e87d548d57..ed1d04a754 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -19,23 +19,23 @@ import { MatrixClient } from 'matrix-js-sdk/src/client'; import { logger } from 'matrix-js-sdk/src/logger'; import { _t } from "../../../../../languageHandler"; -import { DevicesState, useOwnDevices } from '../../devices/useOwnDevices'; +import MatrixClientContext from '../../../../../contexts/MatrixClientContext'; +import Modal from '../../../../../Modal'; import SettingsSubsection from '../../shared/SettingsSubsection'; +import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; +import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; +import LogoutDialog from '../../../dialogs/LogoutDialog'; +import { useOwnDevices } from '../../devices/useOwnDevices'; import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; import SecurityRecommendations from '../../devices/SecurityRecommendations'; import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types'; -import SettingsTab from '../SettingsTab'; -import Modal from '../../../../../Modal'; -import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; -import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; -import LogoutDialog from '../../../dialogs/LogoutDialog'; -import MatrixClientContext from '../../../../../contexts/MatrixClientContext'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; +import SettingsTab from '../SettingsTab'; const useSignOut = ( matrixClient: MatrixClient, - refreshDevices: DevicesState['refreshDevices'], + onSignoutResolvedCallback: () => Promise, ): { onSignOutCurrentDevice: () => void; onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise; @@ -64,9 +64,7 @@ const useSignOut = ( deviceIds, async (success) => { if (success) { - // @TODO(kerrya) clear selection if was bulk deletion - // when added in PSG-659 - await refreshDevices(); + await onSignoutResolvedCallback(); } setSigningOutDeviceIds(signingOutDeviceIds.filter(deviceId => !deviceIds.includes(deviceId))); }, @@ -99,6 +97,7 @@ const SessionManagerTab: React.FC = () => { } = useOwnDevices(); const [filter, setFilter] = useState(); const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const filteredDeviceListRef = useRef(null); const scrollIntoViewTimeoutRef = useRef>(); @@ -116,7 +115,6 @@ const SessionManagerTab: React.FC = () => { const onGoToFilteredList = (filter: DeviceSecurityVariation) => { setFilter(filter); - // @TODO(kerrya) clear selection when added in PSG-659 clearTimeout(scrollIntoViewTimeoutRef.current); // wait a tick for the filtered section to rerender with different height scrollIntoViewTimeoutRef.current = @@ -154,16 +152,25 @@ const SessionManagerTab: React.FC = () => { }); }, [requestDeviceVerification, refreshDevices, currentUserMember]); + const onSignoutResolvedCallback = async () => { + await refreshDevices(); + setSelectedDeviceIds([]); + }; const { onSignOutCurrentDevice, onSignOutOtherDevices, signingOutDeviceIds, - } = useSignOut(matrixClient, refreshDevices); + } = useSignOut(matrixClient, onSignoutResolvedCallback); useEffect(() => () => { clearTimeout(scrollIntoViewTimeoutRef.current); }, [scrollIntoViewTimeoutRef]); + // clear selection when filter changes + useEffect(() => { + setSelectedDeviceIds([]); + }, [filter, setSelectedDeviceIds]); + return { filter={filter} expandedDeviceIds={expandedDeviceIds} signingOutDeviceIds={signingOutDeviceIds} + selectedDeviceIds={selectedDeviceIds} + setSelectedDeviceIds={setSelectedDeviceIds} onFilterChange={setFilter} onDeviceExpandToggle={onDeviceExpandToggle} onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c1b25cb2ab..cba3340ca8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1751,6 +1751,7 @@ "Not ready for secure messaging": "Not ready for secure messaging", "Inactive": "Inactive", "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", + "Sign out": "Sign out", "Filter devices": "Filter devices", "Show": "Show", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", @@ -2610,7 +2611,6 @@ "Private space (invite only)": "Private space (invite only)", "Want to add an existing space instead?": "Want to add an existing space instead?", "Adding...": "Adding...", - "Sign out": "Sign out", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this", "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.", "Incompatible Database": "Incompatible Database", diff --git a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap index 0cdead4e6e..df46340de3 100644 --- a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap @@ -214,6 +214,7 @@ exports[` renders device panel with devices 1`] = ` class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid" > @@ -295,6 +296,7 @@ exports[` renders device panel with devices 1`] = ` class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid" > diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx index 4a57565e19..92701d08cf 100644 --- a/test/components/views/settings/devices/FilteredDeviceList-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -46,9 +46,12 @@ describe('', () => { onSignOutDevices: jest.fn(), saveDeviceName: jest.fn(), setPushNotifications: jest.fn(), + setPusherEnabled: jest.fn(), + setSelectedDeviceIds: jest.fn(), + localNotificationSettings: new Map(), expandedDeviceIds: [], signingOutDeviceIds: [], - localNotificationSettings: new Map(), + selectedDeviceIds: [], devices: { [unverifiedNoMetadata.device_id]: unverifiedNoMetadata, [verifiedNoMetadata.device_id]: verifiedNoMetadata, diff --git a/test/components/views/settings/devices/__snapshots__/SelectableDeviceTile-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/SelectableDeviceTile-test.tsx.snap index 3f03ab1813..2984d265ed 100644 --- a/test/components/views/settings/devices/__snapshots__/SelectableDeviceTile-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/SelectableDeviceTile-test.tsx.snap @@ -3,6 +3,7 @@ exports[` renders selected tile 1`] = ` @@ -17,6 +18,7 @@ exports[` renders unselected device tile with checkbox 1 class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid" > diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 2cc5a32b79..669be2eb95 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -100,6 +100,19 @@ describe('', () => { fireEvent.click(toggle); }; + const toggleDeviceSelection = ( + getByTestId: ReturnType['getByTestId'], + deviceId: DeviceWithVerification['device_id'], + ) => { + const checkbox = getByTestId(`device-tile-checkbox-${deviceId}`); + fireEvent.click(checkbox); + }; + + const isDeviceSelected = ( + getByTestId: ReturnType['getByTestId'], + deviceId: DeviceWithVerification['device_id'], + ): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked; + beforeEach(() => { jest.clearAllMocks(); jest.spyOn(logger, 'error').mockRestore(); @@ -597,6 +610,33 @@ describe('', () => { '[data-testid="device-detail-sign-out-cta"]', ) as Element).getAttribute('aria-disabled')).toEqual(null); }); + + it('deletes multiple devices', async () => { + mockClient.getDevices.mockResolvedValue({ devices: [ + alicesDevice, alicesMobileDevice, alicesOlderMobileDevice, + ] }); + mockClient.deleteMultipleDevices.mockResolvedValue({}); + + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); + toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); + + fireEvent.click(getByTestId('sign-out-selection-cta')); + + // delete called with both ids + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( + [ + alicesMobileDevice.device_id, + alicesOlderMobileDevice.device_id, + ], + undefined, + ); + }); }); }); @@ -702,6 +742,77 @@ describe('', () => { }); }); + describe('Multiple selection', () => { + beforeEach(() => { + mockClient.getDevices.mockResolvedValue({ devices: [ + alicesDevice, alicesMobileDevice, alicesOlderMobileDevice, + ] }); + }); + + it('toggles session selection', async () => { + const { getByTestId, getByText } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); + toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); + + // header displayed correctly + expect(getByText('2 sessions selected')).toBeTruthy(); + + expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); + expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); + + toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); + + // unselected + expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy(); + // still selected + expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); + }); + + it('cancel button clears selection', async () => { + const { getByTestId, getByText } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); + toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); + + // header displayed correctly + expect(getByText('2 sessions selected')).toBeTruthy(); + + fireEvent.click(getByTestId('cancel-selection-cta')); + + // unselected + expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy(); + expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy(); + }); + + it('changing the filter clears selection', async () => { + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); + expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); + + fireEvent.click(getByTestId('unverified-devices-cta')); + + // our session manager waits a tick for rerender + await flushPromisesWithFakeTimers(); + + // unselected + expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy(); + }); + }); + it("lets you change the pusher state", async () => { const { getByTestId } = render(getComponent()); From a704a2fbb73ae853061eb2796cdd47d6c05d4f90 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 30 Sep 2022 10:54:47 +0200 Subject: [PATCH 18/33] Reduce flakiness of spotlight tests (#9310) --- cypress/e2e/spotlight/spotlight.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index d4b2d2cf9b..06bd675e19 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -125,7 +125,7 @@ Cypress.Commands.add("startDM", (name: string) => { .should("have.focus") .type("Hey!{enter}"); cy.contains(".mx_EventTile_body", "Hey!"); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); + cy.contains(".mx_RoomSublist[aria-label=People]", name); }); describe("Spotlight", () => { @@ -365,7 +365,10 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(bot1.getUserId()); cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); - cy.spotlightResults().eq(0).should("contain", groupDm.name); + cy.contains( + ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", + groupDm.name, + ); }); // Search for ByteBot by id, should return group DM and user @@ -374,7 +377,10 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(bot2.getUserId()); cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); - cy.spotlightResults().eq(0).should("contain", groupDm.name); + cy.contains( + ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", + groupDm.name, + ); }); }); }); From c2e2f406afa1142590f92cb3ad6513ffa547179a Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 30 Sep 2022 16:11:04 +0100 Subject: [PATCH 19/33] Fixes silenced notification preventing notification prompt to be shown (#9336) --- src/Notifier.ts | 2 +- src/components/structures/MatrixChat.tsx | 13 -------- .../views/settings/Notifications.tsx | 2 +- src/settings/Settings.tsx | 2 +- src/toasts/DesktopNotificationsToast.ts | 7 ++++ src/utils/notifications.ts | 26 --------------- test/utils/notifications-test.ts | 33 ------------------- 7 files changed, 10 insertions(+), 75 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index ed2eaeee59..8c7a8e4bed 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -336,7 +336,7 @@ export const Notifier = { } const isGuest = client.isGuest(); return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() && - !localNotificationsAreSilenced(client) && !this.isEnabled() && !this._isPromptHidden(); + !this.isEnabled() && !this._isPromptHidden(); }, _isPromptHidden: function() { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e05c451255..1d24e1da08 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -137,7 +137,6 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; -import { createLocalNotificationSettingsIfNeeded } from '../../utils/notifications'; // legacy export export { default as Views } from "../../Views"; @@ -401,8 +400,6 @@ export default class MatrixChat extends React.PureComponent { } } - private get cli(): MatrixClient { return MatrixClientPeg.get(); } - public componentDidMount(): void { window.addEventListener("resize", this.onWindowResized); } @@ -1260,8 +1257,6 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); StorageManager.tryPersistStorage(); - this.cli.on(ClientEvent.Sync, this.onInitialSync); - if ( MatrixClientPeg.currentUserIsJustRegistered() && SettingsStore.getValue("FTUE.useCaseSelection") === null @@ -1288,14 +1283,6 @@ export default class MatrixChat extends React.PureComponent { } } - private onInitialSync = (): void => { - if (this.cli.isInitialSyncComplete()) { - this.cli.off(ClientEvent.Sync, this.onInitialSync); - } - - createLocalNotificationSettingsIfNeeded(this.cli); - }; - private async onShowPostLoginScreen(useCase?: UseCase) { if (useCase) { PosthogAnalytics.instance.setProperty("ftueUseCaseSelection", useCase); diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 54e7e15051..6c44f9979c 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -122,7 +122,7 @@ export default class Notifications extends React.PureComponent { this.state = { phase: Phase.Loading, - deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? false, + deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true, desktopNotifications: SettingsStore.getValue("notificationsEnabled"), desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"), audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"), diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 69edd0b466..02a80f6dda 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -792,7 +792,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "deviceNotificationsEnabled": { supportedLevels: [SettingLevel.DEVICE], - default: false, + default: true, }, "notificationSound": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, diff --git a/src/toasts/DesktopNotificationsToast.ts b/src/toasts/DesktopNotificationsToast.ts index e10a6d46c6..ad36117418 100644 --- a/src/toasts/DesktopNotificationsToast.ts +++ b/src/toasts/DesktopNotificationsToast.ts @@ -18,9 +18,16 @@ import { _t } from "../languageHandler"; import Notifier from "../Notifier"; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { getLocalNotificationAccountDataEventType } from "../utils/notifications"; const onAccept = () => { Notifier.setEnabled(true); + const cli = MatrixClientPeg.get(); + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + cli.setAccountData(eventType, { + is_silenced: false, + }); }; const onReject = () => { diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index ffa346ca2e..f41edd24bb 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -18,36 +18,10 @@ import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/eve import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import SettingsStore from "../settings/SettingsStore"; - -export const deviceNotificationSettingsKeys = [ - "notificationsEnabled", - "notificationBodyEnabled", - "audioNotificationsEnabled", -]; - export function getLocalNotificationAccountDataEventType(deviceId: string): string { return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; } -export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise { - const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); - const event = cli.getAccountData(eventType); - // New sessions will create an account data event to signify they support - // remote toggling of push notifications on this device. Default `is_silenced=true` - // For backwards compat purposes, older sessions will need to check settings value - // to determine what the state of `is_silenced` - if (!event) { - // If any of the above is true, we fall in the "backwards compat" case, - // and `is_silenced` will be set to `false` - const isSilenced = !deviceNotificationSettingsKeys.some(key => SettingsStore.getValue(key)); - - await cli.setAccountData(eventType, { - is_silenced: isSilenced, - }); - } -} - export function localNotificationsAreSilenced(cli: MatrixClient): boolean { const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); const event = cli.getAccountData(eventType); diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 62e12e6ef8..b27a660ebf 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -19,7 +19,6 @@ import { mocked } from "jest-mock"; import { localNotificationsAreSilenced, - createLocalNotificationSettingsIfNeeded, getLocalNotificationAccountDataEventType, } from "../../src/utils/notifications"; import SettingsStore from "../../src/settings/SettingsStore"; @@ -47,38 +46,6 @@ describe('notifications', () => { mocked(SettingsStore).getValue.mockReturnValue(false); }); - describe('createLocalNotification', () => { - it('creates account data event', async () => { - await createLocalNotificationSettingsIfNeeded(mockClient); - const event = mockClient.getAccountData(accountDataEventKey); - expect(event?.getContent().is_silenced).toBe(true); - }); - - // Can't figure out why the mock does not override the value here - /*.each(deviceNotificationSettingsKeys) instead of skip */ - it.skip("unsilenced for existing sessions", async (/*settingKey*/) => { - mocked(SettingsStore) - .getValue - .mockImplementation((key) => { - // return key === settingKey; - }); - - await createLocalNotificationSettingsIfNeeded(mockClient); - const event = mockClient.getAccountData(accountDataEventKey); - expect(event?.getContent().is_silenced).toBe(false); - }); - - it("does not override an existing account event data", async () => { - mockClient.setAccountData(accountDataEventKey, { - is_silenced: false, - }); - - await createLocalNotificationSettingsIfNeeded(mockClient); - const event = mockClient.getAccountData(accountDataEventKey); - expect(event?.getContent().is_silenced).toBe(false); - }); - }); - describe('localNotificationsAreSilenced', () => { it('defaults to true when no setting exists', () => { expect(localNotificationsAreSilenced(mockClient)).toBeTruthy(); From eaff7e945cffbf76b36438392bdc1ace5db5f875 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 30 Sep 2022 17:12:16 +0100 Subject: [PATCH 20/33] Consolidate js-sdk release mode typing CI (#9326) --- .github/workflows/static_analysis.yaml | 4 ++-- scripts/ci/js-sdk-to-release.js | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) delete mode 100755 scripts/ci/js-sdk-to-release.js diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 696faece56..e711182d19 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -27,9 +27,9 @@ jobs: run: "yarn run lint:types" - name: Switch js-sdk to release mode + working-directory: node_modules/matrix-js-sdk run: | - scripts/ci/js-sdk-to-release.js - cd node_modules/matrix-js-sdk + scripts/switch_package_to_release.js yarn install yarn run build:compile yarn run build:types diff --git a/scripts/ci/js-sdk-to-release.js b/scripts/ci/js-sdk-to-release.js deleted file mode 100755 index e1fecfde03..0000000000 --- a/scripts/ci/js-sdk-to-release.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node - -const fsProm = require('fs/promises'); - -const PKGJSON = 'node_modules/matrix-js-sdk/package.json'; - -async function main() { - const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8')); - for (const field of ['main', 'typings']) { - if (pkgJson["matrix_lib_"+field] !== undefined) { - pkgJson[field] = pkgJson["matrix_lib_"+field]; - } - } - await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2)); -} - -main(); From 07a5a1dc6fb02ff961891ddadadcd2725e8bdcf8 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 30 Sep 2022 17:28:53 +0100 Subject: [PATCH 21/33] Fix device selection in pre-join screen for Element Call video rooms (#9321) * Fix device selection in pre-join screen for Element Call video rooms As per https://github.com/vector-im/element-call/pull/609 * Update unit test * Lint * Hold a media stream while we enumerate device so we can do so reliably. This means we can remove the device fallback labels. * i18n * Remove unnecessary useState * Fix fetching video devices when video muted * Actually fix preview stream code * Fix unit test now fallback is no longer a thing * Test changing devices --- src/MediaDeviceHandler.ts | 16 +++- src/components/views/voip/CallView.tsx | 77 ++++++++++--------- src/i18n/strings/en_EN.json | 2 - src/models/Call.ts | 4 +- test/components/views/voip/CallView-test.tsx | 80 ++++++++++++++------ test/models/Call-test.ts | 4 +- test/test-utils/test-utils.ts | 6 ++ 7 files changed, 123 insertions(+), 66 deletions(-) diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index a5160484c8..0e6d2b98bc 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -50,10 +50,20 @@ export default class MediaDeviceHandler extends EventEmitter { return devices.some(d => Boolean(d.label)); } + /** + * Gets the available audio input/output and video input devices + * from the browser: a thin wrapper around mediaDevices.enumerateDevices() + * that also returns results by type of devices. Note that this requires + * user media permissions and an active stream, otherwise you'll get blank + * device labels. + * + * Once the Permissions API + * (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API) + * is ready for primetime, it might help make this simpler. + * + * @return Promise The available media devices + */ public static async getDevices(): Promise { - // Only needed for Electron atm, though should work in modern browsers - // once permission has been granted to the webapp - try { const devices = await navigator.mediaDevices.enumerateDevices(); const output = { diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index f85296f1d0..fbab581e0f 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -45,7 +45,6 @@ interface DeviceButtonProps { devices: MediaDeviceInfo[]; setDevice: (device: MediaDeviceInfo) => void; deviceListLabel: string; - fallbackDeviceLabel: (n: number) => string; muted: boolean; disabled: boolean; toggle: () => void; @@ -54,7 +53,7 @@ interface DeviceButtonProps { } const DeviceButton: FC = ({ - kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle, + kind, devices, setDevice, deviceListLabel, muted, disabled, toggle, unmutedTitle, mutedTitle, }) => { const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu(); const selectDevice = useCallback((device: MediaDeviceInfo) => { @@ -67,10 +66,10 @@ const DeviceButton: FC = ({ const buttonRect = buttonRef.current!.getBoundingClientRect(); contextMenu = - { devices.map((d, index) => + { devices.map((d) => selectDevice(d)} />, ) } @@ -119,26 +118,8 @@ export const Lobby: FC = ({ room, connect, children }) => { const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const videoRef = useRef(null); - const [audioInputs, videoInputs] = useAsyncMemo(async () => { - try { - const devices = await MediaDeviceHandler.getDevices(); - return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]]; - } catch (e) { - logger.warn(`Failed to get media device list`, e); - return [[], []]; - } - }, [], [[], []]); - const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); - const setAudioInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setAudioInput(device.deviceId); - }, []); - const setVideoInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setVideoInput(device.deviceId); - setVideoInputId(device.deviceId); - }, []); - const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); @@ -151,18 +132,46 @@ export const Lobby: FC = ({ room, connect, children }) => { setVideoMuted(!videoMuted); }, [videoMuted, setVideoMuted]); - const videoStream = useAsyncMemo(async () => { - if (videoInputId && !videoMuted) { - try { - return await navigator.mediaDevices.getUserMedia({ - video: { deviceId: videoInputId }, - }); - } catch (e) { - logger.error(`Failed to get stream for device ${videoInputId}`, e); - } + const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => { + let previewStream: MediaStream; + try { + // We get the preview stream before requesting devices: this is because + // we need (in some browsers) an active media stream in order to get + // non-blank labels for the devices. According to the docs, we + // need a stream of each type (audio + video) if we want to enumerate + // audio & video devices, although this didn't seem to be the case + // in practice for me. We request both anyway. + // For similar reasons, we also request a stream even if video is muted, + // which could be a bit strange but allows us to get the device list + // reliably. One option could be to try & get devices without a stream, + // then try again with a stream if we get blank deviceids, but... ew. + previewStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: videoInputId }, + audio: { deviceId: MediaDeviceHandler.getAudioInput() }, + }); + } catch (e) { + logger.error(`Failed to get stream for device ${videoInputId}`, e); } - return null; - }, [videoInputId, videoMuted]); + + const devices = await MediaDeviceHandler.getDevices(); + + // If video is muted, we don't actually want the stream, so we can get rid of + // it now. + if (videoMuted) { + previewStream.getTracks().forEach(t => t.stop()); + previewStream = undefined; + } + + return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]]; + }, [videoInputId, videoMuted], [null, [], []]); + + const setAudioInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setAudioInput(device.deviceId); + }, []); + const setVideoInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setVideoInput(device.deviceId); + setVideoInputId(device.deviceId); + }, []); useEffect(() => { if (videoStream) { @@ -205,7 +214,6 @@ export const Lobby: FC = ({ room, connect, children }) => { devices={audioInputs} setDevice={setAudioInput} deviceListLabel={_t("Audio devices")} - fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })} muted={audioMuted} disabled={connecting} toggle={toggleAudio} @@ -217,7 +225,6 @@ export const Lobby: FC = ({ room, connect, children }) => { devices={videoInputs} setDevice={setVideoInput} deviceListLabel={_t("Video devices")} - fallbackDeviceLabel={n => _t("Video input %(n)s", { n })} muted={videoMuted} disabled={connecting} toggle={toggleVideo} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cba3340ca8..1a051cf8e1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1047,11 +1047,9 @@ "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", "Audio devices": "Audio devices", - "Audio input %(n)s": "Audio input %(n)s", "Mute microphone": "Mute microphone", "Unmute microphone": "Unmute microphone", "Video devices": "Video devices", - "Video input %(n)s": "Video input %(n)s", "Turn off camera": "Turn off camera", "Turn on camera": "Turn on camera", "Join": "Join", diff --git a/src/models/Call.ts b/src/models/Call.ts index b51a49cc9e..417cf16291 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -771,8 +771,8 @@ export class ElementCall extends Call { ): Promise { try { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.deviceId ?? null, - videoInput: videoInput?.deviceId ?? null, + audioInput: audioInput?.label ?? null, + videoInput: videoInput?.label ?? null, }); } catch (e) { throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 827b4d29c1..24d26e5d52 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -187,6 +187,35 @@ describe("CallLobby", () => { }); describe("device buttons", () => { + const fakeVideoInput1: MediaDeviceInfo = { + deviceId: "v1", + groupId: "v1", + label: "Webcam", + kind: "videoinput", + toJSON: () => {}, + }; + const fakeVideoInput2: MediaDeviceInfo = { + deviceId: "v2", + groupId: "v2", + label: "Othercam", + kind: "videoinput", + toJSON: () => {}, + }; + const fakeAudioInput1: MediaDeviceInfo = { + deviceId: "v1", + groupId: "v1", + label: "Headphones", + kind: "audioinput", + toJSON: () => {}, + }; + const fakeAudioInput2: MediaDeviceInfo = { + deviceId: "v2", + groupId: "v2", + label: "Tailphones", + kind: "audioinput", + toJSON: () => {}, + }; + it("hide when no devices are available", async () => { await renderView(); expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); @@ -194,13 +223,7 @@ describe("CallLobby", () => { }); it("show without dropdown when only one device is available", async () => { - mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{ - deviceId: "1", - groupId: "1", - label: "Webcam", - kind: "videoinput", - toJSON: () => {}, - }]); + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]); await renderView(); screen.getByRole("button", { name: /camera/ }); @@ -209,27 +232,40 @@ describe("CallLobby", () => { it("show with dropdown when multiple devices are available", async () => { mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([ - { - deviceId: "1", - groupId: "1", - label: "Headphones", - kind: "audioinput", - toJSON: () => {}, - }, - { - deviceId: "2", - groupId: "1", - label: "", // Should fall back to "Audio input 2" - kind: "audioinput", - toJSON: () => {}, - }, + fakeAudioInput1, fakeAudioInput2, ]); await renderView(); screen.getByRole("button", { name: /microphone/ }); fireEvent.click(screen.getByRole("button", { name: "Audio devices" })); screen.getByRole("menuitem", { name: "Headphones" }); - screen.getByRole("menuitem", { name: "Audio input 2" }); + screen.getByRole("menuitem", { name: "Tailphones" }); + }); + + it("sets video device when selected", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([ + fakeVideoInput1, fakeVideoInput2, + ]); + + await renderView(); + screen.getByRole("button", { name: /camera/ }); + fireEvent.click(screen.getByRole("button", { name: "Video devices" })); + fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label })); + + expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId); + }); + + it("sets audio device when selected", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([ + fakeAudioInput1, fakeAudioInput2, + ]); + + await renderView(); + screen.getByRole("button", { name: /microphone/ }); + fireEvent.click(screen.getByRole("button", { name: "Audio devices" })); + fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label })); + + expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId); }); }); }); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 2ed56e3fcc..83c0456b80 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -616,8 +616,8 @@ describe("ElementCall", () => { await call.connect(); expect(call.connectionState).toBe(ConnectionState.Connected); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: "1", - videoInput: "2", + audioInput: "Headphones", + videoInput: "Built-in webcam", }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 67c038502f..0647f2604d 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -34,6 +34,7 @@ import { } from 'matrix-js-sdk/src/matrix'; import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; +import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; import { makeType } from "../../src/utils/TypeUtils"; @@ -175,6 +176,11 @@ export function createTestClient(): MatrixClient { sendToDevice: jest.fn().mockResolvedValue(undefined), queueToDevice: jest.fn().mockResolvedValue(undefined), encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined), + + getMediaHandler: jest.fn().mockReturnValue({ + setVideoInput: jest.fn(), + setAudioInput: jest.fn(), + } as unknown as MediaHandler), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); From ff59f68a9fa0ac5bc467afe0e8d9af02d15e1666 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 30 Sep 2022 15:26:08 -0400 Subject: [PATCH 22/33] New group call experience: Call tiles (#9332) * Add call tiles * Factor CallDuration out into a reusable component * Correct the separator character in LiveContentSummary --- res/css/_components.pcss | 4 +- res/css/views/elements/_FacePile.pcss | 1 + res/css/views/messages/_CallEvent.pcss | 77 ++++++++ res/css/views/rooms/_EventBubbleTile.pcss | 3 +- ...lSummary.pcss => _LiveContentSummary.pcss} | 15 +- res/css/views/voip/_CallDuration.pcss | 20 ++ .../views/elements/AccessibleButton.tsx | 10 +- src/components/views/messages/CallEvent.tsx | 176 ++++++++++++++++++ src/components/views/rooms/EventTile.tsx | 12 +- .../views/rooms/LiveContentSummary.tsx | 57 ++++++ .../views/rooms/RoomTileCallSummary.tsx | 31 +-- src/components/views/voip/CallDuration.tsx | 51 +++++ src/createRoom.ts | 4 +- src/events/EventTileFactory.tsx | 16 ++ src/i18n/strings/en_EN.json | 6 +- src/settings/Settings.tsx | 1 + src/utils/EventRenderingUtils.ts | 6 +- .../views/messages/CallEvent-test.tsx | 150 +++++++++++++++ test/test-utils/call.ts | 17 +- 19 files changed, 606 insertions(+), 51 deletions(-) create mode 100644 res/css/views/messages/_CallEvent.pcss rename res/css/views/rooms/{_RoomTileCallSummary.pcss => _LiveContentSummary.pcss} (84%) create mode 100644 res/css/views/voip/_CallDuration.pcss create mode 100644 src/components/views/messages/CallEvent.tsx create mode 100644 src/components/views/rooms/LiveContentSummary.tsx create mode 100644 src/components/views/voip/CallDuration.tsx create mode 100644 test/components/views/messages/CallEvent-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 6996d33cee..9161942d87 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -210,6 +210,7 @@ @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; +@import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_DateSeparator.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; @@ -264,6 +265,7 @@ @import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; +@import "./views/rooms/_LiveContentSummary.pcss"; @import "./views/rooms/_MemberInfo.pcss"; @import "./views/rooms/_MemberList.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @@ -285,7 +287,6 @@ @import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomTile.pcss"; -@import "./views/rooms/_RoomTileCallSummary.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; @@ -347,6 +348,7 @@ @import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; +@import "./views/voip/_CallDuration.pcss"; @import "./views/voip/_CallView.pcss"; @import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPadContextMenu.pcss"; diff --git a/res/css/views/elements/_FacePile.pcss b/res/css/views/elements/_FacePile.pcss index dd23eb37e4..7f890a0bb7 100644 --- a/res/css/views/elements/_FacePile.pcss +++ b/res/css/views/elements/_FacePile.pcss @@ -22,6 +22,7 @@ limitations under the License. display: inline-flex; flex-direction: row-reverse; vertical-align: middle; + margin: 0 -1px; /* to cancel out the border on the edges */ /* Overlap the children */ > * + * { diff --git a/res/css/views/messages/_CallEvent.pcss b/res/css/views/messages/_CallEvent.pcss new file mode 100644 index 0000000000..1a11beaa4d --- /dev/null +++ b/res/css/views/messages/_CallEvent.pcss @@ -0,0 +1,77 @@ +/* +Copyright 2022 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_CallEvent_wrapper { + display: flex; + width: 100%; +} + +.mx_CallEvent { + padding: 12px; + box-sizing: border-box; + min-height: 60px; + max-width: 600px; + width: 100%; + background-color: $system; + border-radius: 8px; + + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-8; + + .mx_CallEvent_title { + font-size: $font-15px; + line-height: 24px; /* in px to match the avatar */ + } + + &.mx_CallEvent_inactive .mx_CallEvent_title::before { + display: inline-block; + vertical-align: middle; + content: ''; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + } + + &.mx_CallEvent_active .mx_CallEvent_title { + font-weight: 600; + } + + > .mx_BaseAvatar { + align-self: flex-start; + } + + > .mx_CallEvent_infoRows { + flex-grow: 1; + + display: flex; + flex-direction: column; + gap: $spacing-4; + } + + > .mx_CallDuration { + padding: $spacing-4; + } + + > .mx_CallEvent_button { + box-sizing: border-box; + min-width: 120px; + } +} diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 88368a4bea..ca9ec513f8 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -523,7 +523,8 @@ limitations under the License. max-width: 100%; } - .mx_LegacyCallEvent_wrapper { + .mx_LegacyCallEvent_wrapper, + .mx_CallEvent_wrapper { justify-content: center; } } diff --git a/res/css/views/rooms/_RoomTileCallSummary.pcss b/res/css/views/rooms/_LiveContentSummary.pcss similarity index 84% rename from res/css/views/rooms/_RoomTileCallSummary.pcss rename to res/css/views/rooms/_LiveContentSummary.pcss index 9c5e99c5ec..c56026a829 100644 --- a/res/css/views/rooms/_RoomTileCallSummary.pcss +++ b/res/css/views/rooms/_LiveContentSummary.pcss @@ -14,21 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomTileCallSummary { - .mx_RoomTileCallSummary_text { +.mx_LiveContentSummary { + color: $secondary-content; + + .mx_LiveContentSummary_text { &::before { display: inline-block; vertical-align: text-bottom; content: ''; background-color: $secondary-content; - mask-image: url('$(res)/img/element-icons/call/video-call.svg'); mask-size: 16px; width: 16px; height: 16px; margin-right: 4px; } - &.mx_RoomTileCallSummary_text_active { + &.mx_LiveContentSummary_text_video::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + + &.mx_LiveContentSummary_text_active { color: $accent; &::before { @@ -37,7 +42,7 @@ limitations under the License. } } - .mx_RoomTileCallSummary_participants::before { + .mx_LiveContentSummary_participants::before { display: inline-block; vertical-align: text-bottom; content: ''; diff --git a/res/css/views/voip/_CallDuration.pcss b/res/css/views/voip/_CallDuration.pcss new file mode 100644 index 0000000000..c8dc07ef67 --- /dev/null +++ b/res/css/views/voip/_CallDuration.pcss @@ -0,0 +1,20 @@ +/* +Copyright 2022 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_CallDuration { + color: $secondary-content; + font-size: $font-12px; +} diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 9e63b6cf54..7036575cd1 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -72,7 +72,7 @@ type IProps = DynamicHtmlElementProps disabled?: boolean; className?: string; triggerOnMouseDown?: boolean; - onClick(e?: ButtonEvent): void | Promise; + onClick: ((e: ButtonEvent) => void | Promise) | null; }; interface IAccessibleButtonProps extends React.InputHTMLAttributes { @@ -106,9 +106,9 @@ export default function AccessibleButton( newProps["disabled"] = true; } else { if (triggerOnMouseDown) { - newProps.onMouseDown = onClick; + newProps.onMouseDown = onClick ?? undefined; } else { - newProps.onClick = onClick; + newProps.onClick = onClick ?? undefined; } // We need to consume enter onKeyDown and space onKeyUp // otherwise we are risking also activating other keyboard focusable elements @@ -124,7 +124,7 @@ export default function AccessibleButton( case KeyBindingAction.Enter: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); @@ -144,7 +144,7 @@ export default function AccessibleButton( case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); default: onKeyUp?.(e); break; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx new file mode 100644 index 0000000000..151adaa9f5 --- /dev/null +++ b/src/components/views/messages/CallEvent.tsx @@ -0,0 +1,176 @@ +/* +Copyright 2022 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, { forwardRef, useCallback, useContext, useMemo } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { Call, ConnectionState } from "../../../models/Call"; +import { _t } from "../../../languageHandler"; +import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import type { ButtonEvent } from "../elements/AccessibleButton"; +import MemberAvatar from "../avatars/MemberAvatar"; +import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; +import FacePile from "../elements/FacePile"; +import AccessibleButton from "../elements/AccessibleButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration"; + +const MAX_FACES = 8; + +interface ActiveCallEventProps { + mxEvent: MatrixEvent; + participants: Set; + buttonText: string; + buttonKind: string; + onButtonClick: ((ev: ButtonEvent) => void) | null; +} + +const ActiveCallEvent = forwardRef( + ( + { + mxEvent, + participants, + buttonText, + buttonKind, + onButtonClick, + }, + ref, + ) => { + const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]); + + const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]); + const facePileOverflow = participants.size > facePileMembers.length; + + return
    +
    + +
    + + { _t("%(name)s started a video call", { name: senderName }) } + + + +
    + + + { buttonText } + +
    +
    ; + }, +); + +interface ActiveLoadedCallEventProps { + mxEvent: MatrixEvent; + call: Call; +} + +const ActiveLoadedCallEvent = forwardRef(({ mxEvent, call }, ref) => { + const connectionState = useConnectionState(call); + const participants = useParticipants(call); + + const connect = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: mxEvent.getRoomId()!, + view_call: true, + metricsTrigger: undefined, + }); + }, [mxEvent]); + + const disconnect = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + call.disconnect(); + }, [call]); + + const [buttonText, buttonKind, onButtonClick] = useMemo(() => { + switch (connectionState) { + case ConnectionState.Disconnected: return [_t("Join"), "primary", connect]; + case ConnectionState.Connecting: return [_t("Join"), "primary", null]; + case ConnectionState.Connected: return [_t("Leave"), "danger", disconnect]; + case ConnectionState.Disconnecting: return [_t("Leave"), "danger", null]; + } + }, [connectionState, connect, disconnect]); + + return ; +}); + +interface CallEventProps { + mxEvent: MatrixEvent; +} + +/** + * An event tile representing an active or historical Element call. + */ +export const CallEvent = forwardRef(({ mxEvent }, ref) => { + const noParticipants = useMemo(() => new Set(), []); + const client = useContext(MatrixClientContext); + const call = useCall(mxEvent.getRoomId()!); + const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState + .getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!); + + if ("m.terminated" in latestEvent.getContent()) { + // The call is terminated + return
    +
    + { _t("Video call ended") } + +
    +
    ; + } + + if (call === null) { + // There should be a call, but it hasn't loaded yet + return ; + } + + return ; +}); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 9c601d1ab5..8b88a35670 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -83,6 +83,7 @@ import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; +import { ElementCall } from "../../../models/Call"; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -937,7 +938,7 @@ export class UnwrappedEventTile extends React.Component { public render() { const msgtype = this.props.mxEvent.getContent().msgtype; - const eventType = this.props.mxEvent.getType() as EventType; + const eventType = this.props.mxEvent.getType(); const { hasRenderer, isBubbleMessage, @@ -999,7 +1000,9 @@ export class UnwrappedEventTile extends React.Component { mx_EventTile_sending: !isEditing && isSending, mx_EventTile_highlight: this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu, - mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite, + mx_EventTile_continuation: isContinuation + || eventType === EventType.CallInvite + || ElementCall.CALL_EVENT_TYPE.matches(eventType), mx_EventTile_last: this.props.last, mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_contextual: this.props.contextual, @@ -1053,8 +1056,9 @@ export class UnwrappedEventTile extends React.Component { avatarSize = 14; needsSenderProfile = true; } else if ( - (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite + (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) + || eventType === EventType.CallInvite + || ElementCall.CALL_EVENT_TYPE.matches(eventType) ) { // no avatar or sender profile for continuation messages and call tiles avatarSize = 0; diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx new file mode 100644 index 0000000000..95adf54f13 --- /dev/null +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -0,0 +1,57 @@ +/* +Copyright 2022 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, { FC } from "react"; +import classNames from "classnames"; + +import { _t } from "../../../languageHandler"; + +export enum LiveContentType { + Video, + // More coming soon +} + +interface Props { + type: LiveContentType; + text: string; + active: boolean; + participantCount: number; +} + +/** + * Summary line used to call out live, interactive content such as calls. + */ +export const LiveContentSummary: FC = ({ type, text, active, participantCount }) => ( + + + { text } + + { participantCount > 0 && <> + { " • " } + + { participantCount } + + } + +); diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 9af01f20d4..717ab5e36f 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React, { FC } from "react"; -import classNames from "classnames"; import type { Call } from "../../../models/Call"; -import { _t, TranslatedString } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import { useConnectionState, useParticipants } from "../../../hooks/useCall"; import { ConnectionState } from "../../../models/Call"; +import { LiveContentSummary, LiveContentType } from "./LiveContentSummary"; interface Props { call: Call; @@ -30,7 +30,7 @@ export const RoomTileCallSummary: FC = ({ call }) => { const connectionState = useConnectionState(call); const participants = useParticipants(call); - let text: TranslatedString; + let text: string; let active: boolean; switch (connectionState) { @@ -49,23 +49,10 @@ export const RoomTileCallSummary: FC = ({ call }) => { break; } - return - - { text } - - { participants.size ? <> - { " · " } - - { participants.size } - - : null } - ; + return ; }; diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx new file mode 100644 index 0000000000..38b30038ea --- /dev/null +++ b/src/components/views/voip/CallDuration.tsx @@ -0,0 +1,51 @@ +/* +Copyright 2022 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, { FC, useState, useEffect } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { formatCallTime } from "../../../DateUtils"; + +interface CallDurationProps { + delta: number; +} + +/** + * A call duration counter. + */ +export const CallDuration: FC = ({ delta }) => { + // Clock desync could lead to a negative duration, so just hide it if that happens + if (delta <= 0) return null; + return
    { formatCallTime(new Date(delta)) }
    ; +}; + +interface CallDurationFromEventProps { + mxEvent: MatrixEvent; +} + +/** + * A call duration counter that automatically counts up, given the event that + * started the call. + */ +export const CallDurationFromEvent: FC = ({ mxEvent }) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + return ; +}; diff --git a/src/createRoom.ts b/src/createRoom.ts index c1bcc122ca..19d92164a0 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -158,9 +158,9 @@ export default async function createRoom(opts: IOpts): Promise { events: { ...DEFAULT_EVENT_POWER_LEVELS, // Allow all users to send call membership updates - "org.matrix.msc3401.call.member": 0, + [ElementCall.MEMBER_EVENT_TYPE.name]: 0, // Make calls immutable, even to admins - "org.matrix.msc3401.call": 200, + [ElementCall.CALL_EVENT_TYPE.name]: 200, }, users: { // Temporarily give ourselves the power to set up a call diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index d1702aac98..9ff402f45d 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -28,6 +28,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import MessageEvent from "../components/views/messages/MessageEvent"; import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; +import { CallEvent } from "../components/views/messages/CallEvent"; import TextualEvent from "../components/views/messages/TextualEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent"; import RoomCreate from "../components/views/messages/RoomCreate"; @@ -44,6 +45,7 @@ import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; +import { ElementCall } from "../models/Call"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -74,6 +76,7 @@ const KeyVerificationConclFactory: Factory = (ref, props) => = (ref, props) => ( ); +const CallEventFactory: Factory = (ref, props) => ; const TextualEventFactory: Factory = (ref, props) => ; const VerificationReqFactory: Factory = (ref, props) => ; const HiddenEventFactory: Factory = (ref, props) => ; @@ -113,6 +116,10 @@ const STATE_EVENT_TILE_TYPES = new Map([ [EventType.RoomGuestAccess, TextualEventFactory], ]); +for (const evType of ElementCall.CALL_EVENT_TYPE.names) { + STATE_EVENT_TILE_TYPES.set(evType, CallEventFactory); +} + // Add all the Mjolnir stuff to the renderer too for (const evType of ALL_RULE_TYPES) { STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory); @@ -397,6 +404,15 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boo return hasText(mxEvent, showHiddenEvents); } else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) { return Boolean(mxEvent.getContent()['predecessor']); + } else if (ElementCall.CALL_EVENT_TYPE.names.some(eventType => handler === STATE_EVENT_TILE_TYPES.get(eventType))) { + const intent = mxEvent.getContent()['m.intent']; + const prevContent = mxEvent.getPrevContent(); + // If the call became unterminated or previously had invalid contents, + // then this event marks the start of the call + const newlyStarted = 'm.terminated' in prevContent + || !('m.intent' in prevContent) || !('m.type' in prevContent); + // Only interested in events that mark the start of a non-room call + return typeof intent === 'string' && intent !== 'm.room' && newlyStarted; } else if (handler === JSONEventFactory) { return false; } else { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1a051cf8e1..f8cacb0f70 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1802,6 +1802,8 @@ "Show %(count)s other previews|other": "Show %(count)s other previews", "Show %(count)s other previews|one": "Show %(count)s other preview", "Close preview": "Close preview", + "%(count)s participants|other": "%(count)s participants", + "%(count)s participants|one": "1 participant", "and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|one": "and one other...", "Invite to this room": "Invite to this room", @@ -2001,8 +2003,6 @@ "Video": "Video", "Joining…": "Joining…", "Joined": "Joined", - "%(count)s participants|other": "%(count)s participants", - "%(count)s participants|one": "1 participant", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", "This room has already been upgraded.": "This room has already been upgraded.", "This room is running room version , which this homeserver has marked as unstable.": "This room is running room version , which this homeserver has marked as unstable.", @@ -2181,6 +2181,8 @@ "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", "You cancelled verification.": "You cancelled verification.", "Verification cancelled": "Verification cancelled", + "%(name)s started a video call": "%(name)s started a video call", + "Video call ended": "Video call ended", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 02a80f6dda..b92005d611 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -438,6 +438,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, labsGroup: LabGroup.VoiceAndVideo, displayName: _td("New group call experience"), + controller: new ReloadOnChangeController(), default: false, }, "feature_location_share_live": { diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index ced3874804..3ba4ce5705 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -23,6 +23,7 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; +import { ElementCall } from "../models/Call"; export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; @@ -61,9 +62,8 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool (eventType === EventType.RoomEncryption) || (factory === JitsiEventFactory) ); - const isLeftAlignedBubbleMessage = ( - !isBubbleMessage && - eventType === EventType.CallInvite + const isLeftAlignedBubbleMessage = !isBubbleMessage && ( + eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType) ); let isInfoMessage = ( !isBubbleMessage && diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx new file mode 100644 index 0000000000..70a9006191 --- /dev/null +++ b/test/components/views/messages/CallEvent-test.tsx @@ -0,0 +1,150 @@ +/* +Copyright 2022 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 { render, screen, act, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { mocked, Mocked } from "jest-mock"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { ClientWidgetApi, Widget } from "matrix-widget-api"; + +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { + useMockedCalls, + MockedCall, + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + wrapInMatrixClientContext, +} from "../../../test-utils"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import { CallEvent as UnwrappedCallEvent } from "../../../../src/components/views/messages/CallEvent"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { CallStore } from "../../../../src/stores/CallStore"; +import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { ConnectionState } from "../../../../src/models/Call"; + +const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent); + +describe("CallEvent", () => { + useMockedCalls(); + Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); + jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); + + let client: Mocked; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let call: MockedCall; + let widget: Widget; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + + stubClient(); + client = mocked(MatrixClientPeg.get()); + client.getUserId.mockReturnValue("@alice:example.org"); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + alice = mkRoomMember(room.roomId, "@alice:example.org"); + bob = mkRoomMember(room.roomId, "@bob:example.org"); + jest.spyOn(room, "getMember").mockImplementation( + userId => [alice, bob].find(member => member.userId === userId) ?? null, + ); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + + MockedCall.create(room, "1"); + const maybeCall = CallStore.instance.get(room.roomId); + if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); + call = maybeCall; + + widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + }); + + afterEach(async () => { + cleanup(); // Unmount before we do any cleanup that might update the component + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient)); + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + jest.restoreAllMocks(); + }); + + const renderEvent = () => { render(); }; + + it("shows a message and duration if the call was ended", () => { + jest.advanceTimersByTime(90000); + call.destroy(); + renderEvent(); + + screen.getByText("Video call ended"); + screen.getByText("1m 30s"); + }); + + it("shows placeholder info if the call isn't loaded yet", () => { + jest.spyOn(CallStore.instance, "get").mockReturnValue(null); + jest.advanceTimersByTime(90000); + renderEvent(); + + screen.getByText("@alice:example.org started a video call"); + expect(screen.getByRole("button", { name: "Join" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("shows call details and connection controls if the call is loaded", async () => { + jest.advanceTimersByTime(90000); + call.participants = new Set([alice, bob]); + renderEvent(); + + screen.getByText("@alice:example.org started a video call"); + screen.getByLabelText("2 participants"); + screen.getByText("1m 30s"); + + // Test that the join button works + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Join" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + await act(() => call.connect()); + + // Test that the leave button works + fireEvent.click(screen.getByRole("button", { name: "Leave" })); + await waitFor(() => screen.getByRole("button", { name: "Join" })); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + }); +}); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index f4549026af..1268410ce4 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -18,17 +18,18 @@ import { MatrixWidgetType } from "matrix-widget-api"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { mkEvent } from "./test-utils"; import { Call, ElementCall, JitsiCall } from "../../src/models/Call"; export class MockedCall extends Call { - private static EVENT_TYPE = "org.example.mocked_call"; + public static readonly EVENT_TYPE = "org.example.mocked_call"; public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour - private constructor(room: Room, id: string) { + private constructor(room: Room, public readonly event: MatrixEvent) { super( { - id, + id: event.getStateKey()!, eventId: "$1:example.org", roomId: room.roomId, type: MatrixWidgetType.Custom, @@ -42,7 +43,9 @@ export class MockedCall extends Call { public static get(room: Room): MockedCall | null { const [event] = room.currentState.getStateEvents(this.EVENT_TYPE); - return event?.getContent().terminated ?? true ? null : new MockedCall(room, event.getStateKey()!); + return (event === undefined || "m.terminated" in event.getContent()) + ? null + : new MockedCall(room, event); } public static create(room: Room, id: string) { @@ -52,8 +55,9 @@ export class MockedCall extends Call { type: this.EVENT_TYPE, room: room.roomId, user: "@alice:example.org", - content: { terminated: false }, + content: { "m.type": "m.video", "m.intent": "m.prompt" }, skey: id, + ts: Date.now(), })]); } @@ -78,8 +82,9 @@ export class MockedCall extends Call { type: MockedCall.EVENT_TYPE, room: this.room.roomId, user: "@alice:example.org", - content: { terminated: true }, + content: { ...this.event.getContent(), "m.terminated": "Call ended" }, skey: this.widget.id, + ts: Date.now(), })]); super.destroy(); From bb2f4fb5e6b0a277dc8e0b247c9108f9bd4ca5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 4 Oct 2022 09:38:23 +0200 Subject: [PATCH 23/33] Check for the spec version when determining private read receipt support (#9341) Co-authored-by: Travis Ralston --- src/components/structures/TimelinePanel.tsx | 3 ++- .../views/settings/tabs/user/PreferencesUserSettingsTab.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index b14016d0be..1e262014c2 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -994,7 +994,8 @@ class TimelinePanel extends React.Component { if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { if ( !sendRRs - && !cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable") + && !(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) + && !(await cli.isVersionSupported("v1.4")) ) return; try { return await cli.sendReadReceipt( diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 643a137306..1519e39a0d 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -125,9 +125,12 @@ export default class PreferencesUserSettingsTab extends React.Component { + const cli = MatrixClientPeg.get(); + this.setState({ disablingReadReceiptsSupported: ( - await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") + (await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) + || (await cli.isVersionSupported("v1.4")) ), }); } From 0ded5e05059c99a38bb6817f9a087066a4f6e40d Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 4 Oct 2022 09:53:23 +0200 Subject: [PATCH 24/33] Device manager - record device client information on app start (PSG-633) (#9314) * record device client inforamtion events on app start * matrix-client-information -> matrix_client_information * fix types * remove another unused export * add docs link * add opt in setting for recording device information --- src/DeviceListener.ts | 46 +++++++ .../tabs/user/SecurityUserSettingsTab.tsx | 6 + src/i18n/strings/en_EN.json | 3 +- src/settings/Settings.tsx | 8 ++ src/utils/device/clientInformation.ts | 60 +++++++++ test/DeviceListener-test.ts | 122 ++++++++++++++++++ test/utils/device/clientInformation-test.ts | 86 ++++++++++++ 7 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/utils/device/clientInformation.ts create mode 100644 test/utils/device/clientInformation-test.ts diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index cf9af5befc..be440333e2 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -40,6 +40,10 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; import { isLoggedIn } from "./utils/login"; +import SdkConfig from "./SdkConfig"; +import PlatformPeg from "./PlatformPeg"; +import { recordClientInformation } from "./utils/device/clientInformation"; +import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -60,6 +64,8 @@ export default class DeviceListener { // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; + private shouldRecordClientInformation = false; + private deviceClientInformationSettingWatcherRef: string | undefined; public static sharedInstance() { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); @@ -76,8 +82,15 @@ export default class DeviceListener { MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData); MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); + this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn'); + this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting( + 'deviceClientInformationOptIn', + null, + this.onRecordClientInformationSettingChange, + ); this.dispatcherRef = dis.register(this.onAction); this.recheck(); + this.recordClientInformation(); } public stop() { @@ -95,6 +108,9 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); } + if (this.deviceClientInformationSettingWatcherRef) { + SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); + } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); this.dispatcherRef = null; @@ -200,6 +216,7 @@ export default class DeviceListener { private onAction = ({ action }: ActionPayload) => { if (action !== Action.OnLoggedIn) return; this.recheck(); + this.recordClientInformation(); }; // The server doesn't tell us when key backup is set up, so we poll @@ -343,4 +360,33 @@ export default class DeviceListener { dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } }; + + private onRecordClientInformationSettingChange: CallbackFn = ( + _originalSettingName, _roomId, _level, _newLevel, newValue, + ) => { + const prevValue = this.shouldRecordClientInformation; + + this.shouldRecordClientInformation = !!newValue; + + if (this.shouldRecordClientInformation && !prevValue) { + this.recordClientInformation(); + } + }; + + private recordClientInformation = async () => { + if (!this.shouldRecordClientInformation) { + return; + } + try { + await recordClientInformation( + MatrixClientPeg.get(), + SdkConfig.get(), + PlatformPeg.get(), + ); + } catch (error) { + // this is a best effort operation + // log the error without rethrowing + logger.error('Failed to record client information', error); + } + }; } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 177c3b4f5e..91b448eb3b 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component ) }
  • +
    + { _t("Sessions") } + +
    ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f8cacb0f70..ce14860df6 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -955,6 +955,7 @@ "System font name": "System font name", "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", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Record the client name, version, and url to recognise sessions more easily in session manager", "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", "Enable inline URL previews by default": "Enable inline URL previews by default", @@ -1569,9 +1570,9 @@ "Okay": "Okay", "Privacy": "Privacy", "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.", + "Sessions": "Sessions", "Where you're signed in": "Where you're signed in", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", - "Sessions": "Sessions", "Other sessions": "Other sessions", "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.", "Sidebar": "Sidebar", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index b92005d611..52538f7291 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -740,6 +740,14 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Send analytics data'), default: null, }, + "deviceClientInformationOptIn": { + supportedLevels: [SettingLevel.ACCOUNT], + displayName: _td( + `Record the client name, version, and url ` + + `to recognise sessions more easily in session manager`, + ), + default: false, + }, "FTUE.useCaseSelection": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts new file mode 100644 index 0000000000..32445334f5 --- /dev/null +++ b/src/utils/device/clientInformation.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 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 { MatrixClient } from "matrix-js-sdk/src/client"; + +import BasePlatform from "../../BasePlatform"; +import { IConfigOptions } from "../../IConfigOptions"; + +const formatUrl = (): string | undefined => { + // don't record url for electron clients + if (window.electron) { + return undefined; + } + + // strip query-string and fragment from uri + const url = new URL(window.location.href); + + return [ + url.host, + url.pathname.replace(/\/$/, ""), // Remove trailing slash if present + ].join(""); +}; + +const getClientInformationEventType = (deviceId: string): string => + `io.element.matrix_client_information.${deviceId}`; + +/** + * Record extra client information for the current device + * https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md + */ +export const recordClientInformation = async ( + matrixClient: MatrixClient, + sdkConfig: IConfigOptions, + platform: BasePlatform, +): Promise => { + const deviceId = matrixClient.getDeviceId(); + const { brand } = sdkConfig; + const version = await platform.getAppVersion(); + const type = getClientInformationEventType(deviceId); + const url = formatUrl(); + + await matrixClient.setAccountData(type, { + name: brand, + version, + url, + }); +}; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 0640567441..46f2abcd28 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -18,6 +18,7 @@ limitations under the License. import { EventEmitter } from "events"; import { mocked } from "jest-mock"; import { Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import DeviceListener from "../src/DeviceListener"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio import { isSecretStorageBeingAccessed } from "../src/SecurityManager"; import dis from "../src/dispatcher/dispatcher"; import { Action } from "../src/dispatcher/actions"; +import SettingsStore from "../src/settings/SettingsStore"; +import { mockPlatformPeg } from "./test-utils"; +import { SettingLevel } from "../src/settings/SettingLevel"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +const deviceId = 'my-device-id'; + class MockClient extends EventEmitter { + isGuest = jest.fn(); getUserId = jest.fn(); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); getRooms = jest.fn().mockReturnValue([]); @@ -57,6 +64,8 @@ class MockClient extends EventEmitter { downloadKeys = jest.fn(); isRoomEncrypted = jest.fn(); getClientWellKnown = jest.fn(); + getDeviceId = jest.fn().mockReturnValue(deviceId); + setAccountData = jest.fn(); } const mockDispatcher = mocked(dis); const flushPromises = async () => await new Promise(process.nextTick); @@ -75,8 +84,12 @@ describe('DeviceListener', () => { beforeEach(() => { jest.resetAllMocks(); + mockPlatformPeg({ + getAppVersion: jest.fn().mockResolvedValue('1.2.3'), + }); mockClient = new MockClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -86,6 +99,115 @@ describe('DeviceListener', () => { return instance; }; + describe('client information', () => { + it('watches device client information setting', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting'); + const deviceListener = await createAndStart(); + + expect(watchSettingSpy).toHaveBeenCalledWith( + 'deviceClientInformationOptIn', null, expect.any(Function), + ); + + deviceListener.stop(); + + expect(unwatchSettingSpy).toHaveBeenCalled(); + }); + + describe('when device client information feature is enabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockImplementation( + settingName => settingName === 'deviceClientInformationOptIn', + ); + }); + it('saves client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('catches error and logs when saving client information fails', async () => { + const errorLogSpy = jest.spyOn(logger, 'error'); + const error = new Error('oups'); + mockClient.setAccountData.mockRejectedValue(error); + + // doesn't throw + await createAndStart(); + + expect(errorLogSpy).toHaveBeenCalledWith( + 'Failed to record client information', + error, + ); + }); + + it('saves client information on logged in action', async () => { + const instance = await createAndStart(); + + mockClient.setAccountData.mockClear(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + + describe('when device client information feature is disabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + }); + + it('does not save client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).not.toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('does not save client information on logged in action', async () => { + const instance = await createAndStart(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).not.toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('saves client information after setting is enabled', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + await createAndStart(); + + const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0]; + expect(settingName).toEqual('deviceClientInformationOptIn'); + expect(roomId).toBeNull(); + + callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + }); + describe('recheck', () => { it('does nothing when cross signing feature is not supported', async () => { mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts new file mode 100644 index 0000000000..628c9729d1 --- /dev/null +++ b/test/utils/device/clientInformation-test.ts @@ -0,0 +1,86 @@ +/* +Copyright 2022 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 BasePlatform from "../../../src/BasePlatform"; +import { IConfigOptions } from "../../../src/IConfigOptions"; +import { recordClientInformation } from "../../../src/utils/device/clientInformation"; +import { getMockClientWithEventEmitter } from "../../test-utils"; + +describe('recordClientInformation()', () => { + const deviceId = 'my-device-id'; + const version = '1.2.3'; + const isElectron = window.electron; + + const mockClient = getMockClientWithEventEmitter({ + getDeviceId: jest.fn().mockReturnValue(deviceId), + setAccountData: jest.fn(), + }); + + const sdkConfig: IConfigOptions = { + brand: 'Test Brand', + element_call: { url: '', use_exclusively: false }, + }; + + const platform = { + getAppVersion: jest.fn().mockResolvedValue(version), + } as unknown as BasePlatform; + + beforeEach(() => { + jest.clearAllMocks(); + window.electron = false; + }); + + afterAll(() => { + // restore global + window.electron = isElectron; + }); + + it('saves client information without url for electron clients', async () => { + window.electron = true; + + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: undefined, + }, + ); + }); + + it('saves client information with url for non-electron clients', async () => { + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: 'localhost', + }, + ); + }); +}); From c59bbdf9175a308363bbf2792720680c7710a301 Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 4 Oct 2022 10:12:07 +0200 Subject: [PATCH 25/33] Device manager - select all devices (#9330) * add device selection that does nothing * multi select and sign out of sessions * test multiple selection * fix type after rebase * select all sessions --- .../settings/devices/_FilteredDeviceList.pcss | 2 +- .../settings/devices/FilteredDeviceList.tsx | 15 ++- .../devices/FilteredDeviceListHeader.tsx | 21 +++ .../devices/FilteredDeviceListHeader-test.tsx | 17 ++- .../FilteredDeviceListHeader-test.tsx.snap | 71 +++++++++- .../tabs/user/SessionManagerTab-test.tsx | 121 +++++++++++++++++- .../SessionManagerTab-test.tsx.snap | 25 ++++ 7 files changed, 267 insertions(+), 5 deletions(-) diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index a871b08049..d60c9628fb 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -25,7 +25,7 @@ limitations under the License. display: grid; grid-gap: $spacing-16; margin: 0; - padding: 0 $spacing-8; + padding: 0 $spacing-16; } .mx_FilteredDeviceList_listItem { diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 6d6668a854..4cf7ac1a63 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -266,8 +266,21 @@ export const FilteredDeviceList = onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); }; + const isAllSelected = selectedDeviceIds.length >= sortedDevices.length; + const toggleSelectAll = () => { + if (isAllSelected) { + setSelectedDeviceIds([]); + } else { + setSelectedDeviceIds(sortedDevices.map(device => device.device_id)); + } + }; + return
    - + { selectedDeviceIds.length ? <> , 'className'> { selectedDeviceCount: number; + isAllSelected: boolean; + toggleSelectAll: () => void; children?: React.ReactNode; } const FilteredDeviceListHeader: React.FC = ({ selectedDeviceCount, + isAllSelected, + toggleSelectAll, children, ...rest }) => { + const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all'); return
    + + + { selectedDeviceCount > 0 ? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount }) diff --git a/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx b/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx index 8f7ecdc924..49e8d9a861 100644 --- a/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader'; @@ -22,6 +22,8 @@ import FilteredDeviceListHeader from '../../../../../src/components/views/settin describe('', () => { const defaultProps = { selectedDeviceCount: 0, + isAllSelected: false, + toggleSelectAll: jest.fn(), children:
    test
    , ['data-testid']: 'test123', }; @@ -32,8 +34,21 @@ describe('', () => { expect(container).toMatchSnapshot(); }); + it('renders correctly when all devices are selected', () => { + const { container } = render(getComponent({ isAllSelected: true })); + expect(container).toMatchSnapshot(); + }); + it('renders correctly when some devices are selected', () => { const { getByText } = render(getComponent({ selectedDeviceCount: 2 })); expect(getByText('2 sessions selected')).toBeTruthy(); }); + + it('clicking checkbox toggles selection', () => { + const toggleSelectAll = jest.fn(); + const { getByTestId } = render(getComponent({ toggleSelectAll })); + fireEvent.click(getByTestId('device-select-all-checkbox')); + + expect(toggleSelectAll).toHaveBeenCalled(); + }); }); diff --git a/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap index f474cca811..b8b2330ce9 100644 --- a/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap @@ -1,11 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders correctly when no devices are selected 1`] = ` +exports[` renders correctly when all devices are selected 1`] = `
    +
    + + +
    +
    +`; + +exports[` renders correctly when no devices are selected 1`] = ` +
    +
    +
    + + +
    -
    - -
    -
    - -
    +
    From bd270b08dfc6bd604cd3dc47ed56e198d6bfdf6a Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 5 Oct 2022 13:41:01 +0200 Subject: [PATCH 30/33] Device manager - add foundation for extended device info (#9344) * record device client inforamtion events on app start * matrix-client-information -> matrix_client_information * fix types * remove another unused export * add docs link * display device client information in device details * update snapshots * integration-ish test client information in metadata * tests * fix tests * export helper * DeviceClientInformation type * Device manager - select all devices (#9330) * add device selection that does nothing * multi select and sign out of sessions * test multiple selection * fix type after rebase * select all sessions * rename type * use ExtendedDevice type everywhere * rename clientName to appName for less collision with UA parser * fix bad find and replace * rename ExtendedDeviceInfo to ExtendedDeviceAppInfo * rename DeviceType comp to DeviceTypeIcon * update tests for new required property deviceType * add stubbed user agent parsing --- res/css/_components.pcss | 2 +- ...{_DeviceType.pcss => _DeviceTypeIcon.pcss} | 8 +-- res/css/views/settings/_DevicesPanel.pcss | 2 +- .../views/settings/DevicesPanelEntry.tsx | 8 ++- .../settings/devices/CurrentDeviceSection.tsx | 4 +- .../settings/devices/DeviceDetailHeading.tsx | 4 +- .../views/settings/devices/DeviceDetails.tsx | 4 +- .../views/settings/devices/DeviceTile.tsx | 16 +++-- .../{DeviceType.tsx => DeviceTypeIcon.tsx} | 22 ++++--- .../devices/DeviceVerificationStatusCard.tsx | 4 +- .../settings/devices/FilteredDeviceList.tsx | 28 ++++----- .../devices/SecurityRecommendations.tsx | 6 +- .../views/settings/devices/filter.ts | 6 +- .../views/settings/devices/types.ts | 13 +++-- .../views/settings/devices/useOwnDevices.ts | 23 ++++---- .../settings/tabs/user/SessionManagerTab.tsx | 18 +++--- src/utils/device/parseUserAgent.ts | 45 ++++++++++++++ .../__snapshots__/DevicesPanel-test.tsx.snap | 18 +++--- .../devices/CurrentDeviceSection-test.tsx | 3 + .../devices/DeviceDetailHeading-test.tsx | 2 + .../settings/devices/DeviceDetails-test.tsx | 4 +- .../settings/devices/DeviceTile-test.tsx | 2 + ...eType-test.tsx => DeviceTypeIcon-test.tsx} | 6 +- .../devices/FilteredDeviceList-test.tsx | 19 +++++- .../devices/SelectableDeviceTile-test.tsx | 2 + .../CurrentDeviceSection-test.tsx.snap | 12 ++-- .../__snapshots__/DeviceTile-test.tsx.snap | 24 ++++---- .../__snapshots__/DeviceType-test.tsx.snap | 58 ------------------- .../DeviceTypeIcon-test.tsx.snap | 58 +++++++++++++++++++ .../SelectableDeviceTile-test.tsx.snap | 6 +- .../views/settings/devices/filter-test.ts | 28 +++++++-- .../tabs/user/SessionManagerTab-test.tsx | 8 +-- .../SessionManagerTab-test.tsx.snap | 18 +++--- test/utils/device/parseUserAgent-test.ts | 25 ++++++++ 34 files changed, 319 insertions(+), 187 deletions(-) rename res/css/components/views/settings/devices/{_DeviceType.pcss => _DeviceTypeIcon.pcss} (91%) rename src/components/views/settings/devices/{DeviceType.tsx => DeviceTypeIcon.tsx} (70%) create mode 100644 src/utils/device/parseUserAgent.ts rename test/components/views/settings/devices/{DeviceType-test.tsx => DeviceTypeIcon-test.tsx} (86%) delete mode 100644 test/components/views/settings/devices/__snapshots__/DeviceType-test.tsx.snap create mode 100644 test/components/views/settings/devices/__snapshots__/DeviceTypeIcon-test.tsx.snap create mode 100644 test/utils/device/parseUserAgent-test.ts diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 9161942d87..17fb679f24 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -34,7 +34,7 @@ @import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss"; @import "./components/views/settings/devices/_DeviceSecurityCard.pcss"; @import "./components/views/settings/devices/_DeviceTile.pcss"; -@import "./components/views/settings/devices/_DeviceType.pcss"; +@import "./components/views/settings/devices/_DeviceTypeIcon.pcss"; @import "./components/views/settings/devices/_FilteredDeviceList.pcss"; @import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; diff --git a/res/css/components/views/settings/devices/_DeviceType.pcss b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss similarity index 91% rename from res/css/components/views/settings/devices/_DeviceType.pcss rename to res/css/components/views/settings/devices/_DeviceTypeIcon.pcss index 66372bbdea..546d4f7ea1 100644 --- a/res/css/components/views/settings/devices/_DeviceType.pcss +++ b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DeviceType { +.mx_DeviceTypeIcon { flex: 0 0 auto; position: relative; margin-right: $spacing-8; @@ -22,7 +22,7 @@ limitations under the License. padding: 0 $spacing-8 $spacing-8 0; } -.mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_deviceIcon { --background-color: $system; --icon-color: $secondary-content; @@ -36,12 +36,12 @@ limitations under the License. background-color: var(--background-color); } -.mx_DeviceType_selected .mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_selected .mx_DeviceTypeIcon_deviceIcon { --background-color: $primary-content; --icon-color: $background; } -.mx_DeviceType_verificationIcon { +.mx_DeviceTypeIcon_verificationIcon { position: absolute; bottom: 0; right: 0; diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss index 23a737c977..8a7842d4d0 100644 --- a/res/css/views/settings/_DevicesPanel.pcss +++ b/res/css/views/settings/_DevicesPanel.pcss @@ -58,7 +58,7 @@ limitations under the License. min-height: 35px; padding: 0 $spacing-8; - .mx_DeviceType { + .mx_DeviceTypeIcon { /* hide the new device type in legacy device list for backwards compat reasons */ display: none; diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index 0109c37b9b..aa152826bf 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi import LogoutDialog from '../dialogs/LogoutDialog'; import DeviceTile from './devices/DeviceTile'; import SelectableDeviceTile from './devices/SelectableDeviceTile'; +import { DeviceType } from '../../../utils/device/parseUserAgent'; interface IProps { device: IMyDevice; @@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component { ; - const deviceWithVerification = { + const extendedDevice = { ...this.props.device, isVerified: this.props.verified, + deviceType: DeviceType.Unknown, }; if (this.props.isOwnDevice) { @@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component {
    - + { buttons }
    ; @@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component { return (
    - + { buttons }
    diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index 615c9c69f0..fc58617d31 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -24,10 +24,10 @@ import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceTile from './DeviceTile'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; interface Props { - device?: DeviceWithVerification; + device?: ExtendedDevice; isLoading: boolean; isSigningOut: boolean; localNotificationSettings?: LocalNotificationSettings | undefined; diff --git a/src/components/views/settings/devices/DeviceDetailHeading.tsx b/src/components/views/settings/devices/DeviceDetailHeading.tsx index dea79d3b23..2673ef4e89 100644 --- a/src/components/views/settings/devices/DeviceDetailHeading.tsx +++ b/src/components/views/settings/devices/DeviceDetailHeading.tsx @@ -22,10 +22,10 @@ import Field from '../../elements/Field'; import Spinner from '../../elements/Spinner'; import { Caption } from '../../typography/Caption'; import Heading from '../../typography/Heading'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; saveDeviceName: (deviceName: string) => Promise; } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index b87bfcef3c..4ed50c07b7 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -72,8 +72,8 @@ const DeviceDetails: React.FC = ({ id: 'application', heading: _t('Application'), values: [ - { label: _t('Name'), value: device.clientName }, - { label: _t('Version'), value: device.clientVersion }, + { label: _t('Name'), value: device.appName }, + { label: _t('Version'), value: device.appVersion }, { label: _t('URL'), value: device.url }, ], }, diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index bfeabfabb3..4c8e264751 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -21,16 +21,16 @@ import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; import Heading from "../../typography/Heading"; import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter"; -import { DeviceWithVerification } from "./types"; -import { DeviceType } from "./DeviceType"; +import { ExtendedDevice } from "./types"; +import { DeviceTypeIcon } from "./DeviceTypeIcon"; export interface DeviceTileProps { - device: DeviceWithVerification; + device: ExtendedDevice; isSelected?: boolean; children?: React.ReactNode; onClick?: () => void; } -const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { +const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => { return { device.display_name || device.device_id } ; @@ -48,7 +48,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri return formatRelativeTime(new Date(timestamp)); }; -const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => { +const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => { const isInactive = isDeviceInactive(device); if (!isInactive) { @@ -89,7 +89,11 @@ const DeviceTile: React.FC = ({ ]; return
    - +
    diff --git a/src/components/views/settings/devices/DeviceType.tsx b/src/components/views/settings/devices/DeviceTypeIcon.tsx similarity index 70% rename from src/components/views/settings/devices/DeviceType.tsx rename to src/components/views/settings/devices/DeviceTypeIcon.tsx index a0fbe75c56..03b921f711 100644 --- a/src/components/views/settings/devices/DeviceType.tsx +++ b/src/components/views/settings/devices/DeviceTypeIcon.tsx @@ -21,33 +21,39 @@ import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/ import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; import { _t } from '../../../../languageHandler'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; +import { DeviceType } from '../../../../utils/device/parseUserAgent'; interface Props { - isVerified?: DeviceWithVerification['isVerified']; + isVerified?: ExtendedDevice['isVerified']; isSelected?: boolean; + deviceType?: DeviceType; } -export const DeviceType: React.FC = ({ isVerified, isSelected }) => ( -
    = ({ + isVerified, + isSelected, + deviceType, +}) => ( +
    { /* TODO(kerrya) all devices have an unknown type until PSG-650 */ } { isVerified ? : diff --git a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx index 11e806e54e..127f5eedf6 100644 --- a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx +++ b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx @@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton'; import DeviceSecurityCard from './DeviceSecurityCard'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; onVerifyDevice?: () => void; } diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 4cf7ac1a63..c2e8786052 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -33,7 +33,7 @@ import SelectableDeviceTile from './SelectableDeviceTile'; import { DevicesDictionary, DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; import { DevicesState } from './useOwnDevices'; import FilteredDeviceListHeader from './FilteredDeviceListHeader'; @@ -42,27 +42,27 @@ interface Props { devices: DevicesDictionary; pushers: IPusher[]; localNotificationSettings: Map; - expandedDeviceIds: DeviceWithVerification['device_id'][]; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; - selectedDeviceIds: DeviceWithVerification['device_id'][]; + expandedDeviceIds: ExtendedDevice['device_id'][]; + signingOutDeviceIds: ExtendedDevice['device_id'][]; + selectedDeviceIds: ExtendedDevice['device_id'][]; filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; - onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; - onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void; + onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void; + onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void; saveDeviceName: DevicesState['saveDeviceName']; - onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; + onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void; setPushNotifications: (deviceId: string, enabled: boolean) => Promise; - setSelectedDeviceIds: (deviceIds: DeviceWithVerification['device_id'][]) => void; + setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void; supportsMSC3881?: boolean | undefined; } const isDeviceSelected = ( - deviceId: DeviceWithVerification['device_id'], - selectedDeviceIds: DeviceWithVerification['device_id'][], + deviceId: ExtendedDevice['device_id'], + selectedDeviceIds: ExtendedDevice['device_id'][], ) => selectedDeviceIds.includes(deviceId); // devices without timestamp metadata should be sorted last -const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => +const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) => (right.last_seen_ts || 0) - (left.last_seen_ts || 0); const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) => @@ -149,7 +149,7 @@ const NoResults: React.FC = ({ filter, clearFilter }) =>
    ; const DeviceListItem: React.FC<{ - device: DeviceWithVerification; + device: ExtendedDevice; pusher?: IPusher | undefined; localNotificationSettings?: LocalNotificationSettings | undefined; isExpanded: boolean; @@ -227,11 +227,11 @@ export const FilteredDeviceList = }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); - function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined { + function getPusherForDevice(device: ExtendedDevice): IPusher | undefined { return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id); } - const toggleSelection = (deviceId: DeviceWithVerification['device_id']): void => { + const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => { if (isDeviceSelected(deviceId, selectedDeviceIds)) { // remove from selection setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId)); diff --git a/src/components/views/settings/devices/SecurityRecommendations.tsx b/src/components/views/settings/devices/SecurityRecommendations.tsx index 3132eba38a..ddeb2f2e2e 100644 --- a/src/components/views/settings/devices/SecurityRecommendations.tsx +++ b/src/components/views/settings/devices/SecurityRecommendations.tsx @@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, DevicesDictionary, } from './types'; interface Props { devices: DevicesDictionary; - currentDeviceId: DeviceWithVerification['device_id']; + currentDeviceId: ExtendedDevice['device_id']; goToFilteredList: (filter: DeviceSecurityVariation) => void; } @@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC = ({ currentDeviceId, goToFilteredList, }) => { - const devicesArray = Object.values(devices); + const devicesArray = Object.values(devices); const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( devicesArray, diff --git a/src/components/views/settings/devices/filter.ts b/src/components/views/settings/devices/filter.ts index ad2bc92152..05ceb9c697 100644 --- a/src/components/views/settings/devices/filter.ts +++ b/src/components/views/settings/devices/filter.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceWithVerification, DeviceSecurityVariation } from "./types"; +import { ExtendedDevice, DeviceSecurityVariation } from "./types"; -type DeviceFilterCondition = (device: DeviceWithVerification) => boolean; +type DeviceFilterCondition = (device: ExtendedDevice) => boolean; const MS_DAY = 24 * 60 * 60 * 1000; export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days @@ -32,7 +32,7 @@ const filters: Record = { }; export const filterDevicesBySecurityRecommendation = ( - devices: DeviceWithVerification[], + devices: ExtendedDevice[], securityVariations: DeviceSecurityVariation[], ) => { const activeFilters = securityVariations.map(variation => filters[variation]); diff --git a/src/components/views/settings/devices/types.ts b/src/components/views/settings/devices/types.ts index 9543ac2b32..3fa125a09f 100644 --- a/src/components/views/settings/devices/types.ts +++ b/src/components/views/settings/devices/types.ts @@ -16,14 +16,17 @@ limitations under the License. import { IMyDevice } from "matrix-js-sdk/src/matrix"; +import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent"; + export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null }; -export type ExtendedDeviceInfo = { - clientName?: string; - clientVersion?: string; +export type ExtendedDeviceAppInfo = { + // eg Element Web + appName?: string; + appVersion?: string; url?: string; }; -export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceInfo; -export type DevicesDictionary = Record; +export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation; +export type DevicesDictionary = Record; export enum DeviceSecurityVariation { Verified = 'Verified', diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 2441a63a2b..c3b8cb0212 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -24,6 +24,7 @@ import { MatrixEvent, PUSHER_DEVICE_ID, PUSHER_ENABLED, + UNSTABLE_MSC3852_LAST_SEEN_UA, } from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; @@ -34,8 +35,9 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifi import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; import { getDeviceClientInformation } from "../../../../utils/device/clientInformation"; -import { DevicesDictionary, DeviceWithVerification, ExtendedDeviceInfo } from "./types"; +import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types"; import { useEventEmitter } from "../../../../hooks/useEventEmitter"; +import { parseUserAgent } from "../../../../utils/device/parseUserAgent"; const isDeviceVerified = ( matrixClient: MatrixClient, @@ -63,12 +65,12 @@ const isDeviceVerified = ( } }; -const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceInfo => { +const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => { const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id); return { - clientName: name, - clientVersion: version, + appName: name, + appVersion: version, url, }; }; @@ -87,6 +89,7 @@ const fetchDevicesWithVerification = async ( ...device, isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device), ...parseDeviceExtendedInformation(matrixClient, device), + ...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]), }, }), {}); @@ -104,10 +107,10 @@ export type DevicesState = { currentDeviceId: string; isLoadingDeviceList: boolean; // not provided when current session cannot request verification - requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; + requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise; refreshDevices: () => Promise; - saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise; - setPushNotifications: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise; + saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise; + setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise; error?: OwnDevicesError; supportsMSC3881?: boolean | undefined; }; @@ -189,7 +192,7 @@ export const useOwnDevices = (): DevicesState => { const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; const requestDeviceVerification = isCurrentDeviceVerified && userId - ? async (deviceId: DeviceWithVerification['device_id']) => { + ? async (deviceId: ExtendedDevice['device_id']) => { return await matrixClient.requestVerification( userId, [deviceId], @@ -198,7 +201,7 @@ export const useOwnDevices = (): DevicesState => { : undefined; const saveDeviceName = useCallback( - async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise => { + async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise => { const device = devices[deviceId]; // no change @@ -219,7 +222,7 @@ export const useOwnDevices = (): DevicesState => { }, [matrixClient, devices, refreshDevices]); const setPushNotifications = useCallback( - async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise => { + async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise => { try { const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId); if (pusher) { diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index ed1d04a754..2c94d5a5c2 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -29,7 +29,7 @@ import { useOwnDevices } from '../../devices/useOwnDevices'; import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; import SecurityRecommendations from '../../devices/SecurityRecommendations'; -import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types'; +import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; import SettingsTab from '../SettingsTab'; @@ -38,10 +38,10 @@ const useSignOut = ( onSignoutResolvedCallback: () => Promise, ): { onSignOutCurrentDevice: () => void; - onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; + onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise; + signingOutDeviceIds: ExtendedDevice['device_id'][]; } => { - const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); + const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); const onSignOutCurrentDevice = () => { Modal.createDialog( @@ -53,7 +53,7 @@ const useSignOut = ( ); }; - const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => { + const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => { if (!deviceIds.length) { return; } @@ -96,8 +96,8 @@ const SessionManagerTab: React.FC = () => { supportsMSC3881, } = useOwnDevices(); const [filter, setFilter] = useState(); - const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); - const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); + const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const filteredDeviceListRef = useRef(null); const scrollIntoViewTimeoutRef = useRef>(); @@ -105,7 +105,7 @@ const SessionManagerTab: React.FC = () => { const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; - const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { + const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); } else { @@ -136,7 +136,7 @@ const SessionManagerTab: React.FC = () => { ); }; - const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => { + const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => { if (!requestDeviceVerification) { return; } diff --git a/src/utils/device/parseUserAgent.ts b/src/utils/device/parseUserAgent.ts new file mode 100644 index 0000000000..32c57b7624 --- /dev/null +++ b/src/utils/device/parseUserAgent.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum DeviceType { + Desktop = 'Desktop', + Mobile = 'Mobile', + Web = 'Web', + Unknown = 'Unknown', +} +export type ExtendedDeviceInformation = { + deviceType: DeviceType; + // eg Google Pixel 6 + deviceModel?: string; + // eg Android 11 + deviceOperatingSystem?: string; + // eg Firefox + clientName?: string; + // eg 1.1.0 + clientVersion?: string; +}; + +export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => { + if (!userAgent) { + return { + deviceType: DeviceType.Unknown, + }; + } + // @TODO(kerrya) not yet implemented + return { + deviceType: DeviceType.Unknown, + }; +}; diff --git a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap index df46340de3..05c0ca8c98 100644 --- a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap @@ -112,16 +112,16 @@ exports[` renders device panel with devices 1`] = ` data-testid="device-tile-device_1" >