From 402e5dbc414b350f37b02da5d1fc75db2b0c9d31 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 15 May 2024 09:06:08 +0000 Subject: [PATCH 01/61] Upgrade dependency to matrix-js-sdk@32.3.0-rc.0 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 62eca6b3447..0eae092a3fa 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "32.3.0-rc.0", "matrix-widget-api": "^1.5.0", "memoize-one": "^6.0.0", "minimist": "^1.2.5", diff --git a/yarn.lock b/yarn.lock index 2ed1cbc0abd..e247a7bbcd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7065,9 +7065,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "32.2.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2a716bd076459f48fed967f3eb4158ebdc1f3600" +matrix-js-sdk@32.3.0-rc.0: + version "32.3.0-rc.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-32.3.0-rc.0.tgz#31f8281420db91a4a60b5bd4a1336771e466e560" + integrity sha512-WssOMKp7yDjpIBEW/nCVYgzLl5ndpti3ZxvLgg1yetjsFN89HMp7Kbd+3sWYMfkqUvIyTM6i4dDtHsnvYLDZ7Q== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^4.9.0" From d84bcbc2155a138bcfafc3efa60c4abf7b4a4d14 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 15 May 2024 09:11:36 +0000 Subject: [PATCH 02/61] v3.100.0-rc.0 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0eae092a3fa..fe9d24bff89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.99.0", + "version": "3.100.0-rc.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,7 +23,7 @@ "package.json", ".stylelintrc.js" ], - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -233,5 +233,6 @@ "outputDirectory": "coverage", "outputName": "jest-sonar-report.xml", "relativePaths": true - } + }, + "typings": "./lib/index.d.ts" } From 4f3dcb6bc98804983c8f39f89681c4d81a27d22c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 21 May 2024 12:27:24 +0000 Subject: [PATCH 03/61] Upgrade dependency to matrix-js-sdk@32.3.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index fe9d24bff89..d86b331afd7 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "32.3.0-rc.0", + "matrix-js-sdk": "32.3.0", "matrix-widget-api": "^1.5.0", "memoize-one": "^6.0.0", "minimist": "^1.2.5", diff --git a/yarn.lock b/yarn.lock index e247a7bbcd4..f188e7448fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7065,10 +7065,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@32.3.0-rc.0: - version "32.3.0-rc.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-32.3.0-rc.0.tgz#31f8281420db91a4a60b5bd4a1336771e466e560" - integrity sha512-WssOMKp7yDjpIBEW/nCVYgzLl5ndpti3ZxvLgg1yetjsFN89HMp7Kbd+3sWYMfkqUvIyTM6i4dDtHsnvYLDZ7Q== +matrix-js-sdk@32.3.0: + version "32.3.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-32.3.0.tgz#af4c279de3c684ec03950d21d410a3ccef62cfee" + integrity sha512-C9QvKBf0ZvoNbwhMQT8XgvF5O1+SMA9yJwdYQb95xUv/5ziFme0DSoqGm1AYtZtI6WmMOi112v1PuV75sowqWw== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^4.9.0" From f712b809e27efc761dfafc3e4ff8d45101de9d80 Mon Sep 17 00:00:00 2001 From: Ed Geraghty Date: Tue, 21 May 2024 14:49:12 +0100 Subject: [PATCH 04/61] Remove code smell assertion identified by Sonar (#12547) * This assertion is unnecessary since the receiver accepts the original type of the expression * Implement `client.getDomain()` null check * Update comment since `AutoDiscovery.findClientConfig` may still throw --- src/SlidingSyncManager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index c4387e85d68..4885ffa8dc5 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -359,10 +359,14 @@ export class SlidingSyncManager { let proxyUrl: string | undefined; try { - const clientWellKnown = await AutoDiscovery.findClientConfig(client.getDomain()!); + const clientDomain = await client.getDomain(); + if (clientDomain === null) { + throw new RangeError("Homeserver domain is null"); + } + const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain); proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url; } catch (e) { - // client.getDomain() is invalid, `AutoDiscovery.findClientConfig` has thrown + // Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown } if (proxyUrl != undefined) { From a29cabe45a531448ae6dc1cca6803097de70d680 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 21 May 2024 16:37:00 +0200 Subject: [PATCH 05/61] Report verification and recovery state to posthog (#12516) * Report verification and recovery state to posthog * Fix CryptoApi import * Fix js-sdk import * Review: Use DeviceVerificationStatus instead of CrossSigningStatus * Review: Clean condition to check secrets in 4S * review: Fix redundent !! --- package.json | 2 +- src/DeviceListener.ts | 71 +++++++ test/DeviceListener-test.ts | 374 ++++++++++++++++++++++++++++++++++++ yarn.lock | 8 +- 4 files changed, 450 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 84aff161039..b836dcc3c32 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/analytics-events": "^0.20.0", + "@matrix-org/analytics-events": "^0.21.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/olm": "3.2.15", diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index db3c0bf1f4e..bf23412ccda 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -26,7 +26,9 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -79,6 +81,10 @@ export default class DeviceListener { private enableBulkUnverifiedSessionsReminder = true; private deviceClientInformationSettingWatcherRef: string | undefined; + // Remember the current analytics state to avoid sending the same event multiple times. + private analyticsVerificationState?: string; + private analyticsRecoveryState?: string; + public static sharedInstance(): DeviceListener { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); return window.mxDeviceListener; @@ -301,6 +307,7 @@ export default class DeviceListener { const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); const allSystemsReady = crossSigningReady && secretStorageReady; + await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { hideSetupEncryptionToast(); @@ -407,6 +414,70 @@ export default class DeviceListener { this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; } + /** + * Reports current recovery state to analytics. + * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). + * @param cli - the matrix client + * @private + */ + private async reportCryptoSessionStateToAnalytics(cli: MatrixClient): Promise { + const crypto = cli.getCrypto()!; + const secretStorageReady = await crypto.isSecretStorageReady(); + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const backupInfo = await this.getKeyBackupInfo(); + const is4SEnabled = (await cli.secretStorage.getDefaultKeyId()) != null; + const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!); + + const verificationState = + deviceVerificationStatus?.signedByOwner && deviceVerificationStatus?.crossSigningVerified + ? "Verified" + : "NotVerified"; + + let recoveryState: "Disabled" | "Enabled" | "Incomplete"; + if (!is4SEnabled) { + recoveryState = "Disabled"; + } else { + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + if (backupInfo != null) { + // There is a backup. Check that all secrets are stored in 4S and known locally. + // If they are not, recovery is incomplete. + const backupPrivateKeyIsInCache = (await crypto.getSessionBackupPrivateKey()) != null; + if (secretStorageReady && allCrossSigningSecretsCached && backupPrivateKeyIsInCache) { + recoveryState = "Enabled"; + } else { + recoveryState = "Incomplete"; + } + } else { + // No backup. Just consider cross-signing secrets. + if (secretStorageReady && allCrossSigningSecretsCached) { + recoveryState = "Enabled"; + } else { + recoveryState = "Incomplete"; + } + } + } + + if (this.analyticsVerificationState === verificationState && this.analyticsRecoveryState === recoveryState) { + // No changes, no need to send the event nor update the user properties + return; + } + this.analyticsRecoveryState = recoveryState; + this.analyticsVerificationState = verificationState; + + // Update user properties + PosthogAnalytics.instance.setProperty("recoveryState", recoveryState); + PosthogAnalytics.instance.setProperty("verificationState", verificationState); + + PosthogAnalytics.instance.trackEvent({ + eventName: "CryptoSessionState", + verificationState: verificationState, + recoveryState: recoveryState, + }); + } + /** * Check if key backup is enabled, and if not, raise an `Action.ReportKeyBackupNotEnabled` event (which will * trigger an auto-rageshake). diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index aa6b14af7bc..7f447d36822 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -27,6 +27,8 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { CrossSigningStatus, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import DeviceListener from "../src/DeviceListener"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -41,6 +43,7 @@ import { SettingLevel } from "../src/settings/SettingLevel"; import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils"; import { UIFeature } from "../src/settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; +import { PosthogAnalytics } from "../src/PosthogAnalytics"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -93,6 +96,16 @@ describe("DeviceListener", () => { isSecretStorageReady: jest.fn().mockResolvedValue(true), userHasCrossSigningKeys: jest.fn(), getActiveSessionBackupVersion: jest.fn(), + getCrossSigningStatus: jest.fn().mockReturnValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + }), + getSessionBackupPrivateKey: jest.fn(), } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), @@ -110,6 +123,10 @@ describe("DeviceListener", () => { getAccountData: jest.fn(), deleteAccountData: jest.fn(), getCrypto: jest.fn().mockReturnValue(mockCrypto), + secretStorage: { + isStored: jest.fn().mockReturnValue(null), + getDefaultKeyId: jest.fn().mockReturnValue("00"), + }, }); jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); @@ -552,5 +569,362 @@ describe("DeviceListener", () => { }); }); }); + + describe("Report verification and recovery state to Analytics", () => { + let setPropertySpy: jest.SpyInstance; + let trackEventSpy: jest.SpyInstance; + + beforeEach(() => { + setPropertySpy = jest.spyOn(PosthogAnalytics.instance, "setProperty"); + trackEventSpy = jest.spyOn(PosthogAnalytics.instance, "trackEvent"); + }); + + describe("Report crypto verification state to analytics", () => { + type VerificationTestCases = [string, Partial, "Verified" | "NotVerified"]; + + const testCases: VerificationTestCases[] = [ + [ + "Identity trusted and device is signed by owner", + { + signedByOwner: true, + crossSigningVerified: true, + }, + "Verified", + ], + [ + "Identity is trusted, but device is not signed", + { + signedByOwner: false, + crossSigningVerified: true, + }, + "NotVerified", + ], + [ + "Identity is not trusted, device not signed", + { + signedByOwner: false, + crossSigningVerified: false, + }, + "NotVerified", + ], + [ + "Identity is not trusted, and device signed", + { + signedByOwner: true, + crossSigningVerified: false, + }, + "NotVerified", + ], + ]; + + beforeEach(() => { + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null); + mockCrypto.isSecretStorageReady.mockResolvedValue(false); + }); + + it.each(testCases)("Does report session verification state when %s", async (_, status, expected) => { + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(status as DeviceVerificationStatus); + await createAndStart(); + + // Should have updated user properties + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", expected); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: expected, + recoveryState: "Disabled", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("should not report a status event if no changes", async () => { + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({ + signedByOwner: true, + crossSigningVerified: true, + } as unknown as DeviceVerificationStatus); + + await createAndStart(); + + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: "Disabled", + }; + expect(trackEventSpy).toHaveBeenCalledTimes(1); + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + + // simulate a recheck + mockClient.emit(CryptoEvent.DevicesUpdated, [userId], false); + await flushPromises(); + expect(trackEventSpy).toHaveBeenCalledTimes(1); + + // Now simulate a change + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({ + signedByOwner: false, + crossSigningVerified: true, + } as unknown as DeviceVerificationStatus); + + // simulate a recheck + mockClient.emit(CryptoEvent.DevicesUpdated, [userId], false); + await flushPromises(); + expect(trackEventSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("Report crypto recovery state to analytics", () => { + beforeEach(() => { + // During all these tests we want verification state to be verified. + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({ + signedByOwner: true, + crossSigningVerified: true, + } as unknown as DeviceVerificationStatus); + }); + + describe("When Room Key Backup is not enabled", () => { + beforeEach(() => { + // no backup + mockClient.getKeyBackupVersion.mockResolvedValue(null); + }); + + it("Should report recovery state as Enabled", async () => { + // 4S is enabled + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00"); + + // Session trusted and cross signing secrets in 4S and stored locally + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + await createAndStart(); + + // Should have updated user properties + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified"); + expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Enabled"); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: "Enabled", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Should report recovery state as Incomplete if secrets not cached locally", async () => { + // 4S is enabled + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00"); + + // Session trusted and cross signing secrets in 4S and stored locally + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: false, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + // no backup + mockClient.getKeyBackupVersion.mockResolvedValue(null); + + await createAndStart(); + + // Should have updated user properties + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified"); + expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete"); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: "Incomplete", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + const baseState: CrossSigningStatus = { + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + }; + type MissingSecretsInCacheTestCases = [string, CrossSigningStatus]; + + const partialTestCases: MissingSecretsInCacheTestCases[] = [ + [ + "MSK not cached", + { + ...baseState, + privateKeysCachedLocally: { ...baseState.privateKeysCachedLocally, masterKey: false }, + }, + ], + [ + "SSK not cached", + { + ...baseState, + privateKeysCachedLocally: { + ...baseState.privateKeysCachedLocally, + selfSigningKey: false, + }, + }, + ], + [ + "USK not cached", + { + ...baseState, + privateKeysCachedLocally: { + ...baseState.privateKeysCachedLocally, + userSigningKey: false, + }, + }, + ], + [ + "MSK/USK not cached", + { + ...baseState, + privateKeysCachedLocally: { + ...baseState.privateKeysCachedLocally, + masterKey: false, + userSigningKey: false, + }, + }, + ], + [ + "MSK/SSK not cached", + { + ...baseState, + privateKeysCachedLocally: { + ...baseState.privateKeysCachedLocally, + masterKey: false, + selfSigningKey: false, + }, + }, + ], + [ + "USK/SSK not cached", + { + ...baseState, + privateKeysCachedLocally: { + ...baseState.privateKeysCachedLocally, + userSigningKey: false, + selfSigningKey: false, + }, + }, + ], + ]; + + it.each(partialTestCases)( + "Should report recovery state as Incomplete when %s", + async (_, status) => { + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00"); + + // Session trusted and cross signing secrets in 4S and stored locally + mockCrypto!.getCrossSigningStatus.mockResolvedValue(status); + + await createAndStart(); + + // Should have updated user properties + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified"); + expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete"); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: "Incomplete", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }, + ); + + it("Should report recovery state as Incomplete when some secrets are not in 4S", async () => { + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00"); + + // Some missing secret in 4S + mockCrypto.isSecretStorageReady.mockResolvedValue(false); + + // Session trusted and secrets known locally. + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + } as unknown as CrossSigningStatus); + + await createAndStart(); + + // Should have updated user properties + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified"); + expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete"); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: "Incomplete", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }); + }); + + describe("When Room Key Backup is enabled", () => { + beforeEach(() => { + // backup enabled - just need a mock object + mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo); + }); + + const testCases = [ + ["as Incomplete if backup key not cached locally", false], + ["as Enabled if backup key is cached locally", true], + ]; + it.each(testCases)("Should report recovery state as %s", async (_, isCached) => { + // 4S is enabled + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00"); + + // Session trusted and cross signing secrets in 4S and stored locally + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(isCached ? new Uint8Array() : null); + + await createAndStart(); + + expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified"); + expect(setPropertySpy).toHaveBeenCalledWith( + "recoveryState", + isCached ? "Enabled" : "Incomplete", + ); + + // Should have reported a status change event + const expectedTrackedEvent: CryptoSessionStateChange = { + eventName: "CryptoSessionState", + verificationState: "Verified", + recoveryState: isCached ? "Enabled" : "Incomplete", + }; + expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent); + }); + }); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 695c5deabbf..52ddc95a42d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1899,10 +1899,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== -"@matrix-org/analytics-events@^0.20.0": - version "0.20.0" - resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.20.0.tgz#062a532ddcf0e2e5eb64c5576cd212cb32a11ccf" - integrity sha512-YCRbZrpZU9q+nrB6RsfPZ4NlKs31ySjP2F7GFUZNPKv96GcbihrnMK086td480SJOYpjPv2vttDJC+S67SFe2w== +"@matrix-org/analytics-events@^0.21.0": + version "0.21.0" + resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.21.0.tgz#1de19a6a765f179c01199e72c9c461dc7120fe1a" + integrity sha512-K0E9eje03o3pYc8C93XFTu6DTgNdsVNvdkH7rsFGiHkc15WQybKFyHR7quuuV42jrzGINWpFou0faCWcDBdNbQ== "@matrix-org/emojibase-bindings@^1.1.2": version "1.1.3" From a5e4daa0d1fed63266aad306d6bb1e7d05dcfcd6 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 21 May 2024 17:37:04 +0200 Subject: [PATCH 06/61] Deprecate `Tooltip.tsx` --- src/components/views/elements/Tooltip.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index aafa28b59a7..fdba5f6f5cb 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -57,6 +57,9 @@ export interface ITooltipProps { type State = Partial>; +/** + * @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead + */ export default class Tooltip extends React.PureComponent { private static container: HTMLElement; private parent: Element | null = null; From 7d3b3d7f957c9272254d6fe8feccad7422b818a6 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 21 May 2024 17:42:01 +0200 Subject: [PATCH 07/61] Use tooltip compound in `MessageComposer.tsx` --- .../views/rooms/MessageComposer.tsx | 128 +++++++++--------- 1 file changed, 61 insertions(+), 67 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 613701bf23d..bb4b4c72453 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -25,6 +25,7 @@ import { THREAD_RELATION_TYPE, } from "matrix-js-sdk/src/matrix"; import { Optional } from "matrix-events-sdk"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -40,7 +41,6 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; import { RecordingState } from "../../../audio/VoiceRecording"; -import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from "../../../utils/ShieldUtils"; import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer"; @@ -110,7 +110,6 @@ interface IState { } export class MessageComposer extends React.Component { - private tooltipId = `mx_MessageComposer_${Math.random()}`; private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); @@ -568,12 +567,9 @@ export class MessageComposer extends React.Component { } let recordingTooltip: JSX.Element | undefined; - if (this.state.recordingTimeLeftSeconds) { - const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds); - recordingTooltip = ( - - ); - } + + const isTooltipOpen = Boolean(this.state.recordingTimeLeftSeconds); + const secondsLeft = this.state.recordingTimeLeftSeconds ? Math.round(this.state.recordingTimeLeftSeconds) : 0; const threadId = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null; @@ -599,68 +595,66 @@ export class MessageComposer extends React.Component { }); return ( -
- {recordingTooltip} -
- -
- {e2eIcon} - {composer} -
- {controls} - {canSendMessages && ( - { - setUpVoiceBroadcastPreRecording( - this.props.room, - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastPlaybacksStore, - SdkContextClass.instance.voiceBroadcastRecordingsStore, - SdkContextClass.instance.voiceBroadcastPreRecordingStore, - ); - this.toggleButtonMenu(); - }} - /> - )} - {showSendButton && ( - - )} + +
+ {recordingTooltip} +
+ +
+ {e2eIcon} + {composer} +
+ {controls} + {canSendMessages && ( + { + setUpVoiceBroadcastPreRecording( + this.props.room, + MatrixClientPeg.safeGet(), + SdkContextClass.instance.voiceBroadcastPlaybacksStore, + SdkContextClass.instance.voiceBroadcastRecordingsStore, + SdkContextClass.instance.voiceBroadcastPreRecordingStore, + ); + this.toggleButtonMenu(); + }} + /> + )} + {showSendButton && ( + + )} +
-
+ ); } } From dbe00e5889ed3ffee26f5d334e578159db61db55 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 21 May 2024 18:17:31 +0200 Subject: [PATCH 08/61] Use tooltip compound in `ReadReceiptGroup` component --- .../views/rooms/ReadReceiptGroup.tsx | 68 ++++++++----------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index 3629af58148..d5cb154d578 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { PropsWithChildren } from "react"; import { User } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker"; import { IReadReceiptProps } from "./EventTile"; @@ -87,18 +88,6 @@ export function ReadReceiptGroup({ const tooltipMembers: string[] = readReceipts.map((it) => it.roomMember?.name ?? it.userId); const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars); - const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ - label: ( - <> -
- {_t("timeline|read_receipt_title", { count: readReceipts.length })} -
-
{tooltipText}
- - ), - alignment: Alignment.TopRight, - }); - // return early if there are no read receipts if (readReceipts.length === 0) { // We currently must include `mx_ReadReceiptGroup_container` in @@ -185,34 +174,35 @@ export function ReadReceiptGroup({ return (
-
- - {remText} - +
+ - {avatars} - - - {tooltip} - {contextMenu} -
+ {remText} + + {avatars} + +
+ {contextMenu} +
+
); } From 25fa1238eca98c6b31d22e8b294f28a74d750168 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 22 May 2024 09:29:54 +0200 Subject: [PATCH 09/61] Use tooltip compound in `ReadReceiptPerson` component --- .../views/rooms/ReadReceiptGroup.tsx | 79 ++++++++----------- 1 file changed, 32 insertions(+), 47 deletions(-) diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index d5cb154d578..aec65836426 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -23,12 +23,10 @@ import { IReadReceiptProps } from "./EventTile"; import AccessibleButton from "../elements/AccessibleButton"; import MemberAvatar from "../avatars/MemberAvatar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import { Alignment } from "../elements/Tooltip"; import { formatDate } from "../../../DateUtils"; import { Action } from "../../../dispatcher/actions"; import dis from "../../../dispatcher/dispatcher"; import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu"; -import { useTooltip } from "../../../utils/useTooltip"; import { _t } from "../../../languageHandler"; import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { formatList } from "../../../utils/FormattingUtils"; @@ -219,53 +217,40 @@ function ReadReceiptPerson({ isTwelveHour, onAfterClick, }: ReadReceiptPersonProps): JSX.Element { - const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ - alignment: Alignment.Top, - tooltipClassName: "mx_ReadReceiptGroup_person--tooltip", - label: ( - <> -
{roomMember?.rawDisplayName ?? userId}
-
{userId}
- - ), - }); - return ( - { - dis.dispatch({ - action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the receiver wants. - // The ViewUser action leads to the RightPanelStore, and RightPanelStoreIPanelState defines the - // member property of IRightPanelCardState as `RoomMember | User`, so we’re fine for now, but we - // should definitely clean this up later - member: roomMember ?? ({ userId } as User), - push: false, - }); - onAfterClick?.(); - }} - onMouseOver={showTooltip} - onMouseLeave={hideTooltip} - onFocus={showTooltip} - onBlur={hideTooltip} - onWheel={hideTooltip} - > -
@@ -121,10 +118,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
@@ -284,10 +278,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
@@ -531,10 +522,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
diff --git a/test/components/views/elements/RoomTopic-test.tsx b/test/components/views/elements/RoomTopic-test.tsx index dc05779794e..8e62bd641f4 100644 --- a/test/components/views/elements/RoomTopic-test.tsx +++ b/test/components/views/elements/RoomTopic-test.tsx @@ -16,7 +16,8 @@ limitations under the License. import React from "react"; import { Room } from "matrix-js-sdk/src/matrix"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { mkEvent, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -33,9 +34,12 @@ describe("", () => { window.location.href = originalHref; }); - function runClickTest(topic: string, clickText: string) { + /** + * Create a room with the given topic + * @param topic + */ + function createRoom(topic: string) { stubClient(); - const room = new Room("!pMBteVpcoJRdCJxDmn:matrix.org", MatrixClientPeg.safeGet(), "@alice:example.org"); const topicEvent = mkEvent({ type: "m.room.topic", @@ -45,11 +49,27 @@ describe("", () => { ts: 123, event: true, }); - room.addLiveEvents([topicEvent]); + return room; + } + + /** + * Create a room and render it + * @param topic + */ + const renderRoom = (topic: string) => { + const room = createRoom(topic); render(); + }; + /** + * Create a room and click on the given text + * @param topic + * @param clickText + */ + function runClickTest(topic: string, clickText: string) { + renderRoom(topic); fireEvent.click(screen.getByText(clickText)); } @@ -78,4 +98,18 @@ describe("", () => { expect(window.location.href).toEqual(expectedHref); expect(dis.fire).toHaveBeenCalledWith(Action.ShowRoomTopic); }); + + it("should open the tooltip when hovering a text", async () => { + const topic = "room topic"; + renderRoom(topic); + await userEvent.hover(screen.getByText(topic)); + await waitFor(() => expect(screen.getByRole("tooltip", { name: "Click to read topic" })).toBeInTheDocument()); + }); + + it("should not open the tooltip when hovering a link", async () => { + const topic = "https://matrix.org"; + renderRoom(topic); + await userEvent.hover(screen.getByText(topic)); + await waitFor(() => expect(screen.queryByRole("tooltip", { name: "Click to read topic" })).toBeNull()); + }); }); diff --git a/test/components/views/elements/TooltipTarget-test.tsx b/test/components/views/elements/TooltipTarget-test.tsx deleted file mode 100644 index 0823229a904..00000000000 --- a/test/components/views/elements/TooltipTarget-test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -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 { fireEvent, render } from "@testing-library/react"; - -import { Alignment } from "../../../../src/components/views/elements/Tooltip"; -import TooltipTarget from "../../../../src/components/views/elements/TooltipTarget"; - -describe("", () => { - const defaultProps = { - "tooltipTargetClassName": "test tooltipTargetClassName", - "className": "test className", - "tooltipClassName": "test tooltipClassName", - "label": "test label", - "alignment": Alignment.Left, - "id": "test id", - "data-testid": "test", - }; - - const getComponent = (props = {}) => { - const wrapper = render( - // wrap in element so renderIntoDocument can render functional component - - - child - - , - ); - return wrapper.getByTestId("test"); - }; - - const getVisibleTooltip = () => document.querySelector(".mx_Tooltip.mx_Tooltip_visible"); - - it("renders container", () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); - expect(getVisibleTooltip()).toBeFalsy(); - }); - - const alignmentKeys = Object.keys(Alignment).filter((o: any) => isNaN(o)); - it.each(alignmentKeys)("displays %s aligned tooltip on mouseover", async (alignment: any) => { - const wrapper = getComponent({ alignment: Alignment[alignment] })!; - fireEvent.mouseOver(wrapper); - expect(getVisibleTooltip()).toMatchSnapshot(); - }); - - it("hides tooltip on mouseleave", () => { - const wrapper = getComponent()!; - fireEvent.mouseOver(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - fireEvent.mouseLeave(wrapper); - expect(getVisibleTooltip()).toBeFalsy(); - }); - - it("displays tooltip on focus", () => { - const wrapper = getComponent()!; - fireEvent.focus(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - }); - - it("hides tooltip on blur", async () => { - const wrapper = getComponent()!; - fireEvent.focus(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - fireEvent.blur(wrapper); - expect(getVisibleTooltip()).toBeFalsy(); - }); -}); diff --git a/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap index 8f362565472..b344e3cd58d 100644 --- a/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -288,9 +288,7 @@ exports[`AppTile for a pinned widget should render permission request 1`] = ` Using this widget may share data
displays Bottom aligned tooltip on mouseover 1`] = ` -