diff --git a/.eslintrc.js b/.eslintrc.js index 28d26696cb..f310384972 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org"], + plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,6 +170,8 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", }, overrides: [ { @@ -262,6 +264,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", + "react-compiler/react-compiler": "off", }, }, { diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index cac71db330..475648e774 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,6 +17,7 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); + remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/package.json b/package.json index d79de6e081..2fb13e438c 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^2.0.2", "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^4.18.2", diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index a6d920dcb8..bb766d6b88 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -13,6 +13,14 @@ import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { ElementAppPage } from "../../pages/ElementAppPage"; +// Find and click "Reply" button +const clickButtonReply = async (tile: Locator) => { + await expect(async () => { + await tile.hover(); + await tile.getByRole("button", { name: "Reply", exact: true }).click(); + }).toPass(); +}; + test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.use({ displayName: "Hanako", @@ -222,8 +230,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/1sec.ogg"); @@ -251,18 +258,12 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const tile = page.locator(".mx_EventTile_last"); - // Find and click "Reply" button - const clickButtonReply = async () => { - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); - }; - await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/upload-second.ogg"); @@ -270,7 +271,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with yet another audio file to create a reply chain await uploadFile(page, "playwright/sample-files/upload-third.ogg"); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index 3811c2819e..c0f1e280a2 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -66,6 +66,9 @@ test.describe("Cryptography", function () { // Bob has a second, not cross-signed, device const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + // Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list + await page.getByRole("button", { name: "Not now" }).click(); + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "the bird is in the hand", diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index c880961964..7474c5a435 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -413,3 +413,25 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn await bobSecondDevice.prepareClient(); return bobSecondDevice; } + +/** + * Remove the cached secrets from the indexedDB + * This is a workaround to simulate the case where the secrets are not cached. + */ +export async function deleteCachedSecrets(page: Page) { + await page.evaluate(async () => { + const removeCachedSecrets = new Promise((resolve) => { + const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); + request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => { + const db = event.target.result; + const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); + request.onsuccess = () => { + db.close(); + resolve(undefined); + }; + }; + }); + await removeCachedSecrets; + }); + await page.reload(); +} diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index 29733481dd..e21b30a3c2 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -81,6 +81,7 @@ test.describe("Create Knock Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); }); }); diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts index 9c2f1ee76b..be6619697d 100644 --- a/playwright/e2e/knock/knock-into-room.spec.ts +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -284,6 +284,7 @@ test.describe("Knock Into Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); await spotlightDialog.results.nth(0).click(); diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index 03c93d2620..5185be43c9 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -58,6 +58,16 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis await editComposer.press("Enter"); } +const screenshotOptions = (page?: Page) => ({ + mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined, + // Hide the jump to bottom button in the timeline to avoid flakiness + css: ` + .mx_JumpToBottomButton { + display: none !important; + } + `, +}); + test.describe("Message rendering", () => { [ { direction: "ltr", displayName: "Quentin" }, @@ -79,9 +89,10 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "Hello, world!"); - await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }, ); @@ -89,14 +100,17 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays an egg"); - await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions()); }); test("should render an LTR rich text emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays a *free range* egg"); - await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-ltr-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited LTR message", async ({ page, user, app, room }) => { @@ -106,9 +120,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "Hello, universe!"); - await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a LTR message", async ({ page, user, app, room }) => { @@ -122,32 +137,37 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "response to multiline message"); - await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a basic RTL text message", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render an RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me يضع بيضة"); - await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions()); }); test("should render a richtext RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*"); - await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-rtl-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited RTL message", async ({ page, user, app, room }) => { @@ -157,9 +177,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "مرحبا بالكون!"); - await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a RTL message", async ({ page, user, app, room }) => { @@ -173,9 +194,10 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-trl-${direction}displayname.png`, + screenshotOptions(page), + ); }); }); }); diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index bb72c02610..de954fb8d4 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -35,10 +35,10 @@ test.describe("Pinned messages", () => { mask: [tile.locator(".mx_MessageTimestamp")], // Hide the jump to bottom button in the timeline to avoid flakiness css: ` - .mx_JumpToBottomButton { - display: none !important; - } - `, + .mx_JumpToBottomButton { + display: none !important; + } + `, }); }, ); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 7fe0cb3d47..f19bd68f14 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -111,6 +111,10 @@ test.describe("Room Header", () => { async ({ page, app, user }) => { await createVideoRoom(page, app); + // Dismiss a toast that is otherwise in the way (it's the other + // side but there's no need to have it in the screenshot) + await page.getByRole("button", { name: "Later" }).click(); + const header = page.locator(".mx_RoomHeader"); // There's two room info button - the header itself and the i button diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts index f8adbb2e33..9fc8eecb71 100644 --- a/playwright/e2e/settings/encryption-user-tab/index.ts +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -89,8 +89,7 @@ class Helpers { await expect(dialog.getByText(title, { exact: true })).toBeVisible(); await expect(dialog).toMatchScreenshot(screenshot); - const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText()); - const clipboardContent = await handle.jsonValue(); + const clipboardContent = await this.app.getClipboard(); await dialog.getByRole("textbox").fill(clipboardContent); await dialog.getByRole("button", { name: confirmButtonLabel }).click(); await expect(dialog).toMatchScreenshot("default-recovery.png"); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index e6812cd450..7ce769059a 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -6,13 +6,13 @@ */ import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; -import { Page } from "@playwright/test"; import { test, expect } from "."; import { checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, createBot, + deleteCachedSecrets, verifySession, } from "../../crypto/utils"; @@ -53,7 +53,7 @@ test.describe("Recovery section in Encryption tab", () => { test( "should change the recovery key", - { tag: "@screenshot" }, + { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, homeserver, credentials, util, context }) => { await verifySession(app, "new passphrase"); const dialog = await util.openEncryptionTab(); @@ -81,7 +81,7 @@ test.describe("Recovery section in Encryption tab", () => { }, ); - test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => { + test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => { await verifySession(app, "new passphrase"); await util.removeSecretStorageDefaultKeyId(); @@ -154,25 +154,3 @@ test.describe("Recovery section in Encryption tab", () => { }, ); }); - -/** - * Remove the cached secrets from the indexedDB - * This is a workaround to simulate the case where the secrets are not cached. - */ -async function deleteCachedSecrets(page: Page) { - await page.evaluate(async () => { - const removeCachedSecrets = new Promise((resolve) => { - const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); - request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => { - const db = event.target.result; - const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); - request.onsuccess = () => { - db.close(); - resolve(undefined); - }; - }; - }); - await removeCachedSecrets; - }); - await page.reload(); -} diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 5acb3a672f..37e5606cc1 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -84,7 +84,7 @@ test.describe("Spaces", () => { // Copy matrix.to link await page.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); // Go to space home await page.getByRole("button", { name: "Go to my first room" }).click(); @@ -177,7 +177,7 @@ test.describe("Spaces", () => { const shareDialog = page.locator(".mx_SpacePublicShare"); // Copy link first await shareDialog.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); // Start Matrix invite flow await shareDialog.getByRole("button", { name: "Invite people" }).click(); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index d3d3cb352b..7da6974a92 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -38,11 +38,13 @@ export const test = base.extend<{ room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, room2Name: "Room 2", room2: async ({ room2Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 885c15d90f..3de0f7c0f2 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -1195,6 +1195,7 @@ test.describe("Timeline", () => { }); await sendImage(app.client, room.roomId, NEW_AVATAR); + await app.timeline.scrollToBottom(); await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); // Exclude timestamp and read marker from snapshot diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts new file mode 100644 index 0000000000..9a35d9b9c3 --- /dev/null +++ b/playwright/e2e/voip/pstn.spec.ts @@ -0,0 +1,31 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("PSTN", () => { + test.beforeEach(async ({ page }) => { + // Mock the third party protocols endpoint to look like the HS has PSTN support + await page.route("**/_matrix/client/v3/thirdparty/protocols", async (route) => { + await route.fulfill({ + status: 200, + json: { + "im.vector.protocol.pstn": {}, + }, + }); + }); + }); + + test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => { + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + + await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png"); + await page.getByLabel("Open dial pad").click(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); + }); +}); diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts index 770633decb..f816d7651e 100644 --- a/playwright/flaky-reporter.ts +++ b/playwright/flaky-reporter.ts @@ -24,18 +24,40 @@ type PaginationLinks = { first?: string; }; +// We see quite a few test flakes which are caused by the app exploding +// so we have some magic strings we check the logs for to better track the flake with its cause +const SPECIAL_CASES = { + "ChunkLoadError": "ChunkLoadError", + "Unreachable code should not be executed": "Rust crypto panic", + "Out of bounds memory access": "Rust crypto memory error", +}; + class FlakyReporter implements Reporter { private flakes = new Map(); public onTestEnd(test: TestCase): void { // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return; - const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`; + let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`]; if (test.outcome() === "flaky") { - if (!this.flakes.has(title)) { - this.flakes.set(title, []); + const timedOutRuns = test.results.filter((result) => result.status === "timedOut"); + const pageLogs = timedOutRuns.flatMap((result) => + result.attachments.filter((attachment) => attachment.name.startsWith("page-")), + ); + // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such. + const specialCases = Object.keys(SPECIAL_CASES).filter((log) => + pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)), + ); + if (specialCases.length > 0) { + failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]); + } + + for (const title of failures) { + if (!this.flakes.has(title)) { + this.flakes.set(title, []); + } + this.flakes.get(title).push(test); } - this.flakes.get(title).push(test); } } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 98d0bf30fb..d530c75b54 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -158,10 +158,6 @@ export class ElementAppPage { return button.click(); } - public async getClipboardText(): Promise { - return this.page.evaluate("navigator.clipboard.readText()"); - } - public async openSpotlight(): Promise { const spotlight = new Spotlight(this.page); await spotlight.open(); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 362915ce71..611a6cef19 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -15,7 +15,6 @@ import type { ICreateRoomOpts, ISendEventResponse, MatrixClient, - Room, MatrixEvent, ReceiptType, IRoomDirectoryOptions, @@ -178,21 +177,12 @@ export class Client { */ public async createRoom(options: ICreateRoomOpts): Promise { const client = await this.prepareClient(); - return await client.evaluate(async (cli, options) => { + const roomId = await client.evaluate(async (cli, options) => { const { room_id: roomId } = await cli.createRoom(options); - if (!cli.getRoom(roomId)) { - await new Promise((resolve) => { - const onRoom = (room: Room) => { - if (room.roomId === roomId) { - cli.off(window.matrixcs.ClientEvent.Room, onRoom); - resolve(); - } - }; - cli.on(window.matrixcs.ClientEvent.Room, onRoom); - }); - } return roomId; }, options); + await this.awaitRoomMembership(roomId); + return roomId; } /** diff --git a/playwright/services.ts b/playwright/services.ts index c15d63bd02..a501bf6138 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -155,9 +155,13 @@ export const test = base.extend({ { scope: "worker" }, ], - context: async ({ homeserverType, synapseConfig, logger, context, request, homeserver }, use, testInfo) => { + context: async ( + { homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver }, + use, + testInfo, + ) => { testInfo.skip( - !(homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, + !(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, `Test specifies Synapse config options so is unsupported with ${homeserverType}`, ); homeserver.setRequest(request); diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png new file mode 100644 index 0000000000..8e335bd232 Binary files /dev/null and b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png new file mode 100644 index 0000000000..3be63e2f50 Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png new file mode 100644 index 0000000000..17bea8979d Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png differ diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 72715b666b..8841569a10 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts"; -const TAG = "develop@sha256:436278578c6b396d3a581f6af020edaff37dd7c3d26d20362de9e05e4a70cee8"; +const TAG = "develop@sha256:e48308d68dec00af6ce43a05785d475de21a37bc2afaabb440d3a575bcc3d57d"; const DEFAULT_CONFIG = { server_name: "localhost", diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 28bb5f655e..e50f0d3f9b 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -34,11 +34,9 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager"; -import { isSecureBackupRequired } from "./utils/WellKnownUtils"; +import { isSecretStorageBeingAccessed } from "./SecurityManager"; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; -import { isLoggedIn } from "./utils/login"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; @@ -283,7 +281,21 @@ export default class DeviceListener { const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); - const allSystemsReady = crossSigningReady && secretStorageReady; + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + const isCurrentDeviceTrusted = + crossSigningReady && + Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); + + const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { @@ -294,31 +306,31 @@ export default class DeviceListener { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); - // cross signing isn't enabled - nag to enable it - // There are 3 different toasts for: - if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { - // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session) + if (!crossSigningReady) { + // This account is legacy and doesn't have cross-signing set up at all. + // Prompt the user to set it up. + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } else if (!isCurrentDeviceTrusted) { + // cross signing is ready but the current device is not trusted: prompt the user to verify showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); - this.checkKeyBackupStatus(); + } else if (!allCrossSigningSecretsCached) { + // cross signing ready & device trusted, but we are missing secrets from our local cache. + // prompt the user to enter their recovery key. + showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } else if (defaultKeyId === null) { + // the user just hasn't set up 4S yet: prompt them to do so + showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo) { - // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery. - // Since we now enable key backup at registration time, this will be the common case for - // new users. - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); - } else { - // Toast 3: No cross-signing or key backup on account (set up encryption) - await cli.waitForClientWellKnown(); - if (isSecureBackupRequired(cli) && isLoggedIn()) { - // If we're meant to set up, and Secure Backup is required, - // trigger the flow directly without a toast once logged in. - hideSetupEncryptionToast(); - accessSecretStorage(); - } else { - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); - } - } + // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did + // in 'other' situations. Possibly we should consider prompting for a full reset in this case? + logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { + crossSigningReady, + secretStorageReady, + allCrossSigningSecretsCached, + isCurrentDeviceTrusted, + defaultKeyId, + }); + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } } @@ -334,12 +346,6 @@ export default class DeviceListener { // Unverified devices that have appeared since then const newUnverifiedDeviceIds = new Set(); - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); - // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts if (crossSigningReady) { diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index c2df066fa2..8aaa32f471 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixError, RuleId, TweakName, SyncState } from "matrix-js-sdk/src/matrix"; +import { MatrixError, RuleId, TweakName, SyncState, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { CallError, CallErrorCode, @@ -22,7 +22,6 @@ import { MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import EventEmitter from "events"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; @@ -137,14 +136,23 @@ export enum LegacyCallHandlerEvent { CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", + ProtocolSupport = "protocol_support", } +type EventEmitterMap = { + [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; + [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; + [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; + [LegacyCallHandlerEvent.ProtocolSupport]: () => void; +}; + /** * LegacyCallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ -export default class LegacyCallHandler extends EventEmitter { +export default class LegacyCallHandler extends TypedEventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -271,15 +279,13 @@ export default class LegacyCallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({ action: Action.PstnSupportUpdated }); - if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL], ); } - dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); + this.emit(LegacyCallHandlerEvent.ProtocolSupport); } catch (e) { if (maxTries === 1) { logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); @@ -296,8 +302,8 @@ export default class LegacyCallHandler extends EventEmitter { return !!SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } - public getSupportsPstnProtocol(): boolean | null { - return this.supportsPstnProtocol; + public getSupportsPstnProtocol(): boolean { + return this.supportsPstnProtocol ?? false; } public getSupportsVirtualRooms(): boolean | null { @@ -568,6 +574,7 @@ export default class LegacyCallHandler extends EventEmitter { if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; this.setCallState(call, newState); + // XXX: this is used by the IPC into Electron to keep device awake dis.dispatch({ action: "call_state", room_id: mappedRoomId, diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index dada99b3e7..e2227ea42d 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -392,6 +392,7 @@ export const useRovingTabIndex = ( }); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler const isActive = context.state.activeNode === nodeRef.current; return [onFocus, isActive, ref, nodeRef]; }; diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index b25e93bc75..25e0d7d1a1 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -142,6 +142,7 @@ export const AutocompleteInput: React.FC = ({ {isFocused && suggestions.length ? (
diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 3d0c169267..51aef8f454 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -607,6 +607,7 @@ export const useContextMenu = (inputRef?: RefObject setIsOpen(false); }; + // eslint-disable-next-line react-compiler/react-compiler return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 32e5bbc519..c1eb34597f 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -286,9 +286,7 @@ class FilePanel extends React.Component { ref={this.card} header={_t("right_panel|files_button")} > - {this.card.current && ( - - )} + { @@ -65,6 +66,7 @@ export default class LeftPanel extends React.Component { this.state = { activeSpace: SpaceStore.instance.activeSpace, showBreadcrumbs: LeftPanel.breadcrumbsMode, + supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(), }; } @@ -76,6 +78,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); if (this.listContainerRef.current) { UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -90,6 +93,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); UIStore.instance.stopTrackingElementDimensions("ListContainer"); UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); @@ -101,6 +105,10 @@ export default class LeftPanel extends React.Component { } } + private updateProtocolSupport = (): void => { + this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() }); + }; + private updateActiveSpace = (activeSpace: SpaceKey): void => { this.setState({ activeSpace }); }; @@ -330,9 +338,8 @@ export default class LeftPanel extends React.Component { private renderSearchDialExplore(): React.ReactNode { let dialPadButton: JSX.Element | undefined; - // If we have dialer support, show a button to bring up the dial pad - // to start a new call - if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + // If we have dialer support, show a button to bring up the dial pad to start a new call + if (this.state.supportsPstnProtocol) { dialPadButton = ( - {this.card.current && } + {content} diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index ca67ca6bbf..14f34c9146 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { ISearchResults, IThreadBundledRelationship, @@ -58,7 +58,7 @@ export const RoomSearchView = forwardRef( const [results, setResults] = useState(null); const aborted = useRef(false); // A map from room ID to permalink creator - const permalinkCreators = useRef(new Map()).current; + const permalinkCreators = useMemo(() => new Map(), []); const innerRef = useRef(); useEffect(() => { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 78d14d39df..fe51b60564 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -273,6 +273,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { } const onRetryClicked = (): void => { + // eslint-disable-next-line react-compiler/react-compiler room.state = LocalRoomState.NEW; defaultDispatcher.dispatch({ action: "local_room_event", @@ -1082,7 +1083,7 @@ export class RoomView extends React.Component { } }; - private onCallState = (roomId: string): void => { + private onCallState = (roomId: string | null): void => { // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room @@ -2514,9 +2515,7 @@ export class RoomView extends React.Component { mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitBody = ( <> - {this.roomViewBody.current && ( - - )} + {auxPanel} {pinnedMessageBanner}
diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 7aee8554b1..f6742d8159 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -204,7 +204,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => ref={card} closeButtonRef={closeButonRef} > - {card.current && } + {timelineSet ? ( { PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); }} > - {this.card.current && } +
{timeline}
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( diff --git a/src/components/utils/Box.tsx b/src/components/utils/Box.tsx index c81c9bafed..2de64ba075 100644 --- a/src/components/utils/Box.tsx +++ b/src/components/utils/Box.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useEffect, useRef } from "react"; +import React, { useMemo } from "react"; type FlexProps = { /** @@ -40,25 +40,6 @@ type FlexProps = { grow?: string | null; }; -/** - * Set or remove a CSS property - * @param ref the reference - * @param name the CSS property name - * @param value the CSS property value - */ -function addOrRemoveProperty( - ref: React.MutableRefObject, - name: string, - value?: string | null, -): void { - const style = ref.current!.style; - if (value) { - style.setProperty(name, value); - } else { - style.removeProperty(name); - } -} - /** * A flex child helper */ @@ -71,12 +52,12 @@ export function Box({ children, ...props }: React.PropsWithChildren): JSX.Element { - const ref = useRef(); - - useEffect(() => { - addOrRemoveProperty(ref, `--mx-box-flex`, flex); - addOrRemoveProperty(ref, `--mx-box-shrink`, shrink); - addOrRemoveProperty(ref, `--mx-box-grow`, grow); + const style = useMemo(() => { + const style: Record = {}; + if (flex) style["--mx-box-flex"] = flex; + if (shrink) style["--mx-box-shrink"] = shrink; + if (grow) style["--mx-box-grow"] = grow; + return style; }, [flex, grow, shrink]); return React.createElement( @@ -88,7 +69,7 @@ export function Box({ "mx_Box--shrink": !!shrink, "mx_Box--grow": !!grow, }), - ref, + style, }, children, ); diff --git a/src/components/utils/Flex.tsx b/src/components/utils/Flex.tsx index ae5704d247..3788e32c45 100644 --- a/src/components/utils/Flex.tsx +++ b/src/components/utils/Flex.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useEffect, useRef } from "react"; +import React, { useMemo } from "react"; type FlexProps = { /** @@ -64,15 +64,16 @@ export function Flex({ children, ...props }: React.PropsWithChildren): JSX.Element { - const ref = useRef(); + const style = useMemo( + () => ({ + "--mx-flex-display": display, + "--mx-flex-direction": direction, + "--mx-flex-align": align, + "--mx-flex-justify": justify, + "--mx-flex-gap": gap, + }), + [align, direction, display, gap, justify], + ); - useEffect(() => { - ref.current!.style.setProperty(`--mx-flex-display`, display); - ref.current!.style.setProperty(`--mx-flex-direction`, direction); - ref.current!.style.setProperty(`--mx-flex-align`, align); - ref.current!.style.setProperty(`--mx-flex-justify`, justify); - ref.current!.style.setProperty(`--mx-flex-gap`, gap); - }, [align, direction, display, gap, justify]); - - return React.createElement(as, { ...props, className: classNames("mx_Flex", className), ref }, children); + return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children); } diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index 4a1a2d59f1..88eacb1b93 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -19,7 +19,7 @@ import { UserEvent, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { throttle } from "lodash"; import { RoomMember } from "../../../models/rooms/RoomMember"; @@ -120,19 +120,16 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { const sdkContext = useContext(SDKContext); const [memberMap, setMemberMap] = useState>(new Map()); const [isLoading, setIsLoading] = useState(true); - // This is the last known total number of members in this room. - const totalMemberCount = useRef(0); - - const searchQuery = useRef(""); + const [totalMemberCount, setTotalMemberCount] = useState(0); const loadMembers = useMemo( () => throttle( - async (): Promise => { + async (searchQuery?: string): Promise => { const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList( roomId, - searchQuery.current, + searchQuery, ); const newMemberMap = new Map(); // First add the invited room members @@ -141,7 +138,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { newMemberMap.set(member.userId, roomMember); } // Then add the third party invites - const threePidInvited = getPending3PidInvites(room, searchQuery.current); + const threePidInvited = getPending3PidInvites(room, searchQuery); for (const invited of threePidInvited) { const key = invited.threePidInvite!.event.getContent().display_name; newMemberMap.set(key, invited); @@ -152,26 +149,18 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { newMemberMap.set(member.userId, roomMember); } setMemberMap(newMemberMap); - if (!searchQuery.current) { + if (!searchQuery) { /** * Since searching for members only gives you the relevant * members matching the query, do not update the totalMemberCount! **/ - totalMemberCount.current = newMemberMap.size; + setTotalMemberCount(newMemberMap.size); } }, 500, { leading: true, trailing: true }, ), - [roomId, sdkContext.memberListStore, room], - ); - - const search = useCallback( - (query: string) => { - searchQuery.current = query; - loadMembers(); - }, - [loadMembers], + [sdkContext.memberListStore, roomId, room], ); const isPresenceEnabled = useMemo( @@ -252,12 +241,12 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { return { members: Array.from(memberMap.values()), - search, + search: loadMembers, shouldShowInvite, isPresenceEnabled, isLoading, onInviteButtonClick, - shouldShowSearch: totalMemberCount.current >= 20, + shouldShowSearch: totalMemberCount >= 20, canInvite, }; } diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 68733b4ceb..746a135390 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -58,11 +58,10 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { if (canvas) canvas.height = UIStore.instance.windowHeight; UIStore.instance.on(UI_EVENTS.Resize, resize); + const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored return () => { dis.unregister(dispatcherRef); UIStore.instance.off(UI_EVENTS.Resize, resize); - // eslint-disable-next-line react-hooks/exhaustive-deps - const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored for (const effect in currentEffects) { const effectModule: ICanvasEffect = currentEffects.get(effect)!; if (effectModule && effectModule.isRunning) { diff --git a/src/components/views/elements/Measured.tsx b/src/components/views/elements/Measured.tsx index 6a4abae2de..5f0d3acca8 100644 --- a/src/components/views/elements/Measured.tsx +++ b/src/components/views/elements/Measured.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { RefObject } from "react"; import UIStore, { UI_EVENTS } from "../../../stores/UIStore"; interface IProps { - sensor: Element; + sensor: RefObject; breakpoint: number; onMeasurement(narrow: boolean): void; } @@ -35,14 +35,14 @@ export default class Measured extends React.PureComponent { } public componentDidUpdate(prevProps: Readonly): void { - const previous = prevProps.sensor; - const current = this.props.sensor; + const previous = prevProps.sensor.current; + const current = this.props.sensor.current; if (previous === current) return; if (previous) { UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`); } if (current) { - UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor); + UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor.current); } } diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index 49b313ca44..4cee1ef9b4 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -213,7 +213,7 @@ export default class TimelineCard extends React.Component { header={_t("right_panel|video_room_chat|title")} ref={this.card} > - {this.card.current && } +
{jumpToBottom} void; @@ -440,6 +441,7 @@ export default class RoomList extends React.PureComponent { SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); this.updateLists(); // trigger the first update } @@ -448,8 +450,13 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); defaultDispatcher.unregister(this.dispatcherRef); SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); } + private updateProtocolSupport = (): void => { + this.updateLists(); + }; + private onRoomViewStoreUpdate = (): void => { this.setState({ currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined, @@ -471,8 +478,6 @@ export default class RoomList extends React.PureComponent { metricsViaKeyboard: true, }); } - } else if (payload.action === Action.PstnSupportUpdated) { - this.updateLists(); } }; diff --git a/src/components/views/rooms/UserIdentityWarning.tsx b/src/components/views/rooms/UserIdentityWarning.tsx index cb2f1e792d..06586c2638 100644 --- a/src/components/views/rooms/UserIdentityWarning.tsx +++ b/src/components/views/rooms/UserIdentityWarning.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; @@ -213,9 +213,11 @@ export const UserIdentityWarning: React.FC = ({ room } initialisedRef.current = InitialisationStatus.Completed; }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); - loadMembers().catch((e) => { - logger.error("Error initialising UserIdentityWarning:", e); - }); + useEffect(() => { + loadMembers().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + }, [loadMembers]); // When a user's verification status changes, we check if they need to be // added/removed from the set of members needing approval. diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 25822b9176..2de986a299 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import React, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react"; import classNames from "classnames"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -44,7 +44,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); + const defaultContextValue = useMemo(() => getDefaultContextValue({ editorStateTransfer }), [editorStateTransfer]); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; @@ -55,7 +55,7 @@ export default function EditWysiwygComposer({ } return ( - + getDefaultContextValue({ eventRelation: props.eventRelation }), + [props.eventRelation], + ); return ( - + } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts index f5219c6543..20d877271e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts @@ -21,6 +21,7 @@ export function useComposerFunctions( () => ({ clear: () => { if (ref.current) { + // eslint-disable-next-line react-compiler/react-compiler ref.current.innerHTML = ""; } }, diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts index 52613b6b2a..bc58160ce3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -12,6 +12,7 @@ export function usePlainTextInitialization(initialContent = "", ref: RefObject { // always read and write the ref.current using .innerHTML for consistency in linebreak and HTML entity handling if (ref.current) { + // eslint-disable-next-line react-compiler/react-compiler ref.current.innerHTML = initialContent; } }, [ref, initialContent]); diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index 0e3926d7e8..b60b3fe540 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react"; +import React, { ChangeEvent, JSX, useCallback, useMemo, useState } from "react"; import { InlineField, ToggleControl, @@ -39,12 +39,12 @@ import { useSettingValue } from "../../../hooks/useSettings"; */ export function ThemeChoicePanel(): JSX.Element { const themeState = useTheme(); - const themeWatcher = useRef(new ThemeWatcher()); + const themeWatcher = useMemo(() => new ThemeWatcher(), []); const customThemeEnabled = useSettingValue("feature_custom_themes"); return ( - {themeWatcher.current.isSystemThemeSupported() && ( + {themeWatcher.isSystemThemeSupported() && ( )} diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx index 2a8425823b..f7b69696be 100644 --- a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useCallback, useMemo, useState } from "react"; import { JoinRule, EventType, RoomState, Room } from "matrix-js-sdk/src/matrix"; +import { RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../../../languageHandler"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; @@ -24,48 +25,49 @@ interface ElementCallSwitchProps { const ElementCallSwitch: React.FC = ({ room }) => { const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); - const [content, events, maySend] = useRoomState( + const [content, maySend] = useRoomState( room, useCallback( (state: RoomState) => { - const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + const content = state + ?.getStateEvents(EventType.RoomPowerLevels, "") + ?.getContent(); return [ content ?? {}, - content?.["events"] ?? {}, state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()), - ]; + ] as const; }, [room.client], ), ); const [elementCallEnabled, setElementCallEnabled] = useState(() => { - return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0; }); const onChange = useCallback( (enabled: boolean): void => { setElementCallEnabled(enabled); + // Take a copy to avoid mutating the original + const newContent = { events: {}, ...content }; + if (enabled) { - const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const userLevel = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0; const moderatorLevel = content.kick ?? 50; - events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; } else { - const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; - events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, { - events: events, - ...content, - }); + room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent); }, - [room.client, room.roomId, content, events, isPublic], + [room.client, room.roomId, content, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; diff --git a/src/components/views/spaces/QuickThemeSwitcher.tsx b/src/components/views/spaces/QuickThemeSwitcher.tsx index 1eff342ac3..5d2372647e 100644 --- a/src/components/views/spaces/QuickThemeSwitcher.tsx +++ b/src/components/views/spaces/QuickThemeSwitcher.tsx @@ -27,7 +27,7 @@ type Props = { const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID"; const QuickThemeSwitcher: React.FC = ({ requestClose }) => { - const orderedThemes = useMemo(getOrderedThemes, []); + const orderedThemes = useMemo(() => getOrderedThemes(), []); const themeState = useTheme(); const nonHighContrast = findNonHighContrastTheme(themeState.theme); diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index 94486fdf76..dea00bafa7 100644 --- a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -42,14 +42,12 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result { setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs)); }, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]); - // The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func. - // We make this as simple as possible so its only dep is doUpdate itself. - // eslint-disable-next-line react-hooks/exhaustive-deps - const scheduleUpdate = useCallback( - throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { - leading: false, - trailing: true, - }), + const scheduleUpdate = useMemo( + () => + throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { + leading: false, + trailing: true, + }), [doUpdate], ); diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx index 0e249cecdc..61c6272377 100644 --- a/src/components/views/toasts/GenericToast.tsx +++ b/src/components/views/toasts/GenericToast.tsx @@ -25,6 +25,9 @@ interface IPropsExtended extends IProps { SecondaryIcon?: ComponentType>; destructive?: "primary" | "secondary"; onSecondaryClick(): void; + + // If set, this will override the max-width (of the description) making the toast wider or narrower than standard + overrideWidth?: string; } const GenericToast: React.FC> = ({ @@ -37,12 +40,13 @@ const GenericToast: React.FC> = ({ destructive, onPrimaryClick, onSecondaryClick, + overrideWidth, }) => { const detailContent = detail ?
{detail}
: null; return (
-
+
{description} {detailContent}
diff --git a/src/contexts/ScopedRoomContext.tsx b/src/contexts/ScopedRoomContext.tsx index 2b1827952d..f08911cbb2 100644 --- a/src/contexts/ScopedRoomContext.tsx +++ b/src/contexts/ScopedRoomContext.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; -import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useRef, useState } from "react"; +import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useMemo, useState } from "react"; import { objectKeyChanges } from "../utils/objects.ts"; import { useTypedEventEmitter } from "../hooks/useEventEmitter.ts"; @@ -48,15 +48,16 @@ const ScopedRoomContext = createContext | undefin // Uses react memo and leverages splatting the value to ensure that the context is only updated when the state changes (shallow compare) export const ScopedRoomContextProvider = memo( ({ children, ...state }: { children: ReactNode } & ContextValue): JSX.Element => { - const contextRef = useRef(new EfficientContext(state)); + // eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps + const context = useMemo(() => new EfficientContext(state), []); useEffect(() => { - contextRef.current.setState(state); - }, [state]); + context.setState(state); + }, [context, state]); // Includes the legacy RoomContext provider for backwards compatibility with class components return ( - {children} + {children} ); }, diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 100eb6add0..268539d0e7 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { ReactNode, createContext, useCallback, useContext, useEffect, useState, useMemo } from "react"; /** * A ToastContext helps components display any kind of toast message and can be provided @@ -33,19 +33,19 @@ export function useToastContext(): ToastRack { * the ToastRack object that should be provided to the context */ export function useActiveToast(): [ReactNode | undefined, ToastRack] { - const toastRack = useRef(new ToastRack()); + const toastRack = useMemo(() => new ToastRack(), []); - const [activeToast, setActiveToast] = useState(toastRack.current.getActiveToast()); + const [activeToast, setActiveToast] = useState(toastRack.getActiveToast()); const updateCallback = useCallback(() => { - setActiveToast(toastRack.current.getActiveToast()); + setActiveToast(toastRack.getActiveToast()); }, [setActiveToast, toastRack]); useEffect(() => { - toastRack.current.setCallback(updateCallback); + toastRack.setCallback(updateCallback); }, [toastRack, updateCallback]); - return [activeToast, toastRack.current]; + return [activeToast, toastRack]; } interface DisplayedToast { diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 5205e5badf..cd8b7aea3d 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -135,20 +135,6 @@ export enum Action { */ OpenDialPad = "open_dial_pad", - /** - * Fired when CallHandler has checked for PSTN protocol support - * payload: none - * XXX: Is an action the right thing for this? - */ - PstnSupportUpdated = "pstn_support_updated", - - /** - * Similar to PstnSupportUpdated, fired when CallHandler has checked for virtual room support - * payload: none - * XXX: Ditto - */ - VirtualRoomSupportUpdated = "virtual_room_support_updated", - /** * Fired when an upload has started. Should be used with UploadStartedPayload. */ diff --git a/src/hooks/useAsyncRefreshMemo.ts b/src/hooks/useAsyncRefreshMemo.ts index 646217bb63..3d167d56a6 100644 --- a/src/hooks/useAsyncRefreshMemo.ts +++ b/src/hooks/useAsyncRefreshMemo.ts @@ -34,7 +34,7 @@ export function useAsyncRefreshMemo(fn: Fn, deps: DependencyList, initialV return () => { discard = true; }; - }, deps); // eslint-disable-line react-hooks/exhaustive-deps + }, deps); // eslint-disable-line react-hooks/exhaustive-deps,react-compiler/react-compiler useEffect(refresh, [refresh]); return [value, refresh]; } diff --git a/src/hooks/useRoomNotificationState.ts b/src/hooks/useRoomNotificationState.ts index c964501aa9..e76295a347 100644 --- a/src/hooks/useRoomNotificationState.ts +++ b/src/hooks/useRoomNotificationState.ts @@ -25,6 +25,7 @@ export const useNotificationState = (room: Room): [RoomNotifState | undefined, ( setNotificationState(echoChamber.notificationVolume); } }); + // eslint-disable-next-line react-compiler/react-compiler const setter = useCallback((state: RoomNotifState) => (echoChamber.notificationVolume = state), [echoChamber]); return [notificationState, setter]; }; diff --git a/src/hooks/useTransition.ts b/src/hooks/useTransition.ts index e583ba8557..e1f48eeac4 100644 --- a/src/hooks/useTransition.ts +++ b/src/hooks/useTransition.ts @@ -22,6 +22,6 @@ export const useTransition = (callback: (...params: D) useEffect(() => { if (args.current !== null) func.current(...args.current); args.current = deps; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps }, deps); }; diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index ef68cf7eab..e59aa51766 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2379,7 +2379,7 @@ "all_rooms_home_description": "Alle Räume, denen du beigetreten bist, werden auf der Startseite erscheinen.", "always_show_message_timestamps": "Nachrichtenzeitstempel immer anzeigen", "appearance": { - "bundled_emoji_font": "Verwenden Sie den mitgelieferten Emoji Font.", + "bundled_emoji_font": "Mitgelieferte Emoji-Schriftart verwenden", "compact_layout": "Kompakten Text und Nachrichten anzeigen", "compact_layout_description": "Um diese Funktion nutzen zu können, muss das moderne Nachrichtenlayout ausgewählt sein.", "custom_font": "Systemschriftart verwenden", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 37a739a62e..875da43f14 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -879,14 +879,18 @@ "title": "Destroy cross-signing keys?", "warning": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from." }, + "enter_recovery_key": "Enter recovery key", "event_shield_reason_authenticity_not_guaranteed": "The authenticity of this encrypted message can't be guaranteed on this device.", "event_shield_reason_mismatched_sender_key": "Encrypted by an unverified session", "event_shield_reason_unknown_device": "Encrypted by an unknown or deleted device.", "event_shield_reason_unsigned_device": "Encrypted by a device not verified by its owner.", "event_shield_reason_unverified_identity": "Encrypted by an unverified user.", "export_unsupported": "Your browser does not support the required cryptography extensions", + "forgot_recovery_key": "Forgot recovery key?", "import_invalid_keyfile": "Not a valid %(brand)s keyfile", "import_invalid_passphrase": "Authentication check failed: incorrect password?", + "key_storage_out_of_sync": "Your key storage is out of sync.", + "key_storage_out_of_sync_description": "Confirm your recovery key to maintain access to your key storage and message history.", "messages_not_secure": { "cause_1": "Your homeserver", "cause_2": "The homeserver the user you're verifying is connected to", diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index ecbf99f4b2..3b8e85eb44 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -27,6 +27,8 @@ const getTitle = (kind: Kind): string => { return _t("encryption|set_up_recovery"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_title"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|key_storage_out_of_sync"); } }; @@ -37,6 +39,7 @@ const getIcon = (kind: Kind): string | undefined => { case Kind.SET_UP_RECOVERY: return undefined; case Kind.VERIFY_THIS_SESSION: + case Kind.KEY_STORAGE_OUT_OF_SYNC: return "verification_warning"; } }; @@ -49,6 +52,8 @@ const getSetupCaption = (kind: Kind): string => { return _t("action|continue"); case Kind.VERIFY_THIS_SESSION: return _t("action|verify"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|enter_recovery_key"); } }; @@ -59,6 +64,8 @@ const getSecondaryButtonLabel = (kind: Kind): string => { case Kind.SET_UP_ENCRYPTION: case Kind.VERIFY_THIS_SESSION: return _t("encryption|verification|unverified_sessions_toast_reject"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|forgot_recovery_key"); } }; @@ -70,6 +77,8 @@ const getDescription = (kind: Kind): string => { return _t("encryption|set_up_recovery_toast_description"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_description"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|key_storage_out_of_sync_description"); } }; @@ -89,6 +98,10 @@ export enum Kind { * Prompt the user to verify this session */ VERIFY_THIS_SESSION = "verify_this_session", + /** + * Prompt the user to enter their recovery key + */ + KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync", } const onReject = (): void => { @@ -139,6 +152,7 @@ export const showToast = (kind: Kind): void => { onPrimaryClick: onAccept, secondaryLabel: getSecondaryButtonLabel(kind), onSecondaryClick: onReject, + overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined, }, component: GenericToast, priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index e859559a52..4a299101bd 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -30,8 +30,10 @@ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreM useEffect( () => { + let map: MapLibreMap | undefined; try { - setMap(createMap(cli, !!interactive, bodyId, onError)); + map = createMap(cli, !!interactive, bodyId, onError); + setMap(map); } catch (error) { console.error("Error encountered in useMap", error); if (error instanceof Error) { @@ -46,8 +48,7 @@ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreM }; }, // map is excluded as a dependency - // eslint-disable-next-line react-hooks/exhaustive-deps - [interactive, bodyId, onError], + [cli, interactive, bodyId, onError], ); return map; diff --git a/src/utils/login.ts b/src/utils/login.ts deleted file mode 100644 index 8f5d93ffae..0000000000 --- a/src/utils/login.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import Views from "../Views"; - -export function isLoggedIn(): boolean { - // JRS: Maybe we should move the step that writes this to the window out of - // `element-web` and into this file? Better yet, we should probably create a - // store to hold this state. - // See also https://github.com/vector-im/element-web/issues/15034. - return window.matrixChat?.state.view === Views.LOGGED_IN; -} diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index b63896c64d..bdbf86637d 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -329,7 +329,7 @@ describe("DeviceListener", () => { }); it("shows verify session toast when account has cross signing", async () => { - mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true); + mockCrypto!.isCrossSigningReady.mockResolvedValue(true); await createAndStart(); expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled(); @@ -337,24 +337,25 @@ describe("DeviceListener", () => { SetupEncryptionToast.Kind.VERIFY_THIS_SESSION, ); }); - - it("checks key backup status when when account has cross signing", async () => { - mockCrypto!.getCrossSigningKeyId.mockResolvedValue(null); - mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true); - await createAndStart(); - - expect(mockCrypto!.getActiveSessionBackupVersion).toHaveBeenCalled(); - }); }); describe("when user does have a cross signing id on this device", () => { beforeEach(() => { + mockCrypto!.isCrossSigningReady.mockResolvedValue(true); mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc"); + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue( + new DeviceVerificationStatus({ + trustCrossSignedDevices: true, + crossSigningVerified: true, + }), + ); }); it("shows set up recovery toast when user has a key backup available", async () => { // non falsy response mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo); + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null); + await createAndStart(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( diff --git a/test/unit-tests/toasts/SetupEncryptionToast-test.tsx b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx index 8917587cc1..22b491817c 100644 --- a/test/unit-tests/toasts/SetupEncryptionToast-test.tsx +++ b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx @@ -16,9 +16,15 @@ describe("SetupEncryptionToast", () => { render(); }); - it("should render the se up recovery toast", async () => { + it("should render the 'set up recovery' toast", async () => { showToast(Kind.SET_UP_RECOVERY); await expect(screen.findByText("Set up recovery")).resolves.toBeInTheDocument(); }); + + it("should render the 'key storage out of sync' toast", async () => { + showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + + await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); + }); }); diff --git a/test/unit-tests/utils/login-test.ts b/test/unit-tests/utils/login-test.ts deleted file mode 100644 index b1f488c29f..0000000000 --- a/test/unit-tests/utils/login-test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import MatrixChat from "../../../src/components/structures/MatrixChat.tsx"; -import { isLoggedIn } from "../../../src/utils/login.ts"; -import Views from "../../../src/Views.ts"; - -describe("isLoggedIn", () => { - it("should return true if MatrixChat state view is LOGGED_IN", () => { - window.matrixChat = { - state: { - view: Views.LOGGED_IN, - }, - } as unknown as MatrixChat; - - expect(isLoggedIn()).toBe(true); - }); -}); diff --git a/yarn.lock b/yarn.lock index cbe07be2a1..893d93a725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,7 +61,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.3.tgz#99488264a56b2aded63983abd6a417f03b92ed02" integrity sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g== -"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9": +"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.24.4": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40" integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== @@ -303,7 +303,7 @@ dependencies: "@babel/types" "^7.25.8" -"@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": +"@babel/parser@^7.24.4", "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": version "7.26.3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.3.tgz#8c51c5db6ddf08134af1ddbacf16aaab48bac234" integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== @@ -3529,9 +3529,8 @@ ts-xor "^1.3.0" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" - uid "" "@vector-im/matrix-wysiwyg@2.38.0": version "2.38.0" @@ -5977,6 +5976,18 @@ eslint-plugin-matrix-org@^2.0.2: resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-2.0.2.tgz#95b86b0f16704ab19740f7c3c62eae69e20365e6" integrity sha512-cQy5Rjeq6uyu1mLXlPZwEJdyM0NmclrnEz68y792FSuuxzMyJNNYLGDQ5CkYW8H+PrD825HUFZ34pNXnjMOzOw== +eslint-plugin-react-compiler@^19.0.0-beta-df7b47d-20241124: + version "19.0.0-beta-df7b47d-20241124" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.0.0-beta-df7b47d-20241124.tgz#468751d3a8a6781189405ee56b39b80545306df8" + integrity sha512-82PfnllC8jP/68KdLAbpWuYTcfmtGLzkqy2IW85WopKMTr+4rdQpp+lfliQ/QE79wWrv/dRoADrk3Pdhq25nTw== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + "@babel/plugin-transform-private-methods" "^7.25.9" + hermes-parser "^0.25.1" + zod "^3.22.4" + zod-validation-error "^3.0.3" + eslint-plugin-react-hooks@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz#72e2eefbac4b694f5324154619fee44f5f60f101" @@ -6936,6 +6947,18 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + highlight.js@^11.3.1: version "11.10.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92" @@ -8216,9 +8239,9 @@ jwt-decode@4.0.0, jwt-decode@^4.0.0: integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== katex@^0.16.0: - version "0.16.11" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" - integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== + version "0.16.21" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.21.tgz#8f63c659e931b210139691f2cc7bb35166b792a3" + integrity sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A== dependencies: commander "^8.3.0"