diff --git a/.eslintrc.js b/.eslintrc.js index a017112b4e..2b0dd2c186 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org", "eslint-plugin-react-compiler"], + plugins: ["matrix-org"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,8 +170,6 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", - - "react-compiler/react-compiler": "error", }, overrides: [ { @@ -264,7 +262,6 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", - "react-compiler/react-compiler": "off", }, }, { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e7a976a7be..af52a6b77d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,7 @@ # Ignore translations as those will be updated by GHA for Localazy download /src/i18n/strings +/src/i18n/strings/en_EN.json @element-hq/element-web-reviewers # Ignore the synapse plugin as this is updated by GHA for docker image updating /playwright/plugins/homeserver/synapse/index.ts diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 1e40d0dfd5..5a75040866 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -130,8 +130,12 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: yarn playwright install --with-deps --no-shell chromium + # We skip tests tagged with @mergequeue when running on PRs, but run them in MQ and everywhere else - name: Run Playwright tests - run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }} + run: | + yarn playwright test \ + --shard "${{ matrix.runner }}/${{ strategy.job-total }}" \ + ${{ github.event_name == 'pull_request' && '--grep-invert @mergequeue' || '' }} - name: Upload blob report to GitHub Actions Artifacts if: always() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c531f89b4..35803a60f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,7 +104,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@66088c44e212a906c32a047529a213d81809ec1c + uses: guibranco/github-status-action-v2@d469d49426f5a7b8a1fbcac20ad274d3e4892321 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index b47d4c02f8..c410e4f24c 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,7 +17,6 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); - remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/docs/playwright.md b/docs/playwright.md index 1454c4868f..4af3194220 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -217,3 +217,10 @@ instead of the native `toHaveScreenshot`. If you are running Linux and are unfortunate that the screenshots are not rendering identically, you may wish to specify `--ignore-snapshots` and rely on Docker to render them for you. + +## Test Tags + +We use test tags to categorise tests for running subsets more efficiently. + +- `@mergequeue`: Tests that are slow or flaky and cover areas of the app we update seldom, should not be run on every PR commit but will be run in the Merge Queue. +- `@screenshot`: Tests that use `toMatchScreenshot` to speed up a run of `test:playwright:screenshots`. A test with this tag must not also have the `@mergequeue` tag as this would cause false positives in the stale screenshot detection. diff --git a/package.json b/package.json index 902852a8ef..9cd0163945 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", "@vector-im/compound-design-tokens": "^2.0.1", - "@vector-im/compound-web": "^7.4.0", + "@vector-im/compound-web": "^7.5.0", "@vector-im/matrix-wysiwyg": "2.37.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -116,10 +116,10 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.1.4", - "linkify-react": "4.1.4", - "linkify-string": "4.1.4", - "linkifyjs": "4.1.4", + "linkify-element": "4.2.0", + "linkify-react": "4.2.0", + "linkify-string": "4.2.0", + "linkifyjs": "4.2.0", "lodash": "^4.17.21", "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", @@ -233,7 +233,6 @@ "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", @@ -270,7 +269,7 @@ "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.4.1", + "prettier": "3.4.2", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts index 4c8d641e6f..bd3d859526 100644 --- a/playwright/e2e/crypto/user-verification.spec.ts +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; +import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { doTwoWaySasVerification, awaitVerifier } from "./utils"; import { Client } from "../../pages/client"; @@ -38,6 +39,8 @@ test.describe("User verification", () => { toasts, room: { roomId: dmRoomId }, }) => { + await waitForDeviceKeys(page); + // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -87,6 +90,8 @@ test.describe("User verification", () => { toasts, room: { roomId: dmRoomId }, }) => { + await waitForDeviceKeys(page); + // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -149,3 +154,15 @@ async function createDMRoom(client: Client, userId: string): Promise { ], }); } + +/** + * Wait until we get the other user's device keys. + * In newer rust-crypto versions, the verification request will be ignored if we + * don't have the sender's device keys. + */ +async function waitForDeviceKeys(page: Page): Promise { + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = await page.getByRole("button", { name: "Avatar" }); + await avatar.click(); + await expect(page.getByText("1 session")).toBeVisible(); +} diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index ac50b62294..545d0e3438 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -129,6 +129,7 @@ export class Helpers { const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message }); await timelineMessage.click({ button: "right" }); await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click(); + await this.assertMessageInBanner(message); } /** diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts index 277de62876..42191831c8 100644 --- a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("editing messages", () => { test.describe("in threads", () => { test("An edit of a threaded message makes the room unread", async ({ diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts index 027ab08e2d..a464822305 100644 --- a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("editing messages", () => { test.describe("in the main timeline", () => { test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts index e653b5d9bd..506ed603bd 100644 --- a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("editing messages", () => { test.describe("thread roots", () => { test("An edit of a thread root leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index 7d4f4eb133..457cf99481 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { customEvent, many, test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Ignored events", () => { test("If all events after receipt are unimportant, the room is read", async ({ roomAlpha: room1, diff --git a/playwright/e2e/read-receipts/message-ordering.spec.ts b/playwright/e2e/read-receipts/message-ordering.spec.ts index 65875cf4a9..d7f77fae5f 100644 --- a/playwright/e2e/read-receipts/message-ordering.spec.ts +++ b/playwright/e2e/read-receipts/message-ordering.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Message ordering", () => { test.describe("in the main timeline", () => { test.fixme( diff --git a/playwright/e2e/read-receipts/missing-referents.spec.ts b/playwright/e2e/read-receipts/missing-referents.spec.ts index f798d7d455..a1741851e2 100644 --- a/playwright/e2e/read-receipts/missing-referents.spec.ts +++ b/playwright/e2e/read-receipts/missing-referents.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("messages with missing referents", () => { test.fixme( "A message in an unknown thread is not visible and the room is read", diff --git a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts index 91d850fac8..5407f3cb44 100644 --- a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { many, test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("new messages", () => { test.describe("in threads", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts index 2000e444d6..92f7b10cdd 100644 --- a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { many, test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("new messages", () => { test.describe("in the main timeline", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts index 878d0d4419..3c8ed7849f 100644 --- a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { many, test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("new messages", () => { test.describe("thread roots", () => { test("Reading a thread root does not mark the thread as read", async ({ diff --git a/playwright/e2e/read-receipts/notifications.spec.ts b/playwright/e2e/read-receipts/notifications.spec.ts index 3050987be7..46edc9a7a3 100644 --- a/playwright/e2e/read-receipts/notifications.spec.ts +++ b/playwright/e2e/read-receipts/notifications.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Notifications", () => { test.describe("in the main timeline", () => { test.fixme("A new message that mentions me shows a notification", () => {}); diff --git a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts index bc4a184744..45b5e071ec 100644 --- a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("reactions", () => { test.describe("in threads", () => { test("A reaction to a threaded message does not make the room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts index 59d6eaea40..16d5c92eca 100644 --- a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("reactions", () => { test.describe("in the main timeline", () => { test("Receiving a reaction to a message does not make a room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts index 219a73d5e4..817597a27e 100644 --- a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("reactions", () => { test.describe("thread roots", () => { test("A reaction to a thread root does not make the room unread", async ({ diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts index 3056cc4a54..f6515361f2 100644 --- a/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -13,7 +13,7 @@ import { ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.use({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, diff --git a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts index 715bb4e9fc..25c19a4f97 100644 --- a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("redactions", () => { test.describe("in threads", () => { test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts index c1dceda6a0..143d9685d8 100644 --- a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("redactions", () => { test.describe("in the main timeline", () => { test("Redacting the message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts index a8dc38c47e..01f296075c 100644 --- a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("redactions", () => { test.describe("thread roots", () => { test("Redacting a thread root after it was read leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts index 052e2d756a..80dda202a3 100644 --- a/playwright/e2e/read-receipts/room-list-order.spec.ts +++ b/playwright/e2e/read-receipts/room-list-order.spec.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { test } from "."; -test.describe("Read receipts", () => { +test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Room list order", () => { test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ roomAlpha: room1, diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 51c2723f30..078ca2848f 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:48308e18c5b3ad20bc0d090119618f45b6be4ba727522e37fbf7827d1a109531"; +const DOCKER_TAG = "develop@sha256:6b82dba715fa7ae641010b4cc5e71edaeb9cc05a50ac5b9e4ff09afa9cd2a80d"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index b2b71375bd..41ffca6c93 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png index 0d18bff1c2..147fcfa057 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png index 9cadcde415..5475f9a537 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png index 1ec17661fe..23b88c022c 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png index 75db794a1a..6378098d7a 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png index 357790598d..f2269a0532 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png index 42f27d10bf..6b41f30acd 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png differ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index be36c5b689..f921cd291f 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -44,6 +44,7 @@ import { IConfigOptions } from "../IConfigOptions"; import { MatrixDispatcher } from "../dispatcher/dispatcher"; import { DeepReadonly } from "./common"; import MatrixChat from "../components/structures/MatrixChat"; +import { InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -117,6 +118,7 @@ declare global { mxPerformanceEntryNames: any; mxUIStore: UIStore; mxSetupEncryptionStore?: SetupEncryptionStore; + mxInitialCryptoStore?: InitialCryptoSetupStore; mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; mxOnRecaptchaLoaded?: () => void; diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx index d8c7d912c1..9d8b5585e3 100644 --- a/src/accessibility/context_menu/ContextMenuButton.tsx +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -8,25 +8,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, forwardRef, Ref } from "react"; +import React, { forwardRef, Ref } from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import AccessibleButton, { ButtonProps } from "../../components/views/elements/AccessibleButton"; -type Props = ComponentProps> & { +type Props = ButtonProps & { label?: string; // whether the context menu is currently open isExpanded: boolean; }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = forwardRef(function ( - { label, isExpanded, children, onClick, onContextMenu, element, ...props }: Props, - ref: Ref, +export const ContextMenuButton = forwardRef(function ( + { label, isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, ) { return ( = ComponentProps> & { +type Props = ButtonProps & { // whether the context menu is currently open isExpanded: boolean; }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuTooltipButton = forwardRef(function ( - { isExpanded, children, onClick, onContextMenu, element, ...props }: Props, - ref: Ref, +export const ContextMenuTooltipButton = forwardRef(function ( + { isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, ) { return ( = Omit< - ComponentProps>, - "inputRef" | "tabIndex" -> & { - inputRef?: Ref; +type Props = Omit, "tabIndex"> & { + inputRef?: RefObject; focusOnMouseOver?: boolean; }; // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton = ({ +export const RovingAccessibleButton = ({ inputRef, onFocus, onMouseOver, focusOnMouseOver, - element, ...props }: Props): JSX.Element => { - const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( { + onFocus={(event: React.FocusEvent) => { onFocusInternal(); onFocus?.(event); }} - onMouseOver={(event: React.MouseEvent) => { + onMouseOver={(event: React.MouseEvent) => { if (focusOnMouseOver) onFocusInternal(); onMouseOver?.(event); }} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 548dbff983..ee120c430a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -132,6 +132,7 @@ import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; +import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; // legacy export export { default as Views } from "../../Views"; @@ -428,6 +429,12 @@ export default class MatrixChat extends React.PureComponent { !(await shouldSkipSetupEncryption(cli)) ) { // if cross-signing is not yet set up, do so now if possible. + InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup( + cli, + Boolean(this.tokenLogin), + this.stores, + this.onCompleteSecurityE2eSetupFinished, + ); this.setStateForNewView({ view: Views.E2E_SETUP }); } else { this.onLoggedIn(); @@ -2073,14 +2080,7 @@ export default class MatrixChat extends React.PureComponent { } else if (this.state.view === Views.COMPLETE_SECURITY) { view = ; } else if (this.state.view === Views.E2E_SETUP) { - view = ( - - ); + view = ; } else if (this.state.view === Views.LOGGED_IN) { // `ready` and `view==LOGGED_IN` may be set before `page_type` (because the // latter is set via the dispatcher). If we don't yet have a `page_type`, diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index 9e6263bcfb..82146bcc5e 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 Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, 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 = useMemo(() => new Map(), []); + const permalinkCreators = useRef(new Map()).current; const innerRef = useRef(); useEffect(() => { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 891e6b97f4..772d5698a3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2372,7 +2372,11 @@ export class RoomView extends React.Component { ); const pinnedMessageBanner = ( - + ); let messageComposer; diff --git a/src/components/structures/auth/E2eSetup.tsx b/src/components/structures/auth/E2eSetup.tsx index 80a135fe19..3b064d6134 100644 --- a/src/components/structures/auth/E2eSetup.tsx +++ b/src/components/structures/auth/E2eSetup.tsx @@ -7,17 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; import AuthPage from "../../views/auth/AuthPage"; import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody"; -import CreateCrossSigningDialog from "../../views/dialogs/security/CreateCrossSigningDialog"; +import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog"; interface IProps { - matrixClient: MatrixClient; onFinished: () => void; - accountPassword?: string; - tokenLogin: boolean; } export default class E2eSetup extends React.Component { @@ -25,12 +21,7 @@ export default class E2eSetup extends React.Component { return ( - + ); diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index db72a0a04b..9c7a900643 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -235,12 +235,7 @@ export default class SoftLogout extends React.Component { value={this.state.password} disabled={this.state.busy} /> - + {_t("action|sign_in")} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index b1360f5560..ae5c07e348 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -910,7 +910,7 @@ export class SSOAuthEntry extends React.Component extends React.Component { protected popupWindow: Window | null; - protected fallbackButton = createRef(); + protected fallbackButton = createRef(); public constructor(props: IAuthEntryProps & T) { super(props); diff --git a/src/components/views/dialogs/devtools/SettingExplorer.tsx b/src/components/views/dialogs/devtools/SettingExplorer.tsx index ed4b64d870..ae37fa3e1c 100644 --- a/src/components/views/dialogs/devtools/SettingExplorer.tsx +++ b/src/components/views/dialogs/devtools/SettingExplorer.tsx @@ -298,7 +298,7 @@ const SettingsList: React.FC = ({ onBack, onView, onEdit }) {i} onEdit(i)} className="mx_DevTools_SettingsExplorer_edit" > diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx deleted file mode 100644 index 73da6b178c..0000000000 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2018, 2019 New Vector Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useCallback, useEffect, useState } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { _t } from "../../../../languageHandler"; -import DialogButtons from "../../elements/DialogButtons"; -import BaseDialog from "../BaseDialog"; -import Spinner from "../../elements/Spinner"; -import { createCrossSigning } from "../../../../CreateCrossSigning"; - -interface Props { - matrixClient: MatrixClient; - accountPassword?: string; - tokenLogin: boolean; - onFinished: (success?: boolean) => void; -} - -/* - * Walks the user through the process of creating a cross-signing keys. In most - * cases, only a spinner is shown, but for more complex auth like SSO, the user - * may need to complete some steps to proceed. - */ -const CreateCrossSigningDialog: React.FC = ({ matrixClient, accountPassword, tokenLogin, onFinished }) => { - const [error, setError] = useState(false); - - const bootstrapCrossSigning = useCallback(async () => { - const cryptoApi = matrixClient.getCrypto(); - if (!cryptoApi) return; - - setError(false); - - try { - await createCrossSigning(matrixClient, tokenLogin, accountPassword); - onFinished(true); - } catch (e) { - if (tokenLogin) { - // ignore any failures, we are relying on grace period here - onFinished(false); - return; - } - - setError(true); - logger.error("Error bootstrapping cross-signing", e); - } - }, [matrixClient, tokenLogin, accountPassword, onFinished]); - - const onCancel = useCallback(() => { - onFinished(false); - }, [onFinished]); - - useEffect(() => { - bootstrapCrossSigning(); - }, [bootstrapCrossSigning]); - - let content; - if (error) { - content = ( -
-

{_t("encryption|unable_to_setup_keys_error")}

-
- -
-
- ); - } else { - content = ( -
- -
- ); - } - - return ( - -
{content}
-
- ); -}; - -export default CreateCrossSigningDialog; diff --git a/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx new file mode 100644 index 0000000000..22635662ce --- /dev/null +++ b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2018, 2019 New Vector Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback } from "react"; + +import { _t } from "../../../../languageHandler"; +import DialogButtons from "../../elements/DialogButtons"; +import BaseDialog from "../BaseDialog"; +import Spinner from "../../elements/Spinner"; +import { InitialCryptoSetupStore, useInitialCryptoSetupStatus } from "../../../../stores/InitialCryptoSetupStore"; + +interface Props { + onFinished: (success?: boolean) => void; +} + +/* + * Walks the user through the process of creating a cross-signing keys. + * In most cases, only a spinner is shown, but for more + * complex auth like SSO, the user may need to complete some steps to proceed. + */ +export const InitialCryptoSetupDialog: React.FC = ({ onFinished }) => { + const onRetryClick = useCallback(() => { + InitialCryptoSetupStore.sharedInstance().retry(); + }, []); + + const onCancelClick = useCallback(() => { + onFinished(false); + }, [onFinished]); + + const status = useInitialCryptoSetupStatus(InitialCryptoSetupStore.sharedInstance()); + + let content; + if (status === "error") { + content = ( +
+

{_t("encryption|unable_to_setup_keys_error")}

+
+ +
+
+ ); + } else { + content = ( +
+ +
+ ); + } + + return ( + +
{content}
+
+ ); +}; diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 20d6825b9b..a87b7341e7 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -1253,7 +1253,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n {filterToLabel(filter)} = ButtonProps & { +type TooltipOptionProps = ButtonProps & { + className?: string; endAdornment?: ReactNode; inputRef?: Ref; }; -export const TooltipOption = ({ +export const TooltipOption = ({ inputRef, className, - element, ...props }: TooltipOptionProps): JSX.Element => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); @@ -34,7 +34,6 @@ export const TooltipOption = ({ tabIndex={-1} aria-selected={isActive} role="option" - element={element as keyof JSX.IntrinsicElements} /> ); }; diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index 43d123676b..a1b1986f47 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -168,7 +168,7 @@ export const NetworkDropdown: React.FC = ({ protocols, config, setConfig adornment: ( setUserDefinedServers(without(userDefinedServers, roomServer))} /> ), diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index b8b5297384..8b58f251c3 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -6,7 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, forwardRef, FunctionComponent, HTMLAttributes, InputHTMLAttributes, Ref } from "react"; +import React, { + ComponentProps, + ComponentPropsWithoutRef, + forwardRef, + FunctionComponent, + ReactElement, + KeyboardEvent, + Ref, +} from "react"; import classnames from "classnames"; import { Tooltip } from "@vector-im/compound-web"; @@ -38,20 +46,8 @@ export type AccessibleButtonKind = | "icon_primary" | "icon_primary_outline"; -/** - * This type construct allows us to specifically pass those props down to the element we’re creating that the element - * actually supports. - * - * e.g., if element is set to "a", we’ll support href and target, if it’s set to "input", we support type. - * - * To remain compatible with existing code, we’ll continue to support InputHTMLAttributes - */ -type DynamicHtmlElementProps = - JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps : DynamicElementProps<"div">; -type DynamicElementProps = Partial< - Omit -> & - Omit, "onClick">; +type ElementType = keyof HTMLElementTagNameMap; +const defaultElement = "div"; type TooltipProps = ComponentProps; @@ -60,7 +56,7 @@ type TooltipProps = ComponentProps; * * Extends props accepted by the underlying element specified using the `element` prop. */ -type Props = DynamicHtmlElementProps & { +type Props = { /** * The base element type. "div" by default. */ @@ -105,14 +101,12 @@ type Props = DynamicHtmlElementProps & disableTooltip?: TooltipProps["disabled"]; }; -export type ButtonProps = Props; +export type ButtonProps = Props & Omit, keyof Props>; /** * Type of the props passed to the element that is rendered by AccessibleButton. */ -interface RenderedElementProps extends React.InputHTMLAttributes { - ref?: React.Ref; -} +type RenderedElementProps = React.InputHTMLAttributes & RefProp; /** * AccessibleButton is a generic wrapper for any element that should be treated @@ -124,9 +118,9 @@ interface RenderedElementProps extends React.InputHTMLAttributes { * @param {Object} props react element properties * @returns {Object} rendered react */ -const AccessibleButton = forwardRef(function ( +const AccessibleButton = forwardRef(function ( { - element = "div" as T, + element, onClick, children, kind, @@ -141,10 +135,10 @@ const AccessibleButton = forwardRef(function , - ref: Ref, + }: ButtonProps, + ref: Ref, ): JSX.Element { - const newProps: RenderedElementProps = restProps; + const newProps = restProps as RenderedElementProps; newProps["aria-label"] = newProps["aria-label"] ?? title; if (disabled) { newProps["aria-disabled"] = true; @@ -162,7 +156,7 @@ const AccessibleButton = forwardRef(function { + newProps.onKeyDown = (e: KeyboardEvent) => { const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { @@ -178,7 +172,7 @@ const AccessibleButton = forwardRef(function { + newProps.onKeyUp = (e: KeyboardEvent) => { const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { @@ -207,7 +201,7 @@ const AccessibleButton = forwardRef(function { + ref?: Ref; +} + +interface ButtonComponent { + // With the explicit `element` prop + (props: { element?: C } & ButtonProps & RefProp): ReactElement; + // Without the explicit `element` prop + (props: ButtonProps<"div"> & RefProp<"div">): ReactElement; +} + +export default AccessibleButton as ButtonComponent; diff --git a/src/components/views/elements/EditableItemList.tsx b/src/components/views/elements/EditableItemList.tsx index dc6e6c09a1..ad2d9aceee 100644 --- a/src/components/views/elements/EditableItemList.tsx +++ b/src/components/views/elements/EditableItemList.tsx @@ -133,12 +133,7 @@ export default class EditableItemList

extends React.PureComponent - + {_t("action|add")} diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index ad7d9c825e..3e5a5ead60 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -58,10 +58,11 @@ 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/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index c3dfb24bd1..a852122b75 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -31,7 +31,7 @@ class Emoji extends React.PureComponent { return ( onClick(ev, emoji)} + onClick={(ev: ButtonEvent) => onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" diff --git a/src/components/views/messages/MPollEndBody.tsx b/src/components/views/messages/MPollEndBody.tsx index 94671fea12..1129b3538e 100644 --- a/src/components/views/messages/MPollEndBody.tsx +++ b/src/components/views/messages/MPollEndBody.tsx @@ -90,7 +90,7 @@ export const MPollEndBody = React.forwardRef(({ mxEvent, ...pro const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent); if (!pollStartEvent) { - const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent, cli); + const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent, cli); return ( <> diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 9d21b8fa45..579db054e9 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -435,7 +435,7 @@ export default class MessageActionBar extends React.PureComponent this.onPinClick(e, isPinned)} + onClick={(e: ButtonEvent) => this.onPinClick(e, isPinned)} onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)} key="pin" placement="left" diff --git a/src/components/views/rooms/PinnedMessageBanner.tsx b/src/components/views/rooms/PinnedMessageBanner.tsx index f44b4417c9..32000d5792 100644 --- a/src/components/views/rooms/PinnedMessageBanner.tsx +++ b/src/components/views/rooms/PinnedMessageBanner.tsx @@ -6,10 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX, useEffect, useState } from "react"; +import React, { JSX, useEffect, useRef, useState } from "react"; import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-solid"; import { Button } from "@vector-im/compound-web"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents"; @@ -25,6 +25,7 @@ import { Action } from "../../../dispatcher/actions"; import MessageEvent from "../messages/MessageEvent"; import PosthogTrackers from "../../../PosthogTrackers.ts"; import { EventPreview } from "./EventPreview.tsx"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; /** * The props for the {@link PinnedMessageBanner} component. @@ -38,12 +39,20 @@ interface PinnedMessageBannerProps { * The room where the banner is displayed */ room: Room; + /** + * The resize notifier to notify the timeline to resize itself when the banner is displayed or hidden. + */ + resizeNotifier: ResizeNotifier; } /** * A banner that displays the pinned messages in a room. */ -export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBannerProps): JSX.Element | null { +export function PinnedMessageBanner({ + room, + permalinkCreator, + resizeNotifier, +}: PinnedMessageBannerProps): JSX.Element | null { const pinnedEventIds = usePinnedEvents(room); const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds); const eventCount = pinnedEvents.length; @@ -56,6 +65,8 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan }, [eventCount]); const pinnedEvent = pinnedEvents[currentEventIndex]; + useNotifyTimeline(pinnedEvent, resizeNotifier); + if (!pinnedEvent) return null; const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure(); @@ -128,6 +139,23 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan ); } +/** + * When the banner is displayed or hidden, we want to notify the timeline to resize itself. + * @param pinnedEvent + * @param resizeNotifier + */ +function useNotifyTimeline(pinnedEvent: MatrixEvent | null, resizeNotifier: ResizeNotifier): void { + const previousEvent = useRef(null); + useEffect(() => { + // If we switch from a pinned message to no pinned message or the opposite, we want to resize the timeline + if ((previousEvent.current && !pinnedEvent) || (!previousEvent.current && pinnedEvent)) { + resizeNotifier.notifyTimelineHeightChanged(); + } + + previousEvent.current = pinnedEvent; + }, [pinnedEvent, resizeNotifier]); +} + const MAX_INDICATORS = 3; /** diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index a28d274646..98597c7360 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 Please see LICENSE files in the repository root for full details. */ -import React, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react"; +import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } 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 = useMemo(() => getDefaultContextValue({ editorStateTransfer }), []); + const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; @@ -55,7 +55,7 @@ export default function EditWysiwygComposer({ } return ( - + getDefaultContextValue({ eventRelation: props.eventRelation }), []); + const defaultContextValue = useRef(getDefaultContextValue({ eventRelation: props.eventRelation })); return ( - + } diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx index 8ed6461d0a..6dc3ae48a2 100644 --- a/src/components/views/settings/SetIdServer.tsx +++ b/src/components/views/settings/SetIdServer.tsx @@ -407,7 +407,6 @@ export default class SetIdServer extends React.Component { forceValidity={this.state.error ? false : undefined} /> new ThemeWatcher(), []); + const themeWatcher = useRef(new ThemeWatcher()); const customThemeEnabled = useSettingValue("feature_custom_themes"); return ( - {themeWatcher.isSystemThemeSupported() && ( + {themeWatcher.current.isSystemThemeSupported() && ( )} diff --git a/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx b/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx index e7839b71da..a04430a0c2 100644 --- a/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx +++ b/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx @@ -7,23 +7,21 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { ComponentProps } from "react"; +import React from "react"; import { ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../../languageHandler"; -import AccessibleButton from "../../elements/AccessibleButton"; +import AccessibleButton, { ButtonProps } from "../../elements/AccessibleButton"; -type Props = Omit< - ComponentProps>, - "aria-label" | "title" | "kind" | "className" | "onClick" | "element" +type Props = Omit< + ButtonProps, + "aria-label" | "title" | "kind" | "className" | "element" > & { isExpanded: boolean; - onClick: () => void; }; -export const DeviceExpandDetailsButton = ({ +export const DeviceExpandDetailsButton = ({ isExpanded, - onClick, ...rest }: Props): JSX.Element => { const label = isExpanded ? _t("settings|sessions|hide_details") : _t("settings|sessions|show_details"); @@ -36,7 +34,6 @@ export const DeviceExpandDetailsButton = className={classNames("mx_DeviceExpandDetailsButton", { mx_DeviceExpandDetailsButton_expanded: isExpanded, })} - onClick={onClick} > diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx index 14de26629b..783ea1bce3 100644 --- a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -8,7 +8,6 @@ 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"; @@ -25,49 +24,48 @@ interface ElementCallSwitchProps { const ElementCallSwitch: React.FC = ({ room }) => { const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); - const [content, maySend] = useRoomState( + const [content, events, 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 content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + return 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 = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0; + const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; const moderatorLevel = content.kick ?? 50; - newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; - newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; } else { - const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; - newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; - newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent); + room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, { + events: events, + ...content, + }); }, - [room.client, room.roomId, content, isPublic], + [room.client, room.roomId, content, events, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx index 9ad7df31e9..3e86d779ff 100644 --- a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx @@ -268,7 +268,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> onChange={this.onPersonalRuleChanged} /> value={this.state.newList} onChange={this.onNewListChanged} /> - + {_t("action|subscribe")} diff --git a/src/components/views/spaces/QuickThemeSwitcher.tsx b/src/components/views/spaces/QuickThemeSwitcher.tsx index f4c229ae04..195fcb9899 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/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index 8311e6728e..63a70a97cd 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -71,7 +71,6 @@ export const SpaceAvatar: React.FC avatarUploadRef.current?.click()} - alt="" /> avatarUploadRef.current?.click()} diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index af484445b4..73bb66af38 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -221,7 +221,7 @@ const CreateSpaceButton: React.FC { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); useEffect(() => { if (!isPanelCollapsed && menuDisplayed) { diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index cee4cf54ec..38329c39b7 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -30,7 +30,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent, ButtonProps as AccessibleButtonProps } from "../elements/AccessibleButton"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; @@ -39,8 +39,8 @@ import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -type ButtonProps = Omit< - ComponentProps>, +type ButtonProps = Omit< + AccessibleButtonProps, "title" | "onClick" | "size" | "element" > & { space?: Room; @@ -52,12 +52,12 @@ type ButtonProps = Omit< notificationState?: NotificationState; isNarrow?: boolean; size: string; - innerRef?: RefObject; + innerRef?: RefObject; ContextMenuComponent?: ComponentType>; onClick?(ev?: ButtonEvent): void; }; -export const SpaceButton = ({ +export const SpaceButton = ({ space, spaceKey: _spaceKey, className, @@ -72,8 +72,8 @@ export const SpaceButton = ({ ContextMenuComponent, ...props }: ButtonProps): JSX.Element => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(innerRef); - const [onFocus, isActive, ref] = useRovingTabIndex(handle); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(innerRef); + const [onFocus, isActive, ref] = useRovingTabIndex(handle); const tabIndex = isActive ? 0 : -1; const spaceKey = _spaceKey ?? space?.roomId; diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index 1ea10bed68..110c9d51f8 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, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -42,12 +42,14 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result { setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs)); }, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]); - const scheduleUpdate = useMemo( - () => - throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { - leading: false, - trailing: true, - }), + // 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, + }), [doUpdate], ); diff --git a/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx index bdcd3713cb..105736d04e 100644 --- a/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx @@ -69,7 +69,7 @@ interface IDropdownButtonProps extends ButtonProps { } const LegacyCallViewDropdownButton: React.FC = ({ state, deviceKinds, ...props }) => { - const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); const [hoveringDropdown, setHoveringDropdown] = useState(false); const classes = classNames("mx_LegacyCallViewButtons_button", "mx_LegacyCallViewButtons_dropdownButton", { diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index e9bd392a60..4ae4875c96 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 Please see LICENSE files in the repository root for full details. */ -import { ReactNode, createContext, useCallback, useContext, useEffect, useState, useMemo } from "react"; +import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } 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 = useMemo(() => new ToastRack(), []); + const toastRack = useRef(new ToastRack()); - const [activeToast, setActiveToast] = useState(toastRack.getActiveToast()); + const [activeToast, setActiveToast] = useState(toastRack.current.getActiveToast()); const updateCallback = useCallback(() => { - setActiveToast(toastRack.getActiveToast()); + setActiveToast(toastRack.current.getActiveToast()); }, [setActiveToast, toastRack]); useEffect(() => { - toastRack.setCallback(updateCallback); + toastRack.current.setCallback(updateCallback); }, [toastRack, updateCallback]); - return [activeToast, toastRack]; + return [activeToast, toastRack.current]; } interface DisplayedToast { diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index f8298a28e9..5a4a6043fb 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2936,6 +2936,7 @@ "warning": "WARNUNG: " }, "share": { + "link_copied": "Link kopiert", "permalink_message": "Link zur ausgewählten Nachricht", "permalink_most_recent": "Link zur aktuellsten Nachricht", "share_call": "Konferenzeinladungslink", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fe5e54e559..e9ac73b48b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -398,7 +398,7 @@ }, "bug_reporting": { "additional_context": "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.", - "before_submitting": "Before submitting logs, you must create a GitHub issue to describe your problem.", + "before_submitting": "We recommend creating a GitHub issue to ensure that your report is reviewed.", "collecting_information": "Collecting app version information", "collecting_logs": "Collecting logs", "create_new_issue": "Please create a new issue on GitHub so that we can investigate this bug.", diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index 3d5833446c..66ac807080 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -340,7 +340,7 @@ "set_email_prompt": "Czy chcesz ustawić adres e-mail?", "sign_in_description": "Użyj swojego konta, aby kontynuować.", "sign_in_instead": "Zamiast tego zaloguj się", - "sign_in_instead_prompt": "Zamiast tego zaloguj się", + "sign_in_instead_prompt": "Masz już konto? Zaloguj się tutaj", "sign_in_or_register": "Zaloguj się lub utwórz konto", "sign_in_or_register_description": "Użyj konta lub utwórz nowe, aby kontynuować.", "sign_in_prompt": "Posiadasz już konto? Zaloguj się", @@ -505,6 +505,7 @@ "matrix": "Matrix", "message": "Wiadomość", "message_layout": "Wygląd wiadomości", + "message_timestamp_invalid": "Nieprawidłowy znacznik czasu", "microphone": "Mikrofon", "model": "Model", "modern": "Współczesny", @@ -908,6 +909,8 @@ "warning": "Jeżeli nie ustawiłeś nowej metody odzyskiwania, atakujący może uzyskać dostęp do Twojego konta. Zmień hasło konta i natychmiast ustaw nową metodę odzyskiwania w Ustawieniach." }, "not_supported": "", + "pinned_identity_changed": "Tożsamość użytkownika %(displayName)s (%(userId)s) uległa zmianie. Dowiedz się więcej", + "pinned_identity_changed_no_displayname": "Tożsamość użytkownika %(userId)s uległa zmianie Dowiedz się więcej", "recovery_method_removed": { "description_1": "Ta sesja wykryła, że Twoja fraza bezpieczeństwa i klucz dla bezpiecznych wiadomości zostały usunięte.", "description_2": "Jeśli zrobiłeś to przez pomyłkę, możesz ustawić bezpieczne wiadomości w tej sesji, co zaszyfruje ponownie historię wiadomości za pomocą nowej metody odzyskiwania.", @@ -996,7 +999,7 @@ "unverified_sessions_toast_description": "Sprawdź, by upewnić się że Twoje konto jest bezpieczne", "unverified_sessions_toast_reject": "Później", "unverified_sessions_toast_title": "Masz niezweryfikowane sesje", - "verification_description": "Zweryfikuj swoją tożsamość, aby uzyskać dostęp do wiadomości szyfrowanych i potwierdzić swoją tożsamość innym.", + "verification_description": "Zweryfikuj swoją tożsamość, aby uzyskać dostęp do wiadomości szyfrowanych i potwierdzić swoją tożsamość innym. Jeśli korzystasz z urządzenia mobilnego, otwórz na niej aplikację.", "verification_dialog_title_device": "Zweryfikuj drugie urządzenie", "verification_dialog_title_user": "Żądanie weryfikacji", "verification_skip_warning": "Bez weryfikacji, nie będziesz posiadać dostępu do wszystkich swoich wiadomości, a inni będą Cię widzieć jako niezaufanego.", @@ -1102,7 +1105,15 @@ "you": "Dodano reakcję %(reaction)s do %(message)s" }, "m.sticker": "%(senderName)s: %(stickerName)s", - "m.text": "%(senderName)s: %(message)s" + "m.text": "%(senderName)s: %(message)s", + "prefix": { + "audio": "Audio", + "file": "Plik", + "image": "Obraz", + "poll": "Ankieta", + "video": "Wideo" + }, + "preview": "%(prefix)s: %(preview)s" }, "export_chat": { "cancelled": "Eksport został anulowany", @@ -2939,6 +2950,7 @@ "warning": "OSTRZEŻENIE: " }, "share": { + "link_copied": "Skopiowano link", "permalink_message": "Link do zaznaczonej wiadomości", "permalink_most_recent": "Link do najnowszej wiadomości", "share_call": "Link zaproszenia do konferencji", @@ -3247,8 +3259,8 @@ "historical_event_no_key_backup": "Historia wiadomości nie jest dostępna na tym urządzeniu", "historical_event_unverified_device": "Musisz zweryfikować to urządzenie, aby wyświetlić historię wiadomości", "historical_event_user_not_joined": "Nie masz dostępu do tej wiadomości", - "sender_identity_previously_verified": "Zweryfikowana tożsamość uległa zmianie", - "sender_unsigned_device": "Zaszyfrowano przez urządzenie niezweryfikowane przez właściciela.", + "sender_identity_previously_verified": "Zweryfikowana tożsamość nadawcy uległa zmianie", + "sender_unsigned_device": "Wysłano z niezabezpieczonego urządzenia.", "unable_to_decrypt": "Nie można rozszyfrować wiadomości" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", @@ -3720,6 +3732,7 @@ "error_files_too_large": "Te pliki są zbyt duże do wysłania. Ograniczenie wielkości plików to %(limit)s.", "error_some_files_too_large": "Niektóre pliki są zbyt duże do wysłania. Ograniczenie wielkości plików to %(limit)s.", "error_title": "Błąd wysyłania", + "not_image": "Wybrany plik nie jest prawidłowym plikiem obrazu.", "title": "Prześlij pliki", "title_progress": "Prześlij pliki (%(current)s z %(total)s)", "upload_all_button": "Prześlij wszystko", diff --git a/src/stores/InitialCryptoSetupStore.ts b/src/stores/InitialCryptoSetupStore.ts new file mode 100644 index 0000000000..0c2e49f5ca --- /dev/null +++ b/src/stores/InitialCryptoSetupStore.ts @@ -0,0 +1,140 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import EventEmitter from "events"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { useEffect, useState } from "react"; + +import { createCrossSigning } from "../CreateCrossSigning"; +import { SdkContextClass } from "../contexts/SDKContext"; + +type Status = "in_progress" | "complete" | "error" | undefined; + +export const useInitialCryptoSetupStatus = (store: InitialCryptoSetupStore): Status => { + const [status, setStatus] = useState(store.getStatus()); + + useEffect(() => { + const update = (): void => { + setStatus(store.getStatus()); + }; + + store.on("update", update); + + return () => { + store.off("update", update); + }; + }, [store]); + + return status; +}; + +/** + * Logic for setting up crypto state that's done immediately after + * a user registers. Should be transparent to the user, not requiring + * interaction in most cases. + * As distinct from SetupEncryptionStore which is for setting up + * 4S or verifying the device, will always require interaction + * from the user in some form. + */ +export class InitialCryptoSetupStore extends EventEmitter { + private status: Status = undefined; + + private client?: MatrixClient; + private isTokenLogin?: boolean; + private stores?: SdkContextClass; + private onFinished?: (success: boolean) => void; + + public static sharedInstance(): InitialCryptoSetupStore { + if (!window.mxInitialCryptoStore) window.mxInitialCryptoStore = new InitialCryptoSetupStore(); + return window.mxInitialCryptoStore; + } + + public getStatus(): Status { + return this.status; + } + + /** + * Start the initial crypto setup process. + * + * @param {MatrixClient} client The client to use for the setup + * @param {boolean} isTokenLogin True if the user logged in via a token login, otherwise false + * @param {SdkContextClass} stores The stores to use for the setup + */ + public startInitialCryptoSetup( + client: MatrixClient, + isTokenLogin: boolean, + stores: SdkContextClass, + onFinished: (success: boolean) => void, + ): void { + this.client = client; + this.isTokenLogin = isTokenLogin; + this.stores = stores; + this.onFinished = onFinished; + + // We just start this process: it's progress is tracked by the events rather + // than returning a promise, so we don't bother. + this.doSetup().catch(() => logger.error("Initial crypto setup failed")); + } + + /** + * Retry the initial crypto setup process. + * + * If no crypto setup is currently in process, this will return false. + * + * @returns {boolean} True if a retry was initiated, otherwise false + */ + public retry(): boolean { + if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) return false; + + this.doSetup().catch(() => logger.error("Initial crypto setup failed")); + + return true; + } + + private reset(): void { + this.client = undefined; + this.isTokenLogin = undefined; + this.stores = undefined; + } + + private async doSetup(): Promise { + if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) { + throw new Error("No setup is in progress"); + } + + const cryptoApi = this.client.getCrypto(); + if (!cryptoApi) throw new Error("No crypto module found!"); + + this.status = "in_progress"; + this.emit("update"); + + try { + await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword()); + + this.reset(); + + this.status = "complete"; + this.emit("update"); + this.onFinished?.(true); + } catch (e) { + if (this.isTokenLogin) { + // ignore any failures, we are relying on grace period here + this.reset(); + + this.status = "complete"; + this.emit("update"); + this.onFinished?.(true); + + return; + } + logger.error("Error bootstrapping cross-signing", e); + this.status = "error"; + this.emit("update"); + } + } +} diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 70c721b1ca..a13ba26f72 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -33,6 +33,11 @@ export enum Phase { ConfirmReset = 6, } +/** + * Logic for setting up 4S and/or verifying the user's device: a process requiring + * ongoing interaction with the user, as distinct from InitialCryptoSetupStore which + * a (usually) non-interactive process that happens immediately after registration. + */ export class SetupEncryptionStore extends EventEmitter { private started?: boolean; public phase?: Phase; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 5bc2ac7fc0..de7a71fa80 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -194,6 +194,7 @@ export class StopGapWidgetDriver extends WidgetDriver { EventType.CallSDPStreamMetadataChanged, EventType.CallSDPStreamMetadataChangedPrefix, EventType.CallReplaces, + EventType.CallEncryptionKeysPrefix, ]; for (const eventType of sendRecvToDevice) { this.allowedCapabilities.add( diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index 300a15a4ec..308aedc205 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { useEffect, useMemo } from "react"; +import { useEffect, useState } from "react"; import type { Map as MapLibreMap } from "maplibre-gl"; import { createMap } from "./map"; @@ -26,25 +26,29 @@ interface UseMapProps { */ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreMap | undefined => { const cli = useMatrixClientContext(); + const [map, setMap] = useState(); - const map = useMemo(() => { - try { - return createMap(cli, !!interactive, bodyId, onError); - } catch (error) { - console.error("Error encountered in useMap", error); - if (error instanceof Error) { - onError?.(error); + useEffect( + () => { + try { + setMap(createMap(cli, !!interactive, bodyId, onError)); + } catch (error) { + console.error("Error encountered in useMap", error); + if (error instanceof Error) { + onError?.(error); + } } - } - }, [bodyId, cli, interactive, onError]); - - // cleanup - useEffect(() => { - if (!map) return; - return () => { - map.remove(); - }; - }, [map]); + return () => { + if (map) { + map.remove(); + setMap(undefined); + } + }; + }, + // map is excluded as a dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + [interactive, bodyId, onError], + ); return map; }; diff --git a/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx b/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx deleted file mode 100644 index 3e5dc4eb94..0000000000 --- a/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2018-2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { render, screen, waitFor } from "jest-matrix-react"; -import { mocked } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { createCrossSigning } from "../../../../../src/CreateCrossSigning"; -import CreateCrossSigningDialog from "../../../../../src/components/views/dialogs/security/CreateCrossSigningDialog"; -import { createTestClient } from "../../../../test-utils"; - -jest.mock("../../../../../src/CreateCrossSigning", () => ({ - createCrossSigning: jest.fn(), -})); - -describe("CreateCrossSigningDialog", () => { - let client: MatrixClient; - let createCrossSigningResolve: () => void; - let createCrossSigningReject: (e: Error) => void; - - beforeEach(() => { - client = createTestClient(); - mocked(createCrossSigning).mockImplementation(() => { - return new Promise((resolve, reject) => { - createCrossSigningResolve = resolve; - createCrossSigningReject = reject; - }); - }); - }); - - afterEach(() => { - jest.resetAllMocks(); - jest.restoreAllMocks(); - }); - - it("should call createCrossSigning and show a spinner while it runs", async () => { - const onFinished = jest.fn(); - - render( - , - ); - - expect(createCrossSigning).toHaveBeenCalledWith(client, false, "hunter2"); - expect(screen.getByTestId("spinner")).toBeInTheDocument(); - - createCrossSigningResolve!(); - - await waitFor(() => expect(onFinished).toHaveBeenCalledWith(true)); - }); - - it("should display an error if createCrossSigning fails", async () => { - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - await expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); - }); - - it("ignores failures when tokenLogin is true", async () => { - const onFinished = jest.fn(); - - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - await waitFor(() => expect(onFinished).toHaveBeenCalledWith(false)); - }); - - it("cancels the dialog when the cancel button is clicked", async () => { - const onFinished = jest.fn(); - - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - const cancelButton = await screen.findByRole("button", { name: "Cancel" }); - cancelButton.click(); - - expect(onFinished).toHaveBeenCalledWith(false); - }); - - it("should retry when the retry button is clicked", async () => { - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - const retryButton = await screen.findByRole("button", { name: "Retry" }); - retryButton.click(); - - expect(createCrossSigning).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx b/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx new file mode 100644 index 0000000000..a589b55289 --- /dev/null +++ b/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx @@ -0,0 +1,61 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2018-2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { InitialCryptoSetupDialog } from "../../../../../src/components/views/dialogs/security/InitialCryptoSetupDialog"; +import { InitialCryptoSetupStore } from "../../../../../src/stores/InitialCryptoSetupStore"; + +describe("InitialCryptoSetupDialog", () => { + const storeMock = { + getStatus: jest.fn(), + retry: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }; + + beforeEach(() => { + jest.spyOn(InitialCryptoSetupStore, "sharedInstance").mockReturnValue(storeMock as any); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("should show a spinner while the setup is in progress", async () => { + const onFinished = jest.fn(); + + storeMock.getStatus.mockReturnValue("in_progress"); + + render(); + + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("should display an error if setup has failed", async () => { + storeMock.getStatus.mockReturnValue("error"); + + render(); + + await expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("calls retry when retry button pressed", async () => { + const onFinished = jest.fn(); + storeMock.getStatus.mockReturnValue("error"); + + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Retry" })); + + expect(storeMock.retry).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 94c2678388..1bdbe016d4 100644 --- a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -314,7 +314,6 @@ exports[` with a soft-logged-out session should show the soft-logo class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary" role="button" tabindex="0" - type="submit" > Sign in diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index 5fb1e66115..a4496312f3 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -135,8 +135,9 @@ exports[` has button to edit topic 1`] = ` style="--mx-box-flex: 1;" >

renders the room summary 1`] = ` style="--mx-box-flex: 1;" >

renders the room topic in the summary 1`] = ` style="--mx-box-flex: 1;" >

", () => { const userId = "@alice:server.org"; @@ -28,10 +29,12 @@ describe("", () => { let mockClient: MatrixClient; let room: Room; let permalinkCreator: RoomPermalinkCreator; + let resizeNotifier: ResizeNotifier; beforeEach(() => { mockClient = stubClient(); room = new Room(roomId, mockClient, userId); permalinkCreator = new RoomPermalinkCreator(room); + resizeNotifier = new ResizeNotifier(); jest.spyOn(dis, "dispatch").mockReturnValue(undefined); }); @@ -77,7 +80,7 @@ describe("", () => { */ function renderBanner() { return render( - , + , withClientContextRenderOptions(mockClient), ); } @@ -145,7 +148,9 @@ describe("", () => { event3.getId()!, ]); jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3]); - rerender(); + rerender( + , + ); await expect(screen.findByText("Third pinned message")).resolves.toBeVisible(); expect(asFragment()).toMatchSnapshot(); }); @@ -206,6 +211,42 @@ describe("", () => { expect(asFragment()).toMatchSnapshot(); }); + describe("Notify the timeline to resize", () => { + beforeEach(() => { + jest.spyOn(resizeNotifier, "notifyTimelineHeightChanged"); + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]); + }); + + it("should notify the timeline to resize when we display the banner", async () => { + renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + // The banner is displayed, so we need to resize the timeline + expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + await expect(screen.findByText("First pinned message")).resolves.toBeVisible(); + // The banner is already displayed, so we don't need to resize the timeline + expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(1); + }); + + it("should notify the timeline to resize when we hide the banner", async () => { + const { rerender } = renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + // The banner is displayed, so we need to resize the timeline + expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(1); + + // The banner has no event to display and is hidden + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([]); + rerender( + , + ); + // The timeline should be resized + expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(2); + }); + }); + describe("Right button", () => { beforeEach(() => { jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]); @@ -217,6 +258,8 @@ describe("", () => { jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false); renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + expect(screen.getByRole("button", { name: "View all" })).toBeVisible(); }); @@ -228,6 +271,8 @@ describe("", () => { }); renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + expect(screen.getByRole("button", { name: "View all" })).toBeVisible(); }); @@ -239,6 +284,8 @@ describe("", () => { }); renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + expect(screen.getByRole("button", { name: "Close list" })).toBeVisible(); }); @@ -263,6 +310,7 @@ describe("", () => { }); renderBanner(); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); expect(screen.getByRole("button", { name: "Close list" })).toBeVisible(); jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false); diff --git a/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx b/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx index ff89aa6942..13500f9b1f 100644 --- a/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx +++ b/test/unit-tests/components/views/rooms/ReadReceiptGroup-test.tsx @@ -10,6 +10,7 @@ import React, { ComponentProps } from "react"; import { render, screen, waitFor } from "jest-matrix-react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; import { determineAvatarPosition, @@ -20,6 +21,9 @@ import * as languageHandler from "../../../../../src/languageHandler"; import { stubClient } from "../../../../test-utils"; import dispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; +import { formatDate } from "../../../../../src/DateUtils"; + +jest.mock("../../../../../src/DateUtils"); describe("ReadReceiptGroup", () => { describe("TooltipText", () => { @@ -87,6 +91,10 @@ describe("ReadReceiptGroup", () => { describe("", () => { stubClient(); + // We pick a fixed time but this can still vary depending on the locale + // the tests are run in. We are not testing date formatting here, so stub it out. + mocked(formatDate).mockReturnValue("==MOCK FORMATTED DATE=="); + const ROOM_ID = "roomId"; const USER_ID = "@alice:example.org"; diff --git a/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap index b0ba944a66..60e8f844af 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap @@ -84,7 +84,7 @@ exports[`ReadReceiptGroup should render 1`] = `

- Wed, 15 May, 0:00 + ==MOCK FORMATTED DATE==

diff --git a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx index 888499d524..5c77e88d93 100644 --- a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx +++ b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx @@ -35,7 +35,7 @@ describe("SetIntegrationManager", () => { deleteThreePid: jest.fn(), }); - let stores: SdkContextClass; + let stores!: SdkContextClass; const getComponent = () => ( diff --git a/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap index fcf3406620..2aa08adb94 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap @@ -19,14 +19,14 @@ exports[` should render 1`] = ` class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi" >