diff --git a/package.json b/package.json index 70c78813a6..0d17674bfe 100644 --- a/package.json +++ b/package.json @@ -62,12 +62,13 @@ "resolutions": { "@types/react-dom": "17.0.25", "@types/react": "17.0.80", + "@types/seedrandom": "3.0.4", "oidc-client-ts": "3.0.1", "jwt-decode": "4.0.0" }, "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", @@ -76,11 +77,12 @@ "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", "@vector-im/compound-design-tokens": "^1.2.0", - "@vector-im/compound-web": "^4.2.0", + "@vector-im/compound-web": "^4.3.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "await-lock": "^2.1.0", + "bloom-filters": "^3.0.1", "blurhash": "^2.0.3", "classnames": "^2.2.6", "commonmark": "^0.31.0", @@ -90,7 +92,7 @@ "emojibase-regex": "15.3.0", "escape-html": "^1.0.3", "file-saver": "^2.0.5", - "filesize": "10.1.1", + "filesize": "10.1.2", "gfm.css": "^1.1.2", "glob-to-regexp": "^0.4.1", "graphemer": "^1.4.0", @@ -116,7 +118,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.130.1", + "posthog-js": "1.131.4", "proposal-temporal": "^0.9.0", "qrcode": "1.5.3", "re-resizable": "^6.9.0", @@ -182,12 +184,13 @@ "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "2.11.0", "@types/sdp-transform": "^2.4.6", + "@types/seedrandom": "3.0.4", "@types/tar-js": "^0.3.2", "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^9.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", - "axe-core": "4.9.0", + "axe-core": "4.9.1", "babel-jest": "^29.0.0", "blob-polyfill": "^7.0.0", "eslint": "8.57.0", @@ -200,7 +203,7 @@ "eslint-plugin-matrix-org": "1.2.1", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "express": "^4.18.2", "fake-indexeddb": "^5.0.2", "fetch-mock-jest": "^1.5.1", diff --git a/playwright/Dockerfile b/playwright/Dockerfile index 46d617ccc2..e93826b52f 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.43.1-jammy +FROM mcr.microsoft.com/playwright:v1.44.0-jammy WORKDIR /work/matrix-react-sdk VOLUME ["/work/element-web/node_modules"] diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/verification.spec.ts index 6819606b64..e471b6b2f5 100644 --- a/playwright/e2e/crypto/verification.spec.ts +++ b/playwright/e2e/crypto/verification.spec.ts @@ -240,24 +240,26 @@ test.describe("User verification", () => { test.use({ displayName: "Alice", botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, + room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + await use({ roomId: dmRoomId }); + }, }); test("can receive a verification request when there is no existing DM", async ({ page, - app, bot: bob, user: aliceCredentials, toasts, + room: { roomId: dmRoomId }, }) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - - // accept the DM - await app.viewRoomByName("Bob"); - await page.getByRole("button", { name: "Start chatting" }).click(); - // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -294,6 +296,51 @@ test.describe("User verification", () => { await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); await page.getByRole("button", { name: "Got it" }).click(); }); + + test("can abort emoji verification when emoji mismatch", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + cryptoBackend, + }) => { + test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto"); + + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // Accept verification via toast + const toast = await toasts.getToast("Verification requested"); + await toast.getByRole("button", { name: "Verify Session" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); + // ... and abort the verification + await page.getByRole("button", { name: "They don't match" }).click(); + + const dialog = page.locator(".mx_Dialog"); + await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).not.toBeVisible(); + }); }); /** Extract the qrcode out of an on-screen html element */ diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index a3c5e8c8bc..4008517d09 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -276,4 +276,26 @@ test.describe("Room Header", () => { await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png"); }); }); + + test.describe("with encryption", () => { + test("should render the E2E icon and the buttons", async ({ page, app, user }) => { + // Create an encrypted room + await app.client.createRoom({ + name: "Test Encrypted Room", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + await app.viewRoomByName("Test Encrypted Room"); + + const header = page.locator(".mx_LegacyRoomHeader"); + await expect(header).toMatchScreenshot("encrypted-room-header.png"); + }); + }); }); diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts index 625f1d6bd5..ae3718aba9 100644 --- a/playwright/e2e/settings/general-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts @@ -133,9 +133,7 @@ test.describe("General user settings tab", () => { test("should support adding and removing a profile picture", async ({ uut }) => { const profileSettings = uut.locator(".mx_ProfileSettings"); // Upload a picture - await profileSettings - .locator(".mx_ProfileSettings_avatarUpload") - .setInputFiles("playwright/sample-files/riot.png"); + await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png"); // Find and click "Remove" link button await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click(); diff --git a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png new file mode 100644 index 0000000000..6dced2e990 Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png index 9fc79671a1..281f1cebe5 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/res/css/components/views/elements/_AppPermission.pcss b/res/css/components/views/elements/_AppPermission.pcss index 4bbe0ac07a..3b770c7879 100644 --- a/res/css/components/views/elements/_AppPermission.pcss +++ b/res/css/components/views/elements/_AppPermission.pcss @@ -44,24 +44,3 @@ limitations under the License. } } } - -.mx_Tooltip.mx_Tooltip--appPermission { - box-shadow: none; - background-color: $tooltip-timeline-bg-color; - color: $tooltip-timeline-fg-color; - border: none; - border-radius: 3px; - padding: 6px 8px; - - &.mx_Tooltip--appPermission--dark { - .mx_Tooltip_chevron::after { - border-right-color: $tooltip-timeline-bg-color; - } - } - - ul { - list-style-position: inside; - padding-left: 2px; - margin-left: 0; - } -} diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 0252da01b7..d685617d5b 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -472,3 +472,10 @@ limitations under the License. .mx_SpacePanel_sharePublicSpace { margin: 0; } + +.mx_SpacePanel_Tooltip_KeyboardShortcut { + kbd { + font-family: inherit; + text-transform: capitalize; + } +} diff --git a/res/css/structures/_TabbedView.pcss b/res/css/structures/_TabbedView.pcss index 34a1766c19..04f0587b0a 100644 --- a/res/css/structures/_TabbedView.pcss +++ b/res/css/structures/_TabbedView.pcss @@ -167,7 +167,7 @@ limitations under the License. } /* Hide the labels on tabs, showing only the icons, on narrow viewports. */ -@media (max-width: 768px) { +@media (max-width: 1024px) { .mx_TabbedView_tabsOnLeft.mx_TabbedView_responsive { .mx_TabbedView_tabLabel_text { display: none; diff --git a/res/css/views/messages/_LegacyCallEvent.pcss b/res/css/views/messages/_LegacyCallEvent.pcss index 403086d51d..4b8c6a73c4 100644 --- a/res/css/views/messages/_LegacyCallEvent.pcss +++ b/res/css/views/messages/_LegacyCallEvent.pcss @@ -204,7 +204,7 @@ limitations under the License. } .mx_LegacyCallEvent_info { - align-items: unset; + align-items: center; } } } diff --git a/res/css/views/rooms/_LegacyRoomHeader.pcss b/res/css/views/rooms/_LegacyRoomHeader.pcss index ce088f7deb..a570b0435a 100644 --- a/res/css/views/rooms/_LegacyRoomHeader.pcss +++ b/res/css/views/rooms/_LegacyRoomHeader.pcss @@ -65,6 +65,11 @@ limitations under the License. .mx_BetaCard_betaPill { margin-right: $spacing-8; } + + /* The container of E2EIcon in the legacy header needs to have its height set */ + & > span { + height: 100%; + } } .mx_LegacyRoomHeader_name { diff --git a/res/css/views/settings/_AvatarSetting.pcss b/res/css/views/settings/_AvatarSetting.pcss index 98bf3ab9b8..a6d70a697a 100644 --- a/res/css/views/settings/_AvatarSetting.pcss +++ b/res/css/views/settings/_AvatarSetting.pcss @@ -23,6 +23,7 @@ limitations under the License. .mx_AvatarSetting_hover { transition: opacity var(--hover-transition); + opacity: 0; /* position to place the hover bg over the entire thing */ position: absolute; @@ -50,14 +51,10 @@ limitations under the License. } } - &.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover { + &.mx_AvatarSetting_avatarDisplay:hover .mx_AvatarSetting_hover { opacity: 1; } - &:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover { - opacity: 0; - } - & > * { box-sizing: border-box; } diff --git a/res/css/views/settings/_ProfileSettings.pcss b/res/css/views/settings/_ProfileSettings.pcss index 5caff1f2c0..73cdcd75c8 100644 --- a/res/css/views/settings/_ProfileSettings.pcss +++ b/res/css/views/settings/_ProfileSettings.pcss @@ -17,10 +17,6 @@ limitations under the License. .mx_ProfileSettings { border-bottom: 1px solid $quinary-content; - .mx_ProfileSettings_avatarUpload { - display: none; - } - .mx_ProfileSettings_profile { display: flex; diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index c842e55ec4..cc336cc5ce 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -14,38 +14,64 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { ScalableBloomFilter } from "bloom-filters"; +import { CryptoEvent, HttpApiEvent, MatrixClient, MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error"; import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { PosthogAnalytics } from "./PosthogAnalytics"; +/** The key that we use to store the `reportedEvents` bloom filter in localstorage */ +const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids"; + export class DecryptionFailure { - public readonly ts: number; + /** + * The time between our initial failure to decrypt and our successful + * decryption (if we managed to decrypt). + */ + public timeToDecryptMillis?: number; public constructor( public readonly failedEventId: string, public readonly errorCode: DecryptionFailureCode, - ) { - this.ts = Date.now(); - } + /** + * The time that we failed to decrypt the event. If we failed to decrypt + * multiple times, this will be the time of the first failure. + */ + public readonly ts: number, + /** + * Is the sender on a different server from us? + */ + public readonly isFederated: boolean | undefined, + /** + * Was the failed event ever visible to the user? + */ + public wasVisibleToUser: boolean, + /** + * Has the user verified their own cross-signing identity, as of the most + * recent decryption attempt for this event? + */ + public userTrustsOwnIdentity: boolean | undefined, + ) {} } type ErrorCode = ErrorEvent["name"]; -type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void; +/** Properties associated with decryption errors, for classifying the error. */ +export type ErrorProperties = Omit; +type TrackingFn = (trackedErrCode: ErrorCode, rawError: string, properties: ErrorProperties) => void; export type ErrCodeMapFn = (errcode: DecryptionFailureCode) => ErrorCode; export class DecryptionFailureTracker { private static internalInstance = new DecryptionFailureTracker( - (total, errorCode, rawError) => { - for (let i = 0; i < total; i++) { - PosthogAnalytics.instance.trackEvent({ - eventName: "Error", - domain: "E2EE", - name: errorCode, - context: `mxc_crypto_error_type_${rawError}`, - }); - } + (errorCode, rawError, properties) => { + const event: ErrorEvent = { + eventName: "Error", + domain: "E2EE", + name: errorCode, + context: `mxc_crypto_error_type_${rawError}`, + ...properties, + }; + PosthogAnalytics.instance.trackEvent(event); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation @@ -66,51 +92,69 @@ export class DecryptionFailureTracker { }, ); - // Map of event IDs to DecryptionFailure items. + /** Map of event IDs to `DecryptionFailure` items. + * + * Every `CHECK_INTERVAL_MS`, this map is checked for failures that happened > + * `MAXIMUM_LATE_DECRYPTION_PERIOD` ago (considered undecryptable), or + * decryptions that took > `GRACE_PERIOD_MS` (considered late decryptions). + * + * Any such events are then reported via the `TrackingFn`. + */ public failures: Map = new Map(); - // Set of event IDs that have been visible to the user. + /** Set of event IDs that have been visible to the user. + * + * This will only contain events that are not already in `reportedEvents`. + */ public visibleEvents: Set = new Set(); - // Map of visible event IDs to `DecryptionFailure`s. Every - // `CHECK_INTERVAL_MS`, this map is checked for failures that - // happened > `GRACE_PERIOD_MS` ago. Those that did are - // accumulated in `failureCounts`. - public visibleFailures: Map = new Map(); + /** Bloom filter tracking event IDs of failures that were reported previously */ + private reportedEvents: ScalableBloomFilter = new ScalableBloomFilter(); - /** - * A histogram of the number of failures that will be tracked at the next tracking - * interval, split by failure error code. - */ - private failureCounts: Map = new Map(); - - // Event IDs of failures that were tracked previously - public trackedEvents: Set = new Set(); - - // Set to an interval ID when `start` is called + /** Set to an interval ID when `start` is called */ public checkInterval: number | null = null; public trackInterval: number | null = null; - // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. - public static TRACK_INTERVAL_MS = 60000; - - // Call `checkFailures` every `CHECK_INTERVAL_MS`. + /** Call `checkFailures` every `CHECK_INTERVAL_MS`. */ public static CHECK_INTERVAL_MS = 40000; - // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting - // the failure in `failureCounts`. - public static GRACE_PERIOD_MS = 30000; + /** If the event is successfully decrypted in less than 4s, we don't report. */ + public static GRACE_PERIOD_MS = 4000; + + /** Maximum time for an event to be decrypted to be considered a late + * decryption. If it takes longer, we consider it undecryptable. */ + public static MAXIMUM_LATE_DECRYPTION_PERIOD = 60000; + + /** Properties that will be added to all reported events (mainly reporting + * information about the Matrix client). */ + private baseProperties?: ErrorProperties = {}; + + /** The user's domain (homeserver name). */ + private userDomain?: string; + + /** Whether the user has verified their own cross-signing keys. */ + private userTrustsOwnIdentity: boolean | undefined = undefined; + + /** Whether we are currently checking our own verification status. */ + private checkingVerificationStatus: boolean = false; + + /** Whether we should retry checking our own verification status after we're + * done our current check. i.e. we got notified that our keys changed while + * we were already checking, so the result could be out of date. */ + private retryVerificationStatus: boolean = false; /** * Create a new DecryptionFailureTracker. * - * Call `eventDecrypted(event, err)` on this instance when an event is decrypted. - * - * Call `start()` to start the tracker, and `stop()` to stop tracking. + * Call `start(client)` to start the tracker. The tracker will listen for + * decryption events on the client and track decryption failures, and will + * automatically stop tracking when the client logs out. * * @param {function} fn The tracking function, which will be called when failures - * are tracked. The function should have a signature `(count, trackedErrorCode) => {...}`, - * where `count` is the number of failures and `errorCode` matches the output of `errorCodeMapFn`. + * are tracked. The function should have a signature `(trackedErrorCode, rawError, properties) => {...}`, + * where `errorCode` matches the output of `errorCodeMapFn`, `rawError` is the original + * error (that is, the input to `errorCodeMapFn`), and `properties` is a map of the + * error properties for classifying the error. * * @param {function} errorCodeMapFn The function used to map decryption failure reason codes to the * `trackedErrorCode`. @@ -132,130 +176,253 @@ export class DecryptionFailureTracker { return DecryptionFailureTracker.internalInstance; } - // loadTrackedEvents() { - // this.trackedEvents = new Set(JSON.parse(localStorage.getItem('mx-decryption-failure-event-ids')) || []); - // } + private loadReportedEvents(): void { + const storedFailures = localStorage.getItem(DECRYPTION_FAILURE_STORAGE_KEY); + if (storedFailures) { + this.reportedEvents = ScalableBloomFilter.fromJSON(JSON.parse(storedFailures)); + } else { + this.reportedEvents = new ScalableBloomFilter(); + } + } - // saveTrackedEvents() { - // localStorage.setItem('mx-decryption-failure-event-ids', JSON.stringify([...this.trackedEvents])); - // } + private saveReportedEvents(): void { + localStorage.setItem(DECRYPTION_FAILURE_STORAGE_KEY, JSON.stringify(this.reportedEvents.saveAsJSON())); + } - public eventDecrypted(e: MatrixEvent): void { + /** Callback for when an event is decrypted. + * + * This function is called by our `MatrixEventEvent.Decrypted` event + * handler after a decryption attempt on an event, whether the decryption + * is successful or not. + * + * @param e the event that was decrypted + * + * @param nowTs the current timestamp + */ + private eventDecrypted(e: MatrixEvent, nowTs: number): void { // for now we only track megolm decryption failures if (e.getWireContent().algorithm != "m.megolm.v1.aes-sha2") { return; } - const errCode = e.decryptionFailureReason; - if (errCode !== null) { - this.addDecryptionFailure(new DecryptionFailure(e.getId()!, errCode)); - } else { + if (errCode === null) { // Could be an event in the failures, remove it - this.removeDecryptionFailuresForEvent(e); + this.removeDecryptionFailuresForEvent(e, nowTs); + return; } + + const eventId = e.getId()!; + + // if it's already reported, we don't need to do anything + if (this.reportedEvents.has(eventId)) { + return; + } + + // if we already have a record of this event, use the previously-recorded timestamp + const failure = this.failures.get(eventId); + const ts = failure ? failure.ts : nowTs; + + const sender = e.getSender(); + const senderDomain = sender?.replace(/^.*?:/, ""); + let isFederated: boolean | undefined; + if (this.userDomain !== undefined && senderDomain !== undefined) { + isFederated = this.userDomain !== senderDomain; + } + + const wasVisibleToUser = this.visibleEvents.has(eventId); + this.failures.set( + eventId, + new DecryptionFailure(eventId, errCode, ts, isFederated, wasVisibleToUser, this.userTrustsOwnIdentity), + ); } public addVisibleEvent(e: MatrixEvent): void { const eventId = e.getId()!; - if (this.trackedEvents.has(eventId)) { + // if it's already reported, we don't need to do anything + if (this.reportedEvents.has(eventId)) { return; } + // if we've already marked the event as a failure, mark it as visible + // in the failure object + const failure = this.failures.get(eventId); + if (failure) { + failure.wasVisibleToUser = true; + } + this.visibleEvents.add(eventId); - if (this.failures.has(eventId) && !this.visibleFailures.has(eventId)) { - this.visibleFailures.set(eventId, this.failures.get(eventId)!); + } + + public removeDecryptionFailuresForEvent(e: MatrixEvent, nowTs: number): void { + const eventId = e.getId()!; + const failure = this.failures.get(eventId); + if (failure) { + this.failures.delete(eventId); + + const timeToDecryptMillis = nowTs - failure.ts; + if (timeToDecryptMillis < DecryptionFailureTracker.GRACE_PERIOD_MS) { + // the event decrypted on time, so we don't need to report it + return; + } else if (timeToDecryptMillis <= DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD) { + // The event is a late decryption, so store the time it took. + // If the time to decrypt is longer than + // MAXIMUM_LATE_DECRYPTION_PERIOD, we consider the event as + // undecryptable, and leave timeToDecryptMillis undefined + failure.timeToDecryptMillis = timeToDecryptMillis; + } + this.reportFailure(failure); } } - public addDecryptionFailure(failure: DecryptionFailure): void { - const eventId = failure.failedEventId; - - if (this.trackedEvents.has(eventId)) { + private async handleKeysChanged(client: MatrixClient): Promise { + if (this.checkingVerificationStatus) { + // Flag that we'll need to do another check once the current check completes. + this.retryVerificationStatus = true; return; } - this.failures.set(eventId, failure); - if (this.visibleEvents.has(eventId) && !this.visibleFailures.has(eventId)) { - this.visibleFailures.set(eventId, failure); + this.checkingVerificationStatus = true; + try { + do { + this.retryVerificationStatus = false; + this.userTrustsOwnIdentity = ( + await client.getCrypto()!.getUserVerificationStatus(client.getUserId()!) + ).isCrossSigningVerified(); + } while (this.retryVerificationStatus); + } finally { + this.checkingVerificationStatus = false; } } - public removeDecryptionFailuresForEvent(e: MatrixEvent): void { - const eventId = e.getId()!; - this.failures.delete(eventId); - this.visibleFailures.delete(eventId); - } - /** * Start checking for and tracking failures. */ - public start(): void { + public async start(client: MatrixClient): Promise { + this.loadReportedEvents(); + await this.calculateClientProperties(client); + this.registerHandlers(client); this.checkInterval = window.setInterval( () => this.checkFailures(Date.now()), DecryptionFailureTracker.CHECK_INTERVAL_MS, ); + } - this.trackInterval = window.setInterval(() => this.trackFailures(), DecryptionFailureTracker.TRACK_INTERVAL_MS); + private async calculateClientProperties(client: MatrixClient): Promise { + const baseProperties: ErrorProperties = {}; + this.baseProperties = baseProperties; + + this.userDomain = client.getDomain() ?? undefined; + if (this.userDomain === "matrix.org") { + baseProperties.isMatrixDotOrg = true; + } else if (this.userDomain !== undefined) { + baseProperties.isMatrixDotOrg = false; + } + + const crypto = client.getCrypto(); + if (crypto) { + const version = crypto.getVersion(); + if (version.startsWith("Rust SDK")) { + baseProperties.cryptoSDK = "Rust"; + } else { + baseProperties.cryptoSDK = "Legacy"; + } + this.userTrustsOwnIdentity = ( + await crypto.getUserVerificationStatus(client.getUserId()!) + ).isCrossSigningVerified(); + } + } + + private registerHandlers(client: MatrixClient): void { + // After the client attempts to decrypt an event, we examine it to see + // if it needs to be reported. + const decryptedHandler = (e: MatrixEvent): void => this.eventDecrypted(e, Date.now()); + // When our keys change, we check if the cross-signing keys are now trusted. + const keysChangedHandler = (): void => { + this.handleKeysChanged(client).catch((e) => { + console.log("Error handling KeysChanged event", e); + }); + }; + // When logging out, remove our handlers and destroy state + const loggedOutHandler = (): void => { + client.removeListener(MatrixEventEvent.Decrypted, decryptedHandler); + client.removeListener(CryptoEvent.KeysChanged, keysChangedHandler); + client.removeListener(HttpApiEvent.SessionLoggedOut, loggedOutHandler); + this.stop(); + }; + + client.on(MatrixEventEvent.Decrypted, decryptedHandler); + client.on(CryptoEvent.KeysChanged, keysChangedHandler); + client.on(HttpApiEvent.SessionLoggedOut, loggedOutHandler); } /** * Clear state and stop checking for and tracking failures. */ - public stop(): void { + private stop(): void { if (this.checkInterval) clearInterval(this.checkInterval); if (this.trackInterval) clearInterval(this.trackInterval); + this.userTrustsOwnIdentity = undefined; this.failures = new Map(); this.visibleEvents = new Set(); - this.visibleFailures = new Map(); - this.failureCounts = new Map(); } /** - * Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be - * tracked. Only mark one failure per event ID. + * Mark failures as undecryptable or late. Only mark one failure per event ID. + * * @param {number} nowTs the timestamp that represents the time now. */ public checkFailures(nowTs: number): void { - const failuresGivenGrace: Set = new Set(); const failuresNotReady: Map = new Map(); - for (const [eventId, failure] of this.visibleFailures) { - if (nowTs > failure.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) { - failuresGivenGrace.add(failure); - this.trackedEvents.add(eventId); + for (const [eventId, failure] of this.failures) { + if ( + failure.timeToDecryptMillis !== undefined || + nowTs > failure.ts + DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD + ) { + // we report failures under two conditions: + // - if `timeToDecryptMillis` is set, we successfully decrypted + // the event, but we got the key late. We report it so that we + // have the late decrytion stats. + // - we haven't decrypted yet and it's past the time for it to be + // considered a "late" decryption, so we count it as + // undecryptable. + this.reportFailure(failure); } else { + // the event isn't old enough, so we still need to keep track of it failuresNotReady.set(eventId, failure); } } - this.visibleFailures = failuresNotReady; + this.failures = failuresNotReady; - // Commented out for now for expediency, we need to consider unbound nature of storing - // this in localStorage - // this.saveTrackedEvents(); - - this.aggregateFailures(failuresGivenGrace); - } - - private aggregateFailures(failures: Set): void { - for (const failure of failures) { - const errorCode = failure.errorCode; - this.failureCounts.set(errorCode, (this.failureCounts.get(errorCode) ?? 0) + 1); - } + this.saveReportedEvents(); } /** * If there are failures that should be tracked, call the given trackDecryptionFailure - * function with the number of failures that should be tracked. + * function with the failures that should be tracked. */ - public trackFailures(): void { - for (const [errorCode, count] of this.failureCounts.entries()) { - if (count > 0) { - const trackedErrorCode = this.errorCodeMapFn(errorCode); - - this.fn(count, trackedErrorCode, errorCode); - this.failureCounts.set(errorCode, 0); - } + private reportFailure(failure: DecryptionFailure): void { + const errorCode = failure.errorCode; + const trackedErrorCode = this.errorCodeMapFn(errorCode); + const properties: ErrorProperties = { + timeToDecryptMillis: failure.timeToDecryptMillis ?? -1, + wasVisibleToUser: failure.wasVisibleToUser, + }; + if (failure.isFederated !== undefined) { + properties.isFederated = failure.isFederated; } + if (failure.userTrustsOwnIdentity !== undefined) { + properties.userTrustsOwnIdentity = failure.userTrustsOwnIdentity; + } + if (this.baseProperties) { + Object.assign(properties, this.baseProperties); + } + this.fn(trackedErrorCode, errorCode, properties); + + this.reportedEvents.add(failure.failedEventId); + // once we've added it to reportedEvents, we won't check + // visibleEvents for it any more + this.visibleEvents.delete(failure.failedEventId); } } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index db3c0bf1f4..bf23412ccd 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/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index d14003dbfa..a1c277fc28 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -78,15 +78,6 @@ export interface IMatrixClientPeg { */ opts: IStartClientOpts; - /** - * Return the server name of the user's homeserver - * Throws an error if unable to deduce the homeserver name - * (e.g. if the user is not logged in) - * - * @returns {string} The homeserver name, if present. - */ - getHomeserverName(): string; - /** * Get the current MatrixClient, if any */ @@ -384,14 +375,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { logger.log(`MatrixClientPeg: MatrixClient started`); } - public getHomeserverName(): string { - const matches = /^@[^:]+:(.+)$/.exec(this.safeGet().getSafeUserId()); - if (matches === null || matches.length < 1) { - throw new Error("Failed to derive homeserver name from user ID!"); - } - return matches[1]; - } - private namesToRoomName(names: string[], count: number): string | undefined { const countWithoutMe = count - 1; if (!names.length) { diff --git a/src/Modal.tsx b/src/Modal.tsx index 2ac12d280f..f39372d532 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -65,10 +65,12 @@ interface IOptions { export enum ModalManagerEvent { Opened = "opened", + Closed = "closed", } type HandlerMap = { [ModalManagerEvent.Opened]: () => void; + [ModalManagerEvent.Closed]: () => void; }; export class ModalManager extends TypedEventEmitter { @@ -232,6 +234,7 @@ export class ModalManager extends TypedEventEmitter { const modal = this.getCurrentModal(); if (!modal) { diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index ecff316e6c..0564f008ac 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -97,11 +97,7 @@ export default class PasswordReset { // Note: Though this sounds like a login type for identity servers only, it // has a dual purpose of being used for homeservers too. type: "m.login.email.identity", - // TODO: Remove `threepid_creds` once servers support proper UIA - // See https://github.com/matrix-org/synapse/issues/5665 - // See https://github.com/matrix-org/matrix-doc/issues/2220 threepid_creds: creds, - threepidCreds: creds, }, this.password, this.logoutDevices, diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6d43d83f61..c2254d3dfe 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,13 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - DeviceVerificationStatus, - ICryptoCallbacks, - MatrixClient, - encodeBase64, - SecretStorage, -} from "matrix-js-sdk/src/matrix"; +import { Crypto, ICryptoCallbacks, MatrixClient, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix"; import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase"; import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey"; import { logger } from "matrix-js-sdk/src/logger"; @@ -249,7 +243,7 @@ async function onSecretRequested( deviceId: string, requestId: string, name: string, - deviceTrust: DeviceVerificationStatus, + deviceTrust: Crypto.DeviceVerificationStatus, ): Promise { logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.safeGet(); diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index c4387e85d6..4885ffa8dc 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) { diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 398dc27008..9a78f07df4 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -203,6 +203,7 @@ export const KEY_ICON: Record = { if (IS_MAC) { KEY_ICON[Key.META] = "⌘"; KEY_ICON[Key.ALT] = "⌥"; + KEY_ICON[Key.SHIFT] = "⇧"; } export const CATEGORIES: Record = { diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 9a2a855242..3ef713ba20 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -390,4 +390,3 @@ export const useRovingTabIndex = ( // re-export the semantic helper components for simplicity export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; -export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton"; diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index 2ae8a5de9d..3a3048d41f 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -18,9 +18,9 @@ limitations under the License. import React, { ComponentProps, forwardRef, Ref } from "react"; -import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; -type Props = ComponentProps> & { +type Props = ComponentProps> & { // whether the context menu is currently open isExpanded: boolean; }; @@ -31,17 +31,17 @@ export const ContextMenuTooltipButton = forwardRef(function , ) { return ( - {children} - + ); }); diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx deleted file mode 100644 index 76927c1773..0000000000 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { ComponentProps } from "react"; - -import { useRovingTabIndex } from "../RovingTabIndex"; -import { Ref } from "./types"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; - -type Props = Omit>, "tabIndex"> & { - inputRef?: Ref; -}; - -// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton = ({ - inputRef, - onFocus, - element, - ...props -}: Props): JSX.Element => { - const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); - return ( - { - onFocusInternal(); - onFocus?.(event); - }} - ref={ref} - tabIndex={isActive ? 0 : -1} - /> - ); -}; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index d21164ec8e..0316c43994 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -316,9 +316,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); }); - const dft = DecryptionFailureTracker.instance; - - // Shelved for later date when we have time to think about persisting history of - // tracked events across sessions. - // dft.loadTrackedEventHashMap(); - - dft.start(); - - // When logging out, stop tracking failures and destroy state - cli.on(HttpApiEvent.SessionLoggedOut, () => dft.stop()); - cli.on(MatrixEventEvent.Decrypted, (e) => dft.eventDecrypted(e)); + DecryptionFailureTracker.instance + .start(cli) + .catch((e) => logger.error("Unable to start DecryptionFailureTracker", e)); cli.on(ClientEvent.Room, (room) => { if (cli.isCryptoEnabled()) { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index a71970c08d..4564741a29 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -253,6 +253,7 @@ const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => { showSpaceSettings(space); }} title={_t("common|settings")} + placement="bottom" /> ); } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index f24fa57d7d..2e8b5d91a3 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -30,7 +30,7 @@ import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; -import { RovingAccessibleTooltipButton } from "../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; @@ -426,7 +426,7 @@ export default class UserMenu extends React.Component { - { alt="" width={16} /> - + {topSection} {primaryOptionList} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e8969f12ad..7bed60d603 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -26,10 +26,8 @@ import SettingsStore from "../../../settings/SettingsStore"; import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; -import { Alignment } from "../elements/Tooltip"; import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the @@ -121,9 +119,6 @@ export class PasswordAuthEntry extends React.Component ( - this.setState({ requested: false }) + ? (open) => { + if (!open) this.setState({ requested: false }); + } : undefined } onClick={async (): Promise => { @@ -527,7 +523,7 @@ export class EmailIdentityAuthEntry extends React.Component< }} > {text} - + ), }, )} @@ -634,11 +630,7 @@ export class MsisdnAuthEntry extends React.Component = ({ }) => { if (onClick) { return ( - -
{tooltipTitle}
-
{tooltipCaption}
- - } + aria-label={`${tooltipTitle} ${tooltipCaption}`} + title={tooltipTitle} + caption={tooltipCaption} onClick={onClick} > {_t("common|beta")} -
+ ); } diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index a1fdc13f29..fa0eef5086 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -436,7 +436,7 @@ export default class CreateRoomDialog extends React.Component { { timeline = [
{_t("server_offline|empty_timeline")}
]; } - const serverName = MatrixClientPeg.getHomeserverName(); + const serverName = MatrixClientPeg.safeGet().getDomain(); return ( (SdkConfig.getObject("room_directory")?.get("servers") ?? []); removeAll(configServers, homeServer); // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index d94162393e..76b90506dc 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -106,6 +106,11 @@ type Props = DynamicHtmlElementProps & * Callback for when the tooltip is opened or closed. */ onTooltipOpenChange?: TooltipProps["onOpenChange"]; + + /** + * Whether the tooltip should be disabled. + */ + disableTooltip?: TooltipProps["disabled"]; }; /** @@ -140,6 +145,7 @@ const AccessibleButton = forwardRef(function , ref: Ref, @@ -217,6 +223,7 @@ const AccessibleButton = forwardRef(function {button} diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx deleted file mode 100644 index 759643da1c..0000000000 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 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, { SyntheticEvent, FocusEvent, forwardRef, useEffect, Ref, useState, ComponentProps } from "react"; - -import AccessibleButton from "./AccessibleButton"; -import Tooltip, { Alignment } from "./Tooltip"; - -/** - * Type of props accepted by {@link AccessibleTooltipButton}. - * - * Extends that of {@link AccessibleButton}. - */ -type Props = ComponentProps> & { - /** - * Title to show in the tooltip and use as aria-label - */ - title?: string; - /** - * Tooltip node to show in the tooltip, takes precedence over `title` - */ - tooltip?: React.ReactNode; - /** - * Trigger label to render - */ - label?: string; - /** - * Classname to apply to the tooltip - */ - tooltipClassName?: string; - /** - * Force the tooltip to be hidden - */ - forceHide?: boolean; - /** - * Alignment to render the tooltip with - */ - alignment?: Alignment; - /** - * Function to call when the children are hovered over - */ - onHover?: (hovering: boolean) => void; - /** - * Function to call when the tooltip goes from shown to hidden. - */ - onHideTooltip?(ev: SyntheticEvent): void; -}; - -/** - * @deprecated use AccessibleButton with `title` and `caption` instead. - */ -const AccessibleTooltipButton = forwardRef(function ( - { title, tooltip, children, forceHide, alignment, onHideTooltip, tooltipClassName, element, ...props }: Props, - ref: Ref, -) { - const [hover, setHover] = useState(false); - - useEffect(() => { - // If forceHide is set then force hover to off to hide the tooltip - if (forceHide && hover) { - setHover(false); - } - }, [forceHide, hover]); - - const showTooltip = (): void => { - props.onHover?.(true); - if (forceHide) return; - setHover(true); - }; - - const hideTooltip = (ev: SyntheticEvent): void => { - props.onHover?.(false); - setHover(false); - onHideTooltip?.(ev); - }; - - const onFocus = (ev: FocusEvent): void => { - // We only show the tooltip if focus arrived here from some other - // element, to avoid leaving tooltips hanging around when a modal closes - if (ev.relatedTarget) showTooltip(); - }; - - const tip = hover && (title || tooltip) && ( - - ); - return ( - - {children} - {props.label} - {(tooltip || title) && tip} - - ); -}); - -export default AccessibleTooltipButton; diff --git a/src/components/views/elements/AppPermission.tsx b/src/components/views/elements/AppPermission.tsx index 362863fc3a..fd788290c0 100644 --- a/src/components/views/elements/AppPermission.tsx +++ b/src/components/views/elements/AppPermission.tsx @@ -18,6 +18,7 @@ limitations under the License. import React from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; @@ -29,7 +30,6 @@ import Heading from "../typography/Heading"; import AccessibleButton from "./AccessibleButton"; import { parseUrl } from "../../../utils/UrlUtils"; import { Icon as HelpIcon } from "../../../../res/img/feather-customised/help-circle.svg"; -import TooltipTarget from "./TooltipTarget"; interface IProps { url: string; @@ -99,31 +99,27 @@ export default class AppPermission extends React.Component { ); - const warningTooltipText = ( -
- {_t("analytics|shared_data_heading")} -
    -
  • {_t("widget|shared_data_name")}
  • -
  • {_t("widget|shared_data_avatar")}
  • -
  • {_t("widget|shared_data_mxid")}
  • -
  • {_t("widget|shared_data_device_id")}
  • -
  • {_t("widget|shared_data_theme")}
  • -
  • {_t("widget|shared_data_lang")}
  • -
  • {_t("widget|shared_data_url", { brand })}
  • -
  • {_t("widget|shared_data_room_id")}
  • -
  • {_t("widget|shared_data_widget_id")}
  • -
-
- ); const warningTooltip = ( - +
  • {_t("widget|shared_data_name")}
  • +
  • {_t("widget|shared_data_avatar")}
  • +
  • {_t("widget|shared_data_mxid")}
  • +
  • {_t("widget|shared_data_device_id")}
  • +
  • {_t("widget|shared_data_theme")}
  • +
  • {_t("widget|shared_data_lang")}
  • +
  • {_t("widget|shared_data_url", { brand })}
  • +
  • {_t("widget|shared_data_room_id")}
  • +
  • {_t("widget|shared_data_widget_id")}
  • + + } > - -
    +
    + +
    +
    ); // Due to i18n limitations, we can't dedupe the code for variables in these two messages. diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index f76b945712..d60af8025d 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -14,12 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, RefObject, createRef } from "react"; +import React, { + InputHTMLAttributes, + SelectHTMLAttributes, + TextareaHTMLAttributes, + RefObject, + createRef, + KeyboardEvent, +} from "react"; import classNames from "classnames"; import { debounce } from "lodash"; import { IFieldState, IValidationResult } from "./Validation"; import Tooltip from "./Tooltip"; +import { Key } from "../../../Keyboard"; // Invoke validation from user input (when typing, etc.) at most once every N ms. const VALIDATION_THROTTLE_MS = 200; @@ -232,6 +240,18 @@ export default class Field extends React.PureComponent { return this.props.inputRef ?? this._inputRef; } + private onKeyDown = (evt: KeyboardEvent): void => { + // If the tooltip is displayed to show a feedback and Escape is pressed + // The tooltip is hided + if (this.state.feedbackVisible && evt.key === Key.ESCAPE) { + evt.preventDefault(); + evt.stopPropagation(); + this.setState({ + feedbackVisible: false, + }); + } + }; + public render(): React.ReactNode { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { @@ -318,7 +338,7 @@ export default class Field extends React.PureComponent { }); return ( -
    +
    {prefixContainer} {fieldInput} diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index f926ef5cf4..9647188304 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useRef } from "react"; +import React, { useCallback, useContext, useState } from "react"; import { Room, EventType } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; import { useTopic } from "../../../hooks/room/useTopic"; -import { Alignment } from "./Tooltip"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -28,7 +28,6 @@ import InfoDialog from "../dialogs/InfoDialog"; import { useDispatcher } from "../../../hooks/useDispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; -import TooltipTarget from "./TooltipTarget"; import { Linkify, topicToHtml } from "../../../HtmlUtils"; import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; @@ -49,10 +48,10 @@ export function onRoomTopicLinkClick(e: React.MouseEvent): void { export default function RoomTopic({ room, className, ...props }: IProps): JSX.Element { const client = useContext(MatrixClientContext); - const ref = useRef(null); + const [disableTooltip, setDisableTooltip] = useState(false); const topic = useTopic(room); - const body = topicToHtml(topic?.text, topic?.html, ref); + const body = topicToHtml(topic?.text, topic?.html); const onClick = useCallback( (e: React.MouseEvent) => { @@ -70,14 +69,14 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El [props], ); - const ignoreHover = (ev: React.MouseEvent): boolean => { - return (ev.target as HTMLElement).tagName.toUpperCase() === "A"; + const onHover = (ev: React.MouseEvent | React.FocusEvent): void => { + setDisableTooltip((ev.target as HTMLElement).tagName.toUpperCase() === "A"); }; useDispatcher(dis, (payload) => { if (payload.action === Action.ShowRoomTopic) { const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getSafeUserId()); - const body = topicToHtml(topic?.text, topic?.html, ref, true); + const body = topicToHtml(topic?.text, topic?.html, undefined, true); const modal = Modal.createDialog(InfoDialog, { title: room.name, @@ -115,18 +114,24 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El } }); + // Do not render the tooltip if the topic is empty + // We still need to have a div for the header buttons to be displayed correctly + if (!body) return
    ; + return ( - - {body} - + +
    + {body} +
    +
    ); } diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index aafa28b59a..fdba5f6f5c 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; diff --git a/src/components/views/elements/TooltipTarget.tsx b/src/components/views/elements/TooltipTarget.tsx deleted file mode 100644 index 89de915b45..0000000000 --- a/src/components/views/elements/TooltipTarget.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { forwardRef, HTMLAttributes, useRef } from "react"; -import { randomString } from "matrix-js-sdk/src/randomstring"; - -import useFocus from "../../../hooks/useFocus"; -import useHover from "../../../hooks/useHover"; -import Tooltip, { ITooltipProps } from "./Tooltip"; - -interface IProps - extends HTMLAttributes, - Omit { - tooltipTargetClassName?: string; - ignoreHover?: (ev: React.MouseEvent) => boolean; -} - -/** - * Generic tooltip target element that handles tooltip visibility state - * and displays children - */ -const TooltipTarget = forwardRef( - ( - { - children, - tooltipTargetClassName, - // tooltip pass through props - className, - id, - label, - alignment, - tooltipClassName, - maxParentWidth, - ignoreHover, - ...rest - }, - ref, - ) => { - const idRef = useRef("mx_TooltipTarget_" + randomString(8)); - // Use generated ID if one is not passed - if (id === undefined) { - id = idRef.current; - } - - const [isFocused, focusProps] = useFocus(); - const [isHovering, hoverProps] = useHover(ignoreHover || (() => false)); - - // No need to fill up the DOM with hidden tooltip elements. Only add the - // tooltip when we're hovering over the item (performance) - const tooltip = (isFocused || isHovering) && ( - - ); - - return ( -
    - {children} - {tooltip} -
    - ); - }, -); - -export default TooltipTarget; diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 4105426bb5..457a79b8db 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -20,7 +20,7 @@ import classNames from "classnames"; import { Icon as DownloadIcon } from "../../../../res/img/download.svg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import Spinner from "../elements/Spinner"; import { _t, _td, TranslationKey } from "../../../languageHandler"; import { FileDownloader } from "../../../utils/FileDownloader"; @@ -93,7 +93,7 @@ export default class DownloadActionButton extends React.PureComponent {spinner} - + ); } } diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 29c1c97e1a..eedf5a6046 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import { MatrixEvent, ClientEvent, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix"; import { randomString } from "matrix-js-sdk/src/randomstring"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -27,8 +28,6 @@ import { isSelfLocation, } from "../../../utils/location"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import TooltipTarget from "../elements/TooltipTarget"; -import { Alignment } from "../elements/Tooltip"; import { SmartMarker, Map, LocationViewDialog } from "../location"; import { IBodyProps } from "./IBodyProps"; import { createReconnectedListener } from "../../../utils/connection"; @@ -126,7 +125,7 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent; error: interface LocationBodyContentProps { mxEvent: MatrixEvent; mapId: string; - tooltip?: string; + tooltip: string; onError: (error: Error) => void; onClick?: () => void; } @@ -156,13 +155,9 @@ export const LocationBodyContent: React.FC = ({ return (
    - {tooltip ? ( - - {mapElement} - - ) : ( - mapElement - )} + +
    {mapElement}
    +
    ); }; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 00cfa8c149..3cfc252b8c 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -43,7 +43,7 @@ import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } fr import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -234,7 +234,7 @@ const ReplyInThreadButton: React.FC = ({ mxEvent }) => { const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation"); return ( - = ({ mxEvent }) => { placement="left" > - + ); }; @@ -387,7 +387,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); } const cancelSendingButton = ( - - + ); const threadTooltipButton = ; @@ -427,7 +427,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); // The delete button should appear last, so we can just drop it at the end @@ -454,7 +454,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); } // We hide the react button in search results as we don't show reactions in results @@ -511,7 +511,7 @@ export default class MessageActionBar extends React.PureComponent {this.props.isQuoteExpanded ? : } - , + , ); } diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 2737212d33..1dbd1bd7bf 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -44,20 +44,10 @@ export interface IProps { customReactionImagesEnabled?: boolean; } -interface IState { - tooltipRendered: boolean; - tooltipVisible: boolean; -} - -export default class ReactionsRowButton extends React.PureComponent { +export default class ReactionsRowButton extends React.PureComponent { public static contextType = MatrixClientContext; public context!: React.ContextType; - public state = { - tooltipRendered: false, - tooltipVisible: false, - }; - public onClick = (): void => { const { mxEvent, myReactionEvent, content } = this.props; if (myReactionEvent) { @@ -74,21 +64,6 @@ export default class ReactionsRowButton extends React.PureComponent { - this.setState({ - // To avoid littering the DOM with a tooltip for every reaction, - // only render it on first use. - tooltipRendered: true, - tooltipVisible: true, - }); - }; - - public onMouseLeave = (): void => { - this.setState({ - tooltipVisible: false, - }); - }; - public render(): React.ReactNode { const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; @@ -97,19 +72,6 @@ export default class ReactionsRowButton extends React.PureComponent - ); - } - const room = this.context.getRoom(mxEvent.getRoomId()); let label: string | undefined; let customReactionName: string | undefined; @@ -156,20 +118,24 @@ export default class ReactionsRowButton extends React.PureComponent - {reactionContent} - - {tooltip} - + + {reactionContent} + + + ); } } diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx index f2a3d26109..5b4db10ed6 100644 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { PropsWithChildren } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import { unicodeToShortcode } from "../../../HtmlUtils"; import { _t } from "../../../languageHandler"; import { formatList } from "../../../utils/FormattingUtils"; -import Tooltip from "../elements/Tooltip"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; interface IProps { @@ -30,20 +30,18 @@ interface IProps { content: string; // A list of Matrix reaction events for this key reactionEvents: MatrixEvent[]; - visible: boolean; // Whether to render custom image reactions customReactionImagesEnabled?: boolean; } -export default class ReactionsRowButtonTooltip extends React.PureComponent { +export default class ReactionsRowButtonTooltip extends React.PureComponent> { public static contextType = MatrixClientContext; public context!: React.ContextType; public render(): React.ReactNode { - const { content, reactionEvents, mxEvent, visible } = this.props; + const { content, reactionEvents, mxEvent, children } = this.props; const room = this.context.getRoom(mxEvent.getRoomId()); - let tooltipLabel: JSX.Element | undefined; if (room) { const senders: string[] = []; let customReactionName: string | undefined; @@ -57,34 +55,16 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent - {_t( - "timeline|reactions|tooltip", - { - shortName, - }, - { - reactors: () => { - return
    {formatList(senders, 6)}
    ; - }, - reactedWith: (sub) => { - if (!shortName) { - return null; - } - return
    {sub}
    ; - }, - }, - )} -
    + const formattedSenders = formatList(senders, 6); + const caption = shortName ? _t("timeline|reactions|tooltip_caption", { shortName }) : undefined; + + return ( + + {children} + ); } - let tooltip: JSX.Element | undefined; - if (tooltipLabel) { - tooltip = ; - } - - return tooltip; + return children; } } diff --git a/src/components/views/pips/WidgetPip.tsx b/src/components/views/pips/WidgetPip.tsx index 2ba9e39e25..9bba2ccc53 100644 --- a/src/components/views/pips/WidgetPip.tsx +++ b/src/components/views/pips/WidgetPip.tsx @@ -26,7 +26,7 @@ import WidgetStore from "../../../stores/WidgetStore"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import { Icon as BackIcon } from "../../../../res/img/element-icons/back.svg"; import { Icon as HangupIcon } from "../../../../res/img/element-icons/call/hangup.svg"; import { _t } from "../../../languageHandler"; @@ -125,14 +125,14 @@ export const WidgetPip: FC = ({ widgetId, room, viewingRoom, onStartMovin {(call !== null || WidgetType.JITSI.matches(widget?.type)) && ( - - + )}
    diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index d6e2ee4407..26ba841f71 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { VerificationPhase, VerificationRequest, VerificationRequestEvent } from "matrix-js-sdk/src/crypto-api"; import { RoomMember, User } from "matrix-js-sdk/src/matrix"; @@ -69,13 +69,17 @@ const EncryptionPanel: React.FC = (props: IProps) => { awaitPromise(); } }, [verificationRequestPromise]); + // Use a ref to track whether we are already showing the mismatch modal as state may not update fast enough + // if two change events are fired in quick succession like can happen with rust crypto. + const isShowingMismatchModal = useRef(false); const changeHandler = useCallback(() => { // handle transitions -> cancelled for mismatches which fire a modal instead of showing a card if ( - request && - request.phase === VerificationPhase.Cancelled && + !isShowingMismatchModal.current && + request?.phase === VerificationPhase.Cancelled && MISMATCHES.includes(request.cancellationCode ?? "") ) { + isShowingMismatchModal.current = true; Modal.createDialog(ErrorDialog, { headerImage: require("../../../../res/img/e2e/warning-deprecated.svg").default, title: _t("encryption|messages_not_secure|title"), diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index dbc6acb29b..d9839252f9 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -43,7 +43,6 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import SdkConfig from "../../../SdkConfig"; import MultiInviter from "../../../utils/MultiInviter"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { textualPowerLevel } from "../../../Roles"; @@ -1413,8 +1412,7 @@ const BasicUserInfo: React.FC<{ // We don't need a perfect check here, just something to pass as "probably not our homeserver". If // someone does figure out how to bypass this check the worst that happens is an error. - // FIXME this should be using cli instead of MatrixClientPeg.matrixClient - if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { + if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) { synapseDeactivateButton = ( ; @@ -57,8 +56,7 @@ export default class RoomProfileSettings extends React.Component if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`); const avatarEvent = room.currentState.getStateEvents(EventType.RoomAvatar, ""); - let avatarUrl = avatarEvent?.getContent()["url"] ?? null; - if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); + const avatarUrl = avatarEvent?.getContent()["url"] ?? null; const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, ""); const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : ""; @@ -71,8 +69,8 @@ export default class RoomProfileSettings extends React.Component originalDisplayName: name, displayName: name, originalAvatarUrl: avatarUrl, - avatarUrl: avatarUrl, avatarFile: null, + avatarRemovalPending: false, originalTopic: topic, topic: topic, profileFieldsTouched: {}, @@ -82,16 +80,23 @@ export default class RoomProfileSettings extends React.Component }; } - private uploadAvatar = (): void => { - this.avatarUpload.current?.click(); + private onAvatarChanged = (file: File): void => { + this.setState({ + avatarFile: file, + avatarRemovalPending: false, + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + avatar: true, + }, + }); }; private removeAvatar = (): void => { // clear file upload field so same file can be selected if (this.avatarUpload.current) this.avatarUpload.current.value = ""; this.setState({ - avatarUrl: null, avatarFile: null, + avatarRemovalPending: true, profileFieldsTouched: { ...this.state.profileFieldsTouched, avatar: true, @@ -112,8 +117,8 @@ export default class RoomProfileSettings extends React.Component profileFieldsTouched: {}, displayName: this.state.originalDisplayName, topic: this.state.originalTopic, - avatarUrl: this.state.originalAvatarUrl, avatarFile: null, + avatarRemovalPending: false, }); }; @@ -138,11 +143,12 @@ export default class RoomProfileSettings extends React.Component if (this.state.avatarFile) { const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, { url: uri }, ""); - newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); - newState.originalAvatarUrl = newState.avatarUrl; + newState.originalAvatarUrl = uri; newState.avatarFile = null; - } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { + } else if (this.state.avatarRemovalPending) { await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, {}, ""); + newState.avatarRemovalPending = false; + newState.originalAvatarUrl = null; } if (this.state.originalTopic !== this.state.topic) { @@ -192,34 +198,6 @@ export default class RoomProfileSettings extends React.Component } }; - private onAvatarChanged = (e: React.ChangeEvent): void => { - if (!e.target.files || !e.target.files.length) { - this.setState({ - avatarUrl: this.state.originalAvatarUrl, - avatarFile: null, - profileFieldsTouched: { - ...this.state.profileFieldsTouched, - avatar: false, - }, - }); - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = (ev) => { - this.setState({ - avatarUrl: String(ev.target?.result), - avatarFile: file, - profileFieldsTouched: { - ...this.state.profileFieldsTouched, - avatar: true, - }, - }); - }; - reader.readAsDataURL(file); - }; - public render(): React.ReactNode { let profileSettingsButtons; if (this.state.canSetName || this.state.canSetTopic || this.state.canSetAvatar) { @@ -241,14 +219,6 @@ export default class RoomProfileSettings extends React.Component return (
    -
    />
    {profileSettingsButtons} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 108e0d9d93..5682cce846 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1162,20 +1162,18 @@ export class UnwrappedEventTile extends React.Component const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); let msgOption: JSX.Element | undefined; - if (this.props.showReadReceipts) { - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - msgOption = ; - } else { - msgOption = ( - - ); - } + if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { + msgOption = ; + } else if (this.props.showReadReceipts) { + msgOption = ( + + ); } let replyChain: JSX.Element | undefined; diff --git a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx index c817222dab..bc41f20b22 100644 --- a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; -import { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex"; import Toolbar from "../../../../accessibility/Toolbar"; import { _t } from "../../../../languageHandler"; import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg"; @@ -32,22 +32,22 @@ export function EventTileThreadToolbar({ }): JSX.Element { return ( - - - + - + ); } diff --git a/src/components/views/rooms/ExtraTile.tsx b/src/components/views/rooms/ExtraTile.tsx index 3bb3a21525..3e734651c0 100644 --- a/src/components/views/rooms/ExtraTile.tsx +++ b/src/components/views/rooms/ExtraTile.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import NotificationBadge from "./NotificationBadge"; import { NotificationState } from "../../../stores/notifications/NotificationState"; import { ButtonEvent } from "../elements/AccessibleButton"; @@ -73,15 +73,15 @@ export default function ExtraTile({ ); if (isMinimized) nameContainer = null; - const Button = isMinimized ? RovingAccessibleTooltipButton : RovingAccessibleButton; return ( -
    - + ); } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 613701bf23..bb4b4c7245 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 && ( + + )} +
    -
    + ); } } diff --git a/src/components/views/rooms/MessageComposerFormatBar.tsx b/src/components/views/rooms/MessageComposerFormatBar.tsx index 5893540528..04406158ae 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -18,7 +18,7 @@ import React, { createRef } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import Toolbar from "../../../accessibility/Toolbar"; export enum Formatting { @@ -131,7 +131,7 @@ class FormatButton extends React.PureComponent { // element="button" and type="button" are necessary for the buttons to work on WebKit, // otherwise the text is deselected before onClick can ever be called return ( - 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 +172,35 @@ export function ReadReceiptGroup({ return (
    -
    - - {remText} - +
    + - {avatars} - - - {tooltip} - {contextMenu} -
    + {remText} + + {avatars} + +
    + {contextMenu} +
    +
    ); } @@ -222,60 +210,48 @@ interface ReadReceiptPersonProps extends IReadReceiptProps { onAfterClick?: () => void; } -function ReadReceiptPerson({ +// Export for testing +export function ReadReceiptPerson({ userId, roomMember, ts, 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} - > -
    @@ -531,10 +522,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
    diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 6977fe9ec4..b43e6bea6a 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -302,10 +302,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, false, @@ -334,10 +330,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, false, @@ -430,10 +422,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, true, diff --git a/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx new file mode 100644 index 0000000000..e6e3e1383e --- /dev/null +++ b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2024 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, waitFor, act } from "@testing-library/react"; +import { AuthType } from "matrix-js-sdk/src/interactive-auth"; +import userEvent from "@testing-library/user-event"; + +import { EmailIdentityAuthEntry } from "../../../../src/components/views/auth/InteractiveAuthEntryComponents"; +import { createTestClient } from "../../../test-utils"; + +describe("", () => { + const renderIdentityAuth = () => { + const matrixClient = createTestClient(); + + return render( + , + ); + }; + + test("should render", () => { + const { container } = renderIdentityAuth(); + expect(container).toMatchSnapshot(); + }); + + test("should clear the requested state when the button tooltip is hidden", async () => { + renderIdentityAuth(); + + // After a click on the resend button, the button should display the resent label + screen.getByRole("button", { name: "Resend" }).click(); + await waitFor(() => expect(screen.queryByRole("button", { name: "Resent!" })).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Resend" })).toBeNull(); + + const resentButton = screen.getByRole("button", { name: "Resent!" }); + // Hover briefly the button and wait for the tooltip to be displayed + await userEvent.hover(resentButton); + await waitFor(() => expect(screen.getByRole("tooltip", { name: "Resent!" })).toBeInTheDocument()); + + // On unhover, it should display again the resend button + await act(() => userEvent.unhover(resentButton)); + await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument()); + }); +}); diff --git a/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap new file mode 100644 index 0000000000..65f86a35d2 --- /dev/null +++ b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` +
    +
    +

    + + To create your account, open the link in the email we just sent to + + alice@example.xyz + + . + +

    +

    + + Did not receive it? +

    + +

    +
    +
    +`; diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 5bf1029bc9..f8fe3c00a7 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -82,8 +82,8 @@ function mockClient({ }: MockClientOptions = {}): MatrixClient { stubClient(); const cli = MatrixClientPeg.safeGet(); - MatrixClientPeg.getHomeserverName = jest.fn(() => homeserver); cli.getUserId = jest.fn(() => userId); + cli.getDomain = jest.fn(() => homeserver); cli.getHomeserverUrl = jest.fn(() => homeserver); cli.getThirdpartyProtocols = jest.fn(() => Promise.resolve(thirdPartyProtocols)); cli.publicRooms = jest.fn((options) => { diff --git a/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index 1412074ed9..06b13f1df7 100644 --- a/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -18,7 +18,7 @@ import { render, RenderResult, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { mocked, MockedObject } from "jest-mock"; -import { CryptoApi, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { Crypto, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { defer, IDeferred, sleep } from "matrix-js-sdk/src/utils"; import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; @@ -35,7 +35,7 @@ import RestoreKeyBackupDialog from "../../../../../src/components/views/dialogs/ describe("CreateSecretStorageDialog", () => { let mockClient: MockedObject; - let mockCrypto: MockedObject; + let mockCrypto: MockedObject; beforeEach(() => { mockClient = getMockClientWithEventEmitter({ diff --git a/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx index 0436fb2bf2..c4a5ef1ee1 100644 --- a/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx +++ b/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { screen, fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { CryptoApi, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; +import { Crypto, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; import * as MegolmExportEncryption from "../../../../../src/utils/MegolmExportEncryption"; import ExportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ExportE2eKeysDialog"; @@ -70,7 +70,7 @@ describe("ExportE2eKeysDialog", () => { cli.getCrypto = () => { return { exportRoomKeysAsJson, - } as unknown as CryptoApi; + } as unknown as Crypto.CryptoApi; }; // Mock the result of encrypting the sessions. If we don't do this, the diff --git a/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx b/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx index af7b85b0c2..f119966030 100644 --- a/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx +++ b/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { CryptoApi } from "matrix-js-sdk/src/matrix"; +import { Crypto } from "matrix-js-sdk/src/matrix"; import ImportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ImportE2eKeysDialog"; import * as MegolmExportEncryption from "../../../../../src/utils/MegolmExportEncryption"; @@ -75,7 +75,7 @@ describe("ImportE2eKeysDialog", () => { cli.getCrypto = () => { return { importRoomKeysAsJson, - } as unknown as CryptoApi; + } as unknown as Crypto.CryptoApi; }; // Mock the result of decrypting the sessions, to avoid needing to diff --git a/test/components/views/elements/Field-test.tsx b/test/components/views/elements/Field-test.tsx index ce826282ac..7cb3074927 100644 --- a/test/components/views/elements/Field-test.tsx +++ b/test/components/views/elements/Field-test.tsx @@ -69,6 +69,10 @@ describe("Field", () => { // Expect 'alert' role expect(screen.queryByRole("alert")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("alert")).toBeNull(); }); it("Should mark the feedback as status if valid", async () => { @@ -87,6 +91,10 @@ describe("Field", () => { // Expect 'status' role expect(screen.queryByRole("status")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("status")).toBeNull(); }); it("Should mark the feedback as tooltip if custom tooltip set", async () => { @@ -106,6 +114,10 @@ describe("Field", () => { // Expect 'tooltip' role expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); }); }); }); diff --git a/test/components/views/elements/RoomTopic-test.tsx b/test/components/views/elements/RoomTopic-test.tsx index dc05779794..8e62bd641f 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]); - render(); + 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 0823229a90..0000000000 --- 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 4b84fa46c6..b344e3cd58 100644 --- a/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -116,7 +116,6 @@ exports[`AppTile for a pinned widget should render 1`] = ` Using this widget may share data
    displays Bottom aligned tooltip on mouseover 1`] = ` -