diff --git a/CHANGELOG.md b/CHANGELOG.md index 287e17ddc05..40cf7259ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +Changes in [3.72.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.72.0) (2023-05-10) +===================================================================================================== + +## ✨ Features + * Add UIFeature.locationSharing to hide location sharing ([\#10727](https://github.com/matrix-org/matrix-react-sdk/pull/10727)). + * Memoize field validation results ([\#10714](https://github.com/matrix-org/matrix-react-sdk/pull/10714)). + * Commands for plain text editor ([\#10567](https://github.com/matrix-org/matrix-react-sdk/pull/10567)). Contributed by @alunturner. + * Allow 16 lines of text in the rich text editors ([\#10670](https://github.com/matrix-org/matrix-react-sdk/pull/10670)). Contributed by @alunturner. + * Bail out of `RoomSettingsDialog` when room is not found ([\#10662](https://github.com/matrix-org/matrix-react-sdk/pull/10662)). Contributed by @kerryarchibald. + * Element-R: Populate device list for right-panel ([\#10671](https://github.com/matrix-org/matrix-react-sdk/pull/10671)). Contributed by @florianduros. + * Make existing and new issue URLs configurable ([\#10710](https://github.com/matrix-org/matrix-react-sdk/pull/10710)). Fixes vector-im/element-web#24424. + * Fix usages of ARIA tabpanel ([\#10628](https://github.com/matrix-org/matrix-react-sdk/pull/10628)). Fixes vector-im/element-web#25016. + * Element-R: Starting a DMs with a user ([\#10673](https://github.com/matrix-org/matrix-react-sdk/pull/10673)). Contributed by @florianduros. + * ARIA Accessibility improvements ([\#10675](https://github.com/matrix-org/matrix-react-sdk/pull/10675)). + * ARIA Accessibility improvements ([\#10674](https://github.com/matrix-org/matrix-react-sdk/pull/10674)). + * Add arrow key controls to emoji and reaction pickers ([\#10637](https://github.com/matrix-org/matrix-react-sdk/pull/10637)). Fixes vector-im/element-web#17189. + * Translate credits in help about section ([\#10676](https://github.com/matrix-org/matrix-react-sdk/pull/10676)). + +## 🐛 Bug Fixes + * Fix: reveal images when image previews are disabled ([\#10781](https://github.com/matrix-org/matrix-react-sdk/pull/10781)). Fixes vector-im/element-web#25271. Contributed by @kerryarchibald. + * Fix autocomplete not resetting properly on message send ([\#10741](https://github.com/matrix-org/matrix-react-sdk/pull/10741)). Fixes vector-im/element-web#25170. + * Fix start_sso not working with guests disabled ([\#10720](https://github.com/matrix-org/matrix-react-sdk/pull/10720)). Fixes vector-im/element-web#16624. + * Fix soft crash with Element call widgets ([\#10684](https://github.com/matrix-org/matrix-react-sdk/pull/10684)). + * Send correct receipt when marking a room as read ([\#10730](https://github.com/matrix-org/matrix-react-sdk/pull/10730)). Fixes vector-im/element-web#25207. + * Offload some more waveform processing onto a worker ([\#9223](https://github.com/matrix-org/matrix-react-sdk/pull/9223)). Fixes vector-im/element-web#19756. + * Consolidate login errors ([\#10722](https://github.com/matrix-org/matrix-react-sdk/pull/10722)). Fixes vector-im/element-web#17520. + * Fix all rooms search generating permalinks to wrong room id ([\#10625](https://github.com/matrix-org/matrix-react-sdk/pull/10625)). Fixes vector-im/element-web#25115. + * Posthog properly handle Analytics ID changing from under us ([\#10702](https://github.com/matrix-org/matrix-react-sdk/pull/10702)). Fixes vector-im/element-web#25187. + * Fix Clock being read as an absolute time rather than duration ([\#10706](https://github.com/matrix-org/matrix-react-sdk/pull/10706)). Fixes vector-im/element-web#22582. + * Properly translate errors in `ChangePassword.tsx` so they show up translated to the user but not in our logs ([\#10615](https://github.com/matrix-org/matrix-react-sdk/pull/10615)). Fixes vector-im/element-web#9597. Contributed by @MadLittleMods. + * Honour feature toggles in guest mode ([\#10651](https://github.com/matrix-org/matrix-react-sdk/pull/10651)). Fixes vector-im/element-web#24513. Contributed by @andybalaam. + * Fix default content in devtools event sender ([\#10699](https://github.com/matrix-org/matrix-react-sdk/pull/10699)). Contributed by @tulir. + * Fix a crash when a call ends while you're in it ([\#10681](https://github.com/matrix-org/matrix-react-sdk/pull/10681)). Fixes vector-im/element-web#25153. + * Fix lack of screen reader indication when triggering auto complete ([\#10664](https://github.com/matrix-org/matrix-react-sdk/pull/10664)). Fixes vector-im/element-web#11011. + * Fix typing tile duplicating users ([\#10678](https://github.com/matrix-org/matrix-react-sdk/pull/10678)). Fixes vector-im/element-web#25165. + * Fix wrong room topic tooltip position ([\#10667](https://github.com/matrix-org/matrix-react-sdk/pull/10667)). Fixes vector-im/element-web#25158. + * Fix create subspace dialog not working ([\#10652](https://github.com/matrix-org/matrix-react-sdk/pull/10652)). Fixes vector-im/element-web#24882. + Changes in [3.71.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.71.1) (2023-04-25) ===================================================================================================== diff --git a/cypress.config.ts b/cypress.config.ts index f9bc521bdd9..b57fe7f6c4a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ return require("./cypress/plugins/index.ts").default(on, config); }, baseUrl: "http://localhost:8080", - specPattern: "cypress/e2e/**/*.{js,jsx,ts,tsx}", + specPattern: "cypress/e2e/**/*.spec.{js,jsx,ts,tsx}", }, env: { // Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. diff --git a/cypress/e2e/audio-player/audio-player.spec.ts b/cypress/e2e/audio-player/audio-player.spec.ts index 9443e063115..41f7f1a8568 100644 --- a/cypress/e2e/audio-player/audio-player.spec.ts +++ b/cypress/e2e/audio-player/audio-player.spec.ts @@ -204,8 +204,9 @@ describe("Audio player", () => { // Assert that the counter is zero before clicking the play button cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - // Find and click "Play" button - cy.findByRole("button", { name: "Play" }).click(); + // Find and click "Play" button, the wait is to make the test less flaky + cy.findByRole("button", { name: "Play" }).should("exist"); + cy.wait(500).findByRole("button", { name: "Play" }).click(); // Assert that "Pause" button can be found cy.findByRole("button", { name: "Pause" }).should("exist"); @@ -339,8 +340,9 @@ describe("Audio player", () => { // Assert that the counter is zero before clicking the play button cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); - // Find and click "Play" button - cy.findByRole("button", { name: "Play" }).click(); + // Find and click "Play" button, the wait is to make the test less flaky + cy.findByRole("button", { name: "Play" }).should("exist"); + cy.wait(500).findByRole("button", { name: "Play" }).click(); // Assert that "Pause" button can be found cy.findByRole("button", { name: "Pause" }).should("exist"); @@ -349,7 +351,7 @@ describe("Audio player", () => { cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); // Assert that "Play" button can be found - cy.findByRole("button", { name: "Play" }).should("exist"); + cy.findByRole("button", { name: "Play" }).should("exist").should("not.have.attr", "disabled"); }); }) .realHover() diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 0ed67334169..85d1477116c 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -117,6 +117,70 @@ describe("Composer", () => { cy.viewRoomByName("Composing Room"); }); + describe("Commands", () => { + // TODO add tests for rich text mode + + describe("Plain text mode", () => { + it("autocomplete behaviour tests", () => { + // Select plain text mode after composer is ready + cy.get("div[contenteditable=true]").should("exist"); + cy.findByRole("button", { name: "Hide formatting" }).click(); + + // Typing a single / displays the autocomplete menu and contents + cy.findByRole("textbox").type("/"); + + // Check that the autocomplete options are visible and there are more than 0 items + cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); + + // Entering `//` or `/ ` hides the autocomplete contents + // Add an extra slash for `//` + cy.findByRole("textbox").type("/"); + cy.findByTestId("autocomplete-wrapper").should("be.empty"); + // Remove the extra slash to go back to `/` + cy.findByRole("textbox").type("{Backspace}"); + cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); + // Add a trailing space for `/ ` + cy.findByRole("textbox").type(" "); + cy.findByTestId("autocomplete-wrapper").should("be.empty"); + + // Typing a command that takes no arguments (/devtools) and selecting by click works + cy.findByRole("textbox").type("{Backspace}dev"); + cy.findByTestId("autocomplete-wrapper").within(() => { + cy.findByText("/devtools").click(); + }); + // Check it has closed the autocomplete and put the text into the composer + cy.findByTestId("autocomplete-wrapper").should("not.be.visible"); + cy.findByRole("textbox").within(() => { + cy.findByText("/devtools").should("exist"); + }); + // Send the message and check the devtools dialog appeared, then close it + cy.findByRole("button", { name: "Send message" }).click(); + cy.findByRole("dialog").within(() => { + cy.findByText("Developer Tools").should("exist"); + }); + cy.findByRole("button", { name: "Close dialog" }).click(); + + // Typing a command that takes arguments (/spoiler) and selecting with enter works + cy.findByRole("textbox").type("/spoil"); + cy.findByTestId("autocomplete-wrapper").within(() => { + cy.findByText("/spoiler").should("exist"); + }); + cy.findByRole("textbox").type("{Enter}"); + // Check it has closed the autocomplete and put the text into the composer + cy.findByTestId("autocomplete-wrapper").should("not.be.visible"); + cy.findByRole("textbox").within(() => { + cy.findByText("/spoiler").should("exist"); + }); + // Enter some more text, then send the message + cy.findByRole("textbox").type("this is the spoiler text "); + cy.findByRole("button", { name: "Send message" }).click(); + // Check that a spoiler item has appeared in the timeline and contains the spoiler command text + cy.get("span.mx_EventTile_spoiler").should("exist"); + cy.findByText("this is the spoiler text").should("exist"); + }); + }); + }); + it("sends a message when you click send or press Enter", () => { // Type a message cy.get("div[contenteditable=true]").type("my message 0"); diff --git a/cypress/e2e/crypto/complete-security.spec.ts b/cypress/e2e/crypto/complete-security.spec.ts new file mode 100644 index 00000000000..0838abd4590 --- /dev/null +++ b/cypress/e2e/crypto/complete-security.spec.ts @@ -0,0 +1,101 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; +import { handleVerificationRequest, waitForVerificationRequest } from "./utils"; +import { CypressBot } from "../../support/bot"; + +describe("Complete security", () => { + let homeserver: HomeserverInstance; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + }); + // visit the login page of the app, to load the matrix sdk + cy.visit("/#/login"); + + // wait for the page to load + cy.window({ log: false }).should("have.property", "matrixcs"); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should go straight to the welcome screen if we have no signed device", () => { + const username = Cypress._.uniqueId("user_"); + const password = "supersecret"; + cy.registerUser(homeserver, username, password, "Jeff"); + logIntoElement(homeserver.baseUrl, username, password); + cy.findByText("Welcome Jeff"); + }); + + it("should walk through device verification if we have a signed device", () => { + // create a new user, and have it bootstrap cross-signing + let botClient: CypressBot; + cy.getBot(homeserver, { displayName: "Jeff" }) + .then(async (bot) => { + botClient = bot; + await bot.bootstrapCrossSigning({}); + }) + .then(() => { + // now log in, in Element. We go in through the login page because otherwise the device setup flow + // doesn't get triggered + console.log("%cAccount set up; logging in user", "font-weight: bold; font-size:x-large"); + logIntoElement(homeserver.baseUrl, botClient.getSafeUserId(), botClient.__cypress_password); + + // we should see a prompt for a device verification + cy.findByRole("heading", { name: "Verify this device" }); + const botVerificationRequestPromise = waitForVerificationRequest(botClient); + cy.findByRole("button", { name: "Verify with another device" }).click(); + + // accept the verification request on the "bot" side + cy.wrap(botVerificationRequestPromise).then(async (verificationRequest: VerificationRequest) => { + await verificationRequest.accept(); + await handleVerificationRequest(verificationRequest); + }); + + // confirm that the emojis match + cy.findByRole("button", { name: "They match" }).click(); + + // we should get the confirmation box + cy.findByText(/You've successfully verified/); + + cy.findByRole("button", { name: "Got it" }).click(); + }); + }); +}); + +/** + * Fill in the login form in element with the given creds + */ +function logIntoElement(homeserverUrl: string, username: string, password: string) { + cy.visit("/#/login"); + + // select homeserver + cy.findByRole("button", { name: "Edit" }).click(); + cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserverUrl); + cy.findByRole("button", { name: "Continue" }).click(); + + // wait for the dialog to go away + cy.get(".mx_ServerPickerDialog").should("not.exist"); + + cy.findByRole("textbox", { name: "Username" }).type(username); + cy.findByPlaceholderText("Password").type(password); + cy.findByRole("button", { name: "Sign in" }).click(); +} diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 99e83da26a4..27c9531d44a 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -16,32 +16,18 @@ limitations under the License. import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import type { CypressBot } from "../../support/bot"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; import { UserCredentials } from "../../support/login"; +import { EmojiMapping, handleVerificationRequest, waitForVerificationRequest } from "./utils"; -type EmojiMapping = [emoji: string, name: string]; interface CryptoTestContext extends Mocha.Context { homeserver: HomeserverInstance; bob: CypressBot; } -const waitForVerificationRequest = (cli: MatrixClient): Promise => { - return new Promise((resolve) => { - const onVerificationRequestEvent = (request: VerificationRequest) => { - // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here - cli.off("crypto.verification.request", onVerificationRequestEvent); - resolve(request); - }; - // @ts-ignore - cli.on("crypto.verification.request", onVerificationRequestEvent); - }); -}; - const openRoomInfo = () => { - cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.findByRole("button", { name: "Room info" }).click(); return cy.get(".mx_RightPanel"); }; @@ -117,23 +103,6 @@ function autoJoin(client: MatrixClient) { }); } -const handleVerificationRequest = (request: VerificationRequest): Chainable => { - return cy.wrap( - new Promise((resolve) => { - const onShowSas = (event: ISasEvent) => { - verifier.off("show_sas", onShowSas); - event.confirm(); - verifier.done(); - resolve(event.sas.emoji); - }; - - const verifier = request.beginKeyVerification("m.sas.v1"); - verifier.on("show_sas", onShowSas); - verifier.verify(); - }), - ); -}; - const verify = function (this: CryptoTestContext) { const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob); @@ -150,7 +119,7 @@ const verify = function (this: CryptoTestContext) { .as("bobsVerificationRequest"); cy.findByRole("button", { name: "Verify by emoji" }).click(); cy.get("@bobsVerificationRequest").then((request: VerificationRequest) => { - return handleVerificationRequest(request).then((emojis: EmojiMapping[]) => { + return cy.wrap(handleVerificationRequest(request)).then((emojis: EmojiMapping[]) => { cy.get(".mx_VerificationShowSas_emojiSas_block").then((emojiBlocks) => { emojis.forEach((emoji: EmojiMapping, index: number) => { expect(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1]); diff --git a/cypress/e2e/crypto/decryption-failure.spec.ts b/cypress/e2e/crypto/decryption-failure.spec.ts index fa30c2f2a26..a9ace36c22a 100644 --- a/cypress/e2e/crypto/decryption-failure.spec.ts +++ b/cypress/e2e/crypto/decryption-failure.spec.ts @@ -60,7 +60,7 @@ const handleVerificationRequest = (request: VerificationRequest): Chainable { cy.viewport(800, 600); // SVGA cy.get(".mx_LeftPanel_minimized").should("exist"); // Wait until the left panel is minimized - cy.get(".mx_RightPanel_roomSummaryButton").click(); // Open the right panel to make the timeline narrow + cy.findByRole("button", { name: "Room info" }).click(); // Open the right panel to make the timeline narrow cy.get(".mx_BaseCard").should("exist"); // Ensure the failure bar does not cover the timeline @@ -74,7 +74,7 @@ const checkTimelineNarrow = (button = true) => { cy.get("[data-testid='decryption-failure-bar-button']:last-of-type").should("be.visible"); } - cy.get(".mx_RightPanel_roomSummaryButton").click(); // Close the right panel + cy.findByRole("button", { name: "Room info" }).click(); // Close the right panel cy.get(".mx_BaseCard").should("not.exist"); cy.viewport(1000, 660); // Reset to the default size }; diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts new file mode 100644 index 00000000000..6f99a23d0fd --- /dev/null +++ b/cypress/e2e/crypto/utils.ts @@ -0,0 +1,63 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; + +export type EmojiMapping = [emoji: string, name: string]; + +/** + * wait for the given client to receive an incoming verification request + * + * @param cli - matrix client we expect to receive a request + */ +export function waitForVerificationRequest(cli: MatrixClient): Promise { + return new Promise((resolve) => { + const onVerificationRequestEvent = (request: VerificationRequest) => { + // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here + cli.off("crypto.verification.request", onVerificationRequestEvent); + resolve(request); + }; + // @ts-ignore + cli.on("crypto.verification.request", onVerificationRequestEvent); + }); +} + +/** + * Handle an incoming verification request + * + * Starts the key verification process, and, once it is accepted on the other side, confirms that the + * emojis match. + * + * Returns a promise that resolves, with the emoji list, once we confirm the emojis + * + * @param request - incoming verification request + */ +export function handleVerificationRequest(request: VerificationRequest) { + return new Promise((resolve) => { + const onShowSas = (event: ISasEvent) => { + verifier.off("show_sas", onShowSas); + event.confirm(); + verifier.done(); + resolve(event.sas.emoji); + }; + + const verifier = request.beginKeyVerification("m.sas.v1"); + verifier.on("show_sas", onShowSas); + verifier.verify(); + }); +} diff --git a/cypress/e2e/integration-manager/get-openid-token.spec.ts b/cypress/e2e/integration-manager/get-openid-token.spec.ts index c1026a57876..b2dcb9146ae 100644 --- a/cypress/e2e/integration-manager/get-openid-token.spec.ts +++ b/cypress/e2e/integration-manager/get-openid-token.spec.ts @@ -59,7 +59,7 @@ const INTEGRATION_MANAGER_HTML = ` `; function openIntegrationManager() { - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); } diff --git a/cypress/e2e/integration-manager/kick.spec.ts b/cypress/e2e/integration-manager/kick.spec.ts index 4b29be4b23f..7075c1c199f 100644 --- a/cypress/e2e/integration-manager/kick.spec.ts +++ b/cypress/e2e/integration-manager/kick.spec.ts @@ -62,7 +62,7 @@ const INTEGRATION_MANAGER_HTML = ` `; function openIntegrationManager() { - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); } diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts index c331038db9c..65b195a3c72 100644 --- a/cypress/e2e/integration-manager/read_events.spec.ts +++ b/cypress/e2e/integration-manager/read_events.spec.ts @@ -65,9 +65,9 @@ const INTEGRATION_MANAGER_HTML = ` `; function openIntegrationManager() { - cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.findByRole("button", { name: "Room info" }).click(); cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.contains("Add widgets, bridges & bots").click(); + cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); }); } diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts index a62188b95e4..d8a746b4237 100644 --- a/cypress/e2e/integration-manager/send_event.spec.ts +++ b/cypress/e2e/integration-manager/send_event.spec.ts @@ -67,7 +67,7 @@ const INTEGRATION_MANAGER_HTML = ` `; function openIntegrationManager() { - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); }); diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 1efc69e0323..6e53fc33da9 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -117,7 +117,7 @@ describe("Lazy Loading", () => { function openMemberlist(): void { cy.get(".mx_HeaderButtons").within(() => { - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); }); cy.get(".mx_RoomSummaryCard").within(() => { diff --git a/cypress/e2e/polls/pollHistory.spec.ts b/cypress/e2e/polls/pollHistory.spec.ts index 93eefc49d21..00938ab768b 100644 --- a/cypress/e2e/polls/pollHistory.spec.ts +++ b/cypress/e2e/polls/pollHistory.spec.ts @@ -75,7 +75,7 @@ describe("Poll history", () => { }; function openPollHistory(): void { - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); cy.get(".mx_RoomSummaryCard").within(() => { cy.findByRole("button", { name: "Poll history" }).click(); }); diff --git a/cypress/e2e/right-panel/file-panel.spec.ts b/cypress/e2e/right-panel/file-panel.spec.ts index 318167bb1ee..f2e0e0a013b 100644 --- a/cypress/e2e/right-panel/file-panel.spec.ts +++ b/cypress/e2e/right-panel/file-panel.spec.ts @@ -24,7 +24,7 @@ const NAME = "Alice"; const viewRoomSummaryByName = (name: string): Chainable> => { cy.viewRoomByName(name); - cy.findByRole("tab", { name: "Room info" }).click(); + cy.findByRole("button", { name: "Room info" }).click(); return checkRoomSummaryCard(name); }; diff --git a/cypress/e2e/right-panel/right-panel.spec.ts b/cypress/e2e/right-panel/right-panel.spec.ts index ded88e231a4..733eb3c78fa 100644 --- a/cypress/e2e/right-panel/right-panel.spec.ts +++ b/cypress/e2e/right-panel/right-panel.spec.ts @@ -29,7 +29,7 @@ const getMemberTileByName = (name: string): Chainable> => { const viewRoomSummaryByName = (name: string): Chainable> => { cy.viewRoomByName(name); - cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.findByRole("button", { name: "Room info" }).click(); return checkRoomSummaryCard(name); }; diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts new file mode 100644 index 00000000000..2bdfb1b77d5 --- /dev/null +++ b/cypress/e2e/settings/general-user-settings-tab.spec.ts @@ -0,0 +1,239 @@ +/* +Copyright 2023 Suguru Hirahara + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +const USER_NAME = "Bob"; +const USER_NAME_NEW = "Alice"; +const IntegrationManager = "scalar.vector.im"; + +describe("General user settings tab", () => { + let homeserver: HomeserverInstance; + let userId: string; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, USER_NAME).then((user) => (userId = user.userId)); + cy.tweakConfig({ default_country_code: "US" }); // For checking the international country calling code + }); + cy.openUserSettings("General"); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should be rendered properly", () => { + // Exclude userId from snapshots + const percyCSS = ".mx_ProfileSettings_profile_controls_userId { visibility: hidden !important; }"; + + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab").percySnapshotElement("User settings tab - General", { + percyCSS, + // Emulate TabbedView's actual min and max widths + // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width + // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) + widths: [580, 796], + }); + + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab").within(() => { + // Assert that the top heading is rendered + cy.findByTestId("general").should("have.text", "General").should("be.visible"); + + cy.get(".mx_ProfileSettings_profile") + .scrollIntoView() + .within(() => { + // Assert USER_NAME is rendered + cy.findByRole("textbox", { name: "Display Name" }) + .get(`input[value='${USER_NAME}']`) + .should("be.visible"); + + // Assert that a userId is rendered + cy.get(".mx_ProfileSettings_profile_controls_userId").within(() => { + cy.findByText(userId).should("exist"); + }); + + // Check avatar setting + cy.get(".mx_AvatarSetting_avatar") + .should("exist") + .realHover() + .get(".mx_AvatarSetting_avatar_hovering") + .within(() => { + // Hover effect + cy.get(".mx_AvatarSetting_hoverBg").should("exist"); + cy.get(".mx_AvatarSetting_hover span").within(() => { + cy.findByText("Upload").should("exist"); + }); + }); + }); + + // Wait until spinners disappear + cy.get(".mx_GeneralUserSettingsTab_accountSection .mx_Spinner").should("not.exist"); + cy.get(".mx_GeneralUserSettingsTab_discovery .mx_Spinner").should("not.exist"); + + cy.get(".mx_GeneralUserSettingsTab_accountSection").within(() => { + // Assert that input areas for changing a password exists + cy.get("form.mx_GeneralUserSettingsTab_changePassword") + .scrollIntoView() + .within(() => { + cy.findByLabelText("Current password").should("be.visible"); + cy.findByLabelText("New Password").should("be.visible"); + cy.findByLabelText("Confirm password").should("be.visible"); + }); + + // Check email addresses area + cy.get(".mx_EmailAddresses") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new email address is rendered + cy.findByRole("textbox", { name: "Email Address" }).should("be.visible"); + + // Assert the add button is visible + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + + // Check phone numbers area + cy.get(".mx_PhoneNumbers") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new phone number is rendered + cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); + + // Assert that the add button is rendered + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + }); + + // Check language and region setting dropdown + cy.get(".mx_GeneralUserSettingsTab_languageInput") + .scrollIntoView() + .within(() => { + // Check the default value + cy.findByText("English").should("be.visible"); + + // Click the button to display the dropdown menu + cy.findByRole("button", { name: "Language Dropdown" }).click(); + + // Assert that the default option is rendered and highlighted + cy.findByRole("option", { name: /Bahasa Indonesia/ }) + .should("be.visible") + .should("have.class", "mx_Dropdown_option_highlight"); + + // Click again to close the dropdown + cy.findByRole("button", { name: "Language Dropdown" }).click(); + + // Assert that the default value is rendered again + cy.findByText("English").should("be.visible"); + }); + + cy.get("form.mx_SetIdServer") + .scrollIntoView() + .within(() => { + // Assert that an input area for identity server exists + cy.findByRole("textbox", { name: "Enter a new identity server" }).should("be.visible"); + }); + + cy.get(".mx_SetIntegrationManager") + .scrollIntoView() + .within(() => { + cy.contains(".mx_SetIntegrationManager_heading_manager", IntegrationManager).should("be.visible"); + + // Make sure integration manager's toggle switch is enabled + cy.get(".mx_ToggleSwitch_enabled").should("be.visible"); + + // Assert space between "Manage integrations" and the integration server address is set to 4px; + cy.get(".mx_SetIntegrationManager_heading_manager").should("have.css", "column-gap", "4px"); + + cy.get(".mx_SetIntegrationManager_heading_manager").within(() => { + cy.get(".mx_SettingsTab_heading").should("have.text", "Manage integrations"); + + // Assert the headings' inline end margin values are set to zero in favor of the column-gap declaration + cy.get(".mx_SettingsTab_heading").should("have.css", "margin-inline-end", "0px"); + cy.get(".mx_SettingsTab_subheading").should("have.css", "margin-inline-end", "0px"); + }); + }); + + // Assert the account deactivation button is displayed + cy.findByTestId("account-management-section") + .scrollIntoView() + .findByRole("button", { name: "Deactivate Account" }) + .should("be.visible") + .should("have.class", "mx_AccessibleButton_kind_danger"); + }); + }); + + it("should support adding and removing a profile picture", () => { + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab .mx_ProfileSettings").within(() => { + // Upload a picture + cy.get(".mx_ProfileSettings_avatarUpload").selectFile("cypress/fixtures/riot.png", { force: true }); + + // Find and click "Remove" link button + cy.get(".mx_ProfileSettings_profile").within(() => { + cy.findByRole("button", { name: "Remove" }).click(); + }); + + // Assert that the link button disappeared + cy.get(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm").should("not.exist"); + }); + }); + + it("should set a country calling code based on default_country_code", () => { + // Check phone numbers area + cy.get(".mx_PhoneNumbers") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new phone number is rendered + cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); + + // Check a new phone number dropdown menu + cy.get(".mx_PhoneNumbers_country") + .scrollIntoView() + .within(() => { + // Assert that the country calling code of United States is visible + cy.findByText(/\+1/).should("be.visible"); + + // Click the button to display the dropdown menu + cy.findByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the option for calling code of United Kingdom is visible + cy.findByRole("option", { name: /United Kingdom/ }).should("be.visible"); + + // Click again to close the dropdown + cy.findByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the default value is rendered again + cy.findByText(/\+1/).should("be.visible"); + }); + + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + }); + + it("should support changing a display name", () => { + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab .mx_ProfileSettings").within(() => { + // Change the diaplay name to USER_NAME_NEW + cy.findByRole("textbox", { name: "Display Name" }).type(`{selectAll}{del}${USER_NAME_NEW}{enter}`); + }); + + cy.closeDialog(); + + // Assert the avatar's initial characters are set + cy.get(".mx_UserMenu .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice + cy.get(".mx_RoomView_wrapper .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice + }); +}); diff --git a/cypress/e2e/settings/preferences-user-settings-tab.spec.ts b/cypress/e2e/settings/preferences-user-settings-tab.spec.ts new file mode 100644 index 00000000000..ad16f0a1c59 --- /dev/null +++ b/cypress/e2e/settings/preferences-user-settings-tab.spec.ts @@ -0,0 +1,53 @@ +/* +Copyright 2023 Suguru Hirahara + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +describe("Preferences user settings tab", () => { + let homeserver: HomeserverInstance; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Bob"); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should be rendered properly", () => { + cy.openUserSettings("Preferences"); + + cy.get(".mx_SettingsTab.mx_PreferencesUserSettingsTab").within(() => { + // Assert that the top heading is rendered + cy.findByTestId("preferences").should("have.text", "Preferences").should("be.visible"); + }); + + cy.get(".mx_SettingsTab.mx_PreferencesUserSettingsTab").percySnapshotElement( + "User settings tab - Preferences", + { + // Emulate TabbedView's actual min and max widths + // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width + // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right) + widths: [580, 796], + }, + ); + }); +}); diff --git a/cypress/e2e/settings/set-integration-manager.spec.ts b/cypress/e2e/settings/set-integration-manager.spec.ts deleted file mode 100644 index 57981573944..00000000000 --- a/cypress/e2e/settings/set-integration-manager.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* -Copyright 2023 Suguru Hirahara - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -const USER_NAME = "Alice"; - -describe("Set integration manager", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, USER_NAME); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be correctly rendered", () => { - cy.openUserSettings("General"); - - cy.get(".mx_SetIntegrationManager").within(() => { - // Assert the toggle switch is enabled by default - cy.get(".mx_ToggleSwitch_enabled").should("exist"); - - // Assert space between "Manage integrations" and the integration server address is set to 4px; - cy.get(".mx_SetIntegrationManager_heading_manager").should("have.css", "column-gap", "4px"); - - cy.get(".mx_SetIntegrationManager_heading_manager").within(() => { - cy.get(".mx_SettingsTab_heading").should("have.text", "Manage integrations"); - - // Assert the headings' inline end margin values are set to zero in favor of the column-gap declaration - cy.get(".mx_SettingsTab_heading").should("have.css", "margin-inline-end", "0px"); - cy.get(".mx_SettingsTab_subheading").should("have.css", "margin-inline-end", "0px"); - }); - }); - - cy.get(".mx_SetIntegrationManager").percySnapshotElement("'Manage integrations' on General settings tab", { - widths: [692], // actual width of mx_SetIntegrationManager - }); - }); -}); diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.spec.ts similarity index 70% rename from cypress/e2e/sliding-sync/sliding-sync.ts rename to cypress/e2e/sliding-sync/sliding-sync.spec.ts index 10bd2467970..b7eccb77c62 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.spec.ts @@ -62,7 +62,7 @@ describe("Sliding Sync", () => { // assert order const checkOrder = (wantOrder: string[]) => { - cy.contains(".mx_RoomSublist", "Rooms") + cy.findByRole("group", { name: "Rooms" }) .find(".mx_RoomTile_title") .should((elements) => { expect( @@ -102,16 +102,31 @@ describe("Sliding Sync", () => { it("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }).then(() => cy.contains(".mx_RoomSublist", "Apple")); - cy.createRoom({ name: "Pineapple" }).then(() => cy.contains(".mx_RoomSublist", "Pineapple")); - cy.createRoom({ name: "Orange" }).then(() => cy.contains(".mx_RoomSublist", "Orange")); - // check the rooms are in the right order - cy.get(".mx_RoomTile").should("have.length", 4); // due to the Test Room in beforeEach + cy.createRoom({ name: "Apple" }).then(() => cy.findByRole("treeitem", { name: "Apple" })); + cy.createRoom({ name: "Pineapple" }).then(() => cy.findByRole("treeitem", { name: "Pineapple" })); + cy.createRoom({ name: "Orange" }).then(() => cy.findByRole("treeitem", { name: "Orange" })); + + cy.get(".mx_RoomSublist_tiles").within(() => { + cy.findAllByRole("treeitem").should("have.length", 4); // due to the Test Room in beforeEach + }); + checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - cy.contains(".mx_RoomSublist", "Rooms").find(".mx_RoomSublist_menuButton").click({ force: true }); - cy.contains("A-Z").click(); - cy.get(".mx_StyledRadioButton_checked").should("contain.text", "A-Z"); + cy.findByRole("group", { name: "Rooms" }).within(() => { + cy.get(".mx_RoomSublist_headerContainer") + .realHover() + .findByRole("button", { name: "List options" }) + .click(); + }); + + // force click as the radio button's size is zero + cy.findByRole("menuitemradio", { name: "A-Z" }).click({ force: true }); + + // Assert that the radio button is checked + cy.get(".mx_StyledRadioButton_checked").within(() => { + cy.findByText("A-Z").should("exist"); + }); + checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); }); @@ -119,16 +134,16 @@ describe("Sliding Sync", () => { // create rooms and check room names are correct cy.createRoom({ name: "Apple" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Select the Test Room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); bumpRoom("@roomA"); @@ -145,20 +160,20 @@ describe("Sliding Sync", () => { // create rooms and check room names are correct cy.createRoom({ name: "Apple" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. // Select the Pineapple room - cy.contains(".mx_RoomTile", "Pineapple").click(); + cy.findByRole("treeitem", { name: "Pineapple" }).click(); checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); // Move Apple @@ -166,7 +181,7 @@ describe("Sliding Sync", () => { checkOrder(["Apple", "Pineapple", "Orange", "Test Room"]); // Select the Test Room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); // the rooms reshuffle to match reality checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); @@ -181,19 +196,22 @@ describe("Sliding Sync", () => { }); // check that there is an unread notification (grey) as 1 - cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "1"); + cy.findByRole("treeitem", { name: "Test Room 1 unread message." }).contains(".mx_NotificationBadge_count", "1"); cy.get(".mx_NotificationBadge").should("not.have.class", "mx_NotificationBadge_highlighted"); // send an @mention: highlight count (red) should be 2. cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { return bob.sendTextMessage(roomId, "Hello Sloth"); }); - cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "2"); + cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).contains( + ".mx_NotificationBadge_count", + "2", + ); cy.get(".mx_NotificationBadge").should("have.class", "mx_NotificationBadge_highlighted"); // click on the room, the notif counts should disappear - cy.contains(".mx_RoomTile", "Test Room").click(); - cy.contains(".mx_RoomTile", "Test Room").should("not.have.class", "mx_NotificationBadge_count"); + cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); + cy.findByRole("treeitem", { name: "Test Room" }).should("not.have.class", "mx_NotificationBadge_count"); }); it("should not show unread indicators", () => { @@ -201,8 +219,11 @@ describe("Sliding Sync", () => { createAndJoinBob(); // disable notifs in this room (TODO: CS API call?) - cy.contains(".mx_RoomTile", "Test Room").find(".mx_RoomTile_notificationsButton").click({ force: true }); - cy.contains("Mute room").click(); + cy.findByRole("treeitem", { name: "Test Room" }) + .realHover() + .findByRole("button", { name: "Notification options" }) + .click(); + cy.findByRole("menuitemradio", { name: "Mute room" }).click(); // create a new room so we know when the message has been received as it'll re-shuffle the room list cy.createRoom({ @@ -216,13 +237,11 @@ describe("Sliding Sync", () => { // wait for this message to arrive, tell by the room list resorting checkOrder(["Test Room", "Dummy"]); - cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist"); + cy.findByRole("treeitem", { name: "Test Room" }).get(".mx_NotificationBadge").should("not.exist"); }); it("should update user settings promptly", () => { - cy.get(".mx_UserMenu_userAvatar").click(); - cy.contains("All settings").click(); - cy.contains("Preferences").click(); + cy.openUserSettings("Preferences"); cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") .should("exist") .find(".mx_ToggleSwitch_on") @@ -257,9 +276,9 @@ describe("Sliding Sync", () => { .then((bob) => { bobClient = bob; return Promise.all([ - bob.createRoom({ name: "Join" }), - bob.createRoom({ name: "Reject" }), - bob.createRoom({ name: "Rescind" }), + bob.createRoom({ name: "Room to Join" }), + bob.createRoom({ name: "Room to Reject" }), + bob.createRoom({ name: "Room to Rescind" }), ]); }) .then(([join, reject, rescind]) => { @@ -273,23 +292,44 @@ describe("Sliding Sync", () => { ]); }); - // wait for them all to be on the UI - cy.get(".mx_RoomTile").should("have.length", 4); // due to the Test Room in beforeEach + cy.findByRole("group", { name: "Invites" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for them all to be on the UI + cy.findAllByRole("treeitem").should("have.length", 3); + }); + }); + + // Select the room to join + cy.findByRole("treeitem", { name: "Room to Join" }).click(); - cy.contains(".mx_RoomTile", "Join").click(); - cy.contains(".mx_AccessibleButton", "Accept").click(); + cy.get(".mx_RoomView").within(() => { + // Accept the invite + cy.findByRole("button", { name: "Accept" }).click(); + }); - checkOrder(["Join", "Test Room"]); + checkOrder(["Room to Join", "Test Room"]); - cy.contains(".mx_RoomTile", "Reject").click(); - cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click(); + // Select the room to reject + cy.findByRole("treeitem", { name: "Room to Reject" }).click(); - // wait for the rejected room to disappear - cy.get(".mx_RoomTile").should("have.length", 3); + cy.get(".mx_RoomView").within(() => { + // Reject the invite + cy.findByRole("button", { name: "Reject" }).click(); + }); + + cy.findByRole("group", { name: "Invites" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for the rejected room to disappear + cy.findAllByRole("treeitem").should("have.length", 2); + }); + }); // check the lists are correct - checkOrder(["Join", "Test Room"]); - cy.contains(".mx_RoomSublist", "Invites") + checkOrder(["Room to Join", "Test Room"]); + + cy.findByRole("group", { name: "Invites" }) .find(".mx_RoomTile_title") .should((elements) => { expect( @@ -297,7 +337,7 @@ describe("Sliding Sync", () => { return e.textContent; }), "rooms are sorted", - ).to.deep.equal(["Rescind"]); + ).to.deep.equal(["Room to Rescind"]); }); // now rescind the invite @@ -305,9 +345,15 @@ describe("Sliding Sync", () => { return bob.kick(roomRescind, clientUserId); }); - // wait for the rescind to take effect and check the joined list once more - cy.get(".mx_RoomTile").should("have.length", 2); - checkOrder(["Join", "Test Room"]); + cy.findByRole("group", { name: "Rooms" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for the rescind to take effect and check the joined list once more + cy.findAllByRole("treeitem").should("have.length", 2); + }); + }); + + checkOrder(["Room to Join", "Test Room"]); }); it("should show a favourite DM only in the favourite sublist", () => { @@ -320,8 +366,8 @@ describe("Sliding Sync", () => { cy.getClient().then((cli) => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); }); - cy.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist"); - cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "Favourite DM").should("not.exist"); + cy.findByRole("group", { name: "Favourites" }).findByText("Favourite DM").should("exist"); + cy.findByRole("group", { name: "People" }).findByText("Favourite DM").should("not.exist"); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. @@ -329,7 +375,7 @@ describe("Sliding Sync", () => { it("should clear the reply to field when swapping rooms", () => { cy.createRoom({ name: "Other Room" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Other Room")); + .then(() => cy.findByRole("treeitem", { name: "Other Room" })); cy.get("@roomId").then((roomId) => { return cy.sendEvent(roomId, null, "m.room.message", { body: "Hello world", @@ -337,20 +383,24 @@ describe("Sliding Sync", () => { }); }); // select the room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); cy.get(".mx_ReplyPreview").should("not.exist"); // click reply-to on the Hello World message - cy.contains(".mx_EventTile", "Hello world") - .find('.mx_AccessibleButton[aria-label="Reply"]') - .click({ force: true }); + cy.get(".mx_EventTile_last") + .within(() => { + cy.findByText("Hello world", { timeout: 1000 }); + }) + .realHover() + .findByRole("button", { name: "Reply" }) + .click(); // check it's visible cy.get(".mx_ReplyPreview").should("exist"); // now click Other Room - cy.contains(".mx_RoomTile", "Other Room").click(); + cy.findByRole("treeitem", { name: "Other Room" }).click(); // ensure the reply-to disappears cy.get(".mx_ReplyPreview").should("not.exist"); // click back - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); // ensure the reply-to reappears cy.get(".mx_ReplyPreview").should("exist"); }); @@ -378,12 +428,17 @@ describe("Sliding Sync", () => { }); }); // select the room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); cy.get(".mx_ReplyPreview").should("not.exist"); // click reply-to on the Reply to me message - cy.contains(".mx_EventTile", "Reply to me") - .find('.mx_AccessibleButton[aria-label="Reply"]') - .click({ force: true }); + cy.get(".mx_EventTile") + .last() + .within(() => { + cy.findByText("Reply to me"); + }) + .realHover() + .findByRole("button", { name: "Reply" }) + .click(); // check it's visible cy.get(".mx_ReplyPreview").should("exist"); // now click on the permalink for Permalink me @@ -401,15 +456,15 @@ describe("Sliding Sync", () => { cy.createRoom({ name: "Apple" }) .as("roomA") .then((roomId) => (roomAId = roomId)) - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") .then((roomId) => (roomPId = roomId)) - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Intercept all calls to /sync cy.intercept({ method: "POST", url: "**/sync*" }).as("syncRequest"); @@ -426,7 +481,7 @@ describe("Sliding Sync", () => { }; // Select the Test Room - cy.contains(".mx_RoomTile", "Apple").click(); + cy.findByRole("treeitem", { name: "Apple" }).click(); // and wait for cypress to get the result as alias cy.wait("@syncRequest").then((interception) => { @@ -435,11 +490,11 @@ describe("Sliding Sync", () => { }); // Switch to another room - cy.contains(".mx_RoomTile", "Pineapple").click(); + cy.findByRole("treeitem", { name: "Pineapple" }).click(); cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); // And switch to even another room - cy.contains(".mx_RoomTile", "Apple").click(); + cy.findByRole("treeitem", { name: "Apple" }).click(); cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); // TODO: Add tests for encrypted rooms diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index f89fa297d01..9b1fb241d0d 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -24,7 +24,7 @@ import Chainable = Cypress.Chainable; import { UserCredentials } from "../../support/login"; function openSpaceCreateMenu(): Chainable { - cy.get(".mx_SpaceButton_new").click(); + cy.findByRole("button", { name: "Create a space" }).click(); return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); } @@ -83,64 +83,72 @@ describe("Spaces", () => { openSpaceCreateMenu(); cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu"); cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => { - cy.get(".mx_SpaceCreateMenuType_public").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_public" + cy.findByRole("button", { name: /Public/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Name"]').type("Let's have a Riot"); - cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); - cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); - cy.contains(".mx_AccessibleButton", "Create").click(); + cy.findByRole("textbox", { name: "Name" }).type("Let's have a Riot"); + cy.findByRole("textbox", { name: "Address" }).should("have.value", "lets-have-a-riot"); + cy.findByRole("textbox", { name: "Description" }).type("This is a space to reminisce Riot.im!"); + cy.findByRole("button", { name: "Create" }).click(); }); // Create the default General & Random rooms, as well as a custom "Jokes" room - cy.get('input[label="Room name"][value="General"]').should("exist"); - cy.get('input[label="Room name"][value="Random"]').should("exist"); - cy.get('input[placeholder="Support"]').type("Jokes"); - cy.contains(".mx_AccessibleButton", "Continue").click(); + cy.findByPlaceholderText("General").should("exist"); + cy.findByPlaceholderText("Random").should("exist"); + cy.findByPlaceholderText("Support").type("Jokes"); + cy.findByRole("button", { name: "Continue" }).click(); // Copy matrix.to link - cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" + cy.findByRole("button", { name: /Share invite link/ }).realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); // Go to space home - cy.contains(".mx_AccessibleButton", "Go to my first room").click(); + cy.findByRole("button", { name: "Go to my first room" }).click(); // Assert rooms exist in the room list - cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist"); + cy.findByRole("treeitem", { name: "General" }).should("exist"); + cy.findByRole("treeitem", { name: "Random" }).should("exist"); + cy.findByRole("treeitem", { name: "Jokes" }).should("exist"); }); it("should allow user to create private space", () => { openSpaceCreateMenu().within(() => { - cy.get(".mx_SpaceCreateMenuType_private").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" + cy.findByRole("button", { name: /Private/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Name"]').type("This is not a Riot"); - cy.get('input[label="Address"]').should("not.exist"); - cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); - cy.contains(".mx_AccessibleButton", "Create").click(); + cy.findByRole("textbox", { name: "Name" }).type("This is not a Riot"); + cy.findByRole("textbox", { name: "Address" }).should("not.exist"); + cy.findByRole("textbox", { name: "Description" }).type("This is a private space of mourning Riot.im..."); + cy.findByRole("button", { name: "Create" }).click(); }); - cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); + // Regex pattern due to strings of "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton" + cy.findByRole("button", { name: /Me and my teammates/ }).click(); // Create the default General & Random rooms, as well as a custom "Projects" room - cy.get('input[label="Room name"][value="General"]').should("exist"); - cy.get('input[label="Room name"][value="Random"]').should("exist"); - cy.get('input[placeholder="Support"]').type("Projects"); - cy.contains(".mx_AccessibleButton", "Continue").click(); - - cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); - cy.contains(".mx_AccessibleButton", "Skip for now").click(); + cy.findByPlaceholderText("General").should("exist"); + cy.findByPlaceholderText("Random").should("exist"); + cy.findByPlaceholderText("Support").type("Projects"); + cy.findByRole("button", { name: "Continue" }).click(); + + cy.get(".mx_SpaceRoomView").within(() => { + cy.get("h1").findByText("Invite your teammates"); + cy.findByRole("button", { name: "Skip for now" }).click(); + }); // Assert rooms exist in the room list - cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Projects").should("exist"); + cy.findByRole("treeitem", { name: "General" }).should("exist"); + cy.findByRole("treeitem", { name: "Random" }).should("exist"); + cy.findByRole("treeitem", { name: "Projects" }).should("exist"); // Assert rooms exist in the space explorer cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); @@ -154,23 +162,32 @@ describe("Spaces", () => { }); openSpaceCreateMenu().within(() => { - cy.get(".mx_SpaceCreateMenuType_private").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" + cy.findByRole("button", { name: /Private/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Address"]').should("not.exist"); - cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); - cy.get('input[label="Name"]').type("This is my Riot{enter}"); + cy.findByRole("textbox", { name: "Address" }).should("not.exist"); + cy.findByRole("textbox", { name: "Description" }).type("This is a personal space to mourn Riot.im..."); + cy.findByRole("textbox", { name: "Name" }).type("This is my Riot{enter}"); }); - cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); + // Regex pattern due to of strings of "mx_SpaceRoomView_privateScope_justMeButton" + cy.findByRole("button", { name: /Just me/ }).click(); + + cy.findByText("Sample Room").click({ force: true }); // force click as checkbox size is zero - cy.get(".mx_AddExistingToSpace_entry").click(); - cy.contains(".mx_AccessibleButton", "Add").click(); + // Temporal implementation as multiple elements with the role "button" and name "Add" are found + cy.get(".mx_AddExistingToSpace_footer").within(() => { + cy.findByRole("button", { name: "Add" }).click(); + }); - cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); - cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.get(".mx_SpaceHierarchy_list").within(() => { + // Regex pattern due to the strings of "mx_SpaceHierarchy_roomTile_joined" + cy.findByRole("treeitem", { name: /Sample Room/ }).should("exist"); + }); }); it("should allow user to invite another to a space", () => { @@ -185,20 +202,24 @@ describe("Spaces", () => { }).as("spaceId"); openSpaceContextMenu("#space:localhost").within(() => { - cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click(); + cy.findByRole("menuitem", { name: "Invite" }).click(); }); cy.get(".mx_SpacePublicShare").within(() => { // Copy link first - cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" + cy.findByRole("button", { name: /Share invite link/ }) + .focus() + .realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost"); // Start Matrix invite flow - cy.get(".mx_SpacePublicShare_inviteButton").click(); + // Regex pattern due to strings of "mx_SpacePublicShare_inviteButton" + cy.findByRole("button", { name: /Invite people/ }).click(); }); cy.get(".mx_InviteDialog_other").within(() => { - cy.get('input[type="text"]').type(bot.getUserId()); - cy.contains(".mx_AccessibleButton", "Invite").click(); + cy.findByRole("textbox").type(bot.getUserId()); + cy.findByRole("button", { name: "Invite" }).click(); }); cy.get(".mx_InviteDialog_other").should("not.exist"); @@ -219,7 +240,7 @@ describe("Spaces", () => { .should("exist") .parent() .next() - .find('.mx_SpaceButton[aria-label="My Space"]') + .findByRole("button", { name: "My Space" }) .should("exist"); }); @@ -243,8 +264,11 @@ describe("Spaces", () => { cy.viewSpaceHomeByName(spaceName); }); cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => { - cy.contains(".mx_SpaceHierarchy_roomTile", "Music").should("exist"); - cy.contains(".mx_SpaceHierarchy_roomTile", "Gaming").should("exist"); + // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_name" + cy.findByRole("treeitem", { name: /Music/ }).findByRole("button").should("exist"); + cy.findByRole("treeitem", { name: /Gaming/ }) + .findByRole("button") + .should("exist"); }); }); @@ -260,8 +284,12 @@ describe("Spaces", () => { initial_state: [spaceChildInitialState(spaceId)], }).as("spaceId"); }); - cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Root Space"]').should("exist"); - cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Child Space"]').should("not.exist"); + + // Find collapsed Space panel + cy.findByRole("tree", { name: "Spaces" }).within(() => { + cy.findByRole("button", { name: "Root Space" }).should("exist"); + cy.findByRole("button", { name: "Child Space" }).should("not.exist"); + }); const axeOptions = { rules: { @@ -274,8 +302,12 @@ describe("Spaces", () => { cy.checkA11y(undefined, axeOptions); cy.get(".mx_SpacePanel").percySnapshotElement("Space panel collapsed", { widths: [68] }); - cy.get(".mx_SpaceButton_toggleCollapse").click({ force: true }); - cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); + cy.findByRole("tree", { name: "Spaces" }).within(() => { + // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another + // button with the same name with different class name "mx_SpacePanel_toggleCollapse". + cy.findByRole("button", { name: "Expand" }).realHover().click(); + }); + cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); // TODO: replace :not() selector cy.contains(".mx_SpaceItem", "Root Space") .should("exist") @@ -300,12 +332,12 @@ describe("Spaces", () => { cy.getSpacePanelButton("Test Space").should("exist"); cy.wait(500); // without this we can end up clicking too quickly and it ends up having no effect cy.viewSpaceByName("Test Space"); - cy.contains(".mx_AccessibleButton", "Accept").click(); + cy.findByRole("button", { name: "Accept" }).click(); - cy.contains(".mx_SpaceHierarchy_roomTile.mx_AccessibleButton", "Test Room").within(() => { - cy.contains("Join").should("exist").realHover().click(); - cy.contains("View", { timeout: 5000 }).should("exist").click(); - }); + // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_item" + cy.findByRole("button", { name: /Test Room/ }).realHover(); + cy.findByRole("button", { name: "Join" }).should("exist").realHover().click(); + cy.findByRole("button", { name: "View", timeout: 5000 }).should("exist").realHover().click(); // Assert we get shown the new room intro, and thus not the soft crash screen cy.get(".mx_NewRoomIntro").should("exist"); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index ee1fd78d082..465aeb9520c 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -31,7 +31,6 @@ describe("Threads", () => { }); cy.startHomeserver("default").then((data) => { homeserver = data; - cy.initTestUser(homeserver, "Tom"); }); }); @@ -50,12 +49,15 @@ describe("Threads", () => { }); let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); + cy.createRoom({}) + .then((_roomId) => { + roomId = _roomId; + return cy.inviteUser(roomId, bot.getUserId()); + }) + .then(async () => { + await bot.joinRoom(roomId); + cy.visit("/#/room/" + roomId); + }); // Around 200 characters const MessageLong = @@ -69,20 +71,22 @@ describe("Threads", () => { // Exclude timestamp and read marker from snapshots const percyCSS = ".mx_MessageTimestamp, .mx_RoomView_myReadMarker { visibility: hidden !important; }"; - // User sends message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + // User sends message + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Check the colour of timestamp on the main timeline - cy.get(".mx_RoomView_body .mx_EventTile_last .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); + // Check the colour of timestamp on the main timeline + cy.get(".mx_EventTile_last .mx_EventTile_line .mx_MessageTimestamp").should( + "have.css", + "color", + MessageTimestampColor, + ); - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); + // Wait for message to send, get its ID and save as @threadId + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens") + .as("threadId"); + }); // Bot starts thread cy.get("@threadId").then((threadId) => { @@ -94,15 +98,16 @@ describe("Threads", () => { }); // User asserts timeline thread summary visible & clicks it - cy.get(".mx_RoomView_body").within(() => { - cy.get(".mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); - cy.get(".mx_ThreadSummary").click(); - }); + cy.get(".mx_RoomView_body .mx_ThreadSummary") + .within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); + }) + .click(); // Wait until the both messages are read cy.get(".mx_ThreadView .mx_EventTile_last[data-layout=group]").within(() => { - cy.get(".mx_EventTile_line .mx_MTextBody").should("have.text", MessageLong); + cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar_image").should("be.visible"); // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout @@ -120,7 +125,7 @@ describe("Threads", () => { cy.get(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last").within(() => { // Wait until the messages are rendered - cy.get(".mx_EventTile_line .mx_MTextBody").should("have.text", MessageLong); + cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); // Make sure the avatar inside ReadReceiptGroup is visible on the group layout cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar_image").should("be.visible"); @@ -143,20 +148,22 @@ describe("Threads", () => { // Re-enable the group layout cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - // User responds in thread - cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Test{enter}"); + cy.get(".mx_ThreadView").within(() => { + // User responds in thread + cy.findByRole("textbox", { name: "Send a message…" }).type("Test{enter}"); - // Check the colour of timestamp on EventTile in a thread (mx_ThreadView) - cy.get(".mx_ThreadView .mx_EventTile_last[data-layout='group'] .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); + // Check the colour of timestamp on EventTile in a thread (mx_ThreadView) + cy.get(".mx_EventTile_last[data-layout='group'] .mx_EventTile_line .mx_MessageTimestamp").should( + "have.css", + "color", + MessageTimestampColor, + ); + }); // User asserts summary was updated correctly cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_ThreadSummary_content").should("contain", "Test"); + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Test").should("exist"); }); //////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -167,12 +174,17 @@ describe("Threads", () => { cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); // User reacts to message instead - cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there") - .find('[aria-label="React"]') - .click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_ThreadView").within(() => { + cy.contains(".mx_EventTile .mx_EventTile_line", "Hello there") + .realHover() + .findByRole("toolbar", { name: "Message Actions" }) + .findByRole("button", { name: "React" }) + .click(); + }); + cy.get(".mx_EmojiPicker").within(() => { - cy.get('input[type="text"]').type("wave"); - cy.contains('[role="menuitem"]', "👋").click(); + cy.findByRole("textbox").type("wave"); + cy.findByRole("gridcell", { name: "👋" }).click(); }); cy.get(".mx_ThreadView").within(() => { @@ -229,17 +241,20 @@ describe("Threads", () => { // User redacts their prior response cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") - .find('[aria-label="Options"]') - .click({ force: true }); // Cypress has no ability to hover + .realHover() + .findByRole("button", { name: "Options" }) + .click(); cy.get(".mx_IconizedContextMenu").within(() => { - cy.contains('[role="menuitem"]', "Remove").click(); + cy.findByRole("menuitem", { name: "Remove" }).click(); }); cy.get(".mx_TextInputDialog").within(() => { - cy.contains(".mx_Dialog_primary", "Remove").click(); + cy.findByRole("button", { name: "Remove" }).should("have.class", "mx_Dialog_primary").click(); }); - // Wait until the response is redacted - cy.get(".mx_ThreadView .mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); + cy.get(".mx_ThreadView").within(() => { + // Wait until the response is redacted + cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); + }); // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); @@ -256,12 +271,16 @@ describe("Threads", () => { cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); + }); // User closes right panel after clicking back to thread list - cy.get(".mx_ThreadView .mx_BaseCard_back").click(); - cy.get(".mx_ThreadPanel .mx_BaseCard_close").click(); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("button", { name: "Threads" }).click(); + cy.findByRole("button", { name: "Close" }).click(); + }); // Bot responds to thread cy.get("@threadId").then((threadId) => { @@ -271,21 +290,22 @@ describe("Threads", () => { }); }); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "How are things?"); - // User asserts thread list unread indicator - cy.get('.mx_HeaderButtons [aria-label="Threads"]').should("have.class", "mx_RightPanel_headerButton_unread"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); + }); - // User opens thread list - cy.get('.mx_HeaderButtons [aria-label="Threads"]').click(); + cy.findByRole("button", { name: "Threads" }) + .should("have.class", "mx_RightPanel_headerButton_unread") // User asserts thread list unread indicator + .click(); // User opens thread list // User asserts thread with correct root & latest events & unread dot cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { - cy.get(".mx_EventTile_body").should("contain", "Hello Mr. Bot"); - cy.get(".mx_ThreadSummary_content").should("contain", "How are things?"); + cy.get(".mx_EventTile_body").findByText("Hello Mr. Bot").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); // Check the number of the replies - cy.get(".mx_ThreadPanel_replies_amount").should("have.text", "2"); + cy.get(".mx_ThreadPanel_replies_amount").findByText("2").should("exist"); // Check the colour of timestamp on thread list cy.get(".mx_EventTile_details .mx_MessageTimestamp").should("have.css", "color", MessageTimestampColor); @@ -298,23 +318,29 @@ describe("Threads", () => { }); // User responds & asserts - cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Great!{enter}"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); + cy.get(".mx_ThreadView").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Great!{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Great!").should("exist"); + }); // User edits & asserts - cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => { - cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover - cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); + cy.get(".mx_ThreadView .mx_EventTile_last").within(() => { + cy.findByText("Great!").should("exist"); + cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click(); + cy.findByRole("textbox").type(" How about yourself?{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Great! How about yourself?").should("exist"); }); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "Great! How about yourself?", - ); // User closes right panel - cy.get(".mx_ThreadView .mx_BaseCard_close").click(); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("button", { name: "Close" }).click(); + }); // Bot responds to thread and saves the id of their message to @eventId cy.get("@threadId").then((threadId) => { @@ -329,11 +355,10 @@ describe("Threads", () => { }); // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "I'm very good thanks", - ); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks").should("exist"); + }); // Bot edits their latest event cy.get("@eventId").then((eventId) => { @@ -352,11 +377,10 @@ describe("Threads", () => { }); // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "I'm very good thanks :)", - ); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks :)").should("exist"); + }); }); it("can send voice messages", () => { @@ -373,18 +397,20 @@ describe("Threads", () => { }); // Send message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Create thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .find(".mx_MessageActionBar_threadButton") - .click(); + // Create thread + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover() + .findByRole("button", { name: "Reply in thread" }) + .click(); + }); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - cy.openMessageComposerOptions(true).find(`[aria-label="Voice Message"]`).click(); + cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Voice Message" }).click(); cy.wait(3000); - cy.getComposer(true).find(".mx_MessageComposer_sendMessage").click(); + cy.getComposer(true).findByRole("button", { name: "Send voice message" }).click(); cy.get(".mx_ThreadView .mx_MVoiceMessageBody").should("have.length", 1); }); @@ -392,10 +418,10 @@ describe("Threads", () => { it("should send location and reply to the location on ThreadView", () => { // See: location.spec.ts const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.get(`[data-testid="share-location-option-${shareType}"]`); + return cy.findByTestId(`share-location-option-${shareType}`); }; const submitShareLocation = (): void => { - cy.get('[data-testid="location-picker-submit-button"]').click(); + cy.findByRole("button", { name: "Share location" }).click(); }; let bot: MatrixClient; @@ -407,24 +433,29 @@ describe("Threads", () => { }); let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); + cy.createRoom({}) + .then((_roomId) => { + roomId = _roomId; + return cy.inviteUser(roomId, bot.getUserId()); + }) + .then(async () => { + await bot.joinRoom(roomId); + cy.visit("/#/room/" + roomId); + }); // Exclude timestamp, read marker, and mapboxgl-map from snapshots const percyCSS = ".mx_MessageTimestamp, .mx_RoomView_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; - // User sends message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + // User sends message + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); + // Wait for message to send, get its ID and save as @threadId + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens") + .as("threadId"); + }); // Bot starts thread cy.get("@threadId").then((threadId) => { @@ -439,7 +470,7 @@ describe("Threads", () => { // User sends location on ThreadView cy.get(".mx_ThreadView").should("exist"); - cy.openMessageComposerOptions(true).find("[aria-label='Location']").click(); + cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Location" }).click(); selectLocationShareTypeOption("Pin").click(); cy.get("#mx_LocationPicker_map").click("center"); submitShareLocation(); @@ -447,13 +478,9 @@ describe("Threads", () => { // User replies to the location cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last") - .realHover() - .within(() => { - cy.get("[aria-label='Reply']").click({ force: false }); - }); + cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - cy.get(".mx_BasicMessageComposer_input").type("Please come here.{enter}"); + cy.findByRole("textbox", { name: "Reply to thread…" }).type("Please come here.{enter}"); // Wait until the reply is sent cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); @@ -470,30 +497,38 @@ describe("Threads", () => { roomId = _roomId; cy.visit("/#/room/" + roomId); }); + // Send message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Create thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .find(".mx_MessageActionBar_threadButton") - .click(); + // Create thread + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover() + .findByRole("button", { name: "Reply in thread" }) + .click(); + }); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); // Send message to thread - cy.get(".mx_BaseCard .mx_BasicMessageComposer_input").type("Hello Mr. User{enter}"); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. User{enter}"); + cy.get(".mx_EventTile_last").findByText("Hello Mr. User").should("exist"); - // Close thread - cy.get(".mx_BaseCard_close").click(); + // Close thread + cy.findByRole("button", { name: "Close" }).click(); + }); // Open existing thread cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover() - .find(".mx_MessageActionBar_threadButton") + .findByRole("button", { name: "Reply in thread" }) .click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + + cy.get(".mx_BaseCard").within(() => { + cy.get(".mx_EventTile").first().findByText("Hello Mr. Bot").should("exist"); + cy.get(".mx_EventTile").last().findByText("Hello Mr. User").should("exist"); + }); }); }); diff --git a/cypress/e2e/toasts/analytics-toast.ts b/cypress/e2e/toasts/analytics-toast.spec.ts similarity index 100% rename from cypress/e2e/toasts/analytics-toast.ts rename to cypress/e2e/toasts/analytics-toast.spec.ts diff --git a/cypress/e2e/user-onboarding/user-onboarding-new.ts b/cypress/e2e/user-onboarding/user-onboarding-new.spec.ts similarity index 78% rename from cypress/e2e/user-onboarding/user-onboarding-new.ts rename to cypress/e2e/user-onboarding/user-onboarding-new.spec.ts index b4b6c831051..5d79c9c58eb 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-new.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-new.spec.ts @@ -40,7 +40,7 @@ describe("User Onboarding (new user)", () => { bot1 = _bot1; }); cy.get(".mx_UserOnboardingPage").should("exist"); - cy.get(".mx_UserOnboardingButton").should("exist"); + cy.findByRole("button", { name: "Welcome" }).should("exist"); cy.get(".mx_UserOnboardingList") .should("exist") .should(($list) => { @@ -57,12 +57,12 @@ describe("User Onboarding (new user)", () => { it("page is shown and preference exists", () => { cy.get(".mx_UserOnboardingPage").percySnapshotElement("User onboarding page"); cy.openUserSettings("Preferences"); - cy.contains("Show shortcut to welcome checklist above the room list").should("exist"); + cy.findByText("Show shortcut to welcome checklist above the room list").should("exist"); }); it("app download dialog", () => { - cy.contains(".mx_UserOnboardingTask_action", "Download apps").click(); - cy.get("[role=dialog]").contains("#mx_BaseDialog_title", "Download Element").should("exist"); + cy.findByRole("button", { name: "Download apps" }).click(); + cy.get("[role=dialog]").get("#mx_BaseDialog_title").findByText("Download Element").should("exist"); cy.get("[role=dialog]").percySnapshotElement("App download dialog", { widths: [640], }); @@ -72,18 +72,18 @@ describe("User Onboarding (new user)", () => { cy.get(".mx_ProgressBar") .invoke("val") .then((oldProgress) => { - const findPeopleAction = cy.contains(".mx_UserOnboardingTask_action", "Find friends"); + const findPeopleAction = cy.findByRole("button", { name: "Find friends" }); expect(findPeopleAction).to.exist; findPeopleAction.click(); - cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId()); - cy.get(".mx_InviteDialog_buttonAndSpinner").click(); + cy.get(".mx_InviteDialog_editor").findByRole("textbox").type(bot1.getUserId()); + cy.findByRole("button", { name: "Go" }).click(); cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist"); const message = "Hi!"; - cy.get(".mx_SendMessageComposer").type(`${message}!{enter}`); - cy.contains(".mx_MTextBody.mx_EventTile_content", message); + cy.findByRole("textbox", { name: "Send a message…" }).type(`${message}{enter}`); + cy.get(".mx_MTextBody.mx_EventTile_content").findByText(message); cy.visit("/#/home"); cy.get(".mx_UserOnboardingPage").should("exist"); - cy.get(".mx_UserOnboardingButton").should("exist"); + cy.findByRole("button", { name: "Welcome" }).should("exist"); cy.get(".mx_UserOnboardingList") .should("exist") .should(($list) => { diff --git a/cypress/e2e/user-onboarding/user-onboarding-old.ts b/cypress/e2e/user-onboarding/user-onboarding-old.spec.ts similarity index 94% rename from cypress/e2e/user-onboarding/user-onboarding-old.ts rename to cypress/e2e/user-onboarding/user-onboarding-old.spec.ts index 7f0c2b7612b..502bdd2a0a3 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-old.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-old.spec.ts @@ -44,6 +44,6 @@ describe("User Onboarding (old user)", () => { cy.get(".mx_UserOnboardingPage").should("not.exist"); cy.get(".mx_UserOnboardingButton").should("not.exist"); cy.openUserSettings("Preferences"); - cy.contains("Show shortcut to welcome page above the room list").should("not.exist"); + cy.findByText(/Show shortcut to welcome page above the room list/).should("not.exist"); }); }); diff --git a/cypress/e2e/widgets/events.spec.ts b/cypress/e2e/widgets/events.spec.ts index d9aeb460625..a3c98b4f6fd 100644 --- a/cypress/e2e/widgets/events.spec.ts +++ b/cypress/e2e/widgets/events.spec.ts @@ -159,7 +159,9 @@ describe("Widget Events", () => { cy.viewRoomByName(ROOM_NAME); // approve capabilities - cy.contains(".mx_WidgetCapabilitiesPromptDialog button", "Approve").click(); + cy.get(".mx_WidgetCapabilitiesPromptDialog").within(() => { + cy.findByRole("button", { name: "Approve" }).click(); + }); cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(async () => { // bot creates a new room with 'm.room.topic' diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c56608fadca..535669d6be4 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -174,7 +174,9 @@ Cypress.Commands.add("createSpace", (options: ICreateRoomOpts): Chainable => { return cy.getClient().then(async (cli: MatrixClient) => { - return cli.invite(roomId, userId); + const res = await cli.invite(roomId, userId); + Cypress.log({ name: "inviteUser", message: `sent invite in ${roomId} for ${userId}` }); + return res; }); }); diff --git a/cypress/support/homeserver.ts b/cypress/support/homeserver.ts index 3026c94b064..f233c4d41e6 100644 --- a/cypress/support/homeserver.ts +++ b/cypress/support/homeserver.ts @@ -58,7 +58,9 @@ declare global { function startHomeserver(template: string): Chainable { const homeserverName = Cypress.env("HOMESERVER"); - return cy.task(homeserverName + "Start", template); + return cy.task(homeserverName + "Start", template, { log: false }).then((x) => { + Cypress.log({ name: "startHomeserver", message: `Started homeserver instance ${x.serverId}` }); + }); } function stopHomeserver(homeserver?: HomeserverInstance): Chainable { diff --git a/package.json b/package.json index e00d1377d93..c4daa16b2e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.71.1", + "version": "3.72.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -56,7 +56,7 @@ }, "resolutions": { "@types/react-dom": "17.0.19", - "@types/react": "17.0.55" + "@types/react": "17.0.58" }, "dependencies": { "@babel/runtime": "^7.12.5", @@ -97,14 +97,16 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "25.0.0", + "matrix-js-sdk": "25.1.0", "matrix-widget-api": "^1.3.1", + "memoize-one": "^5.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.51.5", + "posthog-js": "1.53.2", + "proposal-temporal": "^0.9.0", "qrcode": "1.5.1", "re-resizable": "^6.9.0", "react": "17.0.2", @@ -166,7 +168,7 @@ "@types/pako": "^2.0.0", "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "17.0.55", + "@types/react": "17.0.58", "@types/react-beautiful-dnd": "^13.0.0", "@types/react-dom": "17.0.19", "@types/react-transition-group": "^4.4.0", @@ -185,7 +187,7 @@ "cypress-axe": "^1.0.0", "cypress-multi-reporters": "^1.6.1", "cypress-real-events": "^1.7.1", - "eslint": "8.37.0", + "eslint": "8.38.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-deprecate": "^0.7.0", @@ -210,12 +212,12 @@ "postcss-scss": "^4.0.4", "prettier": "2.8.7", "raw-loader": "^4.0.2", - "rimraf": "^4.0.0", + "rimraf": "^5.0.0", "stylelint": "^15.0.0", "stylelint-config-standard": "^32.0.0", "stylelint-scss": "^4.2.0", "ts-node": "^10.9.1", - "typescript": "5.0.3", + "typescript": "5.0.4", "walk": "^2.3.14" }, "@casualbot/jest-sonar-reporter": { diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 2f4c126f954..ba3faceaafb 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -133,7 +133,7 @@ input[type="password"] { font-family: inherit; padding: 9px; font-size: $font-14px; - font-weight: 600; + font-weight: var(--font-semi-bold); min-width: 0; } @@ -576,7 +576,7 @@ legend { margin-bottom: 5px; /* flip colours for the secondary ones */ - font-weight: 600; + font-weight: var(--font-semi-bold); border: 1px solid $accent; color: $accent; background-color: $button-secondary-bg-color; @@ -639,15 +639,6 @@ legend { overflow-y: hidden; } -.mx_DialogDesignChanges_wrapper .mx_Dialog_fixedWidth { - max-width: 636px; /* match splash image width */ - - .mx_AccessibleButton_kind_link { - font-size: inherit; - padding: 0; - } -} - /* TODO: Review mx_GeneralButton usage to see if it can use a different class */ /* These classes were brought in from the old UserSettings and are included here to avoid */ /* breaking the app. */ @@ -658,11 +649,6 @@ legend { margin: auto; } -.mx_linkButton { - cursor: pointer; - color: $accent; -} - .mx_TextInputDialog_label { text-align: left; padding-bottom: 12px; @@ -677,16 +663,6 @@ legend { background-color: $background; } -@define-mixin mx_DialogButton_small { - @mixin mx_DialogButton; - font-size: $font-15px; - padding: 0px 1.5em 0px 1.5em; -} - -.mx_textButton { - @mixin mx_DialogButton_small; -} - .mx_button_row { margin-top: 69px; } @@ -827,7 +803,7 @@ legend { @define-mixin LegacyCallButton { box-sizing: border-box; - font-weight: 600; + font-weight: var(--font-semi-bold); height: $font-24px; line-height: $font-24px; margin-right: 0; @@ -849,7 +825,7 @@ legend { @define-mixin ThreadRepliesAmount { color: $secondary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); white-space: nowrap; position: relative; padding: 0 $spacing-12 0 $spacing-8; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a09eec3e6b9..9aa583d1977 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -339,6 +339,7 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; diff --git a/res/css/_font-sizes.pcss b/res/css/_font-sizes.pcss index 5c977a740f4..5d83ff83df6 100644 --- a/res/css/_font-sizes.pcss +++ b/res/css/_font-sizes.pcss @@ -14,6 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +/* + * SCSS variables defining a range of font sizes. + * + * These are defined in `rem` so that they scale with the `font-size` of the root element (which is adjustable via the + * "Font size" setting). They exist to make the job of converting designs (which tend to be based in pixels) into CSS + * easier. + * + * That means that, slightly confusingly, `$font-10px` is only *actually* 10px at the default font size: at a base + * `font-size` of 15, it is actually 15px. + */ $font-1px: 0.1rem; $font-1-5px: 0.15rem; $font-2px: 0.2rem; diff --git a/res/css/_font-weights.pcss b/res/css/_font-weights.pcss index 3e2b19d516f..7931d6a56a5 100644 --- a/res/css/_font-weights.pcss +++ b/res/css/_font-weights.pcss @@ -14,4 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -$font-semi-bold: 600; +:root { + --font-normal: 400; + --font-semi-bold: 600; +} diff --git a/res/css/_spacing.pcss b/res/css/_spacing.pcss index 63197f2321f..eaf46abc0e8 100644 --- a/res/css/_spacing.pcss +++ b/res/css/_spacing.pcss @@ -14,8 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* 1rem :: 10px */ - +/* SCSS variables representing a range of standard lengths. + * + * Avoid using these in new code: we cannot adjust their values without causing massive confusion, so they are + * effectively equivalent to using hardcoded values. + * + * In future, we plan to introduce variables named according to their purpose rather than their size. Additionally, + * we want switch to custom CSS properties (https://github.com/vector-im/element-web/issues/21656), so we might have + * `--spacing-standard` or something. For now, you might as well use hardcoded px values for lengths (except for font + * sizes, for which see the `$font-px` variables). + */ $spacing-2: 2px; $spacing-4: 4px; $spacing-8: 8px; diff --git a/res/css/components/views/beacon/_BeaconListItem.pcss b/res/css/components/views/beacon/_BeaconListItem.pcss index 19ac4148cca..c9b39bbebf4 100644 --- a/res/css/components/views/beacon/_BeaconListItem.pcss +++ b/res/css/components/views/beacon/_BeaconListItem.pcss @@ -55,7 +55,7 @@ limitations under the License. margin-bottom: $spacing-8; .mx_BeaconStatus_label { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } diff --git a/res/css/components/views/beacon/_OwnBeaconStatus.pcss b/res/css/components/views/beacon/_OwnBeaconStatus.pcss index f007124216a..dedf02da7a6 100644 --- a/res/css/components/views/beacon/_OwnBeaconStatus.pcss +++ b/res/css/components/views/beacon/_OwnBeaconStatus.pcss @@ -27,5 +27,5 @@ limitations under the License. .mx_OwnBeaconStatus_destructiveButton { /* override button link_inline styles */ color: $alert !important; - font-weight: $font-semi-bold !important; + font-weight: var(--font-semi-bold) !important; } diff --git a/res/css/components/views/elements/_FilterDropdown.pcss b/res/css/components/views/elements/_FilterDropdown.pcss index 6a9fe3dc7c0..a73a45c03ee 100644 --- a/res/css/components/views/elements/_FilterDropdown.pcss +++ b/res/css/components/views/elements/_FilterDropdown.pcss @@ -72,7 +72,7 @@ limitations under the License. } .mx_FilterDropdown_optionLabel { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); display: block; } diff --git a/res/css/components/views/elements/_FilterTabGroup.pcss b/res/css/components/views/elements/_FilterTabGroup.pcss index bbf1a279ad4..05329cb7d00 100644 --- a/res/css/components/views/elements/_FilterTabGroup.pcss +++ b/res/css/components/views/elements/_FilterTabGroup.pcss @@ -38,7 +38,7 @@ limitations under the License. &:checked + span { color: $accent; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); // underline box-shadow: 0 1.5px 0 0 currentColor; } diff --git a/res/css/components/views/pips/_WidgetPip.pcss b/res/css/components/views/pips/_WidgetPip.pcss index 80c47719251..cecc0e1365a 100644 --- a/res/css/components/views/pips/_WidgetPip.pcss +++ b/res/css/components/views/pips/_WidgetPip.pcss @@ -42,7 +42,7 @@ limitations under the License. padding: $spacing-12; display: flex; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0)); } diff --git a/res/css/components/views/spaces/_QuickThemeSwitcher.pcss b/res/css/components/views/spaces/_QuickThemeSwitcher.pcss index c0ca83eb177..a729134c124 100644 --- a/res/css/components/views/spaces/_QuickThemeSwitcher.pcss +++ b/res/css/components/views/spaces/_QuickThemeSwitcher.pcss @@ -30,7 +30,7 @@ limitations under the License. } .mx_QuickThemeSwitcher_heading { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; color: $secondary-content; diff --git a/res/css/structures/_FilePanel.pcss b/res/css/structures/_FilePanel.pcss index f04dc3f8dcc..e3f9f2d4d90 100644 --- a/res/css/structures/_FilePanel.pcss +++ b/res/css/structures/_FilePanel.pcss @@ -42,24 +42,22 @@ limitations under the License. display: none; } + /* Overrides for the attachment body tiles */ .mx_EventTile { - /* Overrides for the attachment body tiles */ - &:not([data-layout="bubble"]) { - word-break: break-word; - margin-top: 10px; - padding-top: 0; - - .mx_EventTile_line { - padding-inline-start: 0; - } + word-break: break-word; + margin-top: 10px; + padding-top: 0; + + .mx_EventTile_line { + padding-inline-start: 0; + } - &:hover { - &.mx_EventTile_verified, - &.mx_EventTile_unverified, - &.mx_EventTile_unknown { - .mx_EventTile_line { - box-shadow: none; - } + &:hover { + &.mx_EventTile_verified, + &.mx_EventTile_unverified, + &.mx_EventTile_unknown { + .mx_EventTile_line { + box-shadow: none; } } } diff --git a/res/css/structures/_GenericDropdownMenu.pcss b/res/css/structures/_GenericDropdownMenu.pcss index 75805f11468..c3740cc847d 100644 --- a/res/css/structures/_GenericDropdownMenu.pcss +++ b/res/css/structures/_GenericDropdownMenu.pcss @@ -92,7 +92,7 @@ limitations under the License. span:first-child { color: $primary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } diff --git a/res/css/structures/_HomePage.pcss b/res/css/structures/_HomePage.pcss index f06fa2e35de..24d17f42616 100644 --- a/res/css/structures/_HomePage.pcss +++ b/res/css/structures/_HomePage.pcss @@ -37,7 +37,7 @@ limitations under the License. } h1 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-32px; line-height: $font-44px; margin-bottom: 4px; @@ -45,7 +45,7 @@ limitations under the License. h2 { margin-top: 4px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-25px; color: $muted-fg-color; @@ -73,7 +73,7 @@ limitations under the License. word-break: break-word; box-sizing: border-box; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-20px; color: #fff; /* on all themes */ diff --git a/res/css/structures/_LargeLoader.pcss b/res/css/structures/_LargeLoader.pcss index ba95ea56b65..555eb4bee55 100644 --- a/res/css/structures/_LargeLoader.pcss +++ b/res/css/structures/_LargeLoader.pcss @@ -29,7 +29,7 @@ limitations under the License. .mx_LargeLoader_text { font-size: 24px; - font-weight: 600; + font-weight: var(--font-semi-bold); padding: 0 16px; position: relative; text-align: center; diff --git a/res/css/structures/_QuickSettingsButton.pcss b/res/css/structures/_QuickSettingsButton.pcss index d6686938ad4..128c8e0fbbe 100644 --- a/res/css/structures/_QuickSettingsButton.pcss +++ b/res/css/structures/_QuickSettingsButton.pcss @@ -59,7 +59,7 @@ limitations under the License. contain: unset; /* let the dropdown paint beyond the context menu */ > div > h2 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; color: $primary-content; @@ -72,7 +72,7 @@ limitations under the License. } > div > h4 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; text-transform: uppercase; diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 177e367b58f..19d16ddece1 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -251,7 +251,7 @@ $pulse-color: $alert; .mx_RightPanel_scopeHeader { margin: 24px; text-align: center; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-22px; diff --git a/res/css/structures/_RoomSearch.pcss b/res/css/structures/_RoomSearch.pcss index 4425e87f7ad..bf05186d5aa 100644 --- a/res/css/structures/_RoomSearch.pcss +++ b/res/css/structures/_RoomSearch.pcss @@ -51,7 +51,7 @@ limitations under the License. /* the following rules are to match that of a real input field */ overflow: hidden; margin: 9px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_RoomSearch_shortcutPrompt { @@ -62,7 +62,7 @@ limitations under the License. font-size: $font-12px; line-height: $font-15px; font-family: inherit; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $light-fg-color; margin-right: 6px; white-space: nowrap; diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index b03ea7d0ab1..1ef90f80db2 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -34,6 +34,16 @@ limitations under the License. flex-direction: column; flex: 1; position: relative; + + .mx_MainSplit { + flex: 1 1 0; + } + + .mx_MessageComposer { + width: 100%; + flex: 0 0 auto; + margin-right: 2px; + } } .mx_RoomView_auxPanel { @@ -44,16 +54,6 @@ limitations under the License. overflow: auto; } -.mx_RoomView_auxPanel_fullHeight { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: 3000; - background-color: $background; -} - .mx_RoomView_auxPanel_hiddenHighlights { border-bottom: 1px solid $primary-hairline-color; padding: 10px 26px; @@ -61,14 +61,6 @@ limitations under the License. cursor: pointer; } -.mx_RoomView_auxPanel_apps { - max-width: 1920px !important; -} - -.mx_RoomView .mx_MainSplit { - flex: 1 1 0; -} - .mx_RoomView_messagePanel { width: 100%; overflow-y: auto; @@ -83,20 +75,20 @@ limitations under the License. background-size: 25px; background-repeat: no-repeat; position: relative; -} -.mx_RoomView_messagePanelSearchSpinner::before { - background-color: $info-plinth-fg-color; - mask: url("$(res)/img/feather-customised/search-input.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: 50px; - content: ""; - position: absolute; - top: 286px; - left: 0; - right: 0; - height: 50px; + &::before { + background-color: $info-plinth-fg-color; + mask: url("$(res)/img/feather-customised/search-input.svg"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 50px; + content: ""; + position: absolute; + top: 286px; + left: 0; + right: 0; + height: 50px; + } } .mx_RoomView_body { @@ -110,15 +102,15 @@ limitations under the License. .mx_RoomView_messagePanelSearchSpinner { order: 2; } -} -.mx_RoomView_body .mx_RoomView_timeline { - /* offset parent for mx_RoomView_topUnreadMessagesBar */ - position: relative; - flex: 1; - display: flex; - flex-direction: column; - margin-right: calc($container-gap-width / 2); + .mx_RoomView_timeline { + /* offset parent for mx_RoomView_topUnreadMessagesBar */ + position: relative; + flex: 1; + display: flex; + flex-direction: column; + margin-right: calc($container-gap-width / 2); + } } .mx_RoomView_statusArea { @@ -185,16 +177,16 @@ limitations under the License. /* needed as min-height is set to clientHeight in ScrollPanel to prevent shrinking when WhoIsTypingTile is hidden */ box-sizing: border-box; + + li { + clear: both; + } } .mx_RoomView--local .mx_ScrollPanel .mx_RoomView_MessageList { justify-content: center; } -.mx_RoomView_MessageList li { - clear: both; -} - li.mx_RoomView_myReadMarker_container { height: 0px; margin: 0px; @@ -220,61 +212,17 @@ hr.mx_RoomView_myReadMarker { border: unset; } -.mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { - background-color: $background; -} - -.mx_RoomView_callStatusBar .mx_UploadBar_uploadFilename { - color: $accent-fg-color; - opacity: 1; -} - -.mx_RoomView_inCall .mx_RoomView_statusAreaBox_line { - margin-top: 2px; - border: none; - height: 0px; -} - -.mx_RoomView_inCall .mx_MessageComposer_wrapper { - border-top: 2px hidden; - padding-top: 1px; -} - -.mx_RoomView_voipChevron { - position: absolute; - bottom: -11px; - right: 11px; -} - -.mx_RoomView_voipButton { - float: right; - margin-right: 13px; - margin-top: 13px; - cursor: pointer; -} - -.mx_RoomView_voipButton object { - pointer-events: none; -} - -.mx_RoomView .mx_MessageComposer { - width: 100%; - flex: 0 0 auto; - margin-right: 2px; -} - -.mx_RoomView_ongoingConfCallNotification { - width: 100%; - text-align: center; - background-color: $alert; - color: $accent-fg-color; - font-weight: bold; - padding: 6px 0; - cursor: pointer; -} +.mx_RoomView_inCall { + .mx_RoomView_statusAreaBox_line { + margin-top: 2px; + border: none; + height: 0px; + } -.mx_RoomView_ongoingConfCallNotification a { - color: $accent-fg-color !important; + .mx_MessageComposer_wrapper { + border-top: 2px hidden; + padding-top: 1px; + } } .mx_MatrixChat_useCompactLayout { diff --git a/res/css/structures/_SpaceHierarchy.pcss b/res/css/structures/_SpaceHierarchy.pcss index e178f766609..663c4d680d8 100644 --- a/res/css/structures/_SpaceHierarchy.pcss +++ b/res/css/structures/_SpaceHierarchy.pcss @@ -46,7 +46,7 @@ limitations under the License. .mx_SpaceHierarchy_listHeader_header { grid-column-start: 1; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); margin: 0; } @@ -71,7 +71,7 @@ limitations under the License. .mx_SpaceHierarchy_error { position: relative; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $alert; font-size: $font-15px; line-height: $font-18px; @@ -94,7 +94,7 @@ limitations under the License. .mx_SpaceHierarchy_roomCount { > h3 { display: inline; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-22px; color: $primary-content; @@ -167,7 +167,7 @@ limitations under the License. gap: 6px 12px; .mx_SpaceHierarchy_roomTile_item { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-18px; display: grid; diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 164ae688c68..ca7b887c80d 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -280,7 +280,7 @@ $activeBorderColor: $primary-content; border-radius: 8px; background-color: $panel-actions; font-size: $font-15px !important; /* override inline style */ - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-18px; & + .mx_BaseAvatar_image { @@ -395,7 +395,7 @@ $activeBorderColor: $primary-content; .mx_SpacePanel_contextMenu_header { margin: 12px 16px 12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-18px; overflow: hidden; @@ -447,7 +447,7 @@ $activeBorderColor: $primary-content; color: $tertiary-content; font-size: $font-10px; line-height: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); //margin-left: 8px; } } diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 433febae48d..3487253ee7a 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -23,15 +23,14 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; border-radius: 8px; border: 1px solid $input-border-color; - font-size: $font-15px; + font-size: $font-17px; + font-weight: var(--font-semi-bold); margin: 20px 0; - > h3 { - font-weight: $font-semi-bold; - margin: 0 0 4px; - } - - > span { + > div { + margin-top: 4px; + font-weight: normal; + font-size: $font-15px; color: $secondary-content; } @@ -74,7 +73,7 @@ $SpaceRoomViewInnerWidth: 428px; h1 { margin: 0; font-size: $font-24px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $primary-content; width: max-content; } @@ -121,7 +120,7 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_errorText { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; color: $alert; diff --git a/res/css/structures/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index a7888846917..ce0357a3fc9 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -119,7 +119,7 @@ limitations under the License. h2 { margin: 0; font-size: $font-15px; - font-weight: 600; + font-weight: var(--font-semi-bold); display: inline; width: auto; } diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index f9b399a3074..1d47b2333e1 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -47,7 +47,7 @@ limitations under the License. } .mx_UserMenu_name { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; margin-left: 10px; @@ -155,7 +155,7 @@ limitations under the License. display: inline-block; > span { - font-weight: 600; + font-weight: var(--font-semi-bold); display: block; & + span { diff --git a/res/css/structures/auth/_Login.pcss b/res/css/structures/auth/_Login.pcss index f57346a7073..2eba8cf3d14 100644 --- a/res/css/structures/auth/_Login.pcss +++ b/res/css/structures/auth/_Login.pcss @@ -18,7 +18,7 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; font-size: 15px; - font-weight: 600; + font-weight: var(--font-semi-bold); width: 100%; margin-top: 24px; margin-bottom: 24px; diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index f75d7a4df4d..9548b5cf720 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -25,7 +25,7 @@ limitations under the License. box-sizing: border-box; b { - font-weight: 600; + font-weight: var(--font-semi-bold); } &.mx_AuthBody_flex { @@ -35,14 +35,14 @@ limitations under the License. h1 { font-size: $font-24px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); margin-top: $spacing-8; color: $authpage-primary-color; } h2 { font-size: $font-14px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $authpage-secondary-color; } @@ -155,7 +155,7 @@ limitations under the License. } .mx_Login_submit { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); margin: 0 0 $spacing-16; } @@ -168,7 +168,7 @@ limitations under the License. } .mx_AuthBody_sign-in-instead-button { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); padding: $spacing-4; } @@ -262,7 +262,7 @@ limitations under the License. text-align: center; > a { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } diff --git a/res/css/views/auth/_CompleteSecurityBody.pcss b/res/css/views/auth/_CompleteSecurityBody.pcss index a4880de723a..f29d86d5357 100644 --- a/res/css/views/auth/_CompleteSecurityBody.pcss +++ b/res/css/views/auth/_CompleteSecurityBody.pcss @@ -25,13 +25,13 @@ limitations under the License. h2 { font-size: $font-24px; - font-weight: 600; + font-weight: var(--font-semi-bold); margin-top: 0; } h3 { font-size: $font-14px; - font-weight: 600; + font-weight: var(--font-semi-bold); } a:link, diff --git a/res/css/views/auth/_LanguageSelector.pcss b/res/css/views/auth/_LanguageSelector.pcss index 885ee7f30d5..8a762e0de3c 100644 --- a/res/css/views/auth/_LanguageSelector.pcss +++ b/res/css/views/auth/_LanguageSelector.pcss @@ -21,7 +21,7 @@ limitations under the License. .mx_AuthBody_language .mx_Dropdown_input { border: none; font-size: $font-14px; - font-weight: 600; + font-weight: var(--font-semi-bold); color: $authpage-lang-color; width: auto; } diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss index f1259fde0fa..699d7b0f38e 100644 --- a/res/css/views/auth/_LoginWithQR.pcss +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -111,7 +111,7 @@ limitations under the License. .mx_LoginWithQR_confirmationDigits { text-align: center; margin: $spacing-48 auto; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-24px; color: $primary-content; } diff --git a/res/css/views/beta/_BetaCard.pcss b/res/css/views/beta/_BetaCard.pcss index e4e4db01e56..591fff2d954 100644 --- a/res/css/views/beta/_BetaCard.pcss +++ b/res/css/views/beta/_BetaCard.pcss @@ -32,7 +32,7 @@ limitations under the License. flex: 1; .mx_BetaCard_title { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-22px; color: $primary-content; @@ -126,7 +126,7 @@ limitations under the License. border-radius: 8px; text-transform: uppercase; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: 15px; color: $button-primary-fg-color; display: inline-block; diff --git a/res/css/views/context_menus/_IconizedContextMenu.pcss b/res/css/views/context_menus/_IconizedContextMenu.pcss index f6ebcd7aa54..1fb19f686d6 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.pcss +++ b/res/css/views/context_menus/_IconizedContextMenu.pcss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ limitations under the License. .mx_IconizedContextMenu_optionList_label { font-size: $font-15px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } /* the notFirst class is for cases where the optionList might be under a header of sorts. */ @@ -180,6 +180,10 @@ limitations under the License. margin-right: -5px; } + .mx_IconizedContextMenu_developerTools::before { + mask-image: url("$(res)/img/element-icons/settings/flask.svg"); + } + .mx_IconizedContextMenu_checked::before { mask-image: url("$(res)/img/element-icons/roomlist/checkmark.svg"); } diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss index 9a6372a5adb..f71d43ba0b0 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss @@ -48,7 +48,7 @@ limitations under the License. margin: 0; color: $secondary-content; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-15px; } @@ -96,7 +96,7 @@ limitations under the License. } .mx_AddExistingToSpace_errorHeading { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-18px; color: $alert; @@ -171,7 +171,7 @@ limitations under the License. > div { > h1 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-22px; margin: 0; diff --git a/res/css/views/dialogs/_CompoundDialog.pcss b/res/css/views/dialogs/_CompoundDialog.pcss index 15df4f39511..b9ddf7837a8 100644 --- a/res/css/views/dialogs/_CompoundDialog.pcss +++ b/res/css/views/dialogs/_CompoundDialog.pcss @@ -37,7 +37,7 @@ limitations under the License. h1 { display: inline-block; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-24px; margin: 0; /* managed by header class */ } diff --git a/res/css/views/dialogs/_CreateRoomDialog.pcss b/res/css/views/dialogs/_CreateRoomDialog.pcss index 498c001606e..998b738cb45 100644 --- a/res/css/views/dialogs/_CreateRoomDialog.pcss +++ b/res/css/views/dialogs/_CreateRoomDialog.pcss @@ -19,7 +19,7 @@ limitations under the License. .mx_CreateRoomDialog_details_summary { list-style: none; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); cursor: pointer; color: $accent; @@ -96,7 +96,7 @@ limitations under the License. .mx_SettingsFlag_label { flex: 1 1 0; min-width: 0; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_ToggleSwitch { diff --git a/res/css/views/dialogs/_ExportDialog.pcss b/res/css/views/dialogs/_ExportDialog.pcss index ef96ed63818..64599c669c7 100644 --- a/res/css/views/dialogs/_ExportDialog.pcss +++ b/res/css/views/dialogs/_ExportDialog.pcss @@ -19,7 +19,7 @@ limitations under the License. font-size: $font-16px; display: block; font-family: $font-family; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $primary-content; margin-top: 18px; margin-bottom: 12px; diff --git a/res/css/views/dialogs/_FeedbackDialog.pcss b/res/css/views/dialogs/_FeedbackDialog.pcss index ef7bce0cf27..aa778e1776d 100644 --- a/res/css/views/dialogs/_FeedbackDialog.pcss +++ b/res/css/views/dialogs/_FeedbackDialog.pcss @@ -41,7 +41,7 @@ limitations under the License. > h3 { margin-top: 0; margin-bottom: 8px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; line-height: $font-22px; } diff --git a/res/css/views/dialogs/_ForwardDialog.pcss b/res/css/views/dialogs/_ForwardDialog.pcss index f1818721f13..4190c052e5b 100644 --- a/res/css/views/dialogs/_ForwardDialog.pcss +++ b/res/css/views/dialogs/_ForwardDialog.pcss @@ -27,7 +27,7 @@ limitations under the License. margin: 0 0 6px; color: $secondary-content; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-15px; } diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss index d2db7fa163d..ec50de54e9f 100644 --- a/res/css/views/dialogs/_InviteDialog.pcss +++ b/res/css/views/dialogs/_InviteDialog.pcss @@ -114,7 +114,7 @@ limitations under the License. > span { color: $primary-content; - font-weight: 600; + font-weight: var(--font-semi-bold); } > p { @@ -277,7 +277,7 @@ limitations under the License. input { font-size: 18px; - font-weight: 600; + font-weight: var(--font-semi-bold); padding-top: 0; } @@ -429,7 +429,7 @@ limitations under the License. .mx_InviteDialog_tile_nameStack_name { font-size: $font-15px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $primary-content; } diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss index d99072648f6..8a2d079399c 100644 --- a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss +++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss @@ -54,7 +54,7 @@ limitations under the License. margin: 0; color: $secondary-content; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-15px; } diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index ff7c1002c42..69f854d50b9 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.pcss @@ -54,7 +54,7 @@ limitations under the License. /* Emulate mx_EventTile[data-layout="group"] */ .mx_EventTile { - padding-top: 0 !important; /* Override mx_EventTile:not([data-layout="bubble"]) */ + padding-top: 0; .mx_MessageTimestamp { position: absolute; diff --git a/res/css/views/dialogs/_NewSessionReviewDialog.pcss b/res/css/views/dialogs/_NewSessionReviewDialog.pcss index 0016b5b91ba..0992c980f32 100644 --- a/res/css/views/dialogs/_NewSessionReviewDialog.pcss +++ b/res/css/views/dialogs/_NewSessionReviewDialog.pcss @@ -28,7 +28,7 @@ limitations under the License. } .mx_NewSessionReviewDialog_deviceName { - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_NewSessionReviewDialog_deviceID { diff --git a/res/css/views/dialogs/_PollCreateDialog.pcss b/res/css/views/dialogs/_PollCreateDialog.pcss index e50af35a41a..476ac964b78 100644 --- a/res/css/views/dialogs/_PollCreateDialog.pcss +++ b/res/css/views/dialogs/_PollCreateDialog.pcss @@ -26,7 +26,7 @@ limitations under the License. } h2 { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; margin-top: 0; diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.pcss b/res/css/views/dialogs/_RoomSettingsDialogBridges.pcss index 4a66c3cdaf2..746ba90b2db 100644 --- a/res/css/views/dialogs/_RoomSettingsDialogBridges.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.pcss @@ -94,7 +94,7 @@ limitations under the License. .mx_RoomSettingsDialog_workspace_channel_details { color: $primary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); .mx_RoomSettingsDialog_channel { margin-inline-start: 5px; diff --git a/res/css/views/dialogs/_ServerPickerDialog.pcss b/res/css/views/dialogs/_ServerPickerDialog.pcss index 4d246512539..440ddbf5f62 100644 --- a/res/css/views/dialogs/_ServerPickerDialog.pcss +++ b/res/css/views/dialogs/_ServerPickerDialog.pcss @@ -37,7 +37,7 @@ limitations under the License. > h2 { font-size: $font-15px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); color: $secondary-content; margin: 16px 0 16px 8px; } diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.pcss b/res/css/views/dialogs/_SpaceSettingsDialog.pcss index e0887d1b76f..78c4e42c077 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.pcss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.pcss @@ -18,7 +18,7 @@ limitations under the License. color: $primary-content; .mx_SpaceSettings_errorText { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; color: $alert; @@ -48,7 +48,7 @@ limitations under the License. margin-bottom: 4px; .mx_StyledRadioButton_content { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-18px; color: $primary-content; } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index f85ef87d4e3..1dfd4dccdbf 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -155,10 +155,15 @@ limitations under the License. overflow-y: auto; padding: $spacing-16; + ul { + padding: 0; + margin: 0; + } + .mx_SpotlightDialog_section { > h4, > .mx_SpotlightDialog_sectionHeader > h4 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; color: $secondary-content; diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss index 47541dc452a..a8db4a3d0a6 100644 --- a/res/css/views/dialogs/_VerifyEMailDialog.pcss +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -27,7 +27,7 @@ limitations under the License. h1 { font-size: $font-24px; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_VerifyEMailDialog_text-light { diff --git a/res/css/views/dialogs/security/_CreateSecretStorageDialog.pcss b/res/css/views/dialogs/security/_CreateSecretStorageDialog.pcss index 5dc40898623..e695992008e 100644 --- a/res/css/views/dialogs/security/_CreateSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_CreateSecretStorageDialog.pcss @@ -38,7 +38,7 @@ limitations under the License. .mx_SettingsFlag_label { flex: 1 1 0; min-width: 0; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_ToggleSwitch { @@ -103,7 +103,7 @@ limitations under the License. .mx_CreateSecretStorageDialog_optionTitle { color: $dialog-title-fg-color; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-18px; padding-bottom: 10px; } diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index 38ab7b8eb6b..cda2c5f388d 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -90,7 +90,7 @@ limitations under the License. &.mx_AccessibleButton_kind_primary, &.mx_AccessibleButton_kind_primary_outline, &.mx_AccessibleButton_kind_secondary { - font-weight: 600; + font-weight: var(--font-semi-bold); } &.mx_AccessibleButton_kind_primary, diff --git a/res/css/views/elements/_RoomAliasField.pcss b/res/css/views/elements/_RoomAliasField.pcss index 94f6c12a143..b05f8a9e0cd 100644 --- a/res/css/views/elements/_RoomAliasField.pcss +++ b/res/css/views/elements/_RoomAliasField.pcss @@ -39,7 +39,7 @@ limitations under the License. color: $info-plinth-fg-color; border-left: none; border-right: none; - font-weight: 600; + font-weight: var(--font-semi-bold); padding: 9px 10px; flex: 0 0 auto; } diff --git a/res/css/views/elements/_SSOButtons.pcss b/res/css/views/elements/_SSOButtons.pcss index ed09ac31e54..d91e448b491 100644 --- a/res/css/views/elements/_SSOButtons.pcss +++ b/res/css/views/elements/_SSOButtons.pcss @@ -33,7 +33,7 @@ limitations under the License. border-radius: 8px; display: inline-block; font-size: $font-14px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); border: 1px solid $input-border-color; color: $primary-content; diff --git a/res/css/views/elements/_ServerPicker.pcss b/res/css/views/elements/_ServerPicker.pcss index b993f8bba4e..c2370471af2 100644 --- a/res/css/views/elements/_ServerPicker.pcss +++ b/res/css/views/elements/_ServerPicker.pcss @@ -25,7 +25,7 @@ limitations under the License. line-height: $font-20px; > h2 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); margin: 0 0 20px; grid-column: 1; grid-row: 1; diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index 9ed51010062..9f7c1c3cfe3 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -102,7 +102,7 @@ limitations under the License. } .mx_Tooltip_title { - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_Tooltip_sub { diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss index 26b2c5652fc..32aa208ccc4 100644 --- a/res/css/views/elements/_UseCaseSelection.pcss +++ b/res/css/views/elements/_UseCaseSelection.pcss @@ -26,7 +26,7 @@ limitations under the License. justify-content: flex-end; h1 { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-32px; text-align: center; } diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index 22b26d2867b..941821864d7 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -182,6 +182,14 @@ limitations under the License. list-style: none; width: 38px; cursor: pointer; + + &:focus-within { + background-color: $focus-bg-color; + } +} + +.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper[tabindex="0"] .mx_EmojiPicker_item { + background-color: $focus-bg-color; } .mx_EmojiPicker_item { @@ -209,7 +217,7 @@ limitations under the License. .mx_EmojiPicker_category_label, .mx_EmojiPicker_preview_name { font-size: $font-16px; - font-weight: 600; + font-weight: var(--font-semi-bold); margin: 0; } diff --git a/res/css/views/messages/_CallEvent.pcss b/res/css/views/messages/_CallEvent.pcss index 599b2b86fe0..7749440963e 100644 --- a/res/css/views/messages/_CallEvent.pcss +++ b/res/css/views/messages/_CallEvent.pcss @@ -67,7 +67,7 @@ limitations under the License. } .mx_CallEvent_active .mx_CallEvent_title { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_CallEvent_columns { diff --git a/res/css/views/messages/_DisambiguatedProfile.pcss b/res/css/views/messages/_DisambiguatedProfile.pcss index ab173f27b1c..1d577a3923c 100644 --- a/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/res/css/views/messages/_DisambiguatedProfile.pcss @@ -22,12 +22,12 @@ limitations under the License. cursor: pointer; .mx_DisambiguatedProfile_displayName { - font-weight: 600; + font-weight: var(--font-semi-bold); margin-inline-end: 0; } .mx_DisambiguatedProfile_mxid { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: 1.1rem; margin-inline-start: 5px; opacity: 0.5; /* Match mx_TextualEvent */ diff --git a/res/css/views/messages/_EventTileBubble.pcss b/res/css/views/messages/_EventTileBubble.pcss index c0f4feb98ee..418f83e04c0 100644 --- a/res/css/views/messages/_EventTileBubble.pcss +++ b/res/css/views/messages/_EventTileBubble.pcss @@ -48,7 +48,7 @@ limitations under the License. } .mx_EventTileBubble_title { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-15px; grid-row: 1; } diff --git a/res/css/views/messages/_LegacyCallEvent.pcss b/res/css/views/messages/_LegacyCallEvent.pcss index 8d0f5abbc30..a8f6c83a13f 100644 --- a/res/css/views/messages/_LegacyCallEvent.pcss +++ b/res/css/views/messages/_LegacyCallEvent.pcss @@ -118,7 +118,7 @@ limitations under the License. min-width: 0; .mx_LegacyCallEvent_sender { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: 1.5rem; line-height: 1.8rem; margin-bottom: $spacing-4; diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index e7f3118d571..b86804e9253 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -18,7 +18,7 @@ limitations under the License. margin-top: 8px; h2 { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; margin-top: 0; diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 1b31cc532a8..9e6f4bc892d 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -34,7 +34,7 @@ limitations under the License. > h2 { margin: 0 44px; /* TODO: Use a spacing variable */ font-size: $font-18px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -151,10 +151,11 @@ limitations under the License. margin-right: $spacing-12; } - > h1 { + > h2 { color: $tertiary-content; font-size: $font-12px; font-weight: 500; + margin: $spacing-12; } .mx_BaseCard_Button { @@ -198,7 +199,7 @@ limitations under the License. .mx_AccessibleButton_kind_secondary { color: $secondary-content; background-color: rgba(141, 151, 165, 0.2); - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-14px; } @@ -226,7 +227,7 @@ limitations under the License. position: initial; span:first-of-type { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: inherit; color: $primary-content; } diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index ed7e3887518..863e74a88b4 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -19,8 +19,9 @@ limitations under the License. text-align: center; margin-top: $spacing-20; - h2 { + h1 { margin: $spacing-12 0 $spacing-4; + font-weight: var(--font-semi-bold); } .mx_RoomSummaryCard_alias { @@ -30,7 +31,7 @@ limitations under the License. text-overflow: ellipsis; } - h2, + h1, .mx_RoomSummaryCard_alias { display: -webkit-box; -webkit-line-clamp: 2; @@ -244,7 +245,7 @@ limitations under the License. margin-top: 12px; margin-bottom: 12px; font-size: $font-13px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index a1ad8298804..4549d8681c6 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -174,7 +174,7 @@ limitations under the License. h2 { color: $primary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; margin-top: 24px; margin-bottom: 10px; @@ -204,7 +204,7 @@ limitations under the License. line-height: $font-15px; > b { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index df452aff4a4..4304953f355 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -43,7 +43,7 @@ limitations under the License. h2 { font-size: $font-18px; - font-weight: 600; + font-weight: var(--font-semi-bold); margin: 18px 0 0 0; /* TODO: Use a variable */ } @@ -145,7 +145,7 @@ limitations under the License. h3 { text-transform: uppercase; color: $tertiary-content; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-12px; margin: $spacing-4 0; } diff --git a/res/css/views/room_settings/_AliasSettings.pcss b/res/css/views/room_settings/_AliasSettings.pcss index a218e29e1dc..66ac17d8422 100644 --- a/res/css/views/room_settings/_AliasSettings.pcss +++ b/res/css/views/room_settings/_AliasSettings.pcss @@ -31,7 +31,7 @@ limitations under the License. summary { cursor: pointer; color: $accent; - font-weight: 600; + font-weight: var(--font-semi-bold); list-style: none; /* list-style doesn't do it for webkit */ diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index c4703efe42c..e32782b7e3e 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -329,7 +329,7 @@ $MinWidth: 240px; } .mx_AppPermissionWarning_bolder { - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_AppPermissionWarning h4 { diff --git a/res/css/views/rooms/_DecryptionFailureBar.pcss b/res/css/views/rooms/_DecryptionFailureBar.pcss index 57dc71b7311..f32b0b2bfea 100644 --- a/res/css/views/rooms/_DecryptionFailureBar.pcss +++ b/res/css/views/rooms/_DecryptionFailureBar.pcss @@ -73,7 +73,7 @@ limitations under the License. .mx_DecryptionFailureBar_start_headline { grid-area: headline; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-16px; align-self: center; } diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 21b05a020c6..c525e5b9129 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -44,13 +44,6 @@ limitations under the License. --EventTile_bubble_line-margin-inline-end: -12px; --EventTile_bubble_gap-inline: 5px; - position: relative; - /* Other half of the gutter is provided by margin-bottom on the last tile - of the section */ - margin-top: calc(var(--gutterSize) / 2); - margin-left: var(--EventTile_bubble-margin-inline-start); - font-size: $font-14px; - .mx_MessageTimestamp { width: unset; /* Cancel the default width */ max-width: var(--MessageTimestamp-max-width); diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index a6765ca5af4..97dc3b740fe 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -26,6 +26,11 @@ $left-gutter: 64px; --EventTile_ThreadSummary-line-height: calc(2 * $font-12px); flex-shrink: 0; + max-width: 100%; + clear: both; /* TODO: check if this is necessary */ + padding-top: 18px; + font-size: $font-14px; + position: relative; .mx_EventTile_avatar { cursor: pointer; @@ -405,7 +410,7 @@ $left-gutter: 64px; } &.mx_EventTile_continuation { - padding-top: 0px !important; + padding-top: 0; } &.mx_EventTile_info { @@ -451,6 +456,15 @@ $left-gutter: 64px; } &[data-layout="bubble"] { + /* Other half of the gutter is provided by margin-bottom on the last tile + of the section */ + margin-top: calc(var(--gutterSize) / 2); + margin-left: var(--EventTile_bubble-margin-inline-start); + + /* Reset default values. TODO: remove */ + max-width: unset; + padding-top: 0; + .mx_EventTile_msgOption { .mx_ReadReceiptGroup { position: absolute; @@ -480,14 +494,6 @@ $left-gutter: 64px; } } -.mx_EventTile:not([data-layout="bubble"]) { - max-width: 100%; - clear: both; - padding-top: 18px; - font-size: $font-14px; - position: relative; -} - .mx_GenericEventListSummary { &[data-layout="irc"], &[data-layout="group"] { @@ -1226,106 +1232,101 @@ $left-gutter: 64px; /* Cascading - compact modern layout on the main timeline and the right panel */ .mx_MatrixChat_useCompactLayout { - .mx_EventTile { - /* Override :not([data-layout="bubble"]) */ - &[data-layout="group"] { - --MatrixChat_useCompactLayout_group-padding-top: $spacing-4; - --MatrixChat_useCompactLayout-top-avatar: 2px; - --MatrixChat_useCompactLayout-top-e2eIcon: 3px; - --MatrixChat_useCompactLayout_line-spacing-block: 0px; + .mx_EventTile[data-layout="group"] { + --MatrixChat_useCompactLayout_group-padding-top: $spacing-4; + --MatrixChat_useCompactLayout-top-avatar: 2px; + --MatrixChat_useCompactLayout-top-e2eIcon: 3px; + --MatrixChat_useCompactLayout_line-spacing-block: 0px; - padding-top: var(--MatrixChat_useCompactLayout_group-padding-top); + padding-top: var(--MatrixChat_useCompactLayout_group-padding-top); - .mx_EventTile_line, - .mx_EventTile_reply { - padding-block: var(--MatrixChat_useCompactLayout_line-spacing-block); - } + .mx_EventTile_line, + .mx_EventTile_reply { + padding-block: var(--MatrixChat_useCompactLayout_line-spacing-block); + } - .mx_ReplyChain { - margin-bottom: $spacing-4; - } + .mx_ReplyChain { + margin-bottom: $spacing-4; + } - &.mx_EventTile_info { - padding-top: 0; /* same as the padding for non-compact .mx_EventTile.mx_EventTile_info */ - font-size: $font-13px; + &.mx_EventTile_info { + padding-top: 0; /* same as the padding for non-compact .mx_EventTile.mx_EventTile_info */ + font-size: $font-13px; - .mx_EventTile_e2eIcon, - .mx_EventTile_avatar { - top: 0; - margin-block: var(--MatrixChat_useCompactLayout_line-spacing-block); - } + .mx_EventTile_e2eIcon, + .mx_EventTile_avatar { + top: 0; + margin-block: var(--MatrixChat_useCompactLayout_line-spacing-block); + } - .mx_EventTile_line, - .mx_EventTile_reply { - line-height: $font-20px; - } + .mx_EventTile_line, + .mx_EventTile_reply { + line-height: $font-20px; } + } - &.mx_EventTile_emote { - padding-top: $spacing-8; /* add a bit more space for emotes so that avatars don't collide */ + &.mx_EventTile_emote { + padding-top: $spacing-8; /* add a bit more space for emotes so that avatars don't collide */ - .mx_EventTile_avatar { - top: var(--MatrixChat_useCompactLayout-top-avatar); - } + .mx_EventTile_avatar { + top: var(--MatrixChat_useCompactLayout-top-avatar); + } + + .mx_EventTile_line, + .mx_EventTile_reply { + padding-bottom: 1px; + } + &.mx_EventTile_continuation { .mx_EventTile_line, .mx_EventTile_reply { - padding-bottom: 1px; - } - - &.mx_EventTile_continuation { - .mx_EventTile_line, - .mx_EventTile_reply { - padding-bottom: var(--MatrixChat_useCompactLayout_line-spacing-block); - } + padding-bottom: var(--MatrixChat_useCompactLayout_line-spacing-block); } } + } - /* Cascading - apply zero padding to every element including mx_EventTile_emote */ - &.mx_EventTile_continuation { - padding-top: var(--MatrixChat_useCompactLayout_line-spacing-block); - } + /* Cascading - apply zero padding to every element including mx_EventTile_emote */ + &.mx_EventTile_continuation { + padding-top: var(--MatrixChat_useCompactLayout_line-spacing-block); + } - .mx_EventTile_avatar { - top: var(--MatrixChat_useCompactLayout-top-avatar); - } + .mx_EventTile_avatar { + top: var(--MatrixChat_useCompactLayout-top-avatar); + } - .mx_EventTile_e2eIcon { - top: var(--MatrixChat_useCompactLayout-top-e2eIcon); - } + .mx_EventTile_e2eIcon { + top: var(--MatrixChat_useCompactLayout-top-e2eIcon); + } - .mx_DisambiguatedProfile { - font-size: $font-13px; - } + .mx_DisambiguatedProfile { + font-size: $font-13px; + } - .mx_EventTile_msgOption { - .mx_ReadReceiptGroup { - /* This aligns the avatar with the last line of the */ - /* message. We want to move it one line up - 2rem */ - inset-block-start: -2rem; - } + .mx_EventTile_msgOption { + .mx_ReadReceiptGroup { + /* This aligns the avatar with the last line of the */ + /* message. We want to move it one line up - 2rem */ + inset-block-start: -2rem; } + } - .mx_EventTile_content .markdown-body { - p, - ul, - ol, - dl, - blockquote, - pre, - table { - margin-bottom: $spacing-4; /* 1/4 of the non-compact margin-bottom */ - } + .mx_EventTile_content .markdown-body { + p, + ul, + ol, + dl, + blockquote, + pre, + table { + margin-bottom: $spacing-4; /* 1/4 of the non-compact margin-bottom */ } } + } - &[data-shape="ThreadsList"][data-notification]::before, - .mx_NotificationBadge { - /* stylelint-disable-next-line declaration-colon-space-after */ - inset-block-start: calc( - $notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top) - ); - } + &[data-shape="ThreadsList"][data-notification]::before, + .mx_NotificationBadge { + /* stylelint-disable-next-line declaration-colon-space-after */ + inset-block-start: calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top)); } } diff --git a/res/css/views/rooms/_IRCLayout.pcss b/res/css/views/rooms/_IRCLayout.pcss index 8163b29fc98..cd4371b6a02 100644 --- a/res/css/views/rooms/_IRCLayout.pcss +++ b/res/css/views/rooms/_IRCLayout.pcss @@ -17,57 +17,54 @@ limitations under the License. $irc-line-height: $font-18px; .mx_IRCLayout { - --name-width: 80px; // cf. ircDisplayNameWidth on Settings.tsx + --name-width: 80px; /* cf. ircDisplayNameWidth on Settings.tsx */ --icon-width: 14px; - --right-padding: 5px; --line-height: $irc-line-height; + --right-padding: 5px; /* TODO: Use a spacing variable */ line-height: var(--line-height) !important; + blockquote { + margin: 0; + } + .mx_NewRoomIntro { > h2 { line-height: initial; /* Cancel $irc-line-height */ } } - .mx_EventTile { + .mx_EventTile[data-layout="irc"] { --EventTile_irc_line-padding-block: 1px; - /* timestamps are links which shouldn't be underlined */ - > a { - text-decoration: none; - min-width: $MessageTimestamp_width; - } - display: flex; - flex-direction: row; align-items: flex-start; padding-top: 0; + > a { + text-decoration: none; /* timestamps are links which shouldn't be underlined */ + min-width: $MessageTimestamp_width; /* ensure space for EventTile without timestamp */ + } + > * { margin-right: var(--right-padding); } - .mx_EventTile_msgOption { - order: 5; - flex-shrink: 0; + .mx_EventTile_avatar, + .mx_EventTile_e2eIcon { + height: $irc-line-height; } - .mx_EventTile_line, - .mx_EventTile_reply { - display: flex; - flex-direction: column; - order: 3; - flex-grow: 1; - flex-shrink: 1; - min-width: 0; + .mx_EventTile_avatar, + .mx_DisambiguatedProfile, + .mx_EventTile_e2eIcon, + .mx_EventTile_msgOption { + flex-shrink: 0; } .mx_EventTile_avatar { order: 1; position: relative; - flex-shrink: 0; - height: $irc-line-height; display: flex; align-items: center; @@ -82,10 +79,9 @@ $irc-line-height: $font-18px; } .mx_DisambiguatedProfile { + order: 2; width: var(--name-width); margin-inline-end: 0; /* override mx_EventTile > * */ - order: 2; - flex-shrink: 0; > .mx_DisambiguatedProfile_displayName { width: 100%; @@ -96,9 +92,8 @@ $irc-line-height: $font-18px; > .mx_DisambiguatedProfile_mxid { visibility: collapse; - /* Override the inherited margin. */ - margin-left: 0; - padding: 0 5px; + margin-left: 0; /* Override the inherited margin. */ + padding: 0 5px; /* TODO: Use a spacing variable */ } &:hover { @@ -110,7 +105,7 @@ $irc-line-height: $font-18px; display: inline; background-color: $event-selected-color; border-radius: 8px 0 0 8px; - padding-right: 8px; + padding-right: $spacing-8; } > .mx_DisambiguatedProfile_mxid { @@ -123,12 +118,7 @@ $irc-line-height: $font-18px; .mx_EventTile_e2eIcon { padding: 0; - - flex-shrink: 0; flex-grow: 0; - - height: $font-18px; - background-position: center; } @@ -155,13 +145,34 @@ $irc-line-height: $font-18px; } } + .mx_EventTile_line, + .mx_EventTile_reply { + order: 3; + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + } + .mx_EventTile_reply { order: 4; } + .mx_EventTile_msgOption { + order: 5; + } + .mx_EditMessageComposer_buttons { position: relative; } + + &.mx_EventTile_info { + .mx_ViewSourceEvent, /* For hidden events */ + .mx_TextualEvent { + line-height: $irc-line-height; + } + } } .mx_EventTile_emote { @@ -171,17 +182,6 @@ $irc-line-height: $font-18px; } } - blockquote { - margin: 0; - } - - .mx_EventTile.mx_EventTile_info { - .mx_ViewSourceEvent, /* For hidden events */ - .mx_TextualEvent { - line-height: $irc-line-height; - } - } - .mx_ReplyChain { .mx_DisambiguatedProfile { width: unset; diff --git a/res/css/views/rooms/_MemberInfo.pcss b/res/css/views/rooms/_MemberInfo.pcss index 6fc2ff072b6..021bf54f25e 100644 --- a/res/css/views/rooms/_MemberInfo.pcss +++ b/res/css/views/rooms/_MemberInfo.pcss @@ -50,7 +50,7 @@ limitations under the License. .mx_MemberInfo h2 { font-size: $font-18px; - font-weight: 600; + font-weight: var(--font-semi-bold); margin: 16px 0 16px 15px; } diff --git a/res/css/views/rooms/_MemberList.pcss b/res/css/views/rooms/_MemberList.pcss index 16a107ad301..c0569482122 100644 --- a/res/css/views/rooms/_MemberList.pcss +++ b/res/css/views/rooms/_MemberList.pcss @@ -31,7 +31,7 @@ limitations under the License. h2 { text-transform: uppercase; color: $h3-color; - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-13px; padding-left: 3px; padding-right: 12px; @@ -85,7 +85,7 @@ limitations under the License. display: flex; justify-content: center; color: $button-fg-color; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_MemberList_invite.mx_AccessibleButton_disabled { diff --git a/res/css/views/rooms/_MessageComposerFormatBar.pcss b/res/css/views/rooms/_MessageComposerFormatBar.pcss index 1f9474ce372..2921698a0c6 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.pcss +++ b/res/css/views/rooms/_MessageComposerFormatBar.pcss @@ -99,7 +99,7 @@ limitations under the License. .mx_MessageComposerFormatBar_buttonTooltip { white-space: nowrap; font-size: $font-13px; - font-weight: 600; + font-weight: var(--font-semi-bold); min-width: 54px; text-align: center; diff --git a/res/css/views/rooms/_NewRoomIntro.pcss b/res/css/views/rooms/_NewRoomIntro.pcss index d6eaa84fd00..efb7abddc4d 100644 --- a/res/css/views/rooms/_NewRoomIntro.pcss +++ b/res/css/views/rooms/_NewRoomIntro.pcss @@ -55,7 +55,7 @@ limitations under the License. > h2 { margin-top: 24px; font-size: $font-24px; - font-weight: 600; + font-weight: var(--font-semi-bold); } > p { diff --git a/res/css/views/rooms/_PinnedEventTile.pcss b/res/css/views/rooms/_PinnedEventTile.pcss index aa4fe7c02dd..c3960447831 100644 --- a/res/css/views/rooms/_PinnedEventTile.pcss +++ b/res/css/views/rooms/_PinnedEventTile.pcss @@ -48,7 +48,7 @@ limitations under the License. .mx_PinnedEventTile_sender { grid-area: name; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; text-overflow: ellipsis; diff --git a/res/css/views/rooms/_ReadReceiptGroup.pcss b/res/css/views/rooms/_ReadReceiptGroup.pcss index 337991047a4..e0234fba4e1 100644 --- a/res/css/views/rooms/_ReadReceiptGroup.pcss +++ b/res/css/views/rooms/_ReadReceiptGroup.pcss @@ -86,7 +86,7 @@ limitations under the License. font-size: 12px; line-height: 15px; margin: 16px 16px 8px; - font-weight: 600; + font-weight: var(--font-semi-bold); /* shouldn’t be actually focusable */ outline: none; } diff --git a/res/css/views/rooms/_RoomBreadcrumbs.pcss b/res/css/views/rooms/_RoomBreadcrumbs.pcss index 531d9b12713..48daf72bd87 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.pcss +++ b/res/css/views/rooms/_RoomBreadcrumbs.pcss @@ -44,7 +44,7 @@ limitations under the License. } .mx_RoomBreadcrumbs_placeholder { - font-weight: 600; + font-weight: var(--font-semi-bold); font-size: $font-14px; line-height: 32px; /* specifically to match the height this is not scaled */ height: 32px; diff --git a/res/css/views/rooms/_RoomCallBanner.pcss b/res/css/views/rooms/_RoomCallBanner.pcss index ec26807bb18..67244b57e37 100644 --- a/res/css/views/rooms/_RoomCallBanner.pcss +++ b/res/css/views/rooms/_RoomCallBanner.pcss @@ -36,7 +36,7 @@ limitations under the License. .mx_RoomCallBanner_label { color: $primary-content; - font-weight: 600; + font-weight: var(--font-semi-bold); padding-right: $spacing-8; &::before { diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index fa2c383f72b..7d541559f1c 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -62,40 +62,11 @@ limitations under the License. } } -.mx_RoomHeader_spinner { - flex: 1; - height: 36px; - padding-left: 12px; - padding-right: 12px; -} - -.mx_RoomHeader_textButton { - @mixin mx_DialogButton; - margin-right: 8px; - margin-top: -5px; -} - -.mx_RoomHeader_textButton_danger { - background-color: $alert; -} - -.mx_RoomHeader_cancelButton { - cursor: pointer; - padding-left: 12px; - padding-right: 12px; -} - -.mx_RoomHeader_info { - display: flex; - flex: 1; - align-items: center; -} - .mx_RoomHeader_name { flex: 0 1 auto; overflow: hidden; color: $primary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; min-height: 24px; align-items: center; diff --git a/res/css/views/rooms/_RoomList.pcss b/res/css/views/rooms/_RoomList.pcss index eb2cc9c4ee7..93a8fc09946 100644 --- a/res/css/views/rooms/_RoomList.pcss +++ b/res/css/views/rooms/_RoomList.pcss @@ -50,7 +50,7 @@ limitations under the License. font-size: $font-14px; div:first-child { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-18px; color: $primary-content; } diff --git a/res/css/views/rooms/_RoomListHeader.pcss b/res/css/views/rooms/_RoomListHeader.pcss index db92a8318b2..1d5db3be798 100644 --- a/res/css/views/rooms/_RoomListHeader.pcss +++ b/res/css/views/rooms/_RoomListHeader.pcss @@ -22,7 +22,7 @@ limitations under the License. .mx_RoomListHeader_contextMenuButton { font-size: $font-15px; line-height: $font-24px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); padding: 1px 24px 1px 4px; position: relative; overflow: hidden; diff --git a/res/css/views/rooms/_RoomPreviewBar.pcss b/res/css/views/rooms/_RoomPreviewBar.pcss index 9f7f27f0bc0..739274cfa5d 100644 --- a/res/css/views/rooms/_RoomPreviewBar.pcss +++ b/res/css/views/rooms/_RoomPreviewBar.pcss @@ -24,7 +24,7 @@ limitations under the License. h3 { font-size: $font-18px; - font-weight: 600; + font-weight: var(--font-semi-bold); &.mx_RoomPreviewBar_spinnerTitle { display: flex; @@ -141,7 +141,7 @@ limitations under the License. } .mx_RoomPreviewBar_inviter { - font-weight: 600; + font-weight: var(--font-semi-bold); } a.mx_RoomPreviewBar_inviter { diff --git a/res/css/views/rooms/_RoomPreviewCard.pcss b/res/css/views/rooms/_RoomPreviewCard.pcss index b7acfb1a321..383e24582e9 100644 --- a/res/css/views/rooms/_RoomPreviewCard.pcss +++ b/res/css/views/rooms/_RoomPreviewCard.pcss @@ -26,7 +26,7 @@ limitations under the License. font-size: $font-14px; .mx_RoomPreviewCard_notice { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-24px; color: $primary-content; margin-top: $spacing-24; diff --git a/res/css/views/rooms/_RoomSublist.pcss b/res/css/views/rooms/_RoomSublist.pcss index bb66f469e47..43ade79b5a3 100644 --- a/res/css/views/rooms/_RoomSublist.pcss +++ b/res/css/views/rooms/_RoomSublist.pcss @@ -162,7 +162,7 @@ limitations under the License. max-width: calc(100% - 16px); /* 16px is the badge width */ line-height: $font-16px; font-size: $font-13px; - font-weight: 600; + font-weight: var(--font-semi-bold); /* Ellipsize any text overflow */ text-overflow: ellipsis; @@ -414,7 +414,7 @@ limitations under the License. .mx_RoomSublist_contextMenu_title { font-size: $font-15px; line-height: $font-20px; - font-weight: 600; + font-weight: var(--font-semi-bold); margin-bottom: 4px; } diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index 3fe18ce8b97..42dbe17cd53 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -84,7 +84,7 @@ limitations under the License. line-height: $font-18px; &.mx_RoomTile_titleHasUnreadEvents { - font-weight: 600; + font-weight: var(--font-semi-bold); } } @@ -375,10 +375,6 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/export.svg"); } - .mx_RoomTile_iconDeveloperTools::before { - mask-image: url("$(res)/img/element-icons/settings/flask.svg"); - } - .mx_RoomTile_iconCopyLink::before { mask-image: url("$(res)/img/element-icons/link.svg"); } diff --git a/res/css/views/rooms/_SearchBar.pcss b/res/css/views/rooms/_SearchBar.pcss index 95aa57df0e8..9bde17a71c5 100644 --- a/res/css/views/rooms/_SearchBar.pcss +++ b/res/css/views/rooms/_SearchBar.pcss @@ -49,7 +49,7 @@ limitations under the License. cursor: pointer; color: $primary-content; border-bottom: 2px solid $accent; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_SearchBar_unselected { diff --git a/res/css/views/rooms/_ThreadSummary.pcss b/res/css/views/rooms/_ThreadSummary.pcss index e71c487a068..06049e7f01b 100644 --- a/res/css/views/rooms/_ThreadSummary.pcss +++ b/res/css/views/rooms/_ThreadSummary.pcss @@ -107,7 +107,7 @@ limitations under the License. } .mx_ThreadSummary_sender { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_ThreadSummary_content { diff --git a/res/css/views/rooms/_WhoIsTypingTile.pcss b/res/css/views/rooms/_WhoIsTypingTile.pcss index 7157d18f2e5..b260723c7b9 100644 --- a/res/css/views/rooms/_WhoIsTypingTile.pcss +++ b/res/css/views/rooms/_WhoIsTypingTile.pcss @@ -58,7 +58,7 @@ limitations under the License. .mx_WhoIsTypingTile_label { flex: 1; font-size: $font-14px; - font-weight: 600; + font-weight: var(--font-semi-bold); color: $roomtopic-color; } diff --git a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index 2eee815c3fc..62a596d876d 100644 --- a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -71,8 +71,8 @@ limitations under the License. /* while keeping the autocomplete at the top */ /* of the composer. The parent needs to be a flex container for this to work. */ margin: auto 0; - /* max-height at this level so autocomplete doesn't get scrolled too */ - max-height: 140px; + /* the line height is $font-22px (set in _Editor.pcss) and we want to display 16 lines */ + max-height: calc(16 * $font-22px); overflow-y: auto; } } diff --git a/res/css/views/settings/_CrossSigningPanel.pcss b/res/css/views/settings/_CrossSigningPanel.pcss index 12a0e36835f..1b5f7d1f74c 100644 --- a/res/css/views/settings/_CrossSigningPanel.pcss +++ b/res/css/views/settings/_CrossSigningPanel.pcss @@ -17,7 +17,12 @@ limitations under the License. .mx_CrossSigningPanel_statusList { border-spacing: 0; - td { + th { + text-align: start; + } + + td, + th { padding: 0; &:first-of-type { diff --git a/res/css/views/settings/_CryptographyPanel.pcss b/res/css/views/settings/_CryptographyPanel.pcss index 98dab47c592..855949d013d 100644 --- a/res/css/views/settings/_CryptographyPanel.pcss +++ b/res/css/views/settings/_CryptographyPanel.pcss @@ -1,3 +1,19 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + .mx_CryptographyPanel_sessionInfo { padding: 0em; border-spacing: 0px; @@ -5,13 +21,15 @@ .mx_CryptographyPanel_sessionInfo > tr { vertical-align: baseline; padding: 0em; -} -.mx_CryptographyPanel_sessionInfo > tr > td { - padding-bottom: 0em; - padding-left: 0em; - padding-right: 1em; - padding-top: 0em; + th { + text-align: start; + } + + td, + th { + padding: 0 1em 0 0; + } } .mx_CryptographyPanel_importExportButtons .mx_AccessibleButton { diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss index 8a7842d4d0f..69b0d0a664a 100644 --- a/res/css/views/settings/_DevicesPanel.pcss +++ b/res/css/views/settings/_DevicesPanel.pcss @@ -31,7 +31,7 @@ limitations under the License. .mx_DevicesPanel_header_title { font-size: $font-18px; - font-weight: 600; + font-weight: var(--font-semi-bold); color: $primary-content; } diff --git a/res/css/views/settings/_EmailAddresses.pcss b/res/css/views/settings/_EmailAddresses.pcss index 1c9ce724d1d..f401c64484d 100644 --- a/res/css/views/settings/_EmailAddresses.pcss +++ b/res/css/views/settings/_EmailAddresses.pcss @@ -21,12 +21,6 @@ limitations under the License. margin-bottom: 5px; } -.mx_ExistingEmailAddress_delete { - margin-right: 5px; - cursor: pointer; - vertical-align: middle; -} - .mx_ExistingEmailAddress_email, .mx_ExistingEmailAddress_promptText { flex: 1; diff --git a/res/css/views/settings/_JoinRuleSettings.pcss b/res/css/views/settings/_JoinRuleSettings.pcss index a55432b25a8..18c4395efec 100644 --- a/res/css/views/settings/_JoinRuleSettings.pcss +++ b/res/css/views/settings/_JoinRuleSettings.pcss @@ -27,7 +27,7 @@ limitations under the License. .mx_JoinRuleSettings_spacesWithAccess { > h4 { color: $secondary-content; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-12px; line-height: $font-15px; text-transform: uppercase; @@ -61,7 +61,7 @@ limitations under the License. .mx_StyledRadioButton_content { margin-left: 14px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; color: $primary-content; diff --git a/res/css/views/settings/_Notifications.pcss b/res/css/views/settings/_Notifications.pcss index 45a5b4529d8..635627e0b02 100644 --- a/res/css/views/settings/_Notifications.pcss +++ b/res/css/views/settings/_Notifications.pcss @@ -53,13 +53,13 @@ limitations under the License. } .mx_UserNotifSettings_gridRowHeading { font-size: $font-18px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_UserNotifSettings_gridColumnLabel { color: $secondary-content; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_UserNotifSettings_gridRowError { // occupy full row @@ -79,7 +79,7 @@ limitations under the License. & > div:first-child { /* section header */ font-size: $font-18px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } > table { diff --git a/res/css/views/settings/_PhoneNumbers.pcss b/res/css/views/settings/_PhoneNumbers.pcss index 507b07334ed..d64d1a63338 100644 --- a/res/css/views/settings/_PhoneNumbers.pcss +++ b/res/css/views/settings/_PhoneNumbers.pcss @@ -21,12 +21,6 @@ limitations under the License. margin-bottom: 5px; } -.mx_ExistingPhoneNumber_delete { - margin-right: 5px; - cursor: pointer; - vertical-align: middle; -} - .mx_ExistingPhoneNumber_address, .mx_ExistingPhoneNumber_promptText { flex: 1; diff --git a/res/css/views/settings/_SecureBackupPanel.pcss b/res/css/views/settings/_SecureBackupPanel.pcss index 86f7b2036d0..6dcc8321fd7 100644 --- a/res/css/views/settings/_SecureBackupPanel.pcss +++ b/res/css/views/settings/_SecureBackupPanel.pcss @@ -50,7 +50,12 @@ limitations under the License. .mx_SecureBackupPanel_statusList { border-spacing: 0; - td { + th { + text-align: start; + } + + td, + th { padding: 0; &:first-of-type { diff --git a/res/css/views/settings/_SettingsFieldset.pcss b/res/css/views/settings/_SettingsFieldset.pcss index fbb192a4bc5..556fcdf8eb9 100644 --- a/res/css/views/settings/_SettingsFieldset.pcss +++ b/res/css/views/settings/_SettingsFieldset.pcss @@ -20,9 +20,11 @@ limitations under the License. } .mx_SettingsFieldset_legend { - font-size: $font-16px; + // matches h3 + font-size: $font-18px; + font-weight: var(--font-semi-bold); + line-height: $font-22px; display: block; - font-weight: $font-semi-bold; color: $primary-content; margin-bottom: 10px; margin-top: 12px; diff --git a/res/css/views/settings/_ThemeChoicePanel.pcss b/res/css/views/settings/_ThemeChoicePanel.pcss index 475af77d524..e1d7cc86765 100644 --- a/res/css/views/settings/_ThemeChoicePanel.pcss +++ b/res/css/views/settings/_ThemeChoicePanel.pcss @@ -47,7 +47,7 @@ limitations under the License. margin-right: 10px; margin-top: 10px; - font-weight: 600; + font-weight: var(--font-semi-bold); .mx_StyledRadioButton_content { margin-right: 0; diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss new file mode 100644 index 00000000000..b0388a1d7a9 --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsSection.pcss @@ -0,0 +1,36 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SettingsSection { + --SettingsTab_section-margin-bottom-preferences-labs: 30px; + --SettingsTab_heading_nth_child-margin-top: 30px; /* TODO: Use a spacing variable */ + --SettingsTab_fullWidthField-margin-inline-end: 100px; + --SettingsTab_tooltip-max-width: 120px; /* So it fits in the space provided by the page */ + + color: $primary-content; + + a { + color: $links; + } +} + +.mx_SettingsSection_subSections { + display: grid; + grid-template-columns: 1fr; + grid-gap: $spacing-32; + + padding: $spacing-16 0; +} diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index e049a768e22..5c61911ee1c 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -33,7 +33,7 @@ limitations under the License. .mx_SettingsTab_heading { font-size: $font-20px; - font-weight: 600; + font-weight: var(--font-semi-bold); color: $primary-content; margin-top: 10px; /* TODO: Use a spacing variable */ margin-bottom: 10px; /* TODO: Use a spacing variable */ @@ -47,7 +47,7 @@ limitations under the License. .mx_SettingsTab_subheading { font-size: $font-16px; display: block; - font-weight: 600; + font-weight: var(--font-semi-bold); color: $primary-content; margin-top: $spacing-12; margin-bottom: 10px; /* TODO: Use a spacing variable */ diff --git a/res/css/views/settings/tabs/room/_NotificationSettingsTab.pcss b/res/css/views/settings/tabs/room/_NotificationSettingsTab.pcss index 7d978cb5f76..780401e8f1b 100644 --- a/res/css/views/settings/tabs/room/_NotificationSettingsTab.pcss +++ b/res/css/views/settings/tabs/room/_NotificationSettingsTab.pcss @@ -22,7 +22,7 @@ limitations under the License. color: $primary-content; font-size: $font-15px; line-height: $font-18px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); margin-top: 16px; position: relative; padding-left: 8px; diff --git a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss index aa65e6d4943..6f387380f24 100644 --- a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss @@ -16,6 +16,11 @@ limitations under the License. */ .mx_KeyboardUserSettingsTab .mx_SettingsTab_section { + ul { + margin: 0; + padding: 0; + } + .mx_KeyboardShortcut_shortcutRow, .mx_KeyboardShortcut { display: flex; diff --git a/res/css/views/spaces/_SpaceCreateMenu.pcss b/res/css/views/spaces/_SpaceCreateMenu.pcss index 972c7461f34..3b04be9ff4d 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.pcss +++ b/res/css/views/spaces/_SpaceCreateMenu.pcss @@ -33,7 +33,7 @@ $spacePanelWidth: 68px; > div { > h2 { - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-18px; margin-top: 4px; } diff --git a/res/css/views/toasts/_AnalyticsToast.pcss b/res/css/views/toasts/_AnalyticsToast.pcss index 80e95535a5d..130dff4db97 100644 --- a/res/css/views/toasts/_AnalyticsToast.pcss +++ b/res/css/views/toasts/_AnalyticsToast.pcss @@ -19,13 +19,13 @@ limitations under the License. background-color: $accent; color: #ffffff; border: 1px solid $accent; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } .mx_AccessibleButton_kind_primary { background-color: $accent; color: #ffffff; border: 1px solid $accent; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); } } diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss index 4d879f4f958..dc006208261 100644 --- a/res/css/views/toasts/_IncomingCallToast.pcss +++ b/res/css/views/toasts/_IncomingCallToast.pcss @@ -36,7 +36,7 @@ limitations under the License. .mx_IncomingCallToast_room { display: inline-block; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); font-size: $font-15px; line-height: $font-24px; diff --git a/res/css/views/typography/_Heading.pcss b/res/css/views/typography/_Heading.pcss index 84a008c18f8..a61174ec086 100644 --- a/res/css/views/typography/_Heading.pcss +++ b/res/css/views/typography/_Heading.pcss @@ -16,7 +16,7 @@ limitations under the License. .mx_Heading_h1 { font-size: $font-32px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-39px; margin-inline: unset; margin-block: unset; @@ -24,7 +24,7 @@ limitations under the License. .mx_Heading_h2 { font-size: $font-24px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-29px; margin-inline: unset; margin-block: unset; @@ -32,7 +32,7 @@ limitations under the License. .mx_Heading_h3 { font-size: $font-18px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-22px; margin-inline: unset; margin-block: unset; @@ -40,7 +40,7 @@ limitations under the License. .mx_Heading_h4 { font-size: $font-15px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); line-height: $font-20px; margin-inline: unset; margin-block: unset; diff --git a/res/css/views/voip/_DialPad.pcss b/res/css/views/voip/_DialPad.pcss index 05d1b57eeba..3a54e5b5d4f 100644 --- a/res/css/views/voip/_DialPad.pcss +++ b/res/css/views/voip/_DialPad.pcss @@ -36,7 +36,7 @@ limitations under the License. background-color: $quinary-content; border-radius: 40px; font-size: 18px; - font-weight: 600; + font-weight: var(--font-semi-bold); text-align: center; vertical-align: middle; margin-left: auto; diff --git a/res/css/views/voip/_DialPadContextMenu.pcss b/res/css/views/voip/_DialPadContextMenu.pcss index 046db3133e9..ebed03985f7 100644 --- a/res/css/views/voip/_DialPadContextMenu.pcss +++ b/res/css/views/voip/_DialPadContextMenu.pcss @@ -48,19 +48,19 @@ limitations under the License. .mx_DialPadContextMenu_title { color: $muted-fg-color; font-size: 12px; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_DialPadContextMenu_dialled { height: 1.5em; font-size: 18px; - font-weight: 600; + font-weight: var(--font-semi-bold); border: none; margin: 0px; } .mx_DialPadContextMenu_dialled input { font-size: 18px; - font-weight: 600; + font-weight: var(--font-semi-bold); overflow: hidden; max-width: 185px; text-align: left; diff --git a/res/css/views/voip/_DialPadModal.pcss b/res/css/views/voip/_DialPadModal.pcss index 75ad8a19029..1b57220e0ff 100644 --- a/res/css/views/voip/_DialPadModal.pcss +++ b/res/css/views/voip/_DialPadModal.pcss @@ -41,7 +41,7 @@ limitations under the License. .mx_DialPadModal_title { color: $muted-fg-color; font-size: 12px; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_DialPadModal_cancel { @@ -65,7 +65,7 @@ limitations under the License. .mx_DialPadModal_field input { font-size: 18px; - font-weight: 600; + font-weight: var(--font-semi-bold); } .mx_DialPadModal_dialPad { diff --git a/res/css/voice-broadcast/atoms/_LiveBadge.pcss b/res/css/voice-broadcast/atoms/_LiveBadge.pcss index eb0dbd8e4b3..0a1c15de2cc 100644 --- a/res/css/voice-broadcast/atoms/_LiveBadge.pcss +++ b/res/css/voice-broadcast/atoms/_LiveBadge.pcss @@ -21,7 +21,7 @@ limitations under the License. color: $live-badge-color; display: inline-flex; font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); gap: $spacing-4; padding: 2px 4px; } diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss index a30beb27b6a..4b8bcff47c7 100644 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss @@ -34,7 +34,7 @@ limitations under the License. .mx_VoiceBroadcastHeader_room { font-size: $font-12px; - font-weight: $font-semi-bold; + font-weight: var(--font-semi-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; diff --git a/src/@types/common.ts b/src/@types/common.ts index 3281ad68722..4aeea8a643c 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -54,3 +54,27 @@ export type KeysStartingWith = { }[keyof Input]; export type NonEmptyArray = [T, ...T[]]; + +export type Defaultize = P extends any + ? string extends keyof P + ? P + : Pick> & + Partial>> & + Partial>> + : never; + +export type DeepReadonly = T extends (infer R)[] + ? DeepReadonlyArray + : T extends Function + ? T + : T extends object + ? DeepReadonlyObject + : T; + +interface DeepReadonlyArray extends ReadonlyArray> {} + +type DeepReadonlyObject = { + readonly [P in keyof T]: DeepReadonly; +}; + +export type AtLeastOne }> = Partial & U[keyof U]; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 587dc99dc7e..d8f01cd4be5 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -49,6 +49,7 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import AutoRageshakeStore from "../stores/AutoRageshakeStore"; import { IConfigOptions } from "../IConfigOptions"; import { MatrixDispatcher } from "../dispatcher/dispatcher"; +import { DeepReadonly } from "./common"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -59,7 +60,7 @@ declare global { Olm: { init: () => Promise; }; - mxReactSdkConfig: IConfigOptions; + mxReactSdkConfig: DeepReadonly; // Needed for Safari, unknown to TypeScript webkitAudioContext: typeof AudioContext; diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 5121fdb51a8..fdf0fc65f11 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -293,7 +293,7 @@ export default class AddThreepid { const authClient = new IdentityAuthClient(); const supportsSeparateAddAndBind = await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind(); - let result; + let result: { success: boolean } | MatrixError; if (this.submitUrl) { result = await MatrixClientPeg.get().submitMsisdnTokenOtherUrl( this.submitUrl, @@ -311,7 +311,7 @@ export default class AddThreepid { } else { throw new UserFriendlyError("The add / bind with MSISDN flow is misconfigured"); } - if (result.errcode) { + if (result instanceof Error) { throw result; } diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index 770f5f027b7..1173ca3fd06 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.tsx @@ -45,9 +45,6 @@ export default class AsyncWrapper extends React.Component { public state: IState = {}; public componentDidMount(): void { - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/element-web/issues/3148 - logger.log("Starting load of AsyncWrapper for modal"); this.props.prom .then((result) => { if (this.unmounted) return; diff --git a/src/Avatar.ts b/src/Avatar.ts index 6eec0a6dbf5..0e2ab5a42c4 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -26,7 +26,7 @@ import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( - member: RoomMember, + member: RoomMember | undefined, width: number, height: number, resizeMethod: ResizeMethod, diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index edafff229fa..da083f27167 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -75,7 +75,7 @@ export default abstract class BasePlatform { this.startUpdateCheck = this.startUpdateCheck.bind(this); } - public abstract getConfig(): Promise; + public abstract getConfig(): Promise; public abstract getDefaultDeviceDisplayName(): string; diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts index 01f84421b6a..89ed2b56e54 100644 --- a/src/BlurhashEncoder.ts +++ b/src/BlurhashEncoder.ts @@ -14,15 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; - // @ts-ignore - `.ts` is needed here to make TS happy -import BlurhashWorker from "./workers/blurhash.worker.ts"; - -interface IBlurhashWorkerResponse { - seq: number; - blurhash: string; -} +import BlurhashWorker, { Request, Response } from "./workers/blurhash.worker.ts"; +import { WorkerManager } from "./WorkerManager"; export class BlurhashEncoder { private static internalInstance = new BlurhashEncoder(); @@ -31,29 +25,9 @@ export class BlurhashEncoder { return BlurhashEncoder.internalInstance; } - private readonly worker: Worker; - private seq = 0; - private pendingDeferredMap = new Map>(); - - public constructor() { - this.worker = new BlurhashWorker(); - this.worker.onmessage = this.onMessage; - } - - private onMessage = (ev: MessageEvent): void => { - const { seq, blurhash } = ev.data; - const deferred = this.pendingDeferredMap.get(seq); - if (deferred) { - this.pendingDeferredMap.delete(seq); - deferred.resolve(blurhash); - } - }; + private readonly worker = new WorkerManager(BlurhashWorker); public getBlurhash(imageData: ImageData): Promise { - const seq = this.seq++; - const deferred = defer(); - this.pendingDeferredMap.set(seq, deferred); - this.worker.postMessage({ seq, imageData }); - return deferred.promise; + return this.worker.call({ imageData }).then((resp) => resp.blurhash); } } diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 0e392059169..e743b3feead 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -16,6 +16,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Optional } from "matrix-events-sdk"; + import { _t } from "./languageHandler"; function getDaysArray(): string[] { @@ -194,10 +196,7 @@ function withinCurrentYear(prevDate: Date, nextDate: Date): boolean { return prevDate.getFullYear() === nextDate.getFullYear(); } -export function wantsDateSeparator( - prevEventDate: Date | null | undefined, - nextEventDate: Date | null | undefined, -): boolean { +export function wantsDateSeparator(prevEventDate: Optional, nextEventDate: Optional): boolean { if (!nextEventDate || !prevEventDate) { return false; } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 3281c43267f..afec1f62f47 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -310,7 +310,11 @@ export default class DeviceListener { const newUnverifiedDeviceIds = new Set(); const isCurrentDeviceTrusted = - crossSigningReady && (await cli.checkDeviceTrust(cli.getUserId()!, cli.deviceId!).isCrossSigningVerified()); + crossSigningReady && + Boolean( + (await cli.getCrypto()?.getDeviceVerificationStatus(cli.getUserId()!, cli.deviceId!)) + ?.crossSigningVerified, + ); // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts @@ -319,8 +323,10 @@ export default class DeviceListener { for (const device of devices) { if (device.deviceId === cli.deviceId) continue; - const deviceTrust = await cli.checkDeviceTrust(cli.getUserId()!, device.deviceId!); - if (!deviceTrust.isCrossSigningVerified() && !this.dismissed.has(device.deviceId)) { + const deviceTrust = await cli + .getCrypto()! + .getDeviceVerificationStatus(cli.getUserId()!, device.deviceId!); + if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(device.deviceId)) { if (this.ourDeviceIdsAtStart?.has(device.deviceId)) { oldUnverifiedDeviceIds.add(device.deviceId); } else { diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index b2e44f23ce2..c6913d2eb25 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -186,6 +186,11 @@ export interface IConfigOptions { description: string; show_once?: boolean; }; + + feedback: { + existing_issues_url: string; + new_issue_url: string; + }; } export interface ISsoRedirectOptions { diff --git a/src/IdentityAuthClient.tsx b/src/IdentityAuthClient.tsx index 12f42a3add1..4df9959511f 100644 --- a/src/IdentityAuthClient.tsx +++ b/src/IdentityAuthClient.tsx @@ -118,7 +118,7 @@ export default class IdentityAuthClient { } private async checkToken(token: string): Promise { - const identityServerUrl = this.matrixClient.getIdentityServerUrl(); + const identityServerUrl = this.matrixClient.getIdentityServerUrl()!; try { await this.matrixClient.getIdentityAccount(token); diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index e6a4388e653..bf0d09db293 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -272,7 +272,8 @@ export default class LegacyCallHandler extends EventEmitter { return localNotificationsAreSilenced(cli); } - public silenceCall(callId: string): void { + public silenceCall(callId?: string): void { + if (!callId) return; this.silencedCalls.add(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); @@ -281,15 +282,15 @@ export default class LegacyCallHandler extends EventEmitter { this.pause(AudioID.Ring); } - public unSilenceCall(callId: string): void { - if (this.isForcedSilent()) return; + public unSilenceCall(callId?: string): void { + if (!callId || this.isForcedSilent()) return; this.silencedCalls.delete(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); this.play(AudioID.Ring); } - public isCallSilenced(callId: string): boolean { - return this.isForcedSilent() || this.silencedCalls.has(callId); + public isCallSilenced(callId?: string): boolean { + return this.isForcedSilent() || (!!callId && this.silencedCalls.has(callId)); } /** @@ -395,6 +396,7 @@ export default class LegacyCallHandler extends EventEmitter { } const mappedRoomId = LegacyCallHandler.instance.roomIdForCall(call); + if (!mappedRoomId) return; if (this.getCallForRoom(mappedRoomId)) { logger.log( "Got incoming call for room " + mappedRoomId + " but there's already a call for this room: ignoring", @@ -411,7 +413,8 @@ export default class LegacyCallHandler extends EventEmitter { // the call, we'll be ready to send. NB. This is the protocol-level room ID not // the mapped one: that's where we'll send the events. const cli = MatrixClientPeg.get(); - cli.prepareToEncrypt(cli.getRoom(call.roomId)); + const room = cli.getRoom(call.roomId); + if (room) cli.prepareToEncrypt(room); }; public getCallById(callId: string): MatrixCall | null { @@ -505,7 +508,7 @@ export default class LegacyCallHandler extends EventEmitter { if (this.audioPromises.has(audioId)) { this.audioPromises.set( audioId, - this.audioPromises.get(audioId).then(() => { + this.audioPromises.get(audioId)!.then(() => { audio.load(); return playAudio(); }), @@ -531,7 +534,7 @@ export default class LegacyCallHandler extends EventEmitter { }; if (audio) { if (this.audioPromises.has(audioId)) { - this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(pauseAudio)); + this.audioPromises.set(audioId, this.audioPromises.get(audioId)!.then(pauseAudio)); } else { pauseAudio(); } @@ -546,7 +549,7 @@ export default class LegacyCallHandler extends EventEmitter { // is the call we consider 'the' call for its room. const mappedRoomId = this.roomIdForCall(call); - const callForThisRoom = this.getCallForRoom(mappedRoomId); + const callForThisRoom = mappedRoomId ? this.getCallForRoom(mappedRoomId) : null; return !!callForThisRoom && call.callId === callForThisRoom.callId; } @@ -577,7 +580,7 @@ export default class LegacyCallHandler extends EventEmitter { }); }); call.on(CallEvent.Hangup, () => { - if (!this.matchesCallForThisRoom(call)) return; + if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; this.removeCallForRoom(mappedRoomId); }); @@ -585,7 +588,7 @@ export default class LegacyCallHandler extends EventEmitter { this.onCallStateChanged(newState, oldState, call); }); call.on(CallEvent.Replaced, (newCall: MatrixCall) => { - if (!this.matchesCallForThisRoom(call)) return; + if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; logger.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`); @@ -601,7 +604,7 @@ export default class LegacyCallHandler extends EventEmitter { this.setCallState(newCall, newCall.state); }); call.on(CallEvent.AssertedIdentityChanged, async (): Promise => { - if (!this.matchesCallForThisRoom(call)) return; + if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; logger.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); @@ -632,7 +635,7 @@ export default class LegacyCallHandler extends EventEmitter { const newMappedRoomId = this.roomIdForCall(call); logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); - if (newMappedRoomId !== mappedRoomId) { + if (newMappedRoomId && newMappedRoomId !== mappedRoomId) { this.removeCallForRoom(mappedRoomId); mappedRoomId = newMappedRoomId; logger.log("Moving call to room " + mappedRoomId); @@ -840,7 +843,7 @@ export default class LegacyCallHandler extends EventEmitter { cancelButton: _t("OK"), onFinished: (allow) => { SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); - cli.setFallbackICEServerAllowed(allow); + cli.setFallbackICEServerAllowed(!!allow); }, }, undefined, @@ -898,7 +901,7 @@ export default class LegacyCallHandler extends EventEmitter { // previous calls that are probably stale by now, so just cancel them. if (mappedRoomId !== roomId) { const mappedRoom = MatrixClientPeg.get().getRoom(mappedRoomId); - if (mappedRoom.getPendingEvents().length > 0) { + if (mappedRoom?.getPendingEvents().length) { Resend.cancelUnsentEvents(mappedRoom); } } @@ -933,7 +936,7 @@ export default class LegacyCallHandler extends EventEmitter { } } - public async placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): Promise { + public async placeCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise { // Pause current broadcast, if any SdkContextClass.instance.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); @@ -1114,6 +1117,14 @@ export default class LegacyCallHandler extends EventEmitter { public async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean): Promise { if (consultFirst) { const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); + if (!dmRoomId) { + logger.log("Failed to transfer call, could not ensure dm exists"); + Modal.createDialog(ErrorDialog, { + title: _t("Transfer Failed"), + description: _t("Failed to transfer call"), + }); + return; + } this.placeCall(dmRoomId, call.type, call); dis.dispatch({ @@ -1172,8 +1183,9 @@ export default class LegacyCallHandler extends EventEmitter { // Prevent double clicking the call button const widget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type)); if (widget) { + const room = client.getRoom(roomId); // If there already is a Jitsi widget, pin it - WidgetLayoutStore.instance.moveToContainer(client.getRoom(roomId), widget, Container.Top); + if (room) WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); return; } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 6c2fbec8219..763cfa1e87f 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -63,6 +63,7 @@ import { Action } from "./dispatcher/actions"; import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SdkContextClass } from "./contexts/SDKContext"; +import { messageForLoginError } from "./utils/ErrorUtils"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -176,7 +177,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise */ export async function getStoredSessionOwner(): Promise<[string, boolean] | [null, null]> { const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars(); - return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; + return hsUrl && userId && hasAccessToken ? [userId, !!isGuest] : [null, null]; } /** @@ -230,17 +231,10 @@ export function attemptTokenLogin( .catch((err) => { Modal.createDialog(ErrorDialog, { title: _t("We couldn't log you in"), - description: - err.name === "ConnectionError" - ? _t( - "Your homeserver was unreachable and was not able to log you in. Please try again. " + - "If this continues, please contact your homeserver administrator.", - ) - : _t( - "Your homeserver rejected your log in attempt. " + - "This could be due to things just taking too long. Please try again. " + - "If this continues, please contact your homeserver administrator.", - ), + description: messageForLoginError(err, { + hsUrl: homeserver, + hsName: homeserver, + }), button: _t("Try again"), onFinished: (tryAgain) => { if (tryAgain) { @@ -343,9 +337,9 @@ export interface IStoredSession { * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export async function getStoredSessionVars(): Promise { - const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); - const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); +export async function getStoredSessionVars(): Promise> { + const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY) ?? undefined; + const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) ?? undefined; let accessToken: string | undefined; try { accessToken = await StorageManager.idbLoad("account", "mx_access_token"); @@ -367,8 +361,8 @@ export async function getStoredSessionVars(): Promise { // if we pre-date storing "mx_has_access_token", but we retrieved an access // token, then we should say we have an access token const hasAccessToken = localStorage.getItem("mx_has_access_token") === "true" || !!accessToken; - const userId = localStorage.getItem("mx_user_id"); - const deviceId = localStorage.getItem("mx_device_id"); + const userId = localStorage.getItem("mx_user_id") ?? undefined; + const deviceId = localStorage.getItem("mx_device_id") ?? undefined; let isGuest: boolean; if (localStorage.getItem("mx_is_guest") !== null) { @@ -447,7 +441,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): } let decryptedAccessToken = accessToken; - const pickleKey = await PlatformPeg.get()?.getPickleKey(userId, deviceId); + const pickleKey = await PlatformPeg.get()?.getPickleKey(userId, deviceId ?? ""); if (pickleKey) { logger.log("Got pickle key"); if (typeof accessToken !== "string") { @@ -740,7 +734,7 @@ export function logout(): void { _isLoggingOut = true; const client = MatrixClientPeg.get(); - PlatformPeg.get()?.destroyPickleKey(client.getSafeUserId(), client.getDeviceId()); + PlatformPeg.get()?.destroyPickleKey(client.getSafeUserId(), client.getDeviceId() ?? ""); client.logout(true).then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and diff --git a/src/Modal.tsx b/src/Modal.tsx index c92741cfc6d..d78758685b8 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -23,23 +23,16 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter" import dis from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; +import { Defaultize } from "./@types/common"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; // Type which accepts a React Component which looks like a Modal (accepts an onFinished prop) export type ComponentType = React.ComponentType<{ - onFinished?(...args: any): void; + onFinished(...args: any): void; }>; -type Defaultize = P extends any - ? string extends keyof P - ? P - : Pick> & - Partial>> & - Partial>> - : never; - // Generic type which returns the props of the Modal component with the onFinished being optional. export type ComponentProps = Defaultize< Omit, "onFinished">, @@ -142,7 +135,7 @@ export class ModalManager extends TypedEventEmitter( - Element: React.ComponentType, + Element: C, props?: ComponentProps, className?: string, ): IHandle { @@ -164,7 +157,7 @@ export class ModalManager extends TypedEventEmitter( - prom: Promise, + prom: Promise, props?: ComponentProps, className?: string, options?: IOptions, @@ -308,7 +301,7 @@ export class ModalManager extends TypedEventEmitter( - prom: Promise, + prom: Promise, props?: ComponentProps, className?: string, ): IHandle { diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 5c5805af938..2a7e24294ec 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -312,6 +312,14 @@ export class PosthogAnalytics { Object.assign({ id: analyticsID }, accountData), ); } + if (this.posthog.get_distinct_id() === analyticsID) { + // No point identifying again + return; + } + if (this.posthog.persistence.get_user_state() === "identified") { + // Analytics ID has changed, reset as Posthog will refuse to merge in this case + this.posthog.reset(); + } this.posthog.identify(analyticsID); } catch (e) { // The above could fail due to network requests, but not essential to starting the application, diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 8400a8f03fe..0ca1358e904 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -50,7 +50,7 @@ export default class ScalarAuthClient { } private writeTokenToStore(): void { - window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); + window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken ?? ""); if (this.isDefaultManager) { // We remove the old token from storage to migrate upwards. This is safe // to do because even if the user switches to /app when this is on /develop @@ -260,7 +260,7 @@ export default class ScalarAuthClient { const roomId = room.roomId; const roomName = room.name; let url = this.uiUrl; - url += "?scalar_token=" + encodeURIComponent(this.scalarToken); + if (this.scalarToken) url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); url += "&room_name=" + encodeURIComponent(roomName); url += "&theme=" + encodeURIComponent(ThemeWatcher.getCurrentTheme()); @@ -274,6 +274,7 @@ export default class ScalarAuthClient { } public getStarterLink(starterLinkUrl: string): string { + if (!this.scalarToken) return starterLinkUrl; return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index ff4e63b22a9..c79c727aabc 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -16,12 +16,15 @@ limitations under the License. */ import { Optional } from "matrix-events-sdk"; +import { mergeWith } from "lodash"; import { SnakedObject } from "./utils/SnakedObject"; import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions"; +import { isObject, objectClone } from "./utils/objects"; +import { DeepReadonly, Defaultize } from "./@types/common"; // see element-web config.md for docs, or the IConfigOptions interface for dev docs -export const DEFAULTS: IConfigOptions = { +export const DEFAULTS: DeepReadonly = { brand: "SchildiChat", integrations_ui_url: "https://scalar.vector.im/", integrations_rest_url: "https://scalar.vector.im/api", @@ -50,13 +53,43 @@ export const DEFAULTS: IConfigOptions = { chunk_length: 2 * 60, // two minutes max_length: 4 * 60 * 60, // four hours }, + + feedback: { + existing_issues_url: + "https://github.com/SchildiChat/schildichat-desktop/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc", + new_issue_url: "https://github.com/SchildiChat/schildichat-desktop/issues/new/choose", + }, }; +export type ConfigOptions = Defaultize; + +function mergeConfig( + config: DeepReadonly, + changes: DeepReadonly>, +): DeepReadonly { + // return { ...config, ...changes }; + return mergeWith(objectClone(config), changes, (objValue, srcValue) => { + // Don't merge arrays, prefer values from newer object + if (Array.isArray(objValue)) { + return srcValue; + } + + // Don't allow objects to get nulled out, this will break our types + if (isObject(objValue) && !isObject(srcValue)) { + return objValue; + } + }); +} + +type ObjectType = IConfigOptions[K] extends object + ? SnakedObject> + : Optional>>; + export default class SdkConfig { - private static instance: IConfigOptions; - private static fallback: SnakedObject; + private static instance: DeepReadonly; + private static fallback: SnakedObject>; - private static setInstance(i: IConfigOptions): void { + private static setInstance(i: DeepReadonly): void { SdkConfig.instance = i; SdkConfig.fallback = new SnakedObject(i); @@ -69,7 +102,7 @@ export default class SdkConfig { public static get( key?: K, altCaseName?: string, - ): IConfigOptions | IConfigOptions[K] { + ): DeepReadonly | DeepReadonly[K] { if (key === undefined) { // safe to cast as a fallback - we want to break the runtime contract in this case return SdkConfig.instance || {}; @@ -77,32 +110,29 @@ export default class SdkConfig { return SdkConfig.fallback.get(key, altCaseName); } - public static getObject( - key: K, - altCaseName?: string, - ): Optional>> { + public static getObject(key: K, altCaseName?: string): ObjectType { const val = SdkConfig.get(key, altCaseName); - if (val !== null && val !== undefined) { + if (isObject(val)) { return new SnakedObject(val); } // return the same type for sensitive callers (some want `undefined` specifically) - return val === undefined ? undefined : null; + return (val === undefined ? undefined : null) as ObjectType; } - public static put(cfg: Partial): void { - SdkConfig.setInstance({ ...DEFAULTS, ...cfg }); + public static put(cfg: DeepReadonly): void { + SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg)); } /** - * Resets the config to be completely empty. + * Resets the config. */ - public static unset(): void { - SdkConfig.setInstance({}); // safe to cast - defaults will be applied + public static reset(): void { + SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied } - public static add(cfg: Partial): void { - SdkConfig.put({ ...SdkConfig.get(), ...cfg }); + public static add(cfg: Partial): void { + SdkConfig.put(mergeConfig(SdkConfig.get(), cfg)); } } diff --git a/src/Searching.ts b/src/Searching.ts index 85efeea8c80..25800c8e06e 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -176,7 +176,10 @@ async function localSearch( searchArgs.room_id = roomId; } - const localResult = await eventIndex.search(searchArgs); + const localResult = await eventIndex!.search(searchArgs); + if (!localResult) { + throw new Error("Local search failed"); + } searchArgs.next_batch = localResult.next_batch; @@ -225,7 +228,11 @@ async function localPagination(searchResult: ISeshatSearchResults): Promise string) | null { const onViewJoinRuleSettingsClick = (): void => { defaultDispatcher.dispatch({ action: "open_room_settings", - initial_tab_id: ROOM_SECURITY_TAB, + initial_tab_id: RoomSettingsTab.Security, }); }; diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts new file mode 100644 index 00000000000..5dcb56c6109 --- /dev/null +++ b/src/WorkerManager.ts @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +import { WorkerPayload } from "./workers/worker"; + +export class WorkerManager { + private readonly worker: Worker; + private seq = 0; + private pendingDeferredMap = new Map>(); + + public constructor(WorkerConstructor: { new (): Worker }) { + this.worker = new WorkerConstructor(); + this.worker.onmessage = this.onMessage; + } + + private onMessage = (ev: MessageEvent): void => { + const deferred = this.pendingDeferredMap.get(ev.data.seq); + if (deferred) { + this.pendingDeferredMap.delete(ev.data.seq); + deferred.resolve(ev.data); + } + }; + + public call(request: Request): Promise { + const seq = this.seq++; + const deferred = defer(); + this.pendingDeferredMap.set(seq, deferred); + this.worker.postMessage({ seq, ...request }); + return deferred.promise; + } +} diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b449b10710f..1963459835d 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -61,7 +61,7 @@ export interface IState { refs: Ref[]; } -interface IContext { +export interface IContext { state: IState; dispatch: Dispatch; } @@ -80,7 +80,7 @@ export enum Type { SetFocus = "SET_FOCUS", } -interface IAction { +export interface IAction { type: Type; payload: { ref: Ref; @@ -156,17 +156,19 @@ export const reducer: Reducer = (state: IState, action: IAction }; interface IProps { + handleLoop?: boolean; handleHomeEnd?: boolean; handleUpDown?: boolean; handleLeftRight?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; - onKeyDown?(ev: React.KeyboardEvent, state: IState): void; + onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } export const findSiblingElement = ( refs: RefObject[], startIndex: number, backwards = false, + loop = false, ): RefObject | undefined => { if (backwards) { for (let i = startIndex; i < refs.length && i >= 0; i--) { @@ -174,12 +176,18 @@ export const findSiblingElement = ( return refs[i]; } } + if (loop) { + return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false); + } } else { for (let i = startIndex; i < refs.length && i >= 0; i++) { if (refs[i].current?.offsetParent !== null) { return refs[i]; } } + if (loop) { + return findSiblingElement(refs.slice(0, startIndex), 0, false, false); + } } }; @@ -188,6 +196,7 @@ export const RovingTabIndexProvider: React.FC = ({ handleHomeEnd, handleUpDown, handleLeftRight, + handleLoop, onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { @@ -199,7 +208,7 @@ export const RovingTabIndexProvider: React.FC = ({ const onKeyDownHandler = useCallback( (ev: React.KeyboardEvent) => { if (onKeyDown) { - onKeyDown(ev, context.state); + onKeyDown(ev, context.state, context.dispatch); if (ev.defaultPrevented) { return; } @@ -252,7 +261,7 @@ export const RovingTabIndexProvider: React.FC = ({ handled = true; if (context.state.refs.length > 0) { const idx = context.state.refs.indexOf(context.state.activeRef!); - focusRef = findSiblingElement(context.state.refs, idx + 1); + focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop); } } break; @@ -266,7 +275,7 @@ export const RovingTabIndexProvider: React.FC = ({ handled = true; if (context.state.refs.length > 0) { const idx = context.state.refs.indexOf(context.state.activeRef!); - focusRef = findSiblingElement(context.state.refs, idx - 1, true); + focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop); } } break; @@ -289,7 +298,7 @@ export const RovingTabIndexProvider: React.FC = ({ }); } }, - [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight], + [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop], ); return ( diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 71818c6cda1..28748de73fb 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -22,10 +22,17 @@ import { Ref } from "./types"; interface IProps extends Omit, "inputRef" | "tabIndex"> { inputRef?: Ref; + focusOnMouseOver?: boolean; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { +export const RovingAccessibleButton: React.FC = ({ + inputRef, + onFocus, + onMouseOver, + focusOnMouseOver, + ...props +}) => { const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, onFocus, .. onFocusInternal(); onFocus?.(event); }} + onMouseOver={(event: React.MouseEvent) => { + if (focusOnMouseOver) onFocusInternal(); + onMouseOver?.(event); + }} inputRef={ref} tabIndex={isActive ? 0 : -1} /> diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 945c9839497..6f8f944ab94 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -51,8 +51,7 @@ export default class RoomListActions { room: Room, oldTag: TagID | null, newTag: TagID | null, - oldIndex?: number, - newIndex?: number, + newIndex: number, ): AsyncActionPayload { let metaData: Parameters[2] | null = null; @@ -63,12 +62,8 @@ export default class RoomListActions { newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order); - // If the room was moved "down" (increasing index) in the same list we - // need to use the orders of the tiles with indices shifted by +1 - const offset = newTag === oldTag && oldIndex < newIndex ? 1 : 0; - - const indexBefore = offset + newIndex - 1; - const indexAfter = offset + newIndex; + const indexBefore = newIndex - 1; + const indexAfter = newIndex; const prevOrder = indexBefore <= 0 ? 0 : newList[indexBefore].tags[newTag].order; const nextOrder = indexAfter >= newList.length ? 1 : newList[indexAfter].tags[newTag].order; diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index 7363f192a20..b5983b66c0f 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -15,11 +15,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ComponentType } from "react"; +import React from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; -import Modal from "../../../../Modal"; +import Modal, { ComponentType } from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; @@ -37,7 +37,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { this.props.onFinished(); Modal.createDialogAsync( - import("./CreateKeyBackupDialog") as unknown as Promise>, + import("./CreateKeyBackupDialog") as unknown as Promise, undefined, undefined, /* priority = */ false, diff --git a/src/audio/ManagedPlayback.ts b/src/audio/ManagedPlayback.ts index c33d032b688..6ecedd6766a 100644 --- a/src/audio/ManagedPlayback.ts +++ b/src/audio/ManagedPlayback.ts @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DEFAULT_WAVEFORM, Playback } from "./Playback"; +import { Playback } from "./Playback"; import { PlaybackManager } from "./PlaybackManager"; +import { DEFAULT_WAVEFORM } from "./consts"; /** * A managed playback is a Playback instance that is guided by a PlaybackManager. diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index e1ab1a1c593..43cbf904c0e 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -17,13 +17,18 @@ limitations under the License. import EventEmitter from "events"; import { SimpleObservable } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { defer } from "matrix-js-sdk/src/utils"; +// @ts-ignore - `.ts` is needed here to make TS happy +import PlaybackWorker, { Request, Response } from "../workers/playback.worker.ts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; -import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { arrayFastResample } from "../utils/arrays"; import { IDestroyable } from "../utils/IDestroyable"; import { PlaybackClock } from "./PlaybackClock"; import { createAudioContext, decodeOgg } from "./compat"; import { clamp } from "../utils/numbers"; +import { WorkerManager } from "../WorkerManager"; +import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts"; export enum PlaybackState { Decoding = "decoding", @@ -32,25 +37,7 @@ export enum PlaybackState { Playing = "playing", // active progress through timeline } -export interface PlaybackInterface { - readonly liveData: SimpleObservable; - readonly timeSeconds: number; - readonly durationSeconds: number; - skipTo(timeSeconds: number): Promise; -} - -export const PLAYBACK_WAVEFORM_SAMPLES = 39; const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] -export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); - -function makePlaybackWaveform(input: number[]): number[] { - // First, convert negative amplitudes to positive so we don't detect zero as "noisy". - const noiseWaveform = input.map((v) => Math.abs(v)); - - // Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. - // We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon. - return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); -} export interface PlaybackInterface { readonly currentState: PlaybackState; @@ -68,14 +55,15 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte public readonly thumbnailWaveform: number[]; private readonly context: AudioContext; - private source: AudioBufferSourceNode | MediaElementAudioSourceNode; + private source?: AudioBufferSourceNode | MediaElementAudioSourceNode; private state = PlaybackState.Decoding; - private audioBuf: AudioBuffer; - private element: HTMLAudioElement; + private audioBuf?: AudioBuffer; + private element?: HTMLAudioElement; private resampledWaveform: number[]; private waveformObservable = new SimpleObservable(); private readonly clock: PlaybackClock; private readonly fileSize: number; + private readonly worker = new WorkerManager(PlaybackWorker); /** * Creates a new playback instance from a buffer. @@ -178,12 +166,11 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte // 5mb logger.log("Audio file too large: processing through -

{_t("Me and my teammates")}

+ {_t("Me and my teammates")}
{_t("A private space for you and your teammates")}
diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 0d3d01041be..5e79ffc1f39 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -22,14 +22,14 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../languageHandler"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import AccessibleButton from "../views/elements/AccessibleButton"; import { PosthogScreenTracker, ScreenName } from "../../PosthogTrackers"; import { NonEmptyArray } from "../../@types/common"; +import { RovingAccessibleButton, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; /** * Represents a tab for the TabbedView. */ -export class Tab { +export class Tab { /** * Creates a new tab. * @param {string} id The tab's ID. @@ -39,7 +39,7 @@ export class Tab { * @param {string} screenName The screen name to report to Posthog. */ public constructor( - public readonly id: string, + public readonly id: T, public readonly label: string, public readonly icon: string | null, public readonly body: React.ReactNode, @@ -52,20 +52,20 @@ export enum TabLocation { TOP = "top", } -interface IProps { - tabs: NonEmptyArray; - initialTabId?: string; +interface IProps { + tabs: NonEmptyArray>; + initialTabId?: T; tabLocation: TabLocation; - onChange?: (tabId: string) => void; + onChange?: (tabId: T) => void; screenName?: ScreenName; } -interface IState { - activeTabId: string; +interface IState { + activeTabId: T; } -export default class TabbedView extends React.Component { - public constructor(props: IProps) { +export default class TabbedView extends React.Component, IState> { + public constructor(props: IProps) { super(props); const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId); @@ -78,7 +78,7 @@ export default class TabbedView extends React.Component { tabLocation: TabLocation.LEFT, }; - private getTabById(id: string): Tab | undefined { + private getTabById(id: T): Tab | undefined { return this.props.tabs.find((tab) => tab.id === id); } @@ -87,7 +87,7 @@ export default class TabbedView extends React.Component { * @param {Tab} tab the tab to show * @private */ - private setActiveTab(tab: Tab): void { + private setActiveTab(tab: Tab): void { // make sure this tab is still in available tabs if (!!this.getTabById(tab.id)) { if (this.props.onChange) this.props.onChange(tab.id); @@ -97,10 +97,11 @@ export default class TabbedView extends React.Component { } } - private renderTabLabel(tab: Tab): JSX.Element { - let classes = "mx_TabbedView_tabLabel "; - - if (this.state.activeTabId === tab.id) classes += "mx_TabbedView_tabLabel_active"; + private renderTabLabel(tab: Tab): JSX.Element { + const isActive = this.state.activeTabId === tab.id; + const classes = classNames("mx_TabbedView_tabLabel", { + mx_TabbedView_tabLabel_active: isActive, + }); let tabIcon: JSX.Element | undefined; if (tab.icon) { @@ -108,24 +109,35 @@ export default class TabbedView extends React.Component { } const onClickHandler = (): void => this.setActiveTab(tab); + const id = this.getTabId(tab); const label = _t(tab.label); return ( - {tabIcon} - {label} - + + {label} + + ); } - private renderTabPanel(tab: Tab): React.ReactNode { + private getTabId(tab: Tab): string { + return `mx_tabpanel_${tab.id}`; + } + + private renderTabPanel(tab: Tab): React.ReactNode { + const id = this.getTabId(tab); return ( -
+
{tab.body}
); @@ -147,7 +159,23 @@ export default class TabbedView extends React.Component { return (
{screenName && } -
{labels}
+ + {({ onKeyDownHandler }) => ( +
+ {labels} +
+ )} +
{panel}
); diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index f9430f70978..9531fc1323b 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -788,7 +788,7 @@ class TimelinePanel extends React.Component { } }; - public canResetTimeline = (): boolean => this.messagePanel?.current?.isAtBottom(); + public canResetTimeline = (): boolean => this.messagePanel?.current?.isAtBottom() === true; private onRoomRedaction = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; @@ -1055,7 +1055,7 @@ class TimelinePanel extends React.Component { const sendRRs = SettingsStore.getValue("sendReadReceipts", roomId); debuglog( - `Sending Read Markers for ${this.props.timelineSet.room.roomId}: `, + `Sending Read Markers for ${roomId}: `, `rm=${this.state.readMarkerEventId} `, `rr=${sendRRs ? lastReadEvent?.getId() : null} `, `prr=${lastReadEvent?.getId()}`, @@ -1103,7 +1103,7 @@ class TimelinePanel extends React.Component { // we only do this if we're right at the end, because we're just assuming // that sending an RR for the latest message will set our notif counter // to zero: it may not do this if we send an RR for somewhere before the end. - if (this.isAtEndOfLiveTimeline()) { + if (this.isAtEndOfLiveTimeline() && this.props.timelineSet.room) { this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); dis.dispatch({ diff --git a/src/components/structures/UserView.tsx b/src/components/structures/UserView.tsx index a5cdb0b584c..4cff508dfba 100644 --- a/src/components/structures/UserView.tsx +++ b/src/components/structures/UserView.tsx @@ -79,7 +79,8 @@ export default class UserView extends React.Component { return; } const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo }); - const member = new RoomMember(null, this.props.userId); + // We pass an empty string room ID here, this is slight abuse of the class to simplify code + const member = new RoomMember("", this.props.userId); member.setMembershipEvent(fakeEvent); this.setState({ member, loading: false }); } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 43147266a4b..015ed6bbee9 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -15,15 +15,13 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; import { _t, _td } from "../../../languageHandler"; import Login from "../../../Login"; -import SdkConfig from "../../../SdkConfig"; -import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; +import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; import PlatformPeg from "../../../PlatformPeg"; @@ -212,56 +210,20 @@ export default class LoginComponent extends React.PureComponent this.props.onLoggedIn(data, password); }, (error) => { - if (this.unmounted) { - return; - } - let errorText: ReactNode; + if (this.unmounted) return; + let errorText: ReactNode; // Some error strings only apply for logging in - const usingEmail = username && username.indexOf("@") > 0; - if (error.httpStatus === 400 && usingEmail) { + if (error.httpStatus === 400 && username && username.indexOf("@") > 0) { errorText = _t("This homeserver does not support login using email address."); - } else if (error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { - const errorTop = messageForResourceLimitError(error.data.limit_type, error.data.admin_contact, { - "monthly_active_user": _td("This homeserver has hit its Monthly Active User limit."), - "hs_blocked": _td("This homeserver has been blocked by its administrator."), - "": _td("This homeserver has exceeded one of its resource limits."), - }); - const errorDetail = messageForResourceLimitError(error.data.limit_type, error.data.admin_contact, { - "": _td("Please contact your service administrator to continue using this service."), - }); - errorText = ( -
-
{errorTop}
-
{errorDetail}
-
- ); - } else if (error.httpStatus === 401 || error.httpStatus === 403) { - if (error.errcode === "M_USER_DEACTIVATED") { - errorText = _t("This account has been deactivated."); - } else if (SdkConfig.get("disable_custom_urls")) { - errorText = ( -
-
{_t("Incorrect username and/or password.")}
-
- {_t("Please note you are logging into the %(hs)s server, not matrix.org.", { - hs: this.props.serverConfig.hsName, - })} -
-
- ); - } else { - errorText = _t("Incorrect username and/or password."); - } } else { - // other errors, not specific to doing a password login - errorText = this.errorTextFromError(error); + errorText = messageForLoginError(error, this.props.serverConfig); } this.setState({ busy: false, busyLoggingIn: false, - errorText: errorText, + errorText, // 401 would be the sensible status code for 'incorrect password' // but the login API gives a 403 https://matrix.org/jira/browse/SYN-744 // mentions this (although the bug is for UI auth which is not this) @@ -425,7 +387,7 @@ export default class LoginComponent extends React.PureComponent }, (err) => { this.setState({ - errorText: this.errorTextFromError(err), + errorText: messageForConnectionError(err, this.props.serverConfig), loginIncorrect: false, canTryLogin: false, }); @@ -448,67 +410,6 @@ export default class LoginComponent extends React.PureComponent return true; }; - private errorTextFromError(err: MatrixError): ReactNode { - let errCode = err.errcode; - if (!errCode && err.httpStatus) { - errCode = "HTTP " + err.httpStatus; - } - - let errorText: ReactNode = - _t("There was a problem communicating with the homeserver, please try again later.") + - (errCode ? " (" + errCode + ")" : ""); - - if (err instanceof ConnectionError) { - if ( - window.location.protocol === "https:" && - (this.props.serverConfig.hsUrl.startsWith("http:") || !this.props.serverConfig.hsUrl.startsWith("http")) - ) { - errorText = ( - - {_t( - "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + - "Either use HTTPS or enable unsafe scripts.", - {}, - { - a: (sub) => { - return ( - - {sub} - - ); - }, - }, - )} - - ); - } else { - errorText = ( - - {_t( - "Can't connect to homeserver - please check your connectivity, ensure your " + - "homeserver's SSL certificate is trusted, and that a browser extension " + - "is not blocking requests.", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - - ); - } - } - - return errorText; - } - public renderLoginComponentForFlows(): ReactNode { if (!this.state.flows) return null; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 15dcda4bc29..045ee6fccfc 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { AuthType, createClient, IAuthData, IInputs, MatrixError } from "matrix-js-sdk/src/matrix"; +import { AuthType, createClient, IAuthDict, IAuthData, IInputs, MatrixError } from "matrix-js-sdk/src/matrix"; import React, { Fragment, ReactNode } from "react"; import { IRegisterRequestParams, IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; -import { _t, _td } from "../../../languageHandler"; -import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; +import { _t } from "../../../languageHandler"; +import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../../../Lifecycle"; import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -313,17 +313,15 @@ export default class Registration extends React.Component { let errorText: ReactNode = (response as Error).message || (response as Error).toString(); // can we give a better error message? if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { - const errorTop = messageForResourceLimitError(response.data.limit_type, response.data.admin_contact, { - "monthly_active_user": _td("This homeserver has hit its Monthly Active User limit."), - "hs_blocked": _td("This homeserver has been blocked by its administrator."), - "": _td("This homeserver has exceeded one of its resource limits."), - }); + const errorTop = messageForResourceLimitError( + response.data.limit_type, + response.data.admin_contact, + resourceLimitStrings, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, response.data.admin_contact, - { - "": _td("Please contact your service administrator to continue using this service."), - }, + adminContactStrings, ); errorText = (
@@ -463,7 +461,7 @@ export default class Registration extends React.Component { }); }; - private makeRegisterRequest = (auth: IAuthData | null): Promise => { + private makeRegisterRequest = (auth: IAuthDict | null): Promise => { if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded"); const registerParams: IRegisterRequestParams = { diff --git a/src/components/views/audio_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx index 4aad6349038..19902e7e7a6 100644 --- a/src/components/views/audio_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -15,15 +15,17 @@ limitations under the License. */ import React, { HTMLProps } from "react"; +import { Temporal } from "proposal-temporal"; import { formatSeconds } from "../../../DateUtils"; interface Props extends Pick, "aria-live" | "role"> { seconds: number; - formatFn?: (seconds: number) => string; + formatFn: (seconds: number) => string; } /** + * Clock which represents time periods rather than absolute time. * Simply converts seconds using formatFn. * Defaulting to formatSeconds(). * Note that in this case hours will not be displayed, making it possible to see "82:29". @@ -43,12 +45,23 @@ export default class Clock extends React.Component { return currentFloor !== nextFloor; } + private calculateDuration(seconds: number): string { + return new Temporal.Duration(0, 0, 0, 0, 0, 0, seconds) + .round({ smallestUnit: "seconds", largestUnit: "hours" }) + .toString(); + } + public render(): React.ReactNode { + const { seconds, role } = this.props; return ( - - {/* formatFn set by defaultProps */} - {this.props.formatFn!(this.props.seconds)} - + ); } } diff --git a/src/components/views/audio_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx index 19fd5de7970..c0752371f18 100644 --- a/src/components/views/audio_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -18,8 +18,9 @@ import React from "react"; import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; import Waveform from "./Waveform"; -import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback"; +import { Playback } from "../../../audio/Playback"; import { percentageOf } from "../../../utils/numbers"; +import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts"; interface IProps { playback: Playback; diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index 83f6eca71a4..dc8ff2e1a3c 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -69,7 +69,7 @@ export default class CountryDropdown extends React.Component { const locale = new Intl.Locale(navigator.language ?? navigator.languages[0]); const code = locale.region ?? locale.language ?? locale.baseName; const displayNames = new Intl.DisplayNames(["en"], { type: "region" }); - const displayName = displayNames.of(code).toUpperCase(); + const displayName = displayNames.of(code)?.toUpperCase(); defaultCountry = COUNTRIES.find( (c) => c.iso2 === code.toUpperCase() || c.name.toUpperCase() === displayName, ); @@ -164,6 +164,7 @@ export default class CountryDropdown extends React.Component { searchEnabled={true} disabled={this.props.disabled} label={_t("Country Dropdown")} + autoComplete="tel-country-code" > {options} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 1050e8f6e22..3f232923ccc 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -92,6 +92,7 @@ class PassphraseField extends PureComponent { }, }, ], + memoize: true, }); public onValidate = async (fieldState: IFieldState): Promise => { diff --git a/src/components/views/context_menus/DeveloperToolsOption.tsx b/src/components/views/context_menus/DeveloperToolsOption.tsx new file mode 100644 index 00000000000..9b483c35d6d --- /dev/null +++ b/src/components/views/context_menus/DeveloperToolsOption.tsx @@ -0,0 +1,47 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import Modal from "../../../Modal"; +import DevtoolsDialog from "../dialogs/DevtoolsDialog"; +import { IconizedContextMenuOption } from "./IconizedContextMenu"; +import { _t } from "../../../languageHandler"; + +interface Props { + onFinished: () => void; + roomId: string; +} + +export const DeveloperToolsOption: React.FC = ({ onFinished, roomId }) => { + return ( + { + Modal.createDialog( + DevtoolsDialog, + { + onFinished: () => {}, + roomId: roomId, + }, + "mx_DevtoolsDialog_wrapper", + ); + onFinished(); + }} + label={_t("Developer tools")} + iconClassName="mx_IconizedContextMenu_developerTools" + /> + ); +}; diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index 5c2b0c3884e..a3e56c79e50 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ import ExportDialog from "../dialogs/ExportDialog"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "../right_panel/PinnedMessagesCard"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; -import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog"; +import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import DMRoomMap from "../../../utils/DMRoomMap"; @@ -48,15 +48,18 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import SettingsStore from "../../../settings/SettingsStore"; -import DevtoolsDialog from "../dialogs/DevtoolsDialog"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; +import { DeveloperToolsOption } from "./DeveloperToolsOption"; interface IProps extends IContextMenuProps { room: Room; } +/** + * Room context menu accessible via the room header. + */ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { const cli = useContext(MatrixClientContext); const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => @@ -166,8 +169,8 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { ); const echoChamber = EchoChamber.forRoom(room); - let notificationLabel: string; - let iconClassName: string; + let notificationLabel: string | undefined; + let iconClassName: string | undefined; switch (echoChamber.notificationVolume) { case RoomNotifState.AllMessages: notificationLabel = _t("Default"); @@ -196,7 +199,7 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { dis.dispatch({ action: "open_room_settings", room_id: room.roomId, - initial_tab_id: ROOM_NOTIFICATIONS_TAB, + initial_tab_id: RoomSettingsTab.Notifications, }); onFinished(); @@ -334,7 +337,7 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const removeTag = isApplied ? tagId : inverseTag; const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0)); + dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); } else { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); } @@ -393,23 +396,7 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { {exportChatOption} {SettingsStore.getValue("developerMode") && ( - { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createDialog( - DevtoolsDialog, - { - roomId: room.roomId, - }, - "mx_DevtoolsDialog_wrapper", - ); - onFinished(); - }} - label={_t("Developer tools")} - iconClassName="mx_RoomTile_iconDeveloperTools" - /> + )} {leaveOption} diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index b42574c3afe..8210f0fb72e 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -42,6 +42,8 @@ import IconizedContextMenu, { import { ButtonEvent } from "../elements/AccessibleButton"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; +import { DeveloperToolsOption } from "./DeveloperToolsOption"; +import { useSettingValue } from "../../../hooks/useSettings"; export interface RoomGeneralContextMenuProps extends IContextMenuProps { room: Room; @@ -55,6 +57,9 @@ export interface RoomGeneralContextMenuProps extends IContextMenuProps { onPostLeaveClick?: (event: ButtonEvent) => void; } +/** + * Room context menu accessible via the room list. + */ export const RoomGeneralContextMenu: React.FC = ({ room, onFinished, @@ -105,7 +110,7 @@ export const RoomGeneralContextMenu: React.FC = ({ const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const removeTag = isApplied ? tagId : inverseTag; const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0)); + dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); } else { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); } @@ -259,18 +264,27 @@ export const RoomGeneralContextMenu: React.FC = ({ // /> // ) : null; + const developerModeEnabled = useSettingValue("developerMode"); + const developerToolsOption = developerModeEnabled ? ( + + ) : null; + return ( - {!roomTags.includes(DefaultTagID.Archived) && ( - - {markUnreadOption} - {favoriteOption} - {lowPriorityOption} - {inviteOption} - {copyLinkOption} - {settingsOption} - - )} + + + {markUnreadOption} + {!roomTags.includes(DefaultTagID.Archived) && ( + <> + {favoriteOption} + {lowPriorityOption} + {inviteOption} + {copyLinkOption} + {settingsOption} + + )} + {developerToolsOption} + {leaveOption} ); diff --git a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx index e87fc16ef90..f4cf78681b2 100644 --- a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx +++ b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx @@ -19,7 +19,7 @@ import React from "react"; import BaseDialog from "./BaseDialog"; import { _t } from "../../../languageHandler"; import DialogButtons from "../elements/DialogButtons"; -import Modal from "../../../Modal"; +import Modal, { ComponentProps } from "../../../Modal"; import SdkConfig from "../../../SdkConfig"; import { getPolicyUrl } from "../../../toasts/AnalyticsToast"; @@ -29,7 +29,7 @@ export enum ButtonClicked { } interface IProps { - onFinished?(buttonClicked?: ButtonClicked): void; + onFinished(buttonClicked?: ButtonClicked): void; analyticsOwner: string; privacyPolicyUrl?: string; primaryButton?: string; @@ -45,8 +45,8 @@ export const AnalyticsLearnMoreDialog: React.FC = ({ cancelButton, hasCancel, }) => { - const onPrimaryButtonClick = (): void => onFinished?.(ButtonClicked.Primary); - const onCancelButtonClick = (): void => onFinished?.(ButtonClicked.Cancel); + const onPrimaryButtonClick = (): void => onFinished(ButtonClicked.Primary); + const onCancelButtonClick = (): void => onFinished(ButtonClicked.Cancel); const privacyPolicyLink = privacyPolicyUrl ? ( {_t( @@ -114,7 +114,9 @@ export const AnalyticsLearnMoreDialog: React.FC = ({ ); }; -export const showDialog = (props: Omit): void => { +export const showDialog = ( + props: Omit, "cookiePolicyUrl" | "analyticsOwner">, +): void => { const privacyPolicyUrl = getPolicyUrl(); const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand"); Modal.createDialog( diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx index a262fd4f5ee..00bb7cd7f6e 100644 --- a/src/components/views/dialogs/BetaFeedbackDialog.tsx +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -38,7 +38,7 @@ const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => { return ( = (props: IProps) => { Modal.createDialog(BugReportDialog, {}); }; - const rageshakeUrl = SdkConfig.get().bug_report_endpoint_url; - const hasFeedback = !!rageshakeUrl; + const hasFeedback = !!SdkConfig.get().bug_report_endpoint_url; const onFinished = (sendFeedback: boolean): void => { if (hasFeedback && sendFeedback) { - if (rageshakeUrl) { - const label = props.feature ? `${props.feature}-feedback` : "feedback"; - submitFeedback(rageshakeUrl, label, comment, canContact); - } + const label = props.feature ? `${props.feature}-feedback` : "feedback"; + submitFeedback(label, comment, canContact); Modal.createDialog(InfoDialog, { title: _t("Feedback sent"), @@ -69,8 +62,8 @@ const FeedbackDialog: React.FC = (props: IProps) => { props.onFinished(); }; - let feedbackSection; - if (rageshakeUrl) { + let feedbackSection: JSX.Element | undefined; + if (hasFeedback) { feedbackSection = (

{_t("Comment")}

@@ -97,8 +90,8 @@ const FeedbackDialog: React.FC = (props: IProps) => { ); } - let bugReports: JSX.Element | null = null; - if (rageshakeUrl) { + let bugReports: JSX.Element | undefined; + if (hasFeedback) { bugReports = (

{_t( @@ -117,6 +110,9 @@ const FeedbackDialog: React.FC = (props: IProps) => { ); } + const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url"); + const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url"); + return ( ; children?: ReactNode; onFinished(sendFeedback?: boolean): void; @@ -48,7 +47,7 @@ const GenericFeatureFeedbackDialog: React.FC = ({ const sendFeedback = async (ok: boolean): Promise => { if (!ok) return onFinished(false); - submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData); + submitFeedback(rageshakeLabel, comment, canContact, rageshakeData); onFinished(true); Modal.createDialog(InfoDialog, { diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 03d26459969..cf71fde5e6a 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1433,7 +1433,6 @@ export default class InviteDialog extends React.PureComponent {_t("Transfer")} @@ -1495,7 +1494,7 @@ export default class InviteDialog extends React.PureComponent = [ + const tabs: NonEmptyArray> = [ new Tab(TabId.UserDirectory, _td("User Directory"), "mx_InviteDialog_userDirectoryIcon", usersSection), ]; diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx index e3bc1dc4690..2e71ff199e8 100644 --- a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx @@ -18,72 +18,80 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import React, { useCallback } from "react"; +import { Device } from "matrix-js-sdk/src/models/device"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import * as FormattingUtils from "../../../utils/FormattingUtils"; import { _t } from "../../../languageHandler"; import QuestionDialog from "./QuestionDialog"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -interface IProps { +interface IManualDeviceKeyVerificationDialogProps { userId: string; - device: DeviceInfo; + device: Device; onFinished(confirm?: boolean): void; } -export default class ManualDeviceKeyVerificationDialog extends React.Component { - private onLegacyFinished = (confirm: boolean): void => { - if (confirm) { - MatrixClientPeg.get().setDeviceVerified(this.props.userId, this.props.device.deviceId, true); - } - this.props.onFinished(confirm); - }; +export function ManualDeviceKeyVerificationDialog({ + userId, + device, + onFinished, +}: IManualDeviceKeyVerificationDialogProps): JSX.Element { + const mxClient = useMatrixClientContext(); - public render(): React.ReactNode { - let text; - if (MatrixClientPeg.get().getUserId() === this.props.userId) { - text = _t("Confirm by comparing the following with the User Settings in your other session:"); - } else { - text = _t("Confirm this user's session by comparing the following with their User Settings:"); - } + const onLegacyFinished = useCallback( + (confirm: boolean) => { + if (confirm && mxClient) { + mxClient.setDeviceVerified(userId, device.deviceId, true); + } + onFinished(confirm); + }, + [mxClient, userId, device, onFinished], + ); - const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint()); - const body = ( -

-

{text}

-
-
    -
  • - {this.props.device.getDisplayName()} -
  • -
  • - {" "} - - {this.props.device.deviceId} - -
  • -
  • - {" "} - - - {key} - - -
  • -
-
-

{_t("If they don't match, the security of your communication may be compromised.")}

+ let text; + if (mxClient?.getUserId() === userId) { + text = _t("Confirm by comparing the following with the User Settings in your other session:"); + } else { + text = _t("Confirm this user's session by comparing the following with their User Settings:"); + } + + const fingerprint = device.getFingerprint(); + const key = fingerprint && FormattingUtils.formatCryptoKey(fingerprint); + const body = ( +
+

{text}

+
+
    +
  • + {device.displayName} +
  • +
  • + {" "} + + {device.deviceId} + +
  • +
  • + {" "} + + + {key} + + +
  • +
- ); +

{_t("If they don't match, the security of your communication may be compromised.")}

+
+ ); - return ( - - ); - } + return ( + + ); } diff --git a/src/components/views/dialogs/ModuleUiDialog.tsx b/src/components/views/dialogs/ModuleUiDialog.tsx index cced3907bb8..d4f36f86740 100644 --- a/src/components/views/dialogs/ModuleUiDialog.tsx +++ b/src/components/views/dialogs/ModuleUiDialog.tsx @@ -47,7 +47,7 @@ export class ModuleUiDialog extends ScrollableBaseModal { protected async submit(): Promise { try { - const model = await this.contentRef.current.trySubmit(); + const model = await this.contentRef.current!.trySubmit(); this.props.onFinished(true, model); } catch (e) { logger.error("Error during submission of module dialog:", e); diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 8eaa64bc34b..8ce08208c0d 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -260,7 +260,7 @@ export default class ReportEventDialog extends React.Component { // if the user should also be ignored, do that if (this.state.ignoreUserToo) { - await client.setIgnoredUsers([...client.getIgnoredUsers(), ev.getSender()]); + await client.setIgnoredUsers([...client.getIgnoredUsers(), ev.getSender()!]); } this.props.onFinished(true); @@ -309,8 +309,8 @@ export default class ReportEventDialog extends React.Component { // Display report-to-moderator dialog. // We let the user pick a nature. const client = MatrixClientPeg.get(); - const homeServerName = SdkConfig.get("validated_server_config").hsName; - let subtitle; + const homeServerName = SdkConfig.get("validated_server_config")!.hsName; + let subtitle: string; switch (this.state.nature) { case Nature.Disagreement: subtitle = _t( diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index 8c5de0b579e..2c4a9745b53 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -1,6 +1,8 @@ /* Copyright 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2023 The Matrix.org Foundation C.I.C. + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +18,7 @@ limitations under the License. */ import React from "react"; -import { RoomEvent } from "matrix-js-sdk/src/models/room"; +import { RoomEvent, Room } from "matrix-js-sdk/src/models/room"; import TabbedView, { Tab } from "../../structures/TabbedView"; import { _t, _td } from "../../../languageHandler"; @@ -36,15 +38,18 @@ import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab"; import { ActionPayload } from "../../../dispatcher/payloads"; import { NonEmptyArray } from "../../../@types/common"; import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab"; - -export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; -export const ROOM_VOIP_TAB = "ROOM_VOIP_TAB"; -export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; -export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB"; -export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB"; -export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB"; -export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB"; -export const ROOM_POLL_HISTORY_TAB = "ROOM_POLL_HISTORY_TAB"; +import ErrorBoundary from "../elements/ErrorBoundary"; + +export const enum RoomSettingsTab { + General = "ROOM_GENERAL_TAB", + Voip = "ROOM_VOIP_TAB", + Security = "ROOM_SECURITY_TAB", + Roles = "ROOM_ROLES_TAB", + Notifications = "ROOM_NOTIFICATIONS_TAB", + Bridges = "ROOM_BRIDGES_TAB", + Advanced = "ROOM_ADVANCED_TAB", + PollHistory = "ROOM_POLL_HISTORY_TAB", +} interface IProps { roomId: string; @@ -53,15 +58,17 @@ interface IProps { } interface IState { - roomName: string; + room: Room; } -export default class RoomSettingsDialog extends React.Component { +class RoomSettingsDialog extends React.Component { private dispatcherRef: string; public constructor(props: IProps) { super(props); - this.state = { roomName: "" }; + + const room = this.getRoom(); + this.state = { room }; } public componentDidMount(): void { @@ -70,6 +77,13 @@ export default class RoomSettingsDialog extends React.Component this.onRoomName(); } + public componentDidUpdate(): void { + if (this.props.roomId !== this.state.room.roomId) { + const room = this.getRoom(); + this.setState({ room }); + } + } + public componentWillUnmount(): void { if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); @@ -78,6 +92,21 @@ export default class RoomSettingsDialog extends React.Component MatrixClientPeg.get().removeListener(RoomEvent.Name, this.onRoomName); } + /** + * Get room from client + * @returns Room + * @throws when room is not found + */ + private getRoom(): Room { + const room = MatrixClientPeg.get().getRoom(this.props.roomId)!; + + // something is really wrong if we encounter this + if (!room) { + throw new Error(`Cannot find room ${this.props.roomId}`); + } + return room; + } + private onAction = (payload: ActionPayload): void => { // When view changes below us, close the room settings // whilst the modal is open this can only be triggered when someone hits Leave Room @@ -87,64 +116,58 @@ export default class RoomSettingsDialog extends React.Component }; private onRoomName = (): void => { - this.setState({ - roomName: MatrixClientPeg.get().getRoom(this.props.roomId)?.name ?? "", - }); + // rerender when the room name changes + this.forceUpdate(); }; - private getTabs(): NonEmptyArray { - const tabs: Tab[] = []; + private getTabs(): NonEmptyArray> { + const tabs: Tab[] = []; tabs.push( new Tab( - ROOM_GENERAL_TAB, + RoomSettingsTab.General, _td("General"), "mx_RoomSettingsDialog_settingsIcon", - , + , "RoomSettingsGeneral", ), ); if (SettingsStore.getValue("feature_group_calls")) { tabs.push( new Tab( - ROOM_VOIP_TAB, + RoomSettingsTab.Voip, _td("Voice & Video"), "mx_RoomSettingsDialog_voiceIcon", - , + , ), ); } tabs.push( new Tab( - ROOM_SECURITY_TAB, + RoomSettingsTab.Security, _td("Security & Privacy"), "mx_RoomSettingsDialog_securityIcon", - ( - this.props.onFinished(true)} - /> - ), + this.props.onFinished(true)} />, "RoomSettingsSecurityPrivacy", ), ); tabs.push( new Tab( - ROOM_ROLES_TAB, + RoomSettingsTab.Roles, _td("Roles & Permissions"), "mx_RoomSettingsDialog_rolesIcon", - , + , "RoomSettingsRolesPermissions", ), ); tabs.push( new Tab( - ROOM_NOTIFICATIONS_TAB, + RoomSettingsTab.Notifications, _td("Notifications"), "mx_RoomSettingsDialog_notificationsIcon", ( this.props.onFinished(true)} /> ), @@ -155,10 +178,10 @@ export default class RoomSettingsDialog extends React.Component if (SettingsStore.getValue("feature_bridge_state")) { tabs.push( new Tab( - ROOM_BRIDGES_TAB, + RoomSettingsTab.Bridges, _td("Bridges"), "mx_RoomSettingsDialog_bridgesIcon", - , + , "RoomSettingsBridges", ), ); @@ -166,22 +189,22 @@ export default class RoomSettingsDialog extends React.Component tabs.push( new Tab( - ROOM_POLL_HISTORY_TAB, + RoomSettingsTab.PollHistory, _td("Poll history"), "mx_RoomSettingsDialog_pollsIcon", - this.props.onFinished(true)} />, + this.props.onFinished(true)} />, ), ); if (SettingsStore.getValue(UIFeature.AdvancedSettings)) { tabs.push( new Tab( - ROOM_ADVANCED_TAB, + RoomSettingsTab.Advanced, _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", ( this.props.onFinished(true)} /> ), @@ -190,11 +213,11 @@ export default class RoomSettingsDialog extends React.Component ); } - return tabs as NonEmptyArray; + return tabs as NonEmptyArray>; } public render(): React.ReactNode { - const roomName = this.state.roomName; + const roomName = this.state.room.name; return ( ); } } + +const WrappedRoomSettingsDialog: React.FC = (props) => ( + + + +); + +export default WrappedRoomSettingsDialog; diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 2b208b29f3f..aca963e422b 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -49,7 +49,7 @@ export default class ServerPickerDialog extends React.PureComponent => { ev.preventDefault(); - const valid = await this.fieldRef.current.validate({ allowEmpty: false }); + const valid = await this.fieldRef.current?.validate({ allowEmpty: false }); if (!valid && !this.state.defaultChosen) { - this.fieldRef.current.focus(); - this.fieldRef.current.validate({ allowEmpty: false, focused: true }); + this.fieldRef.current?.focus(); + this.fieldRef.current?.validate({ allowEmpty: false, focused: true }); return; } diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index 9fcfee0dc2a..295074574d9 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -30,6 +30,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import BaseDialog from "./BaseDialog"; import CopyableText from "../elements/CopyableText"; +import { XOR } from "../../../@types/common"; const socials = [ { @@ -63,19 +64,27 @@ const socials = [ }, ]; -interface IProps { - target: Room | User | RoomMember | MatrixEvent; - permalinkCreator?: RoomPermalinkCreator; +interface BaseProps { onFinished(): void; } +interface Props extends BaseProps { + target: Room | User | RoomMember; + permalinkCreator?: RoomPermalinkCreator; +} + +interface EventProps extends BaseProps { + target: MatrixEvent; + permalinkCreator: RoomPermalinkCreator; +} + interface IState { linkSpecificEvent: boolean; permalinkCreator: RoomPermalinkCreator | null; } -export default class ShareDialog extends React.PureComponent { - public constructor(props: IProps) { +export default class ShareDialog extends React.PureComponent, IState> { + public constructor(props: XOR) { super(props); let permalinkCreator: RoomPermalinkCreator | null = null; @@ -103,30 +112,25 @@ export default class ShareDialog extends React.PureComponent { }; private getUrl(): string { - let matrixToUrl; - if (this.props.target instanceof Room) { if (this.state.linkSpecificEvent) { const events = this.props.target.getLiveTimeline().getEvents(); - matrixToUrl = this.state.permalinkCreator!.forEvent(events[events.length - 1].getId()!); + return this.state.permalinkCreator!.forEvent(events[events.length - 1].getId()!); } else { - matrixToUrl = this.state.permalinkCreator!.forShareableRoom(); + return this.state.permalinkCreator!.forShareableRoom(); } } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { - matrixToUrl = makeUserPermalink(this.props.target.userId); - } else if (this.props.target instanceof MatrixEvent) { - if (this.state.linkSpecificEvent) { - matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId()!); - } else { - matrixToUrl = this.props.permalinkCreator.forShareableRoom(); - } + return makeUserPermalink(this.props.target.userId); + } else if (this.state.linkSpecificEvent) { + return this.props.permalinkCreator!.forEvent(this.props.target.getId()!); + } else { + return this.props.permalinkCreator!.forShareableRoom(); } - return matrixToUrl; } public render(): React.ReactNode { - let title; - let checkbox; + let title: string | undefined; + let checkbox: JSX.Element | undefined; if (this.props.target instanceof Room) { title = _t("Share Room"); diff --git a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx index cf1bf354ce4..9ef3e83edea 100644 --- a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx +++ b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx @@ -87,7 +87,7 @@ export const SlidingSyncOptionsDialog: React.FC<{ onFinished(enabled: boolean): const validProxy = withValidation({ async deriveData({ value }): Promise<{ error?: Error }> { try { - await proxyHealthCheck(value, MatrixClientPeg.get().baseUrl); + await proxyHealthCheck(value!, MatrixClientPeg.get().baseUrl); return {}; } catch (error) { return { error }; diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx index 3042405cef9..0963a531181 100644 --- a/src/components/views/dialogs/SpacePreferencesDialog.tsx +++ b/src/components/views/dialogs/SpacePreferencesDialog.tsx @@ -70,7 +70,7 @@ const SpacePreferencesAppearanceTab: React.FC> = ({ space }; const SpacePreferencesDialog: React.FC = ({ space, initialTabId, onFinished }) => { - const tabs: NonEmptyArray = [ + const tabs: NonEmptyArray> = [ new Tab( SpacePreferenceTab.Appearance, _td("Appearance"), diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index e67e6f21ffa..8683a43f43b 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -70,17 +70,17 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin SpaceSettingsTab.Roles, _td("Roles & Permissions"), "mx_RoomSettingsDialog_rolesIcon", - , + , ), SettingsStore.getValue(UIFeature.AdvancedSettings) ? new Tab( SpaceSettingsTab.Advanced, _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", - , + , ) : null, - ].filter(Boolean) as NonEmptyArray; + ].filter(Boolean) as NonEmptyArray>; }, [cli, space, onFinished]); return ( diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx index 0d70e3b71f4..9fa6b075cde 100644 --- a/src/components/views/dialogs/UntrustedDeviceDialog.tsx +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -59,7 +59,7 @@ const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) =

{newSessionText}

- {device.getDisplayName()} ({device.deviceId}) + {device.displayName} ({device.deviceId})

{askToVerifyText}

diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index f8cbae2e9fe..5f534bba1f5 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -81,8 +81,8 @@ export default class UserSettingsDialog extends React.Component this.setState({ newSessionManagerEnabled: newValue }); }; - private getTabs(): NonEmptyArray { - const tabs: Tab[] = []; + private getTabs(): NonEmptyArray> { + const tabs: Tab[] = []; tabs.push( new Tab( @@ -208,7 +208,7 @@ export default class UserSettingsDialog extends React.Component ), ); - return tabs as NonEmptyArray; + return tabs as NonEmptyArray>; } public render(): React.ReactNode { diff --git a/src/components/views/dialogs/devtools/Event.tsx b/src/components/views/dialogs/devtools/Event.tsx index 3e0e681c225..c63659a9150 100644 --- a/src/components/views/dialogs/devtools/Event.tsx +++ b/src/components/views/dialogs/devtools/Event.tsx @@ -182,7 +182,7 @@ export const TimelineEventEditor: React.FC = ({ mxEvent, onBack }) return cli.sendEvent(context.room.roomId, eventType, content || {}); }; - let defaultContent = ""; + let defaultContent: string | undefined; if (mxEvent) { const originalContent = mxEvent.getContent(); diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index a261e441041..9cffc202415 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -42,7 +42,7 @@ const VALIDATION_THROTTLE_MS = 200; export type KeyParams = { passphrase?: string; recoveryKey?: string }; interface IProps { - keyInfo?: ISecretStorageKeyInfo; + keyInfo: ISecretStorageKeyInfo; checkPrivateKey: (k: KeyParams) => Promise; onFinished(result?: false | KeyParams): void; } @@ -130,7 +130,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent => { + if (!this.state.backupInfo) return; this.setState({ loading: true, restoreError: null, @@ -177,7 +178,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent => { - if (!this.state.recoveryKeyValid) return; + if (!this.state.recoveryKeyValid || !this.state.backupInfo) return; this.setState({ loading: true, @@ -228,6 +229,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent => { + if (!this.state.backupInfo) return; await MatrixClientPeg.get().restoreKeyBackupWithSecretStorage( this.state.backupInfo, undefined, @@ -324,7 +326,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { +interface OptionProps { + inputRef?: RefObject; endAdornment?: ReactNode; + id?: string; + className?: string; + onClick: ((ev: ButtonEvent) => void) | null; } export const Option: React.FC = ({ inputRef, children, endAdornment, className, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, children, endAdornment tabIndex={-1} aria-selected={isActive} role="option" + element="li" > {children}
diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 05ab8c1749a..2939d7f46d1 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -1089,7 +1089,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ev.stopPropagation(); ev.preventDefault(); - if (rovingContext.state.refs.length > 0) { + if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) { let refs = rovingContext.state.refs; if (!query && !filter !== null) { // If the current selection is not in the recently viewed row then only include the @@ -1112,6 +1112,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if ( !query && !filter !== null && + rovingContext.state.activeRef && rovingContext.state.refs.length > 0 && refIsForRecentlyViewed(rovingContext.state.activeRef) ) { diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index b90d22574b6..b4ed2e10aea 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -68,6 +68,7 @@ const validServer = withValidation({ : _t("Can't find this server or its room list"), }, ], + memoize: true, }); function useSettingsValueWithSetter( diff --git a/src/components/views/elements/CopyableText.tsx b/src/components/views/elements/CopyableText.tsx index e1783013cb8..36da6cb4a21 100644 --- a/src/components/views/elements/CopyableText.tsx +++ b/src/components/views/elements/CopyableText.tsx @@ -25,7 +25,7 @@ import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface IProps { children?: React.ReactNode; - getTextToCopy: () => string; + getTextToCopy: () => string | null; border?: boolean; className?: string; } @@ -35,7 +35,8 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border = true const onCopyClickInternal = async (e: ButtonEvent): Promise => { e.preventDefault(); - const successful = await copyPlaintext(getTextToCopy()); + const text = getTextToCopy(); + const successful = !!text && (await copyPlaintext(text)); setTooltip(successful ? _t("Copied!") : _t("Failed to copy")); }; diff --git a/src/components/views/elements/DesktopCapturerSourcePicker.tsx b/src/components/views/elements/DesktopCapturerSourcePicker.tsx index b34f951af3b..20bbc729adf 100644 --- a/src/components/views/elements/DesktopCapturerSourcePicker.tsx +++ b/src/components/views/elements/DesktopCapturerSourcePicker.tsx @@ -85,6 +85,8 @@ export interface PickerIProps { onFinished(sourceId?: string): void; } +type TabId = "screen" | "window"; + export default class DesktopCapturerSourcePicker extends React.Component { public interval: number; @@ -134,7 +136,7 @@ export default class DesktopCapturerSourcePicker extends React.Component { const sources = this.state.sources .filter((source) => source.id.startsWith(type)) .map((source) => { @@ -152,7 +154,7 @@ export default class DesktopCapturerSourcePicker extends React.Component = [ + const tabs: NonEmptyArray> = [ this.getTab("screen", _t("Share entire screen")), this.getTab("window", _t("Application window")), ]; diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index f603a4b9570..305cee51967 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -30,7 +30,7 @@ interface IMenuOptionProps { highlighted?: boolean; dropdownKey: string; id?: string; - inputRef?: Ref; + inputRef?: Ref; onClick(dropdownKey: string): void; onMouseEnter(dropdownKey: string): void; } @@ -57,7 +57,7 @@ class MenuOption extends React.Component { }); return ( -
{ ref={this.props.inputRef} > {this.props.children} -
+ ); } } @@ -78,6 +78,7 @@ export interface DropdownProps { label: string; value?: string; className?: string; + autoComplete?: string; children: NonEmptyArray; // negative for consistency with HTML disabled?: boolean; @@ -318,21 +319,21 @@ export default class Dropdown extends React.Component { }); if (!options?.length) { return [ -
+
  • {_t("No results")} -
  • , + , ]; } return options; } public render(): React.ReactNode { - let currentValue; + let currentValue: JSX.Element | undefined; const menuStyle: CSSProperties = {}; if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; - let menu; + let menu: JSX.Element | undefined; if (this.state.expanded) { if (this.props.searchEnabled) { currentValue = ( @@ -340,6 +341,7 @@ export default class Dropdown extends React.Component { id={`${this.props.id}_input`} type="text" autoFocus={true} + autoComplete={this.props.autoComplete} className="mx_Dropdown_option" onChange={this.onInputChange} value={this.state.searchQuery} @@ -355,9 +357,9 @@ export default class Dropdown extends React.Component { ); } menu = ( -
    +
      {this.getMenuOptions()} -
    + ); } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index a089a02d69d..2c4d0e5d0cd 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -129,8 +129,8 @@ export default class EventTilePreview extends React.Component { const event = this.fakeEvent(this.state); return ( -
    - +
    +
    ); } diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 4455b16a9f3..2a1540920b2 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import ToggleSwitch from "./ToggleSwitch"; import { Caption } from "../typography/Caption"; @@ -43,18 +44,15 @@ interface IProps { } export default class LabelledToggleSwitch extends React.PureComponent { + private readonly id = `mx_LabelledToggleSwitch_${randomString(12)}`; + public render(): React.ReactNode { // This is a minimal version of a SettingsFlag const { label, caption } = this.props; let firstPart = ( - {label} - {caption && ( - <> -
    - {caption} - - )} +
    {label}
    + {caption && {caption}}
    ); let secondPart = ( @@ -62,15 +60,14 @@ export default class LabelledToggleSwitch extends React.PureComponent { checked={this.props.value} disabled={this.props.disabled} onChange={this.props.onChange} - title={this.props.label} tooltip={this.props.tooltip} + aria-labelledby={this.id} + aria-describedby={caption ? `${this.id}_caption` : undefined} /> ); if (this.props.toggleInFront) { - const temp = firstPart; - firstPart = secondPart; - secondPart = temp; + [firstPart, secondPart] = [secondPart, firstPart]; } const classes = classNames("mx_SettingsFlag", this.props.className, { diff --git a/src/components/views/elements/LazyRenderList.tsx b/src/components/views/elements/LazyRenderList.tsx index 0a041730339..802e60ca196 100644 --- a/src/components/views/elements/LazyRenderList.tsx +++ b/src/components/views/elements/LazyRenderList.tsx @@ -73,6 +73,7 @@ interface IProps { element?: string; className?: string; + role?: string; } interface IState { @@ -128,6 +129,7 @@ export default class LazyRenderList extends React.Component, const elementProps = { style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` }, className: this.props.className, + role: this.props.role, }; return React.createElement(element, elementProps, renderedItems.map(renderItem)); } diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index eb9ae028a48..02a9bfe2d77 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -103,10 +103,17 @@ export default function RoomTopic({ room, ...props }: IProps): JSX.Element { const className = classNames(props.className, "mx_RoomTopic"); return ( -
    - - {body} - -
    + + {body} + ); } diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f29405ba8d5..588374d17b6 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -41,7 +41,7 @@ interface IProps { } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps): JSX.Element => { +export default ({ checked, disabled = false, onChange, ...props }: IProps): JSX.Element => { const _onClick = (): void => { if (disabled) return; onChange(!checked); @@ -61,8 +61,6 @@ export default ({ checked, disabled = false, title, tooltip, onChange, ...props role="switch" aria-checked={checked} aria-disabled={disabled} - title={title} - tooltip={tooltip} >
    diff --git a/src/components/views/elements/TooltipTarget.tsx b/src/components/views/elements/TooltipTarget.tsx index a8c773d1f60..cbd0555d381 100644 --- a/src/components/views/elements/TooltipTarget.tsx +++ b/src/components/views/elements/TooltipTarget.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes } from "react"; +import React, { forwardRef, HTMLAttributes } from "react"; import useFocus from "../../../hooks/useFocus"; import useHover from "../../../hooks/useHover"; @@ -29,49 +29,55 @@ interface IProps extends HTMLAttributes, Omit = ({ - children, - tooltipTargetClassName, - // tooltip pass through props - className, - id, - label, - alignment, - tooltipClassName, - maxParentWidth, - ignoreHover, - ...rest -}) => { - const [isFocused, focusProps] = useFocus(); - const [isHovering, hoverProps] = useHover(ignoreHover || (() => false)); +const TooltipTarget = forwardRef( + ( + { + children, + tooltipTargetClassName, + // tooltip pass through props + className, + id, + label, + alignment, + tooltipClassName, + maxParentWidth, + ignoreHover, + ...rest + }, + ref, + ) => { + const [isFocused, focusProps] = useFocus(); + const [isHovering, hoverProps] = useHover(ignoreHover || (() => false)); - // No need to fill up the DOM with hidden tooltip elements. Only add the - // tooltip when we're hovering over the item (performance) - const tooltip = (isFocused || isHovering) && ( - - ); + // No need to fill up the DOM with hidden tooltip elements. Only add the + // tooltip when we're hovering over the item (performance) + const tooltip = (isFocused || isHovering) && ( + + ); - return ( -
    - {children} - {tooltip} -
    - ); -}; + return ( +
    + {children} + {tooltip} +
    + ); + }, +); export default TooltipTarget; diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index b1101f41dc2..c0f21826f2f 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -15,8 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ReactChild, ReactNode } from "react"; import classNames from "classnames"; +import memoizeOne from "memoize-one"; type Data = Pick; @@ -40,6 +41,7 @@ interface IArgs { description?(this: T, derivedData: D, results: IResult[]): ReactNode; hideDescriptionIfValid?: boolean; deriveData?(data: Data): Promise; + memoize?: boolean; } export interface IFieldState { @@ -60,7 +62,7 @@ export interface IValidationResult { * @param {Function} description * Function that returns a string summary of the kind of value that will * meet the validation rules. Shown at the top of the validation feedback. - * @param {Boolean} hideDescriptionIfValid + * @param {boolean} hideDescriptionIfValid * If true, don't show the description if the validation passes validation. * @param {Function} deriveData * Optional function that returns a Promise to an object of generic type D. @@ -75,6 +77,9 @@ export interface IValidationResult { * - `valid`: Function returning text to show when the rule is valid. Only shown if set. * - `invalid`: Function returning text to show when the rule is invalid. Only shown if set. * - `final`: A Boolean if true states that this rule will only be considered if all rules before it returned valid. + * @param {boolean?} memoize + * If true, will use memoization to avoid calling deriveData & rules unless the value or allowEmpty change. + * Be careful to not use this if your validation is not pure and depends on other fields, such as "repeat password". * @returns {Function} * A validation function that takes in the current input value and returns * the overall validity and a feedback UI that can be rendered for more detail. @@ -84,73 +89,87 @@ export default function withValidation({ hideDescriptionIfValid, deriveData, rules, -}: IArgs) { - return async function onValidate( + memoize, +}: IArgs): (fieldState: IFieldState) => Promise { + let checkRules = async function ( this: T, - { value, focused, allowEmpty = true }: IFieldState, - ): Promise { - if (!value && allowEmpty) { - return {}; - } - - const data = { value, allowEmpty }; - // We know that if deriveData is set then D will not be undefined - const derivedData: D = (await deriveData?.call(this, data)) as D; - + data: Data, + derivedData: D, + ): Promise<[valid: boolean, results: IResult[]]> { const results: IResult[] = []; let valid = true; - if (rules?.length) { - for (const rule of rules) { - if (!rule.key || !rule.test) { - continue; - } + for (const rule of rules) { + if (!rule.key || !rule.test) { + continue; + } - if (!valid && rule.final) { - continue; - } + if (!valid && rule.final) { + continue; + } + + if (rule.skip?.call(this, data, derivedData)) { + continue; + } - if (rule.skip?.call(this, data, derivedData)) { + // We're setting `this` to whichever component holds the validation + // function. That allows rules to access the state of the component. + const ruleValid: boolean = await rule.test.call(this, data, derivedData); + valid = valid && ruleValid; + if (ruleValid && rule.valid) { + // If the rule's result is valid and has text to show for + // the valid state, show it. + const text = rule.valid.call(this, derivedData); + if (!text) { continue; } - - // We're setting `this` to whichever component holds the validation - // function. That allows rules to access the state of the component. - const ruleValid: boolean = await rule.test.call(this, data, derivedData); - valid = valid && ruleValid; - if (ruleValid && rule.valid) { - // If the rule's result is valid and has text to show for - // the valid state, show it. - const text = rule.valid.call(this, derivedData); - if (!text) { - continue; - } - results.push({ - key: rule.key, - valid: true, - text, - }); - } else if (!ruleValid && rule.invalid) { - // If the rule's result is invalid and has text to show for - // the invalid state, show it. - const text = rule.invalid.call(this, derivedData); - if (!text) { - continue; - } - results.push({ - key: rule.key, - valid: false, - text, - }); + results.push({ + key: rule.key, + valid: true, + text, + }); + } else if (!ruleValid && rule.invalid) { + // If the rule's result is invalid and has text to show for + // the invalid state, show it. + const text = rule.invalid.call(this, derivedData); + if (!text) { + continue; } + results.push({ + key: rule.key, + valid: false, + text, + }); } } + return [valid, results]; + }; + + // We have to memoize it in chunks as `focused` can change frequently, but it isn't passed to these methods + if (memoize) { + if (deriveData) deriveData = memoizeOne(deriveData, isDataEqual); + checkRules = memoizeOne(checkRules, isDerivedDataEqual); + } + + return async function onValidate( + this: T, + { value, focused, allowEmpty = true }: IFieldState, + ): Promise { + if (!value && allowEmpty) { + return {}; + } + + const data = { value, allowEmpty }; + // We know that if deriveData is set then D will not be undefined + const derivedData = (await deriveData?.call(this, data)) as D; + const [valid, results] = await checkRules.call(this, data, derivedData); + // Hide feedback when not focused if (!focused) { return { valid }; } - let details; + let details: ReactNode | undefined; if (results && results.length) { details = (
      @@ -170,7 +189,7 @@ export default function withValidation({ ); } - let summary; + let summary: ReactNode | undefined; if (description && (details || !hideDescriptionIfValid)) { // We're setting `this` to whichever component holds the validation // function. That allows rules to access the state of the component. @@ -178,7 +197,7 @@ export default function withValidation({ summary = content ?
      {content}
      : undefined; } - let feedback; + let feedback: ReactChild | undefined; if (summary || details) { feedback = (
      @@ -194,3 +213,11 @@ export default function withValidation({ }; }; } + +function isDataEqual([a]: [Data], [b]: [Data]): boolean { + return a.value === b.value && a.allowEmpty === b.allowEmpty; +} + +function isDerivedDataEqual([a1, a2]: [Data, any], [b1, b2]: [Data, any]): boolean { + return a2 === b2 && isDataEqual([a1], [b1]); +} diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index 6129193c287..fbd3a674e82 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -22,6 +22,7 @@ import LazyRenderList from "../elements/LazyRenderList"; import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; import { ICustomEmoji } from "../../../emojipicker/customemoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; const OVERFLOW_ROWS = 3; @@ -44,18 +45,31 @@ interface IProps { heightBefore: number; viewportHeight: number; scrollTop: number; - onClick(emoji: IEmoji | ICustomEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji | ICustomEmoji): void; onMouseEnter(emoji: IEmoji | ICustomEmoji): void; onMouseLeave(emoji: IEmoji | ICustomEmoji): void; isEmojiDisabled?: (unicode: string) => boolean; } +function hexEncode(str: string): string { + let hex: string; + let i: number; + + let result = ""; + for (i = 0; i < str.length; i++) { + hex = str.charCodeAt(i).toString(16); + result += ("000" + hex).slice(-4); + } + + return result; +} + class Category extends React.PureComponent { private renderEmojiRow = (rowIndex: number): JSX.Element => { const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8).filter((val) => val); return ( -
      +
      {emojisForRow.map((emoji) => ( { disabled={this.props.isEmojiDisabled?.( "unicode" in emoji ? emoji.unicode : emoji.shortcodes[0], )} + id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode("unicode" in emoji ? emoji.unicode : emoji.shortcodes[0])}`} + role="gridcell" /> ))}
      @@ -105,7 +121,6 @@ class Category extends React.PureComponent { >

      {name}

      { overflowItems={OVERFLOW_ROWS} overflowMargin={0} renderItem={this.renderEmojiRow} + role="grid" /> ); diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index 349455faea3..dbd6d23a8a4 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -18,18 +18,21 @@ limitations under the License. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { MenuItem } from "../../structures/ContextMenu"; import { IEmoji } from "../../../emoji"; import { ICustomEmoji } from "../../../emojipicker/customemoji"; import { mediaFromMxc } from "../../../customisations/Media"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; interface IProps { emoji: IEmoji | ICustomEmoji; selectedEmojis?: Set; - onClick(emoji: IEmoji | ICustomEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji | ICustomEmoji): void; onMouseEnter(emoji: IEmoji | ICustomEmoji): void; onMouseLeave(emoji: IEmoji | ICustomEmoji): void; disabled?: boolean; + id?: string; + role?: string; } class Emoji extends React.PureComponent { @@ -38,7 +41,7 @@ class Emoji extends React.PureComponent { let emojiElement: JSX.Element; if ("unicode" in emoji) { - const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); + const isSelected = selectedEmojis?.has(emoji.unicode); emojiElement = (
      {emoji.unicode} @@ -63,17 +66,18 @@ class Emoji extends React.PureComponent { emojiElement; return ( - onClick(emoji)} + onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" - label={"unicode" in emoji ? emoji.unicode : emoji.shortcodes[0]} disabled={this.props.disabled} + role={this.props.role} + focusOnMouseOver > {emojiElement} - + ); } } diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e166b7048a9..24508e371e8 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import React, { Dispatch } from "react"; import { _t } from "../../../languageHandler"; import * as recent from "../../../emojipicker/recent"; @@ -26,10 +26,20 @@ import Header from "./Header"; import Search from "./Search"; import Preview from "./Preview"; import QuickReactions from "./QuickReactions"; -import Category, { ICategory, CategoryKey } from "./Category"; import { ICustomEmoji, loadImageSet } from "../../../emojipicker/customemoji"; -import { filterBoolean } from "../../../utils/arrays"; import AccessibleButton from "../elements/AccessibleButton"; +import Category, { CategoryKey, ICategory } from "./Category"; +import { filterBoolean } from "../../../utils/arrays"; +import { + IAction as RovingAction, + IState as RovingState, + RovingTabIndexProvider, + Type, +} from "../../../accessibility/RovingTabIndex"; +import { Key } from "../../../Keyboard"; +import { clamp } from "../../../utils/numbers"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import { Ref } from "../../../accessibility/roving/types"; export const CATEGORY_HEADER_HEIGHT = 20; export const EMOJI_HEIGHT = 35; @@ -42,6 +52,7 @@ interface IProps { selectedEmojis?: Set; room?: Room; onChoose(emoji: ICustomEmoji | IEmoji | string): boolean; + onFinished(): void; isEmojiDisabled?: (unicode: string) => boolean; } @@ -192,6 +203,68 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; + private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void { + const node = state.activeRef?.current; + const parent = node?.parentElement; + if (!parent || !state.activeRef) return; + const rowIndex = Array.from(parent.children).indexOf(node); + const refIndex = state.refs.indexOf(state.activeRef); + + let focusRef: Ref | undefined; + let newParent: HTMLElement | undefined; + switch (ev.key) { + case Key.ARROW_LEFT: + focusRef = state.refs[refIndex - 1]; + newParent = focusRef?.current?.parentElement ?? undefined; + break; + + case Key.ARROW_RIGHT: + focusRef = state.refs[refIndex + 1]; + newParent = focusRef?.current?.parentElement ?? undefined; + break; + + case Key.ARROW_UP: + case Key.ARROW_DOWN: { + // For up/down we find the prev/next parent by inspecting the refs either side of our row + const ref = + ev.key === Key.ARROW_UP + ? state.refs[refIndex - rowIndex - 1] + : state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]; + newParent = ref?.current?.parentElement ?? undefined; + const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; + focusRef = state.refs.find((r) => r.current === newTarget); + break; + } + } + + if (focusRef) { + dispatch({ + type: Type.SetFocus, + payload: { ref: focusRef }, + }); + + if (parent !== newParent) { + focusRef.current?.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + } + } + + ev.preventDefault(); + ev.stopPropagation(); + } + + private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void => { + if ( + state.activeRef?.current && + [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key) + ) { + this.keyboardNavigation(ev, state, dispatch); + } + }; + private updateVisibility = (): void => { const body = this.scrollRef.current?.containerRef.current; if (!body) return; @@ -286,11 +359,11 @@ class EmojiPicker extends React.Component { }; private onEnterFilter = (): void => { - const btn = - this.scrollRef.current?.containerRef.current?.querySelector(".mx_EmojiPicker_item"); - if (btn) { - btn.click(); - } + const btn = this.scrollRef.current?.containerRef.current?.querySelector( + '.mx_EmojiPicker_item_wrapper[tabindex="0"]', + ); + btn?.click(); + this.props.onFinished(); }; private onHoverEmoji = (emoji: IEmoji): void => { @@ -305,10 +378,13 @@ class EmojiPicker extends React.Component { }); }; - private onClickEmoji = (emoji: IEmoji | ICustomEmoji): void => { + private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji | ICustomEmoji): void => { if (this.props.onChoose(emoji) !== false) { recent.add("unicode" in emoji ? emoji.unicode : `:${emoji.shortcodes[0]}:`); } + if ((ev as React.KeyboardEvent).key === Key.ENTER) { + this.props.onFinished(); + } }; private reactWith = (reaction: string): void => { @@ -323,46 +399,65 @@ class EmojiPicker extends React.Component { } public render(): React.ReactNode { - let heightBefore = 0; return ( -
      -
      - - - {this.categories.map((category) => { - const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = ( - + {({ onKeyDownHandler }) => { + let heightBefore = 0; + return ( +
      +
      + - ); - const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); - heightBefore += height; - return categoryElement; - })} - - {this.props.allowUnlisted && this.state.filter && ( - this.reactWith(this.state.filter)}> - {_t('React with "%(reaction)s"', { reaction: this.state.filter })} - - )} - {this.state.previewEmoji ? ( - - ) : ( - - )} -
      + + {this.categories.map((category) => { + const emojis = this.memoizedDataByCategory[category.id]; + const categoryElement = ( + + ); + const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); + heightBefore += height; + return categoryElement; + })} + + {this.props.allowUnlisted && this.state.filter && ( + this.reactWith(this.state.filter)}> + {_t('React with "%(reaction)s"', { reaction: this.state.filter })} + + )} + {this.state.previewEmoji ? ( + + ) : ( + + )} +
      + ); + }} + ); } } diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index 27ba307e121..67484de1054 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -18,6 +18,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; +import { findLastIndex } from "lodash"; import { _t } from "../../../languageHandler"; import { CategoryKey, ICategory } from "./Category"; @@ -42,7 +43,14 @@ class Header extends React.PureComponent { } private changeCategoryRelative(delta: number): void { - const current = this.props.categories.findIndex((c) => c.visible); + let current: number; + // As multiple categories may be visible at once, we want to find the one closest to the relative direction + if (delta < 0) { + current = this.props.categories.findIndex((c) => c.visible); + } else { + // XXX: Switch to Array::findLastIndex once we enable ES2023 + current = findLastIndex(this.props.categories, (c) => c.visible); + } this.changeCategoryAbsolute(current + delta, delta); } diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index 6b149069481..a58c6b875fd 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -20,6 +20,8 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { getEmojiFromUnicode, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import Toolbar from "../../../accessibility/Toolbar"; // We use the variation-selector Heart in Quick Reactions for some reason const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => { @@ -32,7 +34,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀 interface IProps { selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; } interface IState { @@ -70,7 +72,7 @@ class QuickReactions extends React.Component { )} -
        + {QUICK_REACTIONS.map((emoji) => ( { selectedEmojis={this.props.selectedEmojis} /> ))} -
      + ); } diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index ee4ddee60ce..de5fc07d889 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -138,6 +138,7 @@ class ReactionPicker extends React.Component { allowUnlisted={true} onChoose={this.onChoose} isEmojiDisabled={this.isEmojiDisabled} + onFinished={this.props.onFinished} selectedEmojis={this.state.selectedEmojis} /> ); diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index edd6b2c4fca..a34a14cbafd 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -20,14 +20,19 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex"; interface IProps { query: string; onChange(value: string): void; onEnter(): void; + onKeyDown(event: React.KeyboardEvent): void; } class Search extends React.PureComponent { + public static contextType = RovingTabIndexContext; + public context!: React.ContextType; + private inputRef = React.createRef(); public componentDidMount(): void { @@ -43,11 +48,14 @@ class Search extends React.PureComponent { ev.stopPropagation(); ev.preventDefault(); break; + + default: + this.props.onKeyDown(ev); } }; public render(): React.ReactNode { - let rightButton; + let rightButton: JSX.Element; if (this.props.query) { rightButton = (
      diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx index 0120b960a82..028671f5846 100644 --- a/src/components/views/messages/DecryptionFailureBody.tsx +++ b/src/components/views/messages/DecryptionFailureBody.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { forwardRef } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; @@ -27,6 +27,12 @@ function getErrorMessage(mxEvent?: MatrixEvent): string { } // A placeholder element for messages that could not be decrypted -export function DecryptionFailureBody({ mxEvent }: Partial): JSX.Element { - return
      {getErrorMessage(mxEvent)}
      ; -} +export const DecryptionFailureBody = forwardRef>( + ({ mxEvent }, ref): JSX.Element => { + return ( +
      + {getErrorMessage(mxEvent)} +
      + ); + }, +); diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index eff3446ddcb..d514ac57479 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -389,7 +389,7 @@ export default class MImageBody extends React.Component { thumbUrl: string | null, content: IMediaEventContent, forcedHeight?: number, - ): JSX.Element { + ): ReactNode { if (!thumbUrl) thumbUrl = contentUrl; // fallback // magic number @@ -524,16 +524,25 @@ export default class MImageBody extends React.Component {
      ); - return contentUrl ? this.wrapImage(contentUrl, thumbnail) : thumbnail; + return this.wrapImage(contentUrl, thumbnail); } // Overridden by MStickerBody - protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { - return ( - - {children} - - ); + protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { + if (contentUrl) { + return ( + + {children} + + ); + } else if (!this.state.showImage) { + return ( +
      + {children} +
      + ); + } + return children; } // Overridden by MStickerBody diff --git a/src/components/views/pips/WidgetPip.tsx b/src/components/views/pips/WidgetPip.tsx index 57a06e89f41..0ffdaf3efe1 100644 --- a/src/components/views/pips/WidgetPip.tsx +++ b/src/components/views/pips/WidgetPip.tsx @@ -123,7 +123,7 @@ export const WidgetPip: FC = ({ widgetId, room, viewingRoom, onStartMovin pointerEvents="none" movePersistedElement={movePersistedElement} /> - {(call !== null || WidgetType.JITSI.matches(widget.type)) && ( + {(call !== null || WidgetType.JITSI.matches(widget?.type)) && ( = ({ className, title, children }) => { return (
      -

      {title}

      +

      {title}

      {children}
      ); diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index 6d6872bc1e9..03106face21 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -54,8 +54,7 @@ export default class HeaderButton extends React.Component { return ( extends React.Component - {this.renderButtons()} -
      - ); + return
      {this.renderButtons()}
      ; } } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index a32d8da047d..860a58df024 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -318,7 +318,7 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose }) />
    - {(name) =>

    {name}

    }
    + {(name) =>

    {name}

    }
    {alias}
    diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 270e837a703..b87c8ab76c1 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -30,7 +30,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { Device } from "matrix-js-sdk/src/models/device"; import dis from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; @@ -79,15 +79,16 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { asyncSome } from "../../../utils/arrays"; -export interface IDevice extends DeviceInfo { +export interface IDevice extends Device { ambiguous?: boolean; } export const disambiguateDevices = (devices: IDevice[]): void => { const names = Object.create(null); for (let i = 0; i < devices.length; i++) { - const name = devices[i].getDisplayName() ?? ""; + const name = devices[i].displayName ?? ""; const indexList = names[name] || []; indexList.push(i); names[name] = indexList; @@ -101,22 +102,22 @@ export const disambiguateDevices = (devices: IDevice[]): void => { } }; -export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice[]): E2EStatus => { +export const getE2EStatus = async (cli: MatrixClient, userId: string, devices: IDevice[]): Promise => { const isMe = userId === cli.getUserId(); const userTrust = cli.checkUserTrust(userId); if (!userTrust.isCrossSigningVerified()) { return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal; } - const anyDeviceUnverified = devices.some((device) => { + const anyDeviceUnverified = await asyncSome(devices, async (device) => { const { deviceId } = device; // For your own devices, we use the stricter check of cross-signing // verification to encourage everyone to trust their own devices via // cross-signing so that other users can then safely trust you. // For other people's devices, the more general verified check that // includes locally verified devices can be used. - const deviceTrust = cli.checkDeviceTrust(userId, deviceId); - return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified(); + const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId); + return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified(); }); return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; }; @@ -148,7 +149,8 @@ function useHasCrossSigningKeys( } setUpdating(true); try { - await cli.downloadKeys([member.userId]); + // We call it to populate the user keys and devices + await cli.getCrypto()?.getUserDeviceInfo([member.userId], true); const xsi = cli.getStoredCrossSigningForUser(member.userId); const key = xsi && xsi.getId(); return !!key; @@ -161,14 +163,20 @@ function useHasCrossSigningKeys( export function DeviceItem({ userId, device }: { userId: string; device: IDevice }): JSX.Element { const cli = useContext(MatrixClientContext); const isMe = userId === cli.getUserId(); - const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); const userTrust = cli.checkUserTrust(userId); - // For your own devices, we use the stricter check of cross-signing - // verification to encourage everyone to trust their own devices via - // cross-signing so that other users can then safely trust you. - // For other people's devices, the more general verified check that - // includes locally verified devices can be used. - const isVerified = isMe ? deviceTrust.isCrossSigningVerified() : deviceTrust.isVerified(); + + /** is the device verified? */ + const isVerified = useAsyncMemo(async () => { + const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, device.deviceId); + if (!deviceTrust) return false; + + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + return isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified(); + }, [cli, userId, device]); const classes = classNames("mx_UserInfo_device", { mx_UserInfo_device_verified: isVerified, @@ -188,18 +196,19 @@ export function DeviceItem({ userId, device }: { userId: string; device: IDevice }; let deviceName; - if (!device.getDisplayName()?.trim()) { + if (!device.displayName?.trim()) { deviceName = device.deviceId; } else { - deviceName = device.ambiguous - ? device.getDisplayName() + " (" + device.deviceId + ")" - : device.getDisplayName(); + deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName; } let trustedLabel: string | undefined; if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); - if (isVerified) { + if (isVerified === undefined) { + // we're still deciding if the device is verified + return
    ; + } else if (isVerified) { return (
    @@ -232,15 +241,17 @@ function DevicesSection({ const [isExpanded, setExpanded] = useState(false); - if (loading) { + const deviceTrusts = useAsyncMemo(() => { + const cryptoApi = cli.getCrypto(); + if (!cryptoApi) return Promise.resolve(undefined); + return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId))); + }, [cli, userId, devices]); + + if (loading || deviceTrusts === undefined) { // still loading return ; } - if (devices === null) { - return

    {_t("Unable to load session list")}

    ; - } const isMe = userId === cli.getUserId(); - const deviceTrusts = devices.map((d) => cli.checkDeviceTrust(userId, d.deviceId)); let expandSectionDevices: IDevice[] = []; const unverifiedDevices: IDevice[] = []; @@ -258,7 +269,7 @@ function DevicesSection({ // cross-signing so that other users can then safely trust you. // For other people's devices, the more general verified check that // includes locally verified devices can be used. - const isVerified = isMe ? deviceTrust.isCrossSigningVerified() : deviceTrust.isVerified(); + const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified()); if (isVerified) { expandSectionDevices.push(device); @@ -1178,6 +1189,19 @@ export const PowerLevelEditor: React.FC<{ ); }; +async function getUserDeviceInfo( + userId: string, + cli: MatrixClient, + downloadUncached = false, +): Promise { + const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], downloadUncached); + const devicesMap = userDeviceMap?.get(userId); + + if (!devicesMap) return; + + return Array.from(devicesMap.values()); +} + export const useDevices = (userId: string): IDevice[] | undefined | null => { const cli = useContext(MatrixClientContext); @@ -1191,10 +1215,9 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { async function downloadDeviceList(): Promise { try { - await cli.downloadKeys([userId], true); - const devices = cli.getStoredDevicesForUser(userId); + const devices = await getUserDeviceInfo(userId, cli, true); - if (cancelled) { + if (cancelled || !devices) { // we got cancelled - presumably a different user now return; } @@ -1217,8 +1240,8 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { useEffect(() => { let cancel = false; const updateDevices = async (): Promise => { - const newDevices = cli.getStoredDevicesForUser(userId); - if (cancel) return; + const newDevices = await getUserDeviceInfo(userId, cli); + if (cancel || !newDevices) return; setDevices(newDevices); }; const onDevicesUpdated = (users: string[]): void => { @@ -1611,10 +1634,12 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha const isRoomEncrypted = useIsEncrypted(cli, room); const devices = useDevices(user.userId) ?? []; - let e2eStatus: E2EStatus | undefined; - if (isRoomEncrypted && devices) { - e2eStatus = getE2EStatus(cli, user.userId, devices); - } + const e2eStatus = useAsyncMemo(async () => { + if (!isRoomEncrypted || !devices) { + return undefined; + } + return await getE2EStatus(cli, user.userId, devices); + }, [cli, isRoomEncrypted, user.userId, devices]); const classes = ["mx_UserInfo"]; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 31c1c61165c..f8578e3460c 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -799,7 +799,7 @@ export default class BasicMessageEditor extends React.Component }; const { completionIndex } = this.state; - const hasAutocomplete = Boolean(this.state.autoComplete); + const hasAutocomplete = !!this.state.autoComplete; let activeDescendant: string | undefined; if (hasAutocomplete && completionIndex! >= 0) { activeDescendant = generateCompletionDomId(completionIndex!); @@ -829,7 +829,7 @@ export default class BasicMessageEditor extends React.Component aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" - aria-expanded={hasAutocomplete ? true : undefined} + aria-expanded={hasAutocomplete ? !this.autocompleteRef.current?.state.hide : undefined} aria-owns={hasAutocomplete ? "mx_Autocomplete" : undefined} aria-activedescendant={activeDescendant} dir="auto" diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 811d02e4577..069772508da 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -386,8 +386,8 @@ class EditMessageComposer extends React.Component { + closeMenu(); + overflowMenuCloser?.(); + }; contextMenu = ( - { - closeMenu(); - overflowMenuCloser?.(); - }} - managed={false} - > - + + ); } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 7e803c72546..e3d3688a7cf 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -228,6 +228,10 @@ export interface EventTileProps { // displayed to the current user either because they're // the author or they are a moderator isSeeingThroughMessageHiddenForModeration?: boolean; + + // The following properties are used by EventTilePreview to disable tab indexes within the event tile + hideTimestamp?: boolean; + inhibitInteraction?: boolean; } interface IState { @@ -271,6 +275,8 @@ export class UnwrappedEventTile extends React.Component public static contextType = RoomContext; public context!: React.ContextType; + private unmounted = false; + public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); @@ -426,6 +432,7 @@ export class UnwrappedEventTile extends React.Component this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); + this.unmounted = false; } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -567,7 +574,7 @@ export class UnwrappedEventTile extends React.Component this.verifyEvent(); }; - private verifyEvent(): void { + private async verifyEvent(): Promise { // if the event was edited, show the verification info for the edit, not // the original const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; @@ -596,7 +603,14 @@ export class UnwrappedEventTile extends React.Component } const eventSenderTrust = - encryptionInfo.sender && MatrixClientPeg.get().checkDeviceTrust(senderId, encryptionInfo.sender.deviceId); + senderId && + encryptionInfo.sender && + (await MatrixClientPeg.get() + .getCrypto() + ?.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId)); + + if (this.unmounted) return; + if (!eventSenderTrust) { this.setState({ verified: E2EState.Unknown }); return; @@ -750,7 +764,7 @@ export class UnwrappedEventTile extends React.Component } } - if (MatrixClientPeg.get().isRoomEncrypted(ev.getRoomId())) { + if (MatrixClientPeg.get().isRoomEncrypted(ev.getRoomId()!)) { // else if room is encrypted // and event is being encrypted or is not_sent (Unknown Devices/Network Error) if (ev.status === EventStatus.ENCRYPTING) { @@ -785,7 +799,7 @@ export class UnwrappedEventTile extends React.Component if (!this.props.showReactions || !this.props.getRelationsForEvent) { return null; } - const eventId = this.props.mxEvent.getId(); + const eventId = this.props.mxEvent.getId()!; return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null; }; @@ -803,7 +817,7 @@ export class UnwrappedEventTile extends React.Component }; private onTimestampContextMenu = (ev: React.MouseEvent): void => { - this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId())); + this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!)); }; private showContextMenu(ev: React.MouseEvent, permalink?: string): void { @@ -989,7 +1003,7 @@ export class UnwrappedEventTile extends React.Component let permalink = "#"; if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!); } // we can't use local echoes as scroll tokens, because their event IDs change. @@ -1038,7 +1052,7 @@ export class UnwrappedEventTile extends React.Component } if (this.props.mxEvent.sender && avatarSize) { - let member; + let member: RoomMember | null = null; // set member to receiver (target) if it is a 3PID invite // so that the correct avatar is shown as the text is // `$target accepted the invitation for $email` @@ -1048,9 +1062,11 @@ export class UnwrappedEventTile extends React.Component member = this.props.mxEvent.sender; } // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead - const viewUserOnClick = ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( - this.context.timelineRenderingType, - ); + const viewUserOnClick = + !this.props.inhibitInteraction && + ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( + this.context.timelineRenderingType, + ); avatar = (
    const showTimestamp = this.props.mxEvent.getTs() && (scBubbleEnabled || - this.props.alwaysShowTimestamps || - this.props.last || - this.state.hover || - this.state.actionBarFocused || - Boolean(this.state.contextMenu)); + !this.props.hideTimestamp && + (this.props.alwaysShowTimestamps || + this.props.last || + this.state.hover || + this.state.actionBarFocused || + Boolean(this.state.contextMenu))); // Thread panel shows the timestamp of the last reply in that thread let ts = @@ -1148,7 +1165,7 @@ export class UnwrappedEventTile extends React.Component ); } - const linkedTimestamp = ( + const linkedTimestamp = !this.props.hideTimestamp ? ( > {timestamp} - ); + ) : null; const placeholderTimestamp = {timestamp}; diff --git a/src/components/views/rooms/MemberTile.tsx b/src/components/views/rooms/MemberTile.tsx index cac793066b3..7ff46c922f7 100644 --- a/src/components/views/rooms/MemberTile.tsx +++ b/src/components/views/rooms/MemberTile.tsx @@ -33,6 +33,7 @@ import MemberAvatar from "./../avatars/MemberAvatar"; import DisambiguatedProfile from "../messages/DisambiguatedProfile"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import { E2EState } from "./E2EIcon"; +import { asyncSome } from "../../../utils/arrays"; interface IProps { member: RoomMember; @@ -127,15 +128,15 @@ export default class MemberTile extends React.Component { } const devices = cli.getStoredDevicesForUser(userId); - const anyDeviceUnverified = devices.some((device) => { + const anyDeviceUnverified = await asyncSome(devices, async (device) => { const { deviceId } = device; // For your own devices, we use the stricter check of cross-signing // verification to encourage everyone to trust their own devices via // cross-signing so that other users can then safely trust you. // For other people's devices, the more general verified check that // includes locally verified devices can be used. - const deviceTrust = cli.checkDeviceTrust(userId, deviceId); - return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified(); + const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId); + return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified()); }); this.setState({ e2eStatus: anyDeviceUnverified ? E2EState.Warning : E2EState.Verified, diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 1294531a926..1bd525c77e0 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -64,6 +64,7 @@ import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/ import { SdkContextClass } from "../../../contexts/SDKContext"; import { VoiceBroadcastInfoState } from "../../../voice-broadcast"; import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog"; +import { UIFeature } from "../../../settings/UIFeature"; let instanceCount = 0; @@ -640,7 +641,9 @@ export class MessageComposer extends React.Component { relation={this.props.relation} onRecordStartEndClick={this.onRecordStartEndClick} setStickerPickerOpen={this.setStickerPickerOpen} - showLocationButton={!window.electron} + showLocationButton={ + !window.electron && SettingsStore.getValue(UIFeature.LocationSharing) + } showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} collapseButtons={this.state.collapseButtons} diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index f8d13ac8d40..7b5fe1ecd79 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -68,11 +68,11 @@ export const OverflowMenuContext = createContext(null const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); - const { room, roomId, narrow } = useContext(RoomContext); + const { room, narrow } = useContext(RoomContext); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); - if (props.haveRecording) { + if (!matrixClient || !room || props.haveRecording) { return null; } @@ -96,7 +96,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { voiceRecordingButton(props, narrow), startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, - showLocationButton(props, room, roomId, matrixClient), + showLocationButton(props, room, matrixClient), ]; } else if (props.collapseButtons) { mainButtons = [ @@ -134,7 +134,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { voiceRecordingButton(props, narrow), startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, - showLocationButton(props, room, roomId, matrixClient), + showLocationButton(props, room, matrixClient), ]; moreButtons = []; } @@ -149,7 +149,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { }); return ( - + {mainButtons} {moreButtons.length > 0 && ( { } } -function showLocationButton( - props: IProps, - room: Room, - roomId: string, - matrixClient: MatrixClient, -): ReactElement | null { - const sender = room.getMember(matrixClient.getUserId()!); +function showLocationButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement | null { + const sender = room.getMember(matrixClient.getSafeUserId()); return props.showLocationButton && sender ? ( { event.preventDefault(); defaultDispatcher.dispatch({ action: "open_room_settings", - initial_tab_id: ROOM_SECURITY_TAB, + initial_tab_id: RoomSettingsTab.Security, }); } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 46a4221b894..53ae9b0537f 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -53,7 +53,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler"; import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; -import SdkConfig, { DEFAULTS } from "../../../SdkConfig"; +import SdkConfig from "../../../SdkConfig"; import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { useWidgets } from "../right_panel/RoomSummaryCard"; import { WidgetType } from "../../../widgets/WidgetType"; @@ -207,7 +207,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); - const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + const brand = SdkConfig.get("element_call").brand; menu = ( @@ -250,7 +250,7 @@ const CallButtons: FC = ({ room }) => { const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); const useElementCallExclusively = useMemo(() => { - return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively; + return SdkConfig.get("element_call").use_exclusively; }, []); const hasLegacyCall = useEventEmitterState( diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index cd31c7f89a3..8efa2a380f2 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -16,7 +16,7 @@ limitations under the License. import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; -import React, { ComponentType, createRef, ReactComponentElement, RefObject, SyntheticEvent } from "react"; +import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react"; import classNames from "classnames"; import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; @@ -111,8 +111,8 @@ type TagAestheticsMap = Partial<{ [tagId in TagID]: ITagAesthetics; }>; -const auxButtonContextMenuPosition = (handle: RefObject): MenuProps => { - const rect = handle.current.getBoundingClientRect(); +const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => { + const rect = handle.getBoundingClientRect(); return { chevronFace: ChevronFace.None, left: rect.left - 7, @@ -133,11 +133,11 @@ const DmAuxButton: React.FC = ({ tabIndex, dispatcher = default // eslint-disable-next-line no-constant-condition if (activeSpace && (showCreateRooms || showInviteUsers) && false) { let contextMenu: JSX.Element | undefined; - if (menuDisplayed) { + if (menuDisplayed && handle.current) { const canInvite = shouldShowSpaceInvite(activeSpace); contextMenu = ( - + {showCreateRooms && ( = ({ tabIndex }) => { return scAddRoomButton; let contextMenu: JSX.Element | null = null; - if (menuDisplayed) { + if (menuDisplayed && handle.current) { contextMenu = ( - + {contextMenuContent} ); @@ -574,6 +574,7 @@ export default class RoomList extends React.PureComponent { if (payload.action === Action.ViewRoomDelta) { const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload; const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!currentRoomId) return; const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread); if (room) { defaultDispatcher.dispatch({ diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 37ad8e3c62d..8b05dbf4710 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -151,7 +151,7 @@ export default class RoomPreviewBar extends React.Component { const result = await MatrixClientPeg.get().lookupThreePid( "email", this.props.invitedEmail, - identityAccessToken, + identityAccessToken!, ); this.setState({ invitedEmailMxid: result.mxid }); } catch (err) { @@ -216,7 +216,7 @@ export default class RoomPreviewBar extends React.Component { return {}; } const kickerMember = this.props.room?.currentState.getMember(myMember.events.member.getSender()); - const memberName = kickerMember ? kickerMember.name : myMember.events.member.getSender(); + const memberName = kickerMember?.name ?? myMember.events.member?.getSender(); const reason = myMember.events.member?.getContent().reason; return { memberName, reason }; } @@ -243,8 +243,8 @@ export default class RoomPreviewBar extends React.Component { if (!inviteEvent) { return null; } - const inviterUserId = inviteEvent.events.member.getSender(); - return room.currentState.getMember(inviterUserId); + const inviterUserId = inviteEvent.events.member?.getSender(); + return inviterUserId ? room.currentState.getMember(inviterUserId) : null; } private isDMInvite(): boolean { @@ -252,8 +252,8 @@ export default class RoomPreviewBar extends React.Component { if (!myMember) { return false; } - const memberContent = myMember.events.member.getContent(); - return memberContent.membership === "invite" && memberContent.is_direct; + const memberContent = myMember.events.member?.getContent(); + return memberContent?.membership === "invite" && memberContent.is_direct; } private makeScreenAfterLogin(): { screen: string; params: Record } { diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 19036d25083..5e33d6374d8 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -446,9 +446,11 @@ export default class RoomSublist extends React.Component { }; private onHeaderClick = (): void => { - const possibleSticky = this.headerButton.current.parentElement; - const sublist = possibleSticky.parentElement.parentElement; - const list = sublist.parentElement.parentElement; + const possibleSticky = this.headerButton.current?.parentElement; + const sublist = possibleSticky?.parentElement?.parentElement; + const list = sublist?.parentElement?.parentElement; + if (!possibleSticky || !list) return; + // the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky const listScrollTop = Math.round(list.scrollTop); const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index aeb910707d9..e19dce1588e 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -216,7 +216,8 @@ export class RoomTile extends React.PureComponent { return; } - const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); + const messagePreview = + (await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? undefined; this.setState({ messagePreview }); } diff --git a/src/components/views/rooms/SearchBar.tsx b/src/components/views/rooms/SearchBar.tsx index c75b340c29a..a4dbfe60ce6 100644 --- a/src/components/views/rooms/SearchBar.tsx +++ b/src/components/views/rooms/SearchBar.tsx @@ -121,6 +121,9 @@ export default class SearchBar extends React.Component { type="text" autoFocus={true} placeholder={_t("Search…")} + aria-label={ + this.state.scope === SearchScope.Room ? _t("Search this room") : _t("Search all rooms") + } onKeyDown={this.onSearchChange} /> = 0; i--) { @@ -443,8 +447,8 @@ export class SendMessageComposer extends React.Component(posthogEvent); @@ -480,7 +484,7 @@ export class SendMessageComposer extends React.Component kickLevel : false, - senderName: sender ? sender.name : this.props.event.getSender(), + senderName: sender?.name ?? this.props.event.getSender(), }; } diff --git a/src/components/views/rooms/WhoIsTypingTile.tsx b/src/components/views/rooms/WhoIsTypingTile.tsx index 3c23e76482c..5552fc5c691 100644 --- a/src/components/views/rooms/WhoIsTypingTile.tsx +++ b/src/components/views/rooms/WhoIsTypingTile.tsx @@ -91,7 +91,7 @@ export default class WhoIsTypingTile extends React.Component { private onRoomTimeline = (event: MatrixEvent, room?: Room): void => { if (room?.roomId === this.props.room.roomId) { - const userId = event.getSender(); + const userId = event.getSender()!; // remove user from usersTyping const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId); if (usersTyping.length !== this.state.usersTyping.length) { @@ -200,14 +200,15 @@ export default class WhoIsTypingTile extends React.Component { } public render(): React.ReactNode { - let usersTyping = this.state.usersTyping; - const stoppedUsersOnTimer = Object.keys(this.state.delayedStopTypingTimers).map((userId) => - this.props.room.getMember(userId), - ); + const usersTyping = [...this.state.usersTyping]; // append the users that have been reported not typing anymore // but have a timeout timer running so they can disappear // when a message comes in - usersTyping = usersTyping.concat(stoppedUsersOnTimer); + for (const userId in this.state.delayedStopTypingTimers) { + const member = this.props.room.getMember(userId); + if (member) usersTyping.push(member); + } + // sort them so the typing members don't change order when // moved to delayedStopTypingTimers usersTyping.sort((a, b) => compare(a.name, b.name)); diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index d68c5260641..c6abc1230b4 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -24,6 +24,7 @@ import { usePlainTextListeners } from "../hooks/usePlainTextListeners"; import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { ComposerFunctions } from "../types"; import { Editor } from "./Editor"; +import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; interface PlainTextComposerProps { disabled?: boolean; @@ -48,14 +49,23 @@ export function PlainTextComposer({ leftComponent, rightComponent, }: PlainTextComposerProps): JSX.Element { - const { ref, onInput, onPaste, onKeyDown, content, setContent } = usePlainTextListeners( - initialContent, - onChange, - onSend, - ); - const composerFunctions = useComposerFunctions(ref, setContent); - usePlainTextInitialization(initialContent, ref); - useSetCursorPosition(disabled, ref); + const { + ref: editorRef, + autocompleteRef, + onInput, + onPaste, + onKeyDown, + content, + setContent, + suggestion, + onSelect, + handleCommand, + handleMention, + } = usePlainTextListeners(initialContent, onChange, onSend); + + const composerFunctions = useComposerFunctions(editorRef, setContent); + usePlainTextInitialization(initialContent, editorRef); + useSetCursorPosition(disabled, editorRef); const { isFocused, onFocus } = useIsFocused(); const computedPlaceholder = (!content && placeholder) || undefined; @@ -68,15 +78,22 @@ export function PlainTextComposer({ onInput={onInput} onPaste={onPaste} onKeyDown={onKeyDown} + onSelect={onSelect} > + - {children?.(ref, composerFunctions)} + {children?.(editorRef, composerFunctions)}
    ); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index cf3e7d6be15..9fcad115f2c 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -33,6 +33,7 @@ import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event"; import { endEditing } from "../utils/editing"; import Autocomplete from "../../Autocomplete"; +import { handleEventWithAutocomplete } from "./utils"; export function useInputEventProcessor( onSend: () => void, @@ -91,7 +92,7 @@ function handleKeyboardEvent( editor: HTMLElement, roomContext: IRoomState, composerContext: ComposerContextState, - mxClient: MatrixClient, + mxClient: MatrixClient | undefined, autocompleteRef: React.RefObject, ): KeyboardEvent | null { const { editorStateTransfer } = composerContext; @@ -99,42 +100,15 @@ function handleKeyboardEvent( const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0; const action = getKeyBindingsManager().getMessageComposerAction(event); - const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide; - // we need autocomplete to take priority when it is open for using enter to select - if (autocompleteIsOpen) { - let handled = false; - const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); - const component = autocompleteRef.current; - if (component && component.countCompletions() > 0) { - switch (autocompleteAction) { - case KeyBindingAction.ForceCompleteAutocomplete: - case KeyBindingAction.CompleteAutocomplete: - autocompleteRef.current.onConfirmCompletion(); - handled = true; - break; - case KeyBindingAction.PrevSelectionInAutocomplete: - autocompleteRef.current.moveSelection(-1); - handled = true; - break; - case KeyBindingAction.NextSelectionInAutocomplete: - autocompleteRef.current.moveSelection(1); - handled = true; - break; - case KeyBindingAction.CancelAutocomplete: - autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent); - handled = true; - break; - default: - break; // don't return anything, allow event to pass through - } - } + const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event); + if (isHandledByAutocomplete) { + return event; + } - if (handled) { - event.preventDefault(); - event.stopPropagation(); - return event; - } + // taking the client from context gives us an client | undefined type, narrow it down + if (mxClient === undefined) { + return null; } switch (action) { diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index f8b045ad657..8369d2684fb 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -15,9 +15,13 @@ limitations under the License. */ import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react"; +import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { IS_MAC, Key } from "../../../../../Keyboard"; +import Autocomplete from "../../Autocomplete"; +import { handleEventWithAutocomplete } from "./utils"; +import { useSuggestion } from "./useSuggestion"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -33,20 +37,44 @@ function amendInnerHtml(text: string): string { .replace(/<\/div>/g, ""); } +/** + * React hook which generates all of the listeners and the ref to be attached to the editor. + * + * Also returns pieces of state and utility functions that are required for use in other hooks + * and by the autocomplete component. + * + * @param initialContent - the content of the editor when it is first mounted + * @param onChange - called whenever there is change in the editor content + * @param onSend - called whenever the user sends the message + * @returns + * - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor + * * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component + * - `content`: state representing the editor's current text content + * - `setContent`: the setter function for `content` + * - `onInput`, `onPaste`, `onKeyDown`: handlers for input, paste and keyDown events + * - the output from the {@link useSuggestion} hook + */ export function usePlainTextListeners( initialContent?: string, onChange?: (content: string) => void, onSend?: () => void, ): { - ref: RefObject; + ref: RefObject; + autocompleteRef: React.RefObject; content?: string; onInput(event: SyntheticEvent): void; onPaste(event: SyntheticEvent): void; onKeyDown(event: KeyboardEvent): void; setContent(text: string): void; + handleMention: (link: string, text: string, attributes: Attributes) => void; + handleCommand: (text: string) => void; + onSelect: (event: SyntheticEvent) => void; + suggestion: MappedSuggestion | null; } { const ref = useRef(null); + const autocompleteRef = useRef(null); const [content, setContent] = useState(initialContent); + const send = useCallback(() => { if (ref.current) { ref.current.innerHTML = ""; @@ -62,6 +90,11 @@ export function usePlainTextListeners( [onChange], ); + // For separation of concerns, the suggestion handling is kept in a separate hook but is + // nested here because we do need to be able to update the `content` state in this hook + // when a user selects a suggestion from the autocomplete menu + const { suggestion, onSelect, handleCommand, handleMention } = useSuggestion(ref, setText); + const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend"); const onInput = useCallback( (event: SyntheticEvent) => { @@ -76,6 +109,13 @@ export function usePlainTextListeners( const onKeyDown = useCallback( (event: KeyboardEvent) => { + // we need autocomplete to take priority when it is open for using enter to select + const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event); + if (isHandledByAutocomplete) { + return; + } + + // resume regular flow if (event.key === Key.ENTER) { // TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey; @@ -95,8 +135,20 @@ export function usePlainTextListeners( } } }, - [enterShouldSend, send], + [autocompleteRef, enterShouldSend, send], ); - return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText }; + return { + ref, + autocompleteRef, + onInput, + onPaste: onInput, + onKeyDown, + content, + setContent: setText, + suggestion, + onSelect, + handleCommand, + handleMention, + }; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts new file mode 100644 index 00000000000..e1db110847f --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -0,0 +1,192 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; +import { SyntheticEvent, useState } from "react"; + +/** + * Information about the current state of the `useSuggestion` hook. + */ +export type Suggestion = MappedSuggestion & { + /** + * The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete + * component but more information is required to allow manipulation of the correct part of the DOM + * when selecting an option from the autocomplete. These three pieces of information allow us to + * do that. + */ + node: Node; + startOffset: number; + endOffset: number; +}; +type SuggestionState = Suggestion | null; + +/** + * React hook to allow tracking and replacing of mentions and commands in a div element + * + * @param editorRef - a ref to the div that is the composer textbox + * @param setText - setter function to set the content of the composer + * @returns + * - `handleMention`: TODO a function that will insert @ or # mentions which are selected from + * the autocomplete into the composer + * - `handleCommand`: a function that will replace the content of the composer with the given replacement text. + * Can be used to process autocomplete of slash commands + * - `onSelect`: a selection change listener to be attached to the plain text composer + * - `suggestion`: if the cursor is inside something that could be interpreted as a command or a mention, + * this will be an object representing that command or mention, otherwise it is null + */ + +export function useSuggestion( + editorRef: React.RefObject, + setText: (text: string) => void, +): { + handleMention: (link: string, text: string, attributes: Attributes) => void; + handleCommand: (text: string) => void; + onSelect: (event: SyntheticEvent) => void; + suggestion: MappedSuggestion | null; +} { + const [suggestion, setSuggestion] = useState(null); + + // TODO handle the mentions (@user, #room etc) + const handleMention = (): void => {}; + + // We create a `seletionchange` handler here because we need to know when the user has moved the cursor, + // we can not depend on input events only + const onSelect = (): void => processSelectionChange(editorRef, suggestion, setSuggestion); + + const handleCommand = (replacementText: string): void => + processCommand(replacementText, suggestion, setSuggestion, setText); + + return { + suggestion: mapSuggestion(suggestion), + handleCommand, + handleMention, + onSelect, + }; +} + +/** + * Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null) + * + * @param suggestion - the suggestion that is the JS equivalent of the rust model's representation + * @returns - null if the input is null, a MappedSuggestion if the input is non-null + */ +export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => { + if (suggestion === null) { + return null; + } else { + const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion; + return mappedSuggestion; + } +}; + +/** + * Replaces the relevant part of the editor text with the replacement text after a command is selected + * from the autocomplete. + * + * @param replacementText - the text that we will insert into the DOM + * @param suggestion - representation of the part of the DOM that will be replaced + * @param setSuggestion - setter function to set the suggestion state + * @param setText - setter function to set the content of the composer + */ +export const processCommand = ( + replacementText: string, + suggestion: SuggestionState, + setSuggestion: React.Dispatch>, + setText: (text: string) => void, +): void => { + // if we do not have a suggestion, return early + if (suggestion === null) { + return; + } + + const { node } = suggestion; + + // for a command, we know we start at the beginning of the text node, so build the replacement + // string (note trailing space) and manually adjust the node's textcontent + const newContent = `${replacementText} `; + node.textContent = newContent; + + // then set the cursor to the end of the node, update the `content` state in the usePlainTextListeners + // hook and clear the suggestion from state + document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length); + setText(newContent); + setSuggestion(null); +}; + +/** + * When the selection changes inside the current editor, check to see if the cursor is inside + * something that could require the autocomplete to be opened and update the suggestion state + * if so + * TODO expand this to handle mentions + * + * @param editorRef - ref to the composer + * @param suggestion - the current suggestion state + * @param setSuggestion - the setter for the suggestion state + */ +export const processSelectionChange = ( + editorRef: React.RefObject, + suggestion: SuggestionState, + setSuggestion: React.Dispatch>, +): void => { + const selection = document.getSelection(); + + // return early if we do not have a current editor ref with a cursor selection inside a text node + if ( + editorRef.current === null || + selection === null || + !selection.isCollapsed || + selection.anchorNode?.nodeName !== "#text" + ) { + return; + } + + // here we have established that both anchor and focus nodes in the selection are + // the same node, so rename to `currentNode` for later use + const { anchorNode: currentNode } = selection; + + // first check is that the text node is the first text node of the editor, as adding paragraphs can result + // in nested

    tags inside the editor

    + const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); + + // if we're not in the first text node or we have no text content, return + if (currentNode !== firstTextNode || currentNode.textContent === null) { + return; + } + + // it's a command if: + // it is the first textnode AND + // it starts with /, not // AND + // then has letters all the way up to the end of the textcontent + const commandRegex = /^\/(\w*)$/; + const commandMatches = currentNode.textContent.match(commandRegex); + + // if we don't have any matches, return, clearing the suggeston state if it is non-null + if (commandMatches === null) { + if (suggestion !== null) { + setSuggestion(null); + } + return; + } else { + setSuggestion({ + keyChar: "/", + type: "command", + text: commandMatches[1], + node: selection.anchorNode, + startOffset: 0, + endOffset: currentNode.textContent.length, + }); + } +}; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 83a18fb55b8..636b5d2bf2a 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MutableRefObject } from "react"; +import { MutableRefObject, RefObject } from "react"; import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { IRoomState } from "../../../../structures/RoomView"; +import Autocomplete from "../../Autocomplete"; +import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts"; export function focusComposer( composerElement: MutableRefObject, @@ -51,3 +54,59 @@ export function setCursorPositionAtTheEnd(element: HTMLElement): void { element.focus(); } + +/** + * When the autocomplete modal is open we need to be able to properly + * handle events that are dispatched. This allows the user to move the selection + * in the autocomplete and select using enter. + * + * @param autocompleteRef - a ref to the autocomplete of interest + * @param event - the keyboard event that has been dispatched + * @returns boolean - whether or not the autocomplete has handled the event + */ +export function handleEventWithAutocomplete( + autocompleteRef: RefObject, + // we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer + event: KeyboardEvent | React.KeyboardEvent, +): boolean { + const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide; + + if (!autocompleteIsOpen) { + return false; + } + + let handled = false; + const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); + const component = autocompleteRef.current; + + if (component && component.countCompletions() > 0) { + switch (autocompleteAction) { + case KeyBindingAction.ForceCompleteAutocomplete: + case KeyBindingAction.CompleteAutocomplete: + autocompleteRef.current.onConfirmCompletion(); + handled = true; + break; + case KeyBindingAction.PrevSelectionInAutocomplete: + autocompleteRef.current.moveSelection(-1); + handled = true; + break; + case KeyBindingAction.NextSelectionInAutocomplete: + autocompleteRef.current.moveSelection(1); + handled = true; + break; + case KeyBindingAction.CancelAutocomplete: + autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent); + handled = true; + break; + default: + break; // don't return anything, allow event to pass through + } + } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + + return handled; +} diff --git a/src/components/views/settings/ChangePassword.tsx b/src/components/views/settings/ChangePassword.tsx index 8ea83263250..0a326468781 100644 --- a/src/components/views/settings/ChangePassword.tsx +++ b/src/components/views/settings/ChangePassword.tsx @@ -24,7 +24,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; -import { _t, _td } from "../../../languageHandler"; +import { UserFriendlyError, _t, _td } from "../../../languageHandler"; import Modal from "../../../Modal"; import PassphraseField from "../auth/PassphraseField"; import { PASSWORD_MIN_SCORE } from "../auth/RegistrationForm"; @@ -48,7 +48,7 @@ interface IProps { /** Was one or more other devices logged out whilst changing the password */ didLogoutOutOtherDevices: boolean; }) => void; - onError: (error: { error: string }) => void; + onError: (error: Error) => void; rowClassName?: string; buttonClassName?: string; buttonKind?: string; @@ -183,7 +183,16 @@ export default class ChangePassword extends React.Component { } }, (err) => { - this.props.onError(err); + if (err instanceof Error) { + this.props.onError(err); + } else { + this.props.onError( + new UserFriendlyError("Error while changing password: %(error)s", { + error: String(err), + cause: undefined, + }), + ); + } }, ) .finally(() => { @@ -196,15 +205,19 @@ export default class ChangePassword extends React.Component { }); } - private checkPassword(oldPass: string, newPass: string, confirmPass: string): { error: string } | undefined { + /** + * Checks the `newPass` and throws an error if it is unacceptable. + * @param oldPass The old password + * @param newPass The new password that the user is trying to be set + * @param confirmPass The confirmation password where the user types the `newPass` + * again for confirmation and should match the `newPass` before we accept their new + * password. + */ + private checkPassword(oldPass: string, newPass: string, confirmPass: string): void { if (newPass !== confirmPass) { - return { - error: _t("New passwords don't match"), - }; + throw new UserFriendlyError("New passwords don't match"); } else if (!newPass || newPass.length === 0) { - return { - error: _t("Passwords can't be empty"), - }; + throw new UserFriendlyError("Passwords can't be empty"); } } @@ -307,11 +320,24 @@ export default class ChangePassword extends React.Component { const oldPassword = this.state.oldPassword; const newPassword = this.state.newPassword; const confirmPassword = this.state.newPasswordConfirm; - const err = this.checkPassword(oldPassword, newPassword, confirmPassword); - if (err) { - this.props.onError(err); - } else { + try { + // TODO: We can remove this check (but should add some Cypress tests to + // sanity check this flow). This logic is redundant with the input field + // validation we do and `verifyFieldsBeforeSubmit()` above. See + // https://github.com/matrix-org/matrix-react-sdk/pull/10615#discussion_r1167364214 + this.checkPassword(oldPassword, newPassword, confirmPassword); return this.onChangePassword(oldPassword, newPassword); + } catch (err) { + if (err instanceof Error) { + this.props.onError(err); + } else { + this.props.onError( + new UserFriendlyError("Error while changing password: %(error)s", { + error: String(err), + cause: undefined, + }), + ); + } } }; diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index e3f62d4ba26..d3926d954fa 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -243,36 +243,34 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
    {_t("Advanced")} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + +
    {_t("Cross-signing public keys:")}{crossSigningPublicKeysOnDevice ? _t("in memory") : _t("not found")}
    {_t("Cross-signing private keys:")} - {crossSigningPrivateKeysInStorage - ? _t("in secret storage") - : _t("not found in storage")} -
    {_t("Master private key:")}{masterPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Self signing private key:")}{selfSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("User signing private key:")}{userSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Homeserver feature support:")}{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}
    {_t("Cross-signing public keys:")}{crossSigningPublicKeysOnDevice ? _t("in memory") : _t("not found")}
    {_t("Cross-signing private keys:")} + {crossSigningPrivateKeysInStorage + ? _t("in secret storage") + : _t("not found in storage")} +
    {_t("Master private key:")}{masterPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Self signing private key:")}{selfSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("User signing private key:")}{userSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Homeserver feature support:")}{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}
    {errorSection} diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index 34b52e405ee..79ddad2544e 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -75,22 +75,20 @@ export default class CryptographyPanel extends React.Component {
    {_t("Cryptography")} - - - - - - - - - - + + + + + + + +
    {_t("Session ID:")} - {deviceId} -
    {_t("Session key:")} - - {identityKey} - -
    {_t("Session ID:")} + {deviceId} +
    {_t("Session key:")} + + {identityKey} + +
    {importExportButtons} {noSendUnverifiedSetting} diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index 603438e7e5f..06bdc5fea99 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -26,14 +26,15 @@ import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { deleteDevicesWithInteractiveAuth } from "./devices/deleteDevices"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { isDeviceVerified } from "../../../utils/device/isDeviceVerified"; +import { fetchExtendedDeviceInformation } from "./devices/useOwnDevices"; +import { DevicesDictionary, ExtendedDevice } from "./devices/types"; interface IProps { className?: string; } interface IState { - devices: IMyDevice[]; + devices?: DevicesDictionary; deviceLoadError?: string; selectedDevices: string[]; deleting?: boolean; @@ -47,7 +48,6 @@ export default class DevicesPanel extends React.Component { public constructor(props: IProps) { super(props); this.state = { - devices: [], selectedDevices: [], }; this.loadDevices = this.loadDevices.bind(this); @@ -70,18 +70,16 @@ export default class DevicesPanel extends React.Component { private loadDevices(): void { const cli = this.context; - cli.getDevices().then( - (resp) => { + fetchExtendedDeviceInformation(cli).then( + (devices) => { if (this.unmounted) { return; } this.setState((state, props) => { - const deviceIds = resp.devices.map((device) => device.device_id); - const selectedDevices = state.selectedDevices.filter((deviceId) => deviceIds.includes(deviceId)); return { - devices: resp.devices || [], - selectedDevices, + devices: devices, + selectedDevices: state.selectedDevices.filter((deviceId) => devices.hasOwnProperty(deviceId)), }; }); }, @@ -119,10 +117,6 @@ export default class DevicesPanel extends React.Component { return idA < idB ? -1 : idA > idB ? 1 : 0; } - private isDeviceVerified(device: IMyDevice): boolean | null { - return isDeviceVerified(this.context, device.device_id); - } - private onDeviceSelectionToggled = (device: IMyDevice): void => { if (this.unmounted) { return; @@ -205,15 +199,15 @@ export default class DevicesPanel extends React.Component { } }; - private renderDevice = (device: IMyDevice): JSX.Element => { - const myDeviceId = this.context.getDeviceId(); - const myDevice = this.state.devices.find((device) => device.device_id === myDeviceId); + private renderDevice = (device: ExtendedDevice): JSX.Element => { + const myDeviceId = this.context.getDeviceId()!; + const myDevice = this.state.devices?.[myDeviceId]; const isOwnDevice = device.device_id === myDeviceId; // If our own device is unverified, it can't verify other // devices, it can only request verification for itself - const canBeVerified = (myDevice && this.isDeviceVerified(myDevice)) || isOwnDevice; + const canBeVerified = (myDevice && myDevice.isVerified) || isOwnDevice; return ( { device={device} selected={this.state.selectedDevices.includes(device.device_id)} isOwnDevice={isOwnDevice} - verified={this.isDeviceVerified(device)} + verified={device.isVerified} canBeVerified={canBeVerified} onDeviceChange={this.loadDevices} onDeviceToggled={this.onDeviceSelectionToggled} @@ -242,21 +236,21 @@ export default class DevicesPanel extends React.Component { return ; } - const myDeviceId = this.context.getDeviceId(); - const myDevice = devices.find((device) => device.device_id === myDeviceId); + const myDeviceId = this.context.getDeviceId()!; + const myDevice = devices[myDeviceId]; if (!myDevice) { return loadError; } - const otherDevices = devices.filter((device) => device.device_id !== myDeviceId); + const otherDevices = Object.values(devices).filter((device) => device.device_id !== myDeviceId); otherDevices.sort(this.deviceCompare); - const verifiedDevices: IMyDevice[] = []; - const unverifiedDevices: IMyDevice[] = []; - const nonCryptoDevices: IMyDevice[] = []; + const verifiedDevices: ExtendedDevice[] = []; + const unverifiedDevices: ExtendedDevice[] = []; + const nonCryptoDevices: ExtendedDevice[] = []; for (const device of otherDevices) { - const verified = this.isDeviceVerified(device); + const verified = device.isVerified; if (verified === true) { verifiedDevices.push(device); } else if (verified === false) { @@ -266,7 +260,7 @@ export default class DevicesPanel extends React.Component { } } - const section = (trustIcon: JSX.Element, title: string, deviceList: IMyDevice[]): JSX.Element => { + const section = (trustIcon: JSX.Element, title: string, deviceList: ExtendedDevice[]): JSX.Element => { if (deviceList.length === 0) { return ; } diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index 5706aa4dfaa..d478639dc0a 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -31,7 +31,7 @@ import { upgradeRoom } from "../../../utils/RoomUpgrade"; import { arrayHasDiff } from "../../../utils/arrays"; import { useLocalEcho } from "../../../hooks/useLocalEcho"; import dis from "../../../dispatcher/dispatcher"; -import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog"; +import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog"; import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions"; @@ -320,7 +320,7 @@ const JoinRuleSettings: React.FC = ({ // open new settings on this tab dis.dispatch({ action: "open_room_settings", - initial_tab_id: ROOM_SECURITY_TAB, + initial_tab_id: RoomSettingsTab.Security, }); }, }); diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 747378684c0..1df87008c78 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; @@ -123,7 +123,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { this.getUpdatedDiagnostics(); try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo!); if (this.unmounted) return; this.setState({ loading: false, @@ -231,9 +231,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { sessionsRemaining, } = this.state; - let statusDescription; - let extraDetailsTableRows; - let extraDetails; + let statusDescription: JSX.Element; + let extraDetailsTableRows: JSX.Element | undefined; + let extraDetails: JSX.Element | undefined; const actions: JSX.Element[] = []; if (error) { statusDescription =
    {_t("Unable to load key backup status")}
    ; @@ -267,7 +267,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { restoreButtonCaption = _t("Connect this session to Key Backup"); } - let uploadStatus; + let uploadStatus: ReactNode; if (!MatrixClientPeg.get().getKeyBackupEnabled()) { // No upload status to show when backup disabled. uploadStatus = ""; @@ -285,7 +285,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { ); } - let backupSigStatuses: React.ReactNode = backupSigStatus.sigs.map((sig, i) => { + let backupSigStatuses: React.ReactNode = backupSigStatus?.sigs.map((sig, i) => { const deviceName = sig.device ? sig.device.getDisplayName() || sig.device.deviceId : null; const validity = (sub: string): JSX.Element => ( @@ -295,7 +295,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { const verify = (sub: string): JSX.Element => ( { {}, { validity }, ); - } else if (sig.valid && sig.deviceTrust.isVerified()) { + } else if (sig.valid && sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + "verified session ", @@ -361,7 +361,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { {}, { validity, verify, device }, ); - } else if (!sig.valid && sig.deviceTrust.isVerified()) { + } else if (!sig.valid && sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has an invalid signature from " + "verified session ", @@ -391,11 +391,11 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { extraDetailsTableRows = ( <> - {_t("Backup version:")} + {_t("Backup version:")} {backupInfo.version} - {_t("Algorithm:")} + {_t("Algorithm:")} {backupInfo.algorithm} @@ -460,7 +460,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { } } - let actionRow; + let actionRow: JSX.Element | undefined; if (actions.length) { actionRow =
    {actions}
    ; } @@ -478,28 +478,26 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
    {_t("Advanced")} - - - - - - - - - - - - - - - - - - {extraDetailsTableRows} - + + + + + + + + + + + + + + + + + {extraDetailsTableRows}
    {_t("Backup key stored:")}{backupKeyStored === true ? _t("in secret storage") : _t("not stored")}
    {_t("Backup key cached:")} - {backupKeyCached ? _t("cached locally") : _t("not found locally")} - {backupKeyWellFormedText} -
    {_t("Secret storage public key:")}{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
    {_t("Secret storage:")}{secretStorageReady ? _t("ready") : _t("not ready")}
    {_t("Backup key stored:")}{backupKeyStored === true ? _t("in secret storage") : _t("not stored")}
    {_t("Backup key cached:")} + {backupKeyCached ? _t("cached locally") : _t("not found locally")} + {backupKeyWellFormedText} +
    {_t("Secret storage public key:")}{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
    {_t("Secret storage:")}{secretStorageReady ? _t("ready") : _t("not ready")}
    {extraDetails}
    diff --git a/src/components/views/settings/account/EmailAddresses.tsx b/src/components/views/settings/account/EmailAddresses.tsx index 5605bfacb66..c90e1c2768c 100644 --- a/src/components/views/settings/account/EmailAddresses.tsx +++ b/src/components/views/settings/account/EmailAddresses.tsx @@ -276,11 +276,11 @@ export default class EmailAddresses extends React.Component { return (
    {existingEmailElements} -
    + { - async (auth?: IAuthData): Promise => { - return matrixClient.deleteMultipleDevices(deviceIds, auth); + async (auth: IAuthDict | null): Promise => { + return matrixClient.deleteMultipleDevices(deviceIds, auth ?? undefined); }; export const deleteDevicesWithInteractiveAuth = async ( @@ -38,7 +38,7 @@ export const deleteDevicesWithInteractiveAuth = async ( return; } try { - await makeDeleteRequest(matrixClient, deviceIds)(); + await makeDeleteRequest(matrixClient, deviceIds)(null); // no interactive auth needed onFinished(true, undefined); } catch (error) { diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index ffd0e00eba2..e4753cad43f 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -50,24 +50,26 @@ const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyD }; }; -const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise => { +/** + * Fetch extended details of the user's own devices + * + * @param matrixClient - Matrix Client + * @returns A dictionary mapping from device ID to ExtendedDevice + */ +export async function fetchExtendedDeviceInformation(matrixClient: MatrixClient): Promise { const { devices } = await matrixClient.getDevices(); - const devicesDict = devices.reduce( - (acc, device: IMyDevice) => ({ - ...acc, - [device.device_id]: { - ...device, - isVerified: isDeviceVerified(matrixClient, device.device_id), - ...parseDeviceExtendedInformation(matrixClient, device), - ...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]), - }, - }), - {}, - ); - + const devicesDict: DevicesDictionary = {}; + for (const device of devices) { + devicesDict[device.device_id] = { + ...device, + isVerified: await isDeviceVerified(matrixClient, device.device_id), + ...parseDeviceExtendedInformation(matrixClient, device), + ...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]), + }; + } return devicesDict; -}; +} export enum OwnDevicesError { Unsupported = "Unsupported", @@ -112,7 +114,7 @@ export const useOwnDevices = (): DevicesState => { const refreshDevices = useCallback(async (): Promise => { setIsLoadingDeviceList(true); try { - const devices = await fetchDevicesWithVerification(matrixClient); + const devices = await fetchExtendedDeviceInformation(matrixClient); setDevices(devices); const { pushers } = await matrixClient.getPushers(); diff --git a/src/components/views/settings/shared/SettingsSection.tsx b/src/components/views/settings/shared/SettingsSection.tsx new file mode 100644 index 00000000000..1fc00905653 --- /dev/null +++ b/src/components/views/settings/shared/SettingsSection.tsx @@ -0,0 +1,48 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes } from "react"; + +import Heading from "../../typography/Heading"; + +export interface SettingsSectionProps extends HTMLAttributes { + heading: string | React.ReactNode; + children?: React.ReactNode; +} + +/** + * A section of settings content + * A SettingsTab may contain one or more SettingsSections + * Eg: + * ``` + * + * + * + * // profile settings form + * + * + * // account settings + * + * + * + * ``` + */ +export const SettingsSection: React.FC = ({ heading, children, ...rest }) => ( +
    + {typeof heading === "string" ? {heading} : <>{heading}} +
    {children}
    +
    +); diff --git a/src/components/views/settings/tabs/SettingsTab.tsx b/src/components/views/settings/tabs/SettingsTab.tsx index 57b29f49c4f..e9f25ec7406 100644 --- a/src/components/views/settings/tabs/SettingsTab.tsx +++ b/src/components/views/settings/tabs/SettingsTab.tsx @@ -15,16 +15,30 @@ limitations under the License. */ import React from "react"; -import Heading from "../../typography/Heading"; - export interface SettingsTabProps { - heading: string; children?: React.ReactNode; } -const SettingsTab: React.FC = ({ heading, children }) => ( +/** + * Container for a tab of settings panel content + * Should contain one or more SettingsSection + * Settings width, padding and spacing between sections + * Eg: + * ``` + * + * + * + * // profile settings form + * + * + * // account settings + * + * + * + * ``` + */ +const SettingsTab: React.FC = ({ children }) => (
    - {heading}
    {children}
    ); diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index 869c51c9494..e8ce957c3b5 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -16,9 +16,9 @@ limitations under the License. import React from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../../languageHandler"; -import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton"; import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog"; import Modal from "../../../../../Modal"; @@ -29,7 +29,7 @@ import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayl import SettingsStore from "../../../../../settings/SettingsStore"; interface IProps { - roomId: string; + room: Room; closeSettingsFn(): void; } @@ -64,8 +64,8 @@ export default class AdvancedRoomSettingsTab extends React.Component { + const room = this.props.room; + room.getRecommendedVersion().then((v) => { const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, ""); const additionalStateChanges: Partial = {}; @@ -85,8 +85,7 @@ export default class AdvancedRoomSettingsTab extends React.Component { - const room = MatrixClientPeg.get().getRoom(this.props.roomId); - Modal.createDialog(RoomUpgradeDialog, { room }); + Modal.createDialog(RoomUpgradeDialog, { room: this.props.room }); }; private onOldRoomClicked = (e: ButtonEvent): void => { @@ -105,12 +104,11 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("This room is not accessible by remote Matrix servers")}
    ; } @@ -143,9 +141,9 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("Advanced")}
    - {room?.isSpaceRoom() ? _t("Space information") : _t("Room information")} + {room.isSpaceRoom() ? _t("Space information") : _t("Room information")}
    {_t("Internal room ID")} - this.props.roomId}>{this.props.roomId} + this.props.room.roomId}> + {this.props.room.roomId} +
    {unfederatableSection}
    @@ -172,7 +172,7 @@ export default class AdvancedRoomSettingsTab extends React.Component{_t("Room version")}
    {_t("Room version:")}  - {room?.getVersion()} + {room.getVersion()}
    {oldRoomLink} {roomUpgradeButton} diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx index 4eb676f4113..8731cee0a6a 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx @@ -30,7 +30,7 @@ const BRIDGE_EVENT_TYPES = [ const BRIDGES_LINK = "https://matrix.org/bridges/"; interface IProps { - roomId: string; + room: Room; } export default class BridgeSettingsTab extends React.Component { @@ -51,9 +51,8 @@ export default class BridgeSettingsTab extends React.Component { public render(): React.ReactNode { // This settings tab will only be invoked if the following function returns more // than 0 events, so no validation is needed at this stage. - const bridgeEvents = BridgeSettingsTab.getBridgeStateEvents(this.props.roomId); - const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); + const bridgeEvents = BridgeSettingsTab.getBridgeStateEvents(this.props.room.roomId); + const room = this.props.room; let content: JSX.Element; if (bridgeEvents.length > 0) { diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 9c47a7432e6..a915aa42e38 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ContextType } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../../languageHandler"; import RoomProfileSettings from "../../../room_settings/RoomProfileSettings"; @@ -28,7 +29,7 @@ import AliasSettings from "../../../room_settings/AliasSettings"; import PosthogTrackers from "../../../../../PosthogTrackers"; interface IProps { - roomId: string; + room: Room; } interface IState { @@ -50,7 +51,7 @@ export default class GeneralRoomSettingsTab extends React.Component { dis.dispatch({ action: "leave_room", - room_id: this.props.roomId, + room_id: this.props.room.roomId, }); PosthogTrackers.trackInteraction("WebRoomSettingsLeaveButton", ev); @@ -58,17 +59,18 @@ export default class GeneralRoomSettingsTab extends React.Component : null; + const urlPreviewSettings = SettingsStore.getValue(UIFeature.URLPreviews) ? ( + + ) : null; let leaveSection; - if (room?.getMyMembership() === "join") { + if (room.getMyMembership() === "join") { leaveSection = ( <> {_t("Leave room")} @@ -85,12 +87,12 @@ export default class GeneralRoomSettingsTab extends React.Component
    {_t("General")}
    - +
    {_t("Room Addresses")}
    ) { super(props, context); - this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)); + this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!); let currentSound = "default"; const soundData = Notifier.getSoundForRoom(this.props.roomId); diff --git a/src/components/views/settings/tabs/room/PollHistoryTab.tsx b/src/components/views/settings/tabs/room/PollHistoryTab.tsx index c1866d3b0df..8c162859b29 100644 --- a/src/components/views/settings/tabs/room/PollHistoryTab.tsx +++ b/src/components/views/settings/tabs/room/PollHistoryTab.tsx @@ -15,23 +15,20 @@ limitations under the License. */ import React, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; import { PollHistory } from "../../../polls/pollHistory/PollHistory"; import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; interface IProps { - roomId: string; + room: Room; onFinished: () => void; } -export const PollHistoryTab: React.FC = ({ roomId, onFinished }) => { +export const PollHistoryTab: React.FC = ({ room, onFinished }) => { const matrixClient = useContext(MatrixClientContext); - const room = matrixClient.getRoom(roomId); - if (!room) { - return null; - } - const permalinkCreator = new RoomPermalinkCreator(room, roomId); + const permalinkCreator = new RoomPermalinkCreator(room, room.roomId); return (
    diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 743cceff75d..3f2c6d65fe0 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -22,6 +22,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { throttle, get } from "lodash"; import { compare } from "matrix-js-sdk/src/utils"; import { IContent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t, _td } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; @@ -129,7 +130,7 @@ export class BannedUser extends React.Component { } interface IProps { - roomId: string; + room: Room; } export default class RolesRoomSettingsTab extends React.Component { @@ -145,7 +146,7 @@ export default class RolesRoomSettingsTab extends React.Component { } private onRoomStateUpdate = (state: RoomState): void => { - if (state.roomId !== this.props.roomId) return; + if (state.roomId !== this.props.room.roomId) return; this.onThisRoomMembership(); }; @@ -171,8 +172,8 @@ export default class RolesRoomSettingsTab extends React.Component { private onPowerLevelsChanged = (value: number, powerLevelKey: string): void => { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const room = this.props.room; + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case @@ -186,7 +187,7 @@ export default class RolesRoomSettingsTab extends React.Component { plContent["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value; } else { const keyPath = powerLevelKey.split("."); - let parentObj: IContent | undefined; + let parentObj: IContent = {}; let currentObj = plContent; for (const key of keyPath) { if (!currentObj[key]) { @@ -198,7 +199,7 @@ export default class RolesRoomSettingsTab extends React.Component { parentObj[keyPath[keyPath.length - 1]] = value; } - client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { + client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { logger.error(e); Modal.createDialog(ErrorDialog, { @@ -213,8 +214,8 @@ export default class RolesRoomSettingsTab extends React.Component { private onUserPowerLevelChanged = (value: number, powerLevelKey: string): void => { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const room = this.props.room; + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); let plContent = plEvent?.getContent() ?? {}; // Clone the power levels just in case @@ -224,7 +225,7 @@ export default class RolesRoomSettingsTab extends React.Component { if (!plContent["users"]) plContent["users"] = {}; plContent["users"][powerLevelKey] = value; - client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { + client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => { logger.error(e); Modal.createDialog(ErrorDialog, { @@ -239,12 +240,12 @@ export default class RolesRoomSettingsTab extends React.Component { public render(): React.ReactNode { const client = MatrixClientPeg.get(); - const room = client.getRoom(this.props.roomId); - const isSpaceRoom = room?.isSpaceRoom(); + const room = this.props.room; + const isSpaceRoom = room.isSpaceRoom(); - const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); const plContent = plEvent ? plEvent.getContent() || {} : {}; - const canChangeLevels = room?.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client); + const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client); const plEventsToLabels: Record = { // These will be translated for us later. @@ -392,7 +393,7 @@ export default class RolesRoomSettingsTab extends React.Component { } } - const banned = room?.getMembersWithMembership("ban"); + const banned = room.getMembersWithMembership("ban"); let bannedUsersSection: JSX.Element | undefined; if (banned?.length) { const canBanUsers = currentUserLevel >= banLevel; @@ -401,16 +402,16 @@ export default class RolesRoomSettingsTab extends React.Component {
      {banned.map((member) => { const banEvent = member.events.member?.getContent(); - const sender = room?.getMember(member.events.member.getSender()); - let bannedBy = member.events.member?.getSender(); // start by falling back to mxid - if (sender) bannedBy = sender.name; + const bannedById = member.events.member?.getSender(); + const sender = bannedById ? room.getMember(bannedById) : undefined; + const bannedBy = sender?.name || bannedById; // fallback to mxid return ( ); })} @@ -443,7 +444,7 @@ export default class RolesRoomSettingsTab extends React.Component { .filter(Boolean); // hide the power level selector for enabling E2EE if it the room is already encrypted - if (client.isRoomEncrypted(this.props.roomId)) { + if (client.isRoomEncrypted(this.props.room.roomId)) { delete eventsLevels[EventType.RoomEncryption]; } @@ -481,9 +482,7 @@ export default class RolesRoomSettingsTab extends React.Component {
      {_t("Roles & Permissions")}
      {privilegedUsersSection} - {canChangeLevels && room !== null && ( - - )} + {canChangeLevels && } {mutedUsersSection} {bannedUsersSection} void; } @@ -61,7 +62,7 @@ export default class SecurityRoomSettingsTab extends React.Component) { super(props, context); - const state = context.getRoom(this.props.roomId)?.currentState; + const state = this.props.room.currentState; this.state = { guestAccess: this.pullContentPropertyFromEvent( @@ -75,7 +76,7 @@ export default class SecurityRoomSettingsTab extends React.Component => { - if (this.context.getRoom(this.props.roomId)?.getJoinRule() === JoinRule.Public) { + if (this.props.room.getJoinRule() === JoinRule.Public) { const dialog = Modal.createDialog(QuestionDialog, { title: _t("Are you sure you want to add encryption to this public room?"), description: ( @@ -172,7 +173,9 @@ export default class SecurityRoomSettingsTab extends React.Component { logger.error(e); this.setState({ encrypted: beforeEncrypted }); @@ -190,7 +193,7 @@ export default class SecurityRoomSettingsTab extends React.Component { - this.context.getRoom(this.props.roomId)?.setBlacklistUnverifiedDevices(checked); + this.props.room.setBlacklistUnverifiedDevices(checked); }; private async hasAliases(): Promise { const cli = this.context; - const response = await cli.getLocalAliases(this.props.roomId); + const response = await cli.getLocalAliases(this.props.room.roomId); const localAliases = response.aliases; return Array.isArray(localAliases) && localAliases.length !== 0; } private renderJoinRule(): JSX.Element { - const client = this.context; - const room = client.getRoom(this.props.roomId); + const room = this.props.room; let aliasWarning: JSX.Element | undefined; - if (room?.getJoinRule() === JoinRule.Public && !this.state.hasAliases) { + if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) { aliasWarning = (
      @@ -260,7 +262,7 @@ export default class SecurityRoomSettingsTab extends React.Component ); } @@ -436,13 +438,14 @@ export default class SecurityRoomSettingsTab extends React.Component {this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced")} diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx index e38eb374c80..94f89be331c 100644 --- a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useMemo, useState } from "react"; import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; @@ -27,16 +28,16 @@ import SettingsTab from "../SettingsTab"; import { ElementCall } from "../../../../../models/Call"; import { useRoomState } from "../../../../../hooks/useRoomState"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; +import { SettingsSection } from "../../shared/SettingsSection"; interface ElementCallSwitchProps { - roomId: string; + room: Room; } -const ElementCallSwitch: React.FC = ({ roomId }) => { - const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]); - const isPublic = useMemo(() => room?.getJoinRule() === JoinRule.Public, [room]); +const ElementCallSwitch: React.FC = ({ room }) => { + const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); const [content, events, maySend] = useRoomState( - room ?? undefined, + room, useCallback((state: RoomState) => { const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); return [ @@ -68,12 +69,12 @@ const ElementCallSwitch: React.FC = ({ roomId }) => { events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, { + MatrixClientPeg.get().sendStateEvent(room.roomId, EventType.RoomPowerLevels, { events: events, ...content, }); }, - [roomId, content, events, isPublic], + [room.roomId, content, events, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; @@ -95,15 +96,17 @@ const ElementCallSwitch: React.FC = ({ roomId }) => { }; interface Props { - roomId: string; + room: Room; } -export const VoipRoomSettingsTab: React.FC = ({ roomId }) => { +export const VoipRoomSettingsTab: React.FC = ({ room }) => { return ( - - - - + + + + + + ); }; diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index dc3bf9f408b..7b10119705f 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -21,9 +21,9 @@ import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { IThreepid } from "matrix-js-sdk/src/@types/threepids"; import { logger } from "matrix-js-sdk/src/logger"; import { IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix"; -import { MatrixError } from "matrix-js-sdk/src/matrix"; +import { HTTPError } from "matrix-js-sdk/src/matrix"; -import { _t } from "../../../../../languageHandler"; +import { UserFriendlyError, _t } from "../../../../../languageHandler"; import ProfileSettings from "../../ProfileSettings"; import * as languageHandler from "../../../../../languageHandler"; import SettingsStore from "../../../../../settings/SettingsStore"; @@ -43,7 +43,7 @@ import Spinner from "../../../elements/Spinner"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { UIFeature } from "../../../../../settings/UIFeature"; import { ActionPayload } from "../../../../../dispatcher/payloads"; -import ErrorDialog from "../../../dialogs/ErrorDialog"; +import ErrorDialog, { extractErrorMessageFromError } from "../../../dialogs/ErrorDialog"; import AccountPhoneNumbers from "../../account/PhoneNumbers"; import AccountEmailAddresses from "../../account/EmailAddresses"; import DiscoveryEmailAddresses from "../../discovery/EmailAddresses"; @@ -66,13 +66,20 @@ interface IState { haveIdServer: boolean; serverSupportsSeparateAddAndBind?: boolean; idServerHasUnsignedTerms: boolean; - requiredPolicyInfo: { - // This object is passed along to a component for handling - hasTerms: boolean; - policiesAndServices: ServicePolicyPair[] | null; // From the startTermsFlow callback - agreedUrls: string[] | null; // From the startTermsFlow callback - resolve: ((values: string[]) => void) | null; // Promise resolve function for startTermsFlow callback - }; + requiredPolicyInfo: + | { + // This object is passed along to a component for handling + hasTerms: false; + policiesAndServices: null; // From the startTermsFlow callback + agreedUrls: null; // From the startTermsFlow callback + resolve: null; // Promise resolve function for startTermsFlow callback + } + | { + hasTerms: boolean; + policiesAndServices: ServicePolicyPair[]; + agreedUrls: string[]; + resolve: (values: string[]) => void; + }; emails: IThreepid[]; msisdns: IThreepid[]; loading3pids: boolean; // whether or not the emails and msisdns have been loaded @@ -191,19 +198,19 @@ export default class GeneralUserSettingsTab extends React.Component { - if (!this.state.haveIdServer) { + // By starting the terms flow we get the logic for checking which terms the user has signed + // for free. So we might as well use that for our own purposes. + const idServerUrl = MatrixClientPeg.get().getIdentityServerUrl(); + if (!this.state.haveIdServer || !idServerUrl) { this.setState({ idServerHasUnsignedTerms: false }); return; } - // By starting the terms flow we get the logic for checking which terms the user has signed - // for free. So we might as well use that for our own purposes. - const idServerUrl = MatrixClientPeg.get().getIdentityServerUrl(); const authClient = new IdentityAuthClient(); try { const idAccessToken = await authClient.getAccessToken({ check: false }); await startTermsFlow( - [new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken)], + [new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken!)], (policiesAndServices, agreedUrls, extraClassNames) => { return new Promise((resolve, reject) => { this.setState({ @@ -253,18 +260,35 @@ export default class GeneralUserSettingsTab extends React.Component { - // TODO: Figure out a design that doesn't involve replacing the current dialog - let errMsg = err.error || err.message || ""; - if (err.httpStatus === 403) { - errMsg = _t("Failed to change password. Is your password correct?"); - } else if (!errMsg) { - errMsg += ` (HTTP status ${err.httpStatus})`; + private onPasswordChangeError = (err: Error): void => { + logger.error("Failed to change password: " + err); + + let underlyingError = err; + if (err instanceof UserFriendlyError && err.cause instanceof Error) { + underlyingError = err.cause; } - logger.error("Failed to change password: " + errMsg); + + const errorMessage = extractErrorMessageFromError( + err, + _t("Unknown password change error (%(stringifiedError)s)", { + stringifiedError: String(err), + }), + ); + + let errorMessageToDisplay = errorMessage; + if (underlyingError instanceof HTTPError && underlyingError.httpStatus === 403) { + errorMessageToDisplay = _t("Failed to change password. Is your password correct?"); + } else if (underlyingError instanceof HTTPError) { + errorMessageToDisplay = _t("%(errorMessage)s (HTTP status %(httpStatus)s)", { + errorMessage, + httpStatus: underlyingError.httpStatus, + }); + } + + // TODO: Figure out a design that doesn't involve replacing the current dialog Modal.createDialog(ErrorDialog, { - title: _t("Error"), - description: errMsg, + title: _t("Error changing password"), + description: errorMessageToDisplay, }); }; @@ -468,7 +492,7 @@ export default class GeneralUserSettingsTab extends React.Component +
      {_t("Account management")} {_t("Deactivating your account is a permanent action — be careful!")} @@ -528,8 +552,10 @@ export default class GeneralUserSettingsTab extends React.Component -
      {_t("General")}
      +
      +
      + {_t("General")} +
      {this.renderProfileSection()} {this.renderAccountSection()} {this.renderLanguageSection()} diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index 84901c33e2e..0ef667ee61f 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -164,38 +164,65 @@ export default class HelpUserSettingsTab extends React.Component .
    • - The{" "} - - twemoji-colr - {" "} - font is ©  - - Mozilla Foundation - {" "} - used under the terms of  - - Apache 2.0 - - . + {_t( + "The twemoji-colr font is © Mozilla Foundation " + + "used under the terms of Apache 2.0.", + {}, + { + colr: (sub) => ( + + {sub} + + ), + author: (sub) => ( + + {sub} + + ), + terms: (sub) => ( + + {sub} + + ), + }, + )}
    • - The{" "} - - Twemoji - {" "} - emoji art is ©  - - Twitter, Inc and other contributors - {" "} - used under the terms of  - - CC-BY 4.0 - - . + {_t( + "The Twemoji emoji art is © " + + "Twitter, Inc and other contributors used under the terms of " + + "CC-BY 4.0.", + {}, + { + twemoji: (sub) => ( + + {sub} + + ), + author: (sub) => ( + + {sub} + + ), + terms: (sub) => ( + + {sub} + + ), + }, + )}
    • The "schildi" ring sound is © Ana Gelez used under the terms of  diff --git a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx index cf9a41a5540..ef79b98d483 100644 --- a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx @@ -41,10 +41,10 @@ const KeyboardShortcutRow: React.FC = ({ name }) => { if (!displayName || !value) return null; return ( -
      +
    • {displayName} -
    • + ); }; @@ -59,12 +59,12 @@ const KeyboardShortcutSection: React.FC = ({ cate return (
      {_t(category.categoryLabel)}
      -
      +
        {" "} {category.settingNames.map((shortcutName) => { return ; })}{" "} -
      +
    ); }; diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index 498aaf03317..f505ebb76f9 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -67,7 +67,7 @@ export default class LabsUserSettingsTab extends React.Component<{}> { const groups = new EnhancedMap(); this.labs.forEach((f) => { groups - .getOrCreate(SettingsStore.getLabGroup(f), []) + .getOrCreate(SettingsStore.getLabGroup(f)!, []) .push(); }); diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index ffb0f4e5f57..0a62fd3ac2f 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -155,7 +155,9 @@ export default class PreferencesUserSettingsTab extends React.Component -
    {_t("Preferences")}
    +
    + {_t("Preferences")} +
    {roomListSettings.length > 0 && (
    diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 7305975be1a..2b86e5b60f7 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -38,6 +38,7 @@ import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo"; import QuestionDialog from "../../../dialogs/QuestionDialog"; import { FilterVariation } from "../../devices/filter"; import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading"; +import { SettingsSection } from "../../shared/SettingsSection"; const confirmSignOut = async (sessionsToSignOutCount: number): Promise => { const { finished } = Modal.createDialog(QuestionDialog, { @@ -161,7 +162,7 @@ const SessionManagerTab: React.FC = () => { const shouldShowOtherSessions = otherSessionsCount > 0; const onVerifyCurrentDevice = (): void => { - Modal.createDialog(SetupEncryptionDialog as unknown as React.ComponentType, { onFinished: refreshDevices }); + Modal.createDialog(SetupEncryptionDialog, { onFinished: refreshDevices }); }; const onTriggerDeviceVerification = useCallback( @@ -225,62 +226,64 @@ const SessionManagerTab: React.FC = () => { } return ( - - - saveDeviceName(currentDeviceId, deviceName)} - onVerifyCurrentDevice={onVerifyCurrentDevice} - onSignOutCurrentDevice={onSignOutCurrentDevice} - signOutAllOtherSessions={signOutAllOtherSessions} - otherSessionsCount={otherSessionsCount} - /> - {shouldShowOtherSessions && ( - - } - description={_t( - `For best security, verify your sessions and sign out ` + - `from any session that you don't recognize or use anymore.`, - )} - data-testid="other-sessions-section" - > - + + + saveDeviceName(currentDeviceId, deviceName)} + onVerifyCurrentDevice={onVerifyCurrentDevice} + onSignOutCurrentDevice={onSignOutCurrentDevice} + signOutAllOtherSessions={signOutAllOtherSessions} + otherSessionsCount={otherSessionsCount} + /> + {shouldShowOtherSessions && ( + } - onSignOutDevices={onSignOutOtherDevices} - saveDeviceName={saveDeviceName} - setPushNotifications={setPushNotifications} - ref={filteredDeviceListRef} - supportsMSC3881={supportsMSC3881} - /> - - )} - + description={_t( + `For best security, verify your sessions and sign out ` + + `from any session that you don't recognize or use anymore.`, + )} + data-testid="other-sessions-section" + > + + + )} + + ); }; diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index a42b6518899..56e956551a1 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -67,14 +67,14 @@ const QuickSettingsButton: React.FC<{ {_t("All settings")} - {SettingsStore.getValue("developerMode") && ( + {SettingsStore.getValue("developerMode") && SdkContextClass.instance.roomViewStore.getRoomId() && ( { closeMenu(); Modal.createDialog( DevtoolsDialog, { - roomId: SdkContextClass.instance.roomViewStore.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId()!, }, "mx_DevtoolsDialog_wrapper", ); @@ -133,6 +133,7 @@ const QuickSettingsButton: React.FC<{ title={_t("Quick settings")} inputRef={handle} forceHide={!isPanelCollapsed} + aria-expanded={!isPanelCollapsed} > {!isPanelCollapsed ? _t("Settings") : null} diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 64fc408b774..ded069778d5 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -89,8 +89,8 @@ const SpaceCreateMenuType: React.FC<{ }> = ({ title, description, className, onClick }) => { return ( -

    {title}

    - {description} + {title} +
    {description}
    ); }; diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index 85446ab2517..68bf940831a 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -52,7 +52,7 @@ const SpacePublicShare: React.FC = ({ space, onFinished }) => { } }} > -

    {_t("Share invite link")}

    + {_t("Share invite link")} {copiedText}
    {space.canInvite(MatrixClientPeg.get()?.getUserId()) && shouldShowComponent(UIComponent.InviteUsers) ? ( @@ -63,8 +63,8 @@ const SpacePublicShare: React.FC = ({ space, onFinished }) => { showRoomInviteDialog(space.roomId); }} > -

    {_t("Invite people")}

    - {_t("Invite with email or username")} + {_t("Invite people")} +
    {_t("Invite with email or username")}
    ) : null}
    diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index 7993a2af642..368d6c96fc0 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -97,6 +97,7 @@ const SpaceSettingsVisibilityTab: React.FC = ({ matrixClient: cli, space onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced" + aria-expanded={showAdvancedSection} > {showAdvancedSection ? _t("Hide advanced") : _t("Show advanced")} diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index a2f8719add0..1d31e9d141f 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -122,7 +122,7 @@ export const SpaceButton = forwardRef( } let contextMenu: JSX.Element | undefined; - if (menuDisplayed && handle.current && ContextMenuComponent) { + if (space && menuDisplayed && handle.current && ContextMenuComponent) { contextMenu = ( { // it fails. // It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID // back to the default after the call is over - Dave - element.setSinkId(audioOutput); + element!.setSinkId(audioOutput); } catch (e) { logger.error("Couldn't set requested audio output device: using default", e); logger.warn("Couldn't set requested audio output device: using default", e); @@ -103,7 +103,7 @@ export default class AudioFeed extends React.Component { if (!element) return; element.pause(); - element.src = null; + element.removeAttribute("src"); // As per comment in componentDidMount, setting the sink ID back to the // default once the call is over makes setSinkId work reliably. - Dave diff --git a/src/components/views/voip/LegacyCallView.tsx b/src/components/views/voip/LegacyCallView.tsx index 86be87608d5..5978acb316f 100644 --- a/src/components/views/voip/LegacyCallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -339,16 +339,17 @@ export default class LegacyCallView extends React.Component { private onCallResumeClick = (): void => { const userFacingRoomId = LegacyCallHandler.instance.roomIdForCall(this.props.call); - LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId); + if (userFacingRoomId) LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId); }; private onTransferClick = (): void => { const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(this.props.call.callId); - this.props.call.transferToCall(transfereeCall); + if (transfereeCall) this.props.call.transferToCall(transfereeCall); }; private onHangupClick = (): void => { - LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call)); + const roomId = LegacyCallHandler.instance.roomIdForCall(this.props.call); + if (roomId) LegacyCallHandler.instance.hangupOrReject(roomId); }; private onToggleSidebar = (): void => { @@ -451,13 +452,12 @@ export default class LegacyCallView extends React.Component { let holdTransferContent: React.ReactNode; if (transfereeCall) { - const transferTargetRoom = MatrixClientPeg.get().getRoom( - LegacyCallHandler.instance.roomIdForCall(call), - ); + const cli = MatrixClientPeg.get(); + const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); + const transferTargetRoom = callRoomId ? cli.getRoom(callRoomId) : null; const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); - const transfereeRoom = MatrixClientPeg.get().getRoom( - LegacyCallHandler.instance.roomIdForCall(transfereeCall), - ); + const transfereeCallRoomId = LegacyCallHandler.instance.roomIdForCall(transfereeCall); + const transfereeRoom = transfereeCallRoomId ? cli.getRoom(transfereeCallRoomId) : null; const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); holdTransferContent = ( @@ -579,6 +579,8 @@ export default class LegacyCallView extends React.Component { const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); const secondaryCallRoomId = LegacyCallHandler.instance.roomIdForCall(secondaryCall); const callRoom = callRoomId ? client.getRoom(callRoomId) : null; + if (!callRoom) return null; + const secCallRoom = secondaryCallRoomId ? client.getRoom(secondaryCallRoomId) : null; const callViewClasses = classNames({ diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 503c53ec66e..c02154936f4 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -150,7 +150,7 @@ export default class VideoFeed extends React.PureComponent { if (!element) return; element.pause(); - element.src = null; + element.removeAttribute("src"); // As per comment in componentDidMount, setting the sink ID back to the // default once the call is over makes setSinkId work reliably. - Dave diff --git a/src/createRoom.ts b/src/createRoom.ts index 2b5831498be..cf090b06ca4 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -397,7 +397,10 @@ export default async function createRoom(opts: IOpts): Promise { */ export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]): Promise { try { - const usersDeviceMap = await client.downloadKeys(userIds); + const usersDeviceMap = await client.getCrypto()?.getUserDeviceInfo(userIds, true); + if (!usersDeviceMap) { + return false; + } for (const devices of usersDeviceMap.values()) { if (devices.size === 0) { diff --git a/src/customisations/Security.ts b/src/customisations/Security.ts index bfc2caadddd..2d83a564f13 100644 --- a/src/customisations/Security.ts +++ b/src/customisations/Security.ts @@ -42,7 +42,10 @@ function getSecretStorageKey(): Uint8Array | null { } /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ -function getDehydrationKey(keyInfo: ISecretStorageKeyInfo): Promise { +function getDehydrationKey( + keyInfo: ISecretStorageKeyInfo, + checkFunc: (key: Uint8Array) => void, +): Promise { return Promise.resolve(null); } diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index a2f999dddd6..eacd94d37ea 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -22,17 +22,12 @@ import { Action } from "../actions"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IOpts } from "../../createRoom"; import { JoinRoomPayload } from "./JoinRoomPayload"; +import { AtLeastOne } from "../../@types/common"; /* eslint-disable camelcase */ -export interface ViewRoomPayload extends Pick { +interface BaseViewRoomPayload extends Pick { action: Action.ViewRoom; - // either or both of room_id or room_alias must be specified - // where possible, a room_id should be provided with a room_alias as it reduces - // the number of API calls required. - room_id?: string; - room_alias?: string; - event_id?: string; // the event to ensure is in view if any highlighted?: boolean; // whether to highlight `event_id` scroll_into_view?: boolean; // whether to scroll `event_id` into view @@ -57,4 +52,13 @@ export interface ViewRoomPayload extends Pick { metricsTrigger: ViewRoomEvent["trigger"]; metricsViaKeyboard?: ViewRoomEvent["viaKeyboard"]; } + +export type ViewRoomPayload = BaseViewRoomPayload & + AtLeastOne<{ + // either or both of room_id or room_alias must be specified + // where possible, a room_id should be provided with a room_alias as it reduces + // the number of API calls required. + room_id?: string; + room_alias?: string; + }>; /* eslint-enable camelcase */ diff --git a/src/editor/model.ts b/src/editor/model.ts index 65058c68cb5..78c749c94e5 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -99,10 +99,10 @@ export default class EditorModel { private insertPart(index: number, part: Part): void { this._parts.splice(index, 0, part); - if (this.activePartIdx && this.activePartIdx >= index) { + if (this.activePartIdx !== null && this.activePartIdx >= index) { ++this.activePartIdx; } - if (this.autoCompletePartIdx && this.autoCompletePartIdx >= index) { + if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx >= index) { ++this.autoCompletePartIdx; } } @@ -111,12 +111,12 @@ export default class EditorModel { this._parts.splice(index, 1); if (index === this.activePartIdx) { this.activePartIdx = null; - } else if (this.activePartIdx && this.activePartIdx > index) { + } else if (this.activePartIdx !== null && this.activePartIdx > index) { --this.activePartIdx; } if (index === this.autoCompletePartIdx) { this.autoCompletePartIdx = null; - } else if (this.autoCompletePartIdx && this.autoCompletePartIdx > index) { + } else if (this.autoCompletePartIdx !== null && this.autoCompletePartIdx > index) { --this.autoCompletePartIdx; } } diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index bca7547b6ce..84c0a3ec204 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -235,8 +235,11 @@ export default class EventIndex extends EventEmitter { const indexManager = PlatformPeg.get()?.getEventIndexingManager(); if (!indexManager) return; + const associatedId = ev.getAssociatedId(); + if (!associatedId) return; + try { - await indexManager.deleteEvent(ev.getAssociatedId()); + await indexManager.deleteEvent(associatedId); } catch (e) { logger.log("EventIndex: Error deleting event from index", e); } @@ -519,10 +522,10 @@ export default class EventIndex extends EventEmitter { const profiles: Record = {}; stateEvents.forEach((ev) => { - if (ev.event.content && ev.event.content.membership === "join") { - profiles[ev.event.sender] = { - displayname: ev.event.content.displayname, - avatar_url: ev.event.content.avatar_url, + if (ev.getContent().membership === "join") { + profiles[ev.getSender()!] = { + displayname: ev.getContent().displayname, + avatar_url: ev.getContent().avatar_url, }; } }); @@ -733,7 +736,7 @@ export default class EventIndex extends EventEmitter { const matrixEvents = events.map((e) => { const matrixEvent = eventMapper(e.event); - const member = new RoomMember(room.roomId, matrixEvent.getSender()); + const member = new RoomMember(room.roomId, matrixEvent.getSender()!); // We can't really reconstruct the whole room state from our // EventIndex to calculate the correct display name. Use the diff --git a/src/integrations/IntegrationManagers.ts b/src/integrations/IntegrationManagers.ts index 9adcb561623..da1bb62ba98 100644 --- a/src/integrations/IntegrationManagers.ts +++ b/src/integrations/IntegrationManagers.ts @@ -153,7 +153,7 @@ export class IntegrationManagers { if (kind === Kind.Account) { // Order by state_keys (IDs) - managers.sort((a, b) => compare(a.id, b.id)); + managers.sort((a, b) => compare(a.id ?? "", b.id ?? "")); } ordered.push(...managers); @@ -199,7 +199,7 @@ export class IntegrationManagers { logger.log("Looking up integration manager via .well-known"); if (domainName.startsWith("http:") || domainName.startsWith("https:")) { // trim off the scheme and just use the domain - domainName = url.parse(domainName).host; + domainName = url.parse(domainName).host!; } let wkConfig: IClientWellKnown; diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index 3369a18157b..456298ce11e 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -16,7 +16,7 @@ limitations under the License. */ import * as linkifyjs from "linkifyjs"; -import { Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; +import { EventListeners, Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; import linkifyElement from "linkify-element"; import linkifyString from "linkify-string"; import { User } from "matrix-js-sdk/src/matrix"; @@ -136,7 +136,7 @@ export const ELEMENT_URL_PATTERN = ")(#.*)"; export const options: Opts = { - events: function (href: string, type: string): Partial { + events: function (href: string, type: string): EventListeners { switch (type as Type) { case Type.URL: { // intercept local permalinks to users and show them like userids (in userinfo of current room) @@ -146,7 +146,7 @@ export const options: Opts = { return { // @ts-ignore see https://linkify.js.org/docs/options.html click: function (e: MouseEvent) { - onUserClick(e, permalink.userId); + onUserClick(e, permalink.userId!); }, }; } else { @@ -185,9 +185,11 @@ export const options: Opts = { }, }; } + + return {}; }, - formatHref: function (href: string, type: Type | string): string { + formatHref: function (href: string, type: Type | string): string | null { switch (type) { case Type.RoomAlias: case Type.UserId: @@ -205,7 +207,7 @@ export const options: Opts = { className: "linkified", - target: function (href: string, type: Type | string): string { + target: function (href: string, type: Type | string): string | null { if (type === Type.URL) { try { const transformed = tryTransformPermalinkToLocalHref(href); diff --git a/src/models/Call.ts b/src/models/Call.ts index 6f96e9d887c..c78aed3a402 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -213,7 +213,7 @@ export abstract class Call extends TypedEventEmitter = {}, @@ -292,7 +291,7 @@ export async function submitFeedback( } catch (err) {} // PlatformPeg already logs this. const body = new FormData(); - body.append("label", label); + if (label) body.append("label", label); body.append("text", comment); body.append("can_contact", canContact ? "yes" : "no"); diff --git a/src/resizer/sizer.ts b/src/resizer/sizer.ts index ce4bc247d0a..dcf20f928d7 100644 --- a/src/resizer/sizer.ts +++ b/src/resizer/sizer.ts @@ -86,9 +86,9 @@ export default class Sizer { public clearItemSize(item: HTMLElement): void { if (this.vertical) { - item.style.height = null!; + item.style.removeProperty("height"); } else { - item.style.width = null!; + item.style.removeProperty("width"); } } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ced8babcdb2..d82446721f2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -220,7 +220,7 @@ export const SETTINGS: { [setting: string]: ISetting } = { ), feedbackLabel: "video-room-feedback", feedbackSubheading: _td( - "Thank you for trying the beta, " + "please go into as much detail as you can so we can improve it.", + "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", ), image: require("../../res/img/betas/video_rooms.png"), requiresRefresh: true, @@ -1174,6 +1174,10 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_UI_FEATURE, default: true, }, + [UIFeature.LocationSharing]: { + supportedLevels: LEVELS_UI_FEATURE, + default: true, + }, [UIFeature.Voip]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/src/settings/UIFeature.ts b/src/settings/UIFeature.ts index ab780f8b1e9..259363c85a5 100644 --- a/src/settings/UIFeature.ts +++ b/src/settings/UIFeature.ts @@ -15,10 +15,11 @@ limitations under the License. */ // see settings.md for documentation on conventions -export enum UIFeature { +export const enum UIFeature { AdvancedEncryption = "UIFeature.advancedEncryption", URLPreviews = "UIFeature.urlPreviews", Widgets = "UIFeature.widgets", + LocationSharing = "UIFeature.locationSharing", Voip = "UIFeature.voip", Feedback = "UIFeature.feedback", Registration = "UIFeature.registration", diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index 76519c0b612..9467c6eb811 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -16,7 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../SettingLevel"; import { CallbackFn, WatchManager } from "../WatchManager"; import { Theme } from "../enums/Theme"; @@ -180,10 +179,12 @@ export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsH // public for access to migrations - not exposed from the SettingsHandler interface public readFeature(featureName: string): boolean | null { - if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest()) { - // Guests should not have any labs features enabled. - return false; - } + // Previously, we disabled all features for guests, but since different + // installations can have site-specific config files which might set up + // different behaviour that is relevant to guests, we removed that + // special behaviour. See + // https://github.com/vector-im/element-web/issues/24513 for the + // discussion. // XXX: This turns they key names into `mx_labs_feature_feature_x` (double feature). // This is because all feature names start with `feature_` as a matter of policy. diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index aef1ffa98bf..94bbe1faad2 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -87,7 +87,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === Action.ViewRoom) { - if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) { + if (payload.auto_join && payload.room_id && !this.matrixClient.getRoom(payload.room_id)) { // Queue the room instead of pushing it immediately. We're probably just // waiting for a room join to complete. this.waitingRooms.push({ roomId: payload.room_id, addedTs: Date.now() }); diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index db9e57f46c2..2509dc92a32 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -426,6 +426,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { roomId: Room["roomId"], beaconInfoContent: MBeaconInfoEventContent, ): Promise => { + if (!this.matrixClient) return; // explicitly stop any live beacons this user has // to ensure they remain stopped // if the new replacing beacon is redacted @@ -435,7 +436,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // eslint-disable-next-line camelcase const { event_id } = await doMaybeLocalRoomAction( roomId, - (actualRoomId: string) => this.matrixClient.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), + (actualRoomId: string) => this.matrixClient!.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), this.matrixClient, ); @@ -552,7 +553,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { const updateContent = makeBeaconInfoContent(timeout, live, description, assetType, timestamp); try { - await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, updateContent); + await this.matrixClient!.unstable_setLiveBeacon(beacon.roomId, updateContent); // cleanup any errors const hadError = this.beaconUpdateErrors.has(beacon.identifier); if (hadError) { @@ -576,7 +577,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.lastPublishedPositionTimestamp = Date.now(); await Promise.all( this.healthyLiveBeaconIds.map((beaconId) => - this.sendLocationToBeacon(this.beacons.get(beaconId), position), + this.beacons.has(beaconId) ? this.sendLocationToBeacon(this.beacons.get(beaconId)!, position) : null, ), ); }; @@ -589,7 +590,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri): Promise => { const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); try { - await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); + await this.matrixClient!.sendEvent(beacon.roomId, M_BEACON.name, content); this.incrementBeaconLocationPublishErrorCount(beacon.identifier, false); } catch (error) { logger.error(error); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index dd87a97a57e..7713e3f005a 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -228,6 +228,7 @@ export class RoomViewStore extends EventEmitter { } private doMaybeSetCurrentVoiceBroadcastPlayback(room: Room): void { + if (!this.stores.client) return; doMaybeSetCurrentVoiceBroadcastPlayback( room, this.stores.client, @@ -532,8 +533,8 @@ export class RoomViewStore extends EventEmitter { const cli = MatrixClientPeg.get(); // take a copy of roomAlias & roomId as they may change by the time the join is complete - const { roomAlias, roomId } = this.state; - const address = roomAlias || roomId; + const { roomAlias, roomId = payload.roomId } = this.state; + const address = roomAlias || roomId!; const viaServers = this.state.viaServers || []; try { await retry( @@ -554,7 +555,7 @@ export class RoomViewStore extends EventEmitter { // room. this.dis.dispatch({ action: Action.JoinRoomReady, - roomId, + roomId: roomId!, metricsTrigger: payload.metricsTrigger, }); } catch (err) { diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index d2d2ee0a4d8..46c0193f94f 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -109,14 +109,20 @@ export class SetupEncryptionStore extends EventEmitter { const dehydratedDevice = await cli.getDehydratedDevice(); const ownUserId = cli.getUserId()!; const crossSigningInfo = cli.getStoredCrossSigningForUser(ownUserId); - this.hasDevicesToVerifyAgainst = cli - .getStoredDevicesForUser(ownUserId) - .some( - (device) => - device.getIdentityKey() && - (!dehydratedDevice || device.deviceId != dehydratedDevice.device_id) && - crossSigningInfo?.checkDeviceTrust(crossSigningInfo, device, false, true).isCrossSigningVerified(), - ); + this.hasDevicesToVerifyAgainst = cli.getStoredDevicesForUser(ownUserId).some((device) => { + if (!device.getIdentityKey() || (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id)) { + return false; + } + // check if the device is signed by the cross-signing key stored for our user. Note that this is + // *different* to calling `cryptoApi.getDeviceVerificationStatus`, because even if we have stored + // a cross-signing key for our user, we don't necessarily trust it yet (In legacy Crypto, we have not + // yet imported it into `Crypto.crossSigningInfo`, which for maximal confusion is a different object to + // `Crypto.getStoredCrossSigningForUser(ownUserId)`). + // + // TODO: figure out wtf to to here for rust-crypto + const verificationStatus = crossSigningInfo?.checkDeviceTrust(crossSigningInfo, device, false, true); + return !!verificationStatus?.isCrossSigningVerified(); + }); this.phase = Phase.Intro; this.emit("update"); diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts index 6dfb7d05610..c62974378f1 100644 --- a/src/stores/ThreepidInviteStore.ts +++ b/src/stores/ThreepidInviteStore.ts @@ -83,7 +83,11 @@ export default class ThreepidInviteStore extends EventEmitter { for (let i = 0; i < localStorage.length; i++) { const keyName = localStorage.key(i); if (!keyName?.startsWith(STORAGE_PREFIX)) continue; - results.push(JSON.parse(localStorage.getItem(keyName)) as IPersistedThreepidInvite); + try { + results.push(JSON.parse(localStorage.getItem(keyName)!) as IPersistedThreepidInvite); + } catch (e) { + console.warn("Failed to parse 3pid invite", e); + } } return results; } diff --git a/src/stores/local-echo/RoomEchoChamber.ts b/src/stores/local-echo/RoomEchoChamber.ts index 15a3affdda4..2eac117451b 100644 --- a/src/stores/local-echo/RoomEchoChamber.ts +++ b/src/stores/local-echo/RoomEchoChamber.ts @@ -27,7 +27,7 @@ export enum CachedRoomKey { NotificationVolume, } -export class RoomEchoChamber extends GenericEchoChamber { +export class RoomEchoChamber extends GenericEchoChamber { private properties = new Map(); public constructor(context: RoomEchoContext) { @@ -67,11 +67,12 @@ export class RoomEchoChamber extends GenericEchoChamber needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request const { member } = card.state; - const pendingRequest = pendingVerificationRequestForUser(member); + const pendingRequest = member ? pendingVerificationRequestForUser(member) : undefined; if (pendingRequest) { return { phase: RightPanelPhases.EncryptionPanel, diff --git a/src/stores/right-panel/RightPanelStoreIPanelState.ts b/src/stores/right-panel/RightPanelStoreIPanelState.ts index 826a767ee2f..3599730e4f4 100644 --- a/src/stores/right-panel/RightPanelStoreIPanelState.ts +++ b/src/stores/right-panel/RightPanelStoreIPanelState.ts @@ -71,8 +71,8 @@ interface IRightPanelForRoomStored { history: Array; } -export function convertToStorePanel(cacheRoom: IRightPanelForRoom): IRightPanelForRoomStored { - if (!cacheRoom) return cacheRoom; +export function convertToStorePanel(cacheRoom?: IRightPanelForRoom): IRightPanelForRoomStored | undefined { + if (!cacheRoom) return undefined; const storeHistory = [...cacheRoom.history].map((panelState) => convertCardToStore(panelState)); return { isOpen: cacheRoom.isOpen, history: storeHistory }; } diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index 7c6ae4a617f..e0dfb5adca3 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -287,7 +287,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // Do a quick check to see if we've completely broken the index - for (let i = 1; i <= CATEGORY_ORDER.length; i++) { + for (let i = 1; i < CATEGORY_ORDER.length; i++) { const lastCat = CATEGORY_ORDER[i - 1]; const lastCatIndex = indices[lastCat]; const thisCat = CATEGORY_ORDER[i]; diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 2cb0e07c437..da73ea8e97f 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -145,7 +145,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _activeSpace: SpaceKey = MetaSpace.Home; // set properly by onReady private _suggestedRooms: ISuggestedRoom[] = []; private _invitedSpaces = new Set(); - private spaceOrderLocalEchoMap = new Map(); + private spaceOrderLocalEchoMap = new Map(); // The following properties are set by onReady as they live in account_data private _allRoomsInHome = false; private _showSpaceDMBadges = false; @@ -315,7 +315,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { try { - const { rooms } = await this.matrixClient.getRoomHierarchy(space.roomId, limit, 1, true); + const { rooms } = await this.matrixClient!.getRoomHierarchy(space.roomId, limit, 1, true); const viaMap = new EnhancedMap>(); rooms.forEach((room) => { @@ -346,7 +346,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; public addRoomToSpace(space: Room, roomId: string, via: string[], suggested = false): Promise { - return this.matrixClient.sendStateEvent( + return this.matrixClient!.sendStateEvent( space.roomId, EventType.SpaceChild, { @@ -372,7 +372,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { ); }) .map((ev) => { - const history = this.matrixClient.getRoomUpgradeHistory( + const history = this.matrixClient!.getRoomUpgradeHistory( ev.getStateKey()!, true, this._msc3946ProcessDynamicPredecessor, @@ -476,7 +476,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { ): Set => { if (space === MetaSpace.Home && this.allRoomsInHome) { return new Set( - this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).map((r) => r.roomId), + this.matrixClient!.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).map((r) => r.roomId), ); } @@ -625,8 +625,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.roomIdsBySpace.delete(MetaSpace.Home); } else { const rooms = new Set( - this.matrixClient - .getVisibleRooms(this._msc3946ProcessDynamicPredecessor) + this.matrixClient!.getVisibleRooms(this._msc3946ProcessDynamicPredecessor) .filter(this.showInHomeSpace) .map((r) => r.roomId), ); @@ -826,9 +825,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Expand room IDs to all known versions of the given rooms const expandedRoomIds = new Set( Array.from(roomIds).flatMap((roomId) => { - return this.matrixClient - .getRoomUpgradeHistory(roomId, true, this._msc3946ProcessDynamicPredecessor) - .map((r) => r.roomId); + return this.matrixClient!.getRoomUpgradeHistory( + roomId, + true, + this._msc3946ProcessDynamicPredecessor, + ).map((r) => r.roomId); }), ); @@ -992,7 +993,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private onRoomState = (ev: MatrixEvent): void => { const room = this.matrixClient?.getRoom(ev.getRoomId()); - if (!room) return; + if (!this.matrixClient || !room) return; switch (ev.getType()) { case EventType.SpaceChild: { @@ -1232,7 +1233,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Persist last viewed room from a space // we don't await setActiveSpace above as we only care about this.activeSpace being up to date // synchronously for the below code - everything else can and should be async. - window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); + window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id ?? ""); break; } @@ -1318,10 +1319,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } case "Spaces.showPeopleInSpace": - // getSpaceFilteredUserIds will return the appropriate value - this.emit(payload.roomId); - if (!this.enabledMetaSpaces.some((s) => s === MetaSpace.Home || s === MetaSpace.People)) { - this.updateNotificationStates([payload.roomId]); + if (payload.roomId) { + // getSpaceFilteredUserIds will return the appropriate value + this.emit(payload.roomId); + if (!this.enabledMetaSpaces.some((s) => s === MetaSpace.Home || s === MetaSpace.People)) { + this.updateNotificationStates([payload.roomId]); + } } break; @@ -1377,7 +1380,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return sortBy(spaces, [this.getSpaceTagOrdering, "roomId"]); } - private async setRootSpaceOrder(space: Room, order: string): Promise { + private async setRootSpaceOrder(space: Room, order?: string): Promise { this.spaceOrderLocalEchoMap.set(space.roomId, order); try { await this.matrixClient?.setRoomAccountData(space.roomId, EventType.SpaceOrder, { order }); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 4c37ffd84c5..9e24126b15d 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -274,19 +274,22 @@ export class StopGapWidgetDriver extends WidgetDriver { await Promise.all( Object.entries(contentMap).flatMap(([userId, userContentMap]) => Object.entries(userContentMap).map(async ([deviceId, content]): Promise => { + const devices = deviceInfoMap.get(userId); + if (!devices) return; + if (deviceId === "*") { // Send the message to all devices we have keys for await client.encryptAndSendToDevices( - Array.from(deviceInfoMap.get(userId).values()).map((deviceInfo) => ({ + Array.from(devices.values()).map((deviceInfo) => ({ userId, deviceInfo, })), content, ); - } else { + } else if (devices.has(deviceId)) { // Send the message to a specific device await client.encryptAndSendToDevices( - [{ userId, deviceInfo: deviceInfoMap.get(userId).get(deviceId) }], + [{ userId, deviceInfo: devices.get(deviceId)! }], content, ); } diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index f260895c30a..2bfd555ea30 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -363,7 +363,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } public getContainerWidgets(room: Optional, container: Container): IApp[] { - return this.byRoom.get(room?.roomId)?.get(container)?.ordered || []; + return (room && this.byRoom.get(room.roomId)?.get(container)?.ordered) || []; } public isInContainer(room: Room, widget: IApp, container: Container): boolean { diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts index 244a95f06cb..b6ea52c162c 100644 --- a/src/stores/widgets/WidgetPermissionStore.ts +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -58,7 +58,7 @@ export class WidgetPermissionStore { return OIDCState.Unknown; } - public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState): void { + public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string | undefined, newState: OIDCState): void { const settingsKey = this.packSettingKey(widget, kind, roomId); let currentValues = SettingsStore.getValue<{ diff --git a/src/toasts/UnverifiedSessionToast.tsx b/src/toasts/UnverifiedSessionToast.tsx index 8d619d87675..b3e9a63b591 100644 --- a/src/toasts/UnverifiedSessionToast.tsx +++ b/src/toasts/UnverifiedSessionToast.tsx @@ -48,7 +48,7 @@ export const showToast = async (deviceId: string): Promise => { const device = await cli.getDevice(deviceId); const extendedDevice = { ...device, - isVerified: isDeviceVerified(cli, deviceId), + isVerified: await isDeviceVerified(cli, deviceId), deviceType: DeviceType.Unknown, }; diff --git a/src/utils/AutoDiscoveryUtils.tsx b/src/utils/AutoDiscoveryUtils.tsx index 70f40af883e..aaa602abb40 100644 --- a/src/utils/AutoDiscoveryUtils.tsx +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -189,12 +189,12 @@ export default class AutoDiscoveryUtils { * @returns {Promise} Resolves to the validated configuration. */ public static buildValidatedConfigFromDiscovery( - serverName: string, - discoveryResult: ClientConfig, + serverName?: string, + discoveryResult?: ClientConfig, syntaxOnly = false, isSynthetic = false, ): ValidatedServerConfig { - if (!discoveryResult || !discoveryResult["m.homeserver"]) { + if (!discoveryResult?.["m.homeserver"]) { // This shouldn't happen without major misconfiguration, so we'll log a bit of information // in the log so we can find this bit of code but otherwise tell the user "it broke". logger.error("Ended up in a state of not knowing which homeserver to connect to."); @@ -249,7 +249,7 @@ export default class AutoDiscoveryUtils { throw new UserFriendlyError("Unexpected error resolving homeserver configuration"); } - let preferredHomeserverName = serverName ? serverName : hsResult["server_name"]; + let preferredHomeserverName = serverName ?? hsResult["server_name"]; const url = new URL(preferredHomeserverUrl); if (!preferredHomeserverName) preferredHomeserverName = url.hostname; diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index e45e3091a1a..365bd916c5d 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -15,9 +15,21 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { MatrixError, ConnectionError } from "matrix-js-sdk/src/http-api"; import { _t, _td, Tags, TranslatedString } from "../languageHandler"; +import SdkConfig from "../SdkConfig"; +import { ValidatedServerConfig } from "./ValidatedServerConfig"; + +export const resourceLimitStrings = { + "monthly_active_user": _td("This homeserver has hit its Monthly Active User limit."), + "hs_blocked": _td("This homeserver has been blocked by its administrator."), + "": _td("This homeserver has exceeded one of its resource limits."), +}; + +export const adminContactStrings = { + "": _td("Please contact your service administrator to continue using this service."), +}; /** * Produce a translated error message for a @@ -63,14 +75,16 @@ export function messageForResourceLimitError( export function messageForSyncError(err: Error): ReactNode { if (err instanceof MatrixError && err.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { - const limitError = messageForResourceLimitError(err.data.limit_type, err.data.admin_contact, { - "monthly_active_user": _td("This homeserver has hit its Monthly Active User limit."), - "hs_blocked": _td("This homeserver has been blocked by its administrator."), - "": _td("This homeserver has exceeded one of its resource limits."), - }); - const adminContact = messageForResourceLimitError(err.data.limit_type, err.data.admin_contact, { - "": _td("Please contact your service administrator to continue using the service."), - }); + const limitError = messageForResourceLimitError( + err.data.limit_type, + err.data.admin_contact, + resourceLimitStrings, + ); + const adminContact = messageForResourceLimitError( + err.data.limit_type, + err.data.admin_contact, + adminContactStrings, + ); return (
    {limitError}
    @@ -81,3 +95,109 @@ export function messageForSyncError(err: Error): ReactNode { return
    {_t("Unable to connect to Homeserver. Retrying…")}
    ; } } + +export function messageForLoginError( + err: MatrixError, + serverConfig: Pick, +): ReactNode { + if (err.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { + const errorTop = messageForResourceLimitError( + err.data.limit_type, + err.data.admin_contact, + resourceLimitStrings, + ); + const errorDetail = messageForResourceLimitError( + err.data.limit_type, + err.data.admin_contact, + adminContactStrings, + ); + return ( +
    +
    {errorTop}
    +
    {errorDetail}
    +
    + ); + } else if (err.httpStatus === 401 || err.httpStatus === 403) { + if (err.errcode === "M_USER_DEACTIVATED") { + return _t("This account has been deactivated."); + } else if (SdkConfig.get("disable_custom_urls")) { + return ( +
    +
    {_t("Incorrect username and/or password.")}
    +
    + {_t("Please note you are logging into the %(hs)s server, not matrix.org.", { + hs: serverConfig.hsName, + })} +
    +
    + ); + } else { + return _t("Incorrect username and/or password."); + } + } else { + return messageForConnectionError(err, serverConfig); + } +} + +export function messageForConnectionError( + err: Error, + serverConfig: Pick, +): ReactNode { + let errorText = _t("There was a problem communicating with the homeserver, please try again later."); + + if (err instanceof ConnectionError) { + if ( + window.location.protocol === "https:" && + (serverConfig.hsUrl.startsWith("http:") || !serverConfig.hsUrl.startsWith("http")) + ) { + return ( + + {_t( + "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + + "Either use HTTPS or enable unsafe scripts.", + {}, + { + a: (sub) => { + return ( + + {sub} + + ); + }, + }, + )} + + ); + } + + return ( + + {_t( + "Can't connect to homeserver - please check your connectivity, ensure your " + + "homeserver's SSL certificate is trusted, and that a browser extension " + + "is not blocking requests.", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + )} + + ); + } else if (err instanceof MatrixError) { + if (err.errcode) { + errorText += `(${err.errcode})`; + } else if (err.httpStatus) { + errorText += ` (HTTP ${err.httpStatus})`; + } + } + + return errorText; +} diff --git a/src/utils/ShieldUtils.ts b/src/utils/ShieldUtils.ts index 089cf3feeb7..a9efe4584f7 100644 --- a/src/utils/ShieldUtils.ts +++ b/src/utils/ShieldUtils.ts @@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import DMRoomMap from "./DMRoomMap"; +import { asyncSome } from "./arrays"; export enum E2EStatus { Warning = "warning", @@ -54,8 +55,9 @@ export async function shieldStatusForRoom(client: MatrixClient, room: Room): Pro const targets = includeUser ? [...verified, client.getUserId()!] : verified; for (const userId of targets) { const devices = client.getStoredDevicesForUser(userId); - const anyDeviceNotVerified = devices.some(({ deviceId }) => { - return !client.checkDeviceTrust(userId, deviceId).isVerified(); + const anyDeviceNotVerified = await asyncSome(devices, async ({ deviceId }) => { + const verificationStatus = await client.getCrypto()?.getDeviceVerificationStatus(userId, deviceId); + return !verificationStatus?.isVerified(); }); if (anyDeviceNotVerified) { return E2EStatus.Warning; diff --git a/src/utils/SortMembers.ts b/src/utils/SortMembers.ts index d19b461b1e9..8be4e8a9399 100644 --- a/src/utils/SortMembers.ts +++ b/src/utils/SortMembers.ts @@ -67,7 +67,7 @@ interface IActivityScore { // We do this by checking every room to see who has sent a message in the last few hours, and giving them // a score which correlates to the freshness of their message. In theory, this results in suggestions // which are closer to "continue this conversation" rather than "this person exists". -export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivityScore | undefined } { +export function buildActivityScores(cli: MatrixClient): { [userId: string]: IActivityScore } { const now = new Date().getTime(); const earliestAgeConsidered = now - 60 * 60 * 1000; // 1 hour ago const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic @@ -75,6 +75,7 @@ export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivi .flatMap((room) => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered)) .filter((ev) => ev.getTs() > earliestAgeConsidered); const senderEvents = groupBy(events, (ev) => ev.getSender()); + // If the iteratee in mapValues returns undefined that key will be removed from the resultant object return mapValues(senderEvents, (events) => { if (!events.length) return; const lastEvent = maxBy(events, (ev) => ev.getTs())!; @@ -87,7 +88,7 @@ export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivi // an approximate maximum for being selected. score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane }; - }); + }) as { [key: string]: IActivityScore }; } interface IMemberScore { @@ -96,13 +97,14 @@ interface IMemberScore { numRooms: number; } -export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberScore | undefined } { +export function buildMemberScores(cli: MatrixClient): { [userId: string]: IMemberScore } { const maxConsideredMembers = 200; const consideredRooms = joinedRooms(cli).filter((room) => room.getJoinedMemberCount() < maxConsideredMembers); const memberPeerEntries = consideredRooms.flatMap((room) => room.getJoinedMembers().map((member) => ({ member, roomSize: room.getJoinedMemberCount() })), ); const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId); + // If the iteratee in mapValues returns undefined that key will be removed from the resultant object return mapValues(userMeta, (roomMemberships) => { if (!roomMemberships.length) return; const maximumPeers = maxConsideredMembers * roomMemberships.length; @@ -112,5 +114,5 @@ export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberSc numRooms: roomMemberships.length, score: Math.max(0, Math.pow(1 - totalPeers / maximumPeers, 5)), }; - }); + }) as { [userId: string]: IMemberScore }; } diff --git a/src/utils/UserInteractiveAuth.ts b/src/utils/UserInteractiveAuth.ts index c953f0c9bdc..2eed476c3c5 100644 --- a/src/utils/UserInteractiveAuth.ts +++ b/src/utils/UserInteractiveAuth.ts @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import { IAuthDict } from "matrix-js-sdk/src/interactive-auth"; import { UIAResponse } from "matrix-js-sdk/src/@types/uia"; import Modal from "../Modal"; import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "../components/views/dialogs/InteractiveAuthDialog"; -type FunctionWithUIA = (auth?: IAuthData, ...args: A[]) => Promise>; +type FunctionWithUIA = (auth?: IAuthDict | null, ...args: A[]) => Promise>; export function wrapRequestWithDialog( requestFunction: FunctionWithUIA, @@ -29,7 +29,7 @@ export function wrapRequestWithDialog( return async function (...args): Promise { return new Promise((resolve, reject) => { const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA; - boundFunction(undefined, ...args) + boundFunction(null, ...args) .then((res) => resolve(res as R)) .catch((error) => { if (error.httpStatus !== 401 || !error.data?.flows) { @@ -40,7 +40,7 @@ export function wrapRequestWithDialog( Modal.createDialog(InteractiveAuthDialog, { ...opts, authData: error.data, - makeRequest: (authData?: IAuthData) => boundFunction(authData, ...args), + makeRequest: (authData: IAuthDict | null) => boundFunction(authData, ...args), onFinished: (success, result) => { if (success) { resolve(result as R); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index bbc75fa4f7e..63d2e26739e 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -57,6 +57,7 @@ export function arrayFastResample(input: number[], points: number): number[] { * @param {number} points The number of samples to end up with. * @returns {number[]} The resampled array. */ +// ts-prune-ignore-next export function arraySmoothingResample(input: number[], points: number): number[] { if (input.length === points) return input; // short-circuit a complicated call @@ -99,6 +100,7 @@ export function arraySmoothingResample(input: number[], points: number): number[ * @param {number} newMax The maximum value to scale to. * @returns {number[]} The rescaled array. */ +// ts-prune-ignore-next export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { const min: number = Math.min(...input); const max: number = Math.max(...input); @@ -324,6 +326,16 @@ export async function asyncEvery(values: T[], predicate: (value: T) => Promis return true; } +/** + * Async version of Array.some. + */ +export async function asyncSome(values: T[], predicate: (value: T) => Promise): Promise { + for (const value of values) { + if (await predicate(value)) return true; + } + return false; +} + export function filterBoolean(values: Array): T[] { return values.filter(Boolean) as T[]; } diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index 86ab7faa2d4..4b7d8e60e0d 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import BasePlatform from "../../BasePlatform"; import { IConfigOptions } from "../../IConfigOptions"; +import { DeepReadonly } from "../../@types/common"; export type DeviceClientInformation = { name?: string; @@ -49,7 +50,7 @@ export const getClientInformationEventType = (deviceId: string): string => `${cl */ export const recordClientInformation = async ( matrixClient: MatrixClient, - sdkConfig: IConfigOptions, + sdkConfig: DeepReadonly, platform?: BasePlatform, ): Promise => { const deviceId = matrixClient.getDeviceId()!; diff --git a/src/utils/device/isDeviceVerified.ts b/src/utils/device/isDeviceVerified.ts index 0f8fdb6082e..1671fa01a30 100644 --- a/src/utils/device/isDeviceVerified.ts +++ b/src/utils/device/isDeviceVerified.ts @@ -25,10 +25,10 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; * @returns `true` if the device has been correctly cross-signed. `false` if the device is unknown or not correctly * cross-signed. `null` if there was an error fetching the device info. */ -export const isDeviceVerified = (client: MatrixClient, deviceId: string): boolean | null => { +export const isDeviceVerified = async (client: MatrixClient, deviceId: string): Promise => { try { - const trustLevel = client.checkDeviceTrust(client.getSafeUserId(), deviceId); - return trustLevel.isCrossSigningVerified(); + const trustLevel = await client.getCrypto()?.getDeviceVerificationStatus(client.getSafeUserId(), deviceId); + return trustLevel?.crossSigningVerified ?? false; } catch (e) { console.error("Error getting device cross-signing info", e); return null; diff --git a/src/utils/localRoom/isLocalRoom.ts b/src/utils/localRoom/isLocalRoom.ts index dffebd97770..c38ffa08e99 100644 --- a/src/utils/localRoom/isLocalRoom.ts +++ b/src/utils/localRoom/isLocalRoom.ts @@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/matrix"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom"; -export function isLocalRoom(roomOrID?: Room | string): boolean { +export function isLocalRoom(roomOrID?: Room | string | null): boolean { if (typeof roomOrID === "string") { return roomOrID.startsWith(LOCAL_ROOM_ID_PREFIX); } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index eda75bb17a4..a5c13ebb046 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -28,7 +28,7 @@ export const deviceNotificationSettingsKeys = [ "audioNotificationsEnabled", ]; -export function getLocalNotificationAccountDataEventType(deviceId: string): string { +export function getLocalNotificationAccountDataEventType(deviceId: string | null): string { return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; } @@ -66,14 +66,7 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean { * @returns a promise that resolves when the room has been marked as read */ export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> { - const roomEvents = room.getLiveTimeline().getEvents(); - const lastThreadEvents = room.lastThread?.events; - - const lastRoomEvent = roomEvents?.[roomEvents?.length - 1]; - const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1]; - - const lastEvent = - (lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadLastEvent; + const lastEvent = room.getLastLiveEvent(); try { if (lastEvent) { diff --git a/src/utils/objects.ts b/src/utils/objects.ts index c2496b4c7c4..f505b71a4c6 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -141,3 +141,12 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] { export function objectClone(obj: O): O { return JSON.parse(JSON.stringify(obj)); } + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: any): item is object { + return item && typeof item === "object" && !Array.isArray(item); +} diff --git a/src/verification.ts b/src/verification.ts index c7cdd8073a8..83411d5650c 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -26,7 +26,7 @@ import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases"; import { accessSecretStorage } from "./SecurityManager"; import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; import { IDevice } from "./components/views/right_panel/UserInfo"; -import ManualDeviceKeyVerificationDialog from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import { ManualDeviceKeyVerificationDialog } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { IRightPanelCardState } from "./stores/right-panel/RightPanelStoreIPanelState"; import { findDMForUser } from "./utils/dm/findDMForUser"; diff --git a/src/workers/blurhash.worker.ts b/src/workers/blurhash.worker.ts index 031cc67c905..183ffefc43e 100644 --- a/src/workers/blurhash.worker.ts +++ b/src/workers/blurhash.worker.ts @@ -16,14 +16,19 @@ limitations under the License. import { encode } from "blurhash"; +import { WorkerPayload } from "./worker"; + const ctx: Worker = self as any; -interface IBlurhashWorkerRequest { - seq: number; +export interface Request { imageData: ImageData; } -ctx.addEventListener("message", (event: MessageEvent): void => { +export interface Response { + blurhash: string; +} + +ctx.addEventListener("message", (event: MessageEvent): void => { const { seq, imageData } = event.data; const blurhash = encode( imageData.data, diff --git a/src/workers/playback.worker.ts b/src/workers/playback.worker.ts new file mode 100644 index 00000000000..6288013f676 --- /dev/null +++ b/src/workers/playback.worker.ts @@ -0,0 +1,42 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { WorkerPayload } from "./worker"; +import { arrayRescale, arraySmoothingResample } from "../utils/arrays"; +import { PLAYBACK_WAVEFORM_SAMPLES } from "../audio/consts"; + +const ctx: Worker = self as any; + +export interface Request { + data: number[]; +} + +export interface Response { + waveform: number[]; +} + +ctx.addEventListener("message", async (event: MessageEvent): Promise => { + const { seq, data } = event.data; + + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = data.map((v) => Math.abs(v)); + + // Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon. + const waveform = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + ctx.postMessage({ seq, waveform }); +}); diff --git a/src/workers/worker.ts b/src/workers/worker.ts new file mode 100644 index 00000000000..b62fb887269 --- /dev/null +++ b/src/workers/worker.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface WorkerPayload { + seq: number; +} diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index c0470200d7b..fe6a61c90ae 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -15,10 +15,10 @@ limitations under the License. */ import { Mocked, mocked } from "jest-mock"; -import { MatrixEvent, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, Room, MatrixClient, DeviceVerificationStatus, CryptoApi } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; -import { CrossSigningInfo, DeviceTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; @@ -60,6 +60,7 @@ const flushPromises = async () => await new Promise(process.nextTick); describe("DeviceListener", () => { let mockClient: Mocked | undefined; + let mockCrypto: Mocked | undefined; // spy on various toasts' hide and show functions // easier than mocking @@ -75,6 +76,11 @@ describe("DeviceListener", () => { mockPlatformPeg({ getAppVersion: jest.fn().mockResolvedValue("1.2.3"), }); + mockCrypto = { + getDeviceVerificationStatus: jest.fn().mockResolvedValue({ + crossSigningVerified: false, + }), + } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), getUserId: jest.fn().mockReturnValue(userId), @@ -97,7 +103,7 @@ describe("DeviceListener", () => { setAccountData: jest.fn(), getAccountData: jest.fn(), deleteAccountData: jest.fn(), - checkDeviceTrust: jest.fn().mockReturnValue(new DeviceTrustLevel(false, false, false, false)), + getCrypto: jest.fn().mockReturnValue(mockCrypto), }); jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); @@ -391,14 +397,14 @@ describe("DeviceListener", () => { const device2 = new DeviceInfo("d2"); const device3 = new DeviceInfo("d3"); - const deviceTrustVerified = new DeviceTrustLevel(true, false, false, false); - const deviceTrustUnverified = new DeviceTrustLevel(false, false, false, false); + const deviceTrustVerified = new DeviceVerificationStatus({ crossSigningVerified: true }); + const deviceTrustUnverified = new DeviceVerificationStatus({}); beforeEach(() => { mockClient!.isCrossSigningReady.mockResolvedValue(true); mockClient!.getStoredDevicesForUser.mockReturnValue([currentDevice, device2, device3]); // all devices verified by default - mockClient!.checkDeviceTrust.mockReturnValue(deviceTrustVerified); + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(deviceTrustVerified); mockClient!.deviceId = currentDevice.deviceId; jest.spyOn(SettingsStore, "getValue").mockImplementation( (settingName) => settingName === UIFeature.BulkUnverifiedSessionsReminder, @@ -423,7 +429,7 @@ describe("DeviceListener", () => { jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); // currentDevice, device2 are verified, device3 is unverified // ie if reminder was enabled it should be shown - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case currentDevice.deviceId: case device2.deviceId: @@ -438,7 +444,7 @@ describe("DeviceListener", () => { it("hides toast when current device is unverified", async () => { // device2 verified, current and device3 unverified - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case device2.deviceId: return deviceTrustVerified; @@ -454,7 +460,7 @@ describe("DeviceListener", () => { it("hides toast when reminder is snoozed", async () => { mocked(isBulkUnverifiedDeviceReminderSnoozed).mockReturnValue(true); // currentDevice, device2 are verified, device3 is unverified - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case currentDevice.deviceId: case device2.deviceId: @@ -470,7 +476,7 @@ describe("DeviceListener", () => { it("shows toast with unverified devices at app start", async () => { // currentDevice, device2 are verified, device3 is unverified - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case currentDevice.deviceId: case device2.deviceId: @@ -488,7 +494,7 @@ describe("DeviceListener", () => { it("hides toast when unverified sessions at app start have been dismissed", async () => { // currentDevice, device2 are verified, device3 is unverified - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case currentDevice.deviceId: case device2.deviceId: @@ -510,7 +516,7 @@ describe("DeviceListener", () => { it("hides toast when unverified sessions are added after app start", async () => { // currentDevice, device2 are verified, device3 is unverified - mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { switch (deviceId) { case currentDevice.deviceId: case device2.deviceId: diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts index aafbc1275c7..be902e54f83 100644 --- a/test/LegacyCallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -305,7 +305,7 @@ describe("LegacyCallHandler", () => { MatrixClientPeg.unset(); document.body.removeChild(audioElement); - SdkConfig.unset(); + SdkConfig.reset(); }); it("should look up the correct user and start a call in the room when a phone number is dialled", async () => { @@ -516,7 +516,7 @@ describe("LegacyCallHandler without third party protocols", () => { MatrixClientPeg.unset(); document.body.removeChild(audioElement); - SdkConfig.unset(); + SdkConfig.reset(); }); it("should still start a native call", async () => { diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index e0b47e028ed..1ba7c01d533 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -33,6 +33,10 @@ const getFakePosthog = (): PostHog => identify: jest.fn(), reset: jest.fn(), register: jest.fn(), + get_distinct_id: jest.fn(), + persistence: { + get_user_state: jest.fn(), + }, } as unknown as PostHog); interface ITestEvent extends IPosthogEvent { @@ -52,25 +56,28 @@ describe("PosthogAnalytics", () => { beforeEach(() => { fakePosthog = getFakePosthog(); - window.crypto = { - subtle: { - digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => { - const message = new TextDecoder().decode(encodedMessage); - const hexHash = shaHashes[message]; - const bytes: number[] = []; - for (let c = 0; c < hexHash.length; c += 2) { - bytes.push(parseInt(hexHash.slice(c, c + 2), 16)); - } - return bytes as unknown as ArrayBuffer; + Object.defineProperty(window, "crypto", { + value: { + subtle: { + digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => { + const message = new TextDecoder().decode(encodedMessage); + const hexHash = shaHashes[message]; + const bytes: number[] = []; + for (let c = 0; c < hexHash.length; c += 2) { + bytes.push(parseInt(hexHash.slice(c, c + 2), 16)); + } + return bytes; + }, }, - } as unknown as SubtleCrypto, - } as unknown as Crypto; + }, + }); }); afterEach(() => { - // @ts-ignore - window.crypto = null; - SdkConfig.unset(); // we touch the config, so clean up + Object.defineProperty(window, "crypto", { + value: null, + }); + SdkConfig.reset(); // we touch the config, so clean up }); describe("Initialisation", () => { diff --git a/test/SdkConfig-test.ts b/test/SdkConfig-test.ts index a6ac58e9c59..aba0e9646af 100644 --- a/test/SdkConfig-test.ts +++ b/test/SdkConfig-test.ts @@ -30,6 +30,9 @@ describe("SdkConfig", () => { chunk_length: 42, max_length: 1337, }, + feedback: { + existing_issues_url: "https://existing", + } as any, }); }); @@ -37,7 +40,16 @@ describe("SdkConfig", () => { const customConfig = JSON.parse(JSON.stringify(DEFAULTS)); customConfig.voice_broadcast.chunk_length = 42; customConfig.voice_broadcast.max_length = 1337; + customConfig.feedback.existing_issues_url = "https://existing"; expect(SdkConfig.get()).toEqual(customConfig); }); + + it("should allow overriding individual fields of sub-objects", () => { + const feedback = SdkConfig.getObject("feedback"); + expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`); + expect(feedback.get("new_issue_url")).toMatchInlineSnapshot( + `"https://github.com/vector-im/element-web/issues/new/choose"`, + ); + }); }); }); diff --git a/test/WorkerManager-test.ts b/test/WorkerManager-test.ts new file mode 100644 index 00000000000..39057ee04dc --- /dev/null +++ b/test/WorkerManager-test.ts @@ -0,0 +1,59 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { WorkerManager } from "../src/WorkerManager"; + +describe("WorkerManager", () => { + it("should generate consecutive sequence numbers for each call", () => { + const postMessage = jest.fn(); + const manager = new WorkerManager(jest.fn(() => ({ postMessage } as unknown as Worker))); + + manager.call({ data: "One" }); + manager.call({ data: "Two" }); + manager.call({ data: "Three" }); + + const one = postMessage.mock.calls.find((c) => c[0].data === "One")!; + const two = postMessage.mock.calls.find((c) => c[0].data === "Two")!; + const three = postMessage.mock.calls.find((c) => c[0].data === "Three")!; + + expect(one[0].seq).toBe(0); + expect(two[0].seq).toBe(1); + expect(three[0].seq).toBe(2); + }); + + it("should support resolving out of order", async () => { + const postMessage = jest.fn(); + const worker = { postMessage } as unknown as Worker; + const manager = new WorkerManager(jest.fn(() => worker)); + + const oneProm = manager.call({ data: "One" }); + const twoProm = manager.call({ data: "Two" }); + const threeProm = manager.call({ data: "Three" }); + + const one = postMessage.mock.calls.find((c) => c[0].data === "One")![0].seq; + const two = postMessage.mock.calls.find((c) => c[0].data === "Two")![0].seq; + const three = postMessage.mock.calls.find((c) => c[0].data === "Three")![0].seq; + + worker.onmessage!({ data: { seq: one, data: 1 } } as MessageEvent); + await expect(oneProm).resolves.toEqual(expect.objectContaining({ data: 1 })); + + worker.onmessage!({ data: { seq: three, data: 3 } } as MessageEvent); + await expect(threeProm).resolves.toEqual(expect.objectContaining({ data: 3 })); + + worker.onmessage!({ data: { seq: two, data: 2 } } as MessageEvent); + await expect(twoProm).resolves.toEqual(expect.objectContaining({ data: 2 })); + }); +}); diff --git a/test/audio/Playback-test.ts b/test/audio/Playback-test.ts index 8aa96f2a201..d1eb1e58d15 100644 --- a/test/audio/Playback-test.ts +++ b/test/audio/Playback-test.ts @@ -20,6 +20,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { createAudioContext, decodeOgg } from "../../src/audio/compat"; import { Playback, PlaybackState } from "../../src/audio/Playback"; +jest.mock("../../src/WorkerManager", () => ({ + WorkerManager: jest.fn(() => ({ + call: jest.fn().mockResolvedValue({ waveform: [0, 0, 1, 1] }), + })), +})); + jest.mock("../../src/audio/compat", () => ({ createAudioContext: jest.fn(), decodeOgg: jest.fn(), diff --git a/test/components/structures/LoggedInView-test.tsx b/test/components/structures/LoggedInView-test.tsx index 5e389d65dea..6c9a3abb656 100644 --- a/test/components/structures/LoggedInView-test.tsx +++ b/test/components/structures/LoggedInView-test.tsx @@ -54,6 +54,7 @@ describe("", () => { element_call: {}, }, currentRoomId: "", + currentUserId: "@bob:server", }; const getComponent = (props = {}): RenderResult => diff --git a/test/components/structures/RoomSearchView-test.tsx b/test/components/structures/RoomSearchView-test.tsx index 45563f0839b..199d1eecc50 100644 --- a/test/components/structures/RoomSearchView-test.tsx +++ b/test/components/structures/RoomSearchView-test.tsx @@ -28,7 +28,6 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { RoomSearchView } from "../../../src/components/structures/RoomSearchView"; import { SearchScope } from "../../../src/components/views/rooms/SearchBar"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks"; import { stubClient } from "../../test-utils"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -43,15 +42,13 @@ describe("", () => { const resizeNotifier = new ResizeNotifier(); let client: MatrixClient; let room: Room; - let permalinkCreator: RoomPermalinkCreator; beforeEach(async () => { stubClient(); client = MatrixClientPeg.get(); client.supportsThreads = jest.fn().mockReturnValue(true); - room = new Room("!room:server", client, client.getUserId()!); + room = new Room("!room:server", client, client.getSafeUserId()); mocked(client.getRoom).mockReturnValue(room); - permalinkCreator = new RoomPermalinkCreator(room, room.roomId); jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(100); }); @@ -69,7 +66,6 @@ describe("", () => { scope={SearchScope.All} promise={deferred.promise} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} />, @@ -92,7 +88,7 @@ describe("", () => { result: { room_id: room.roomId, event_id: "$2", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 1, content: { body: "Foo Test Bar", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -103,7 +99,7 @@ describe("", () => { { room_id: room.roomId, event_id: "$1", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 1, content: { body: "Before", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -113,7 +109,7 @@ describe("", () => { { room_id: room.roomId, event_id: "$3", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 1, content: { body: "After", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -128,7 +124,6 @@ describe("", () => { count: 1, })} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -154,7 +149,7 @@ describe("", () => { result: { room_id: room.roomId, event_id: "$2", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 1, content: { body: "Foo Test Bar", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -172,7 +167,6 @@ describe("", () => { count: 1, })} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -192,7 +186,7 @@ describe("", () => { result: { room_id: room.roomId, event_id: "$2", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 1, content: { body: "Foo Test Bar", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -221,7 +215,7 @@ describe("", () => { result: { room_id: room.roomId, event_id: "$4", - sender: client.getUserId()!, + sender: client.getSafeUserId(), origin_server_ts: 4, content: { body: "Potato", msgtype: "m.text" }, type: EventType.RoomMessage, @@ -245,7 +239,6 @@ describe("", () => { scope={SearchScope.All} promise={Promise.resolve(searchResults)} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -267,7 +260,6 @@ describe("", () => { scope={SearchScope.All} promise={deferred.promise} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -291,7 +283,6 @@ describe("", () => { scope={SearchScope.All} promise={deferred.promise} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -315,7 +306,6 @@ describe("", () => { scope={SearchScope.All} promise={deferred.promise} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -417,7 +407,6 @@ describe("", () => { scope={SearchScope.All} promise={Promise.resolve(searchResults)} resizeNotifier={resizeNotifier} - permalinkCreator={permalinkCreator} className="someClass" onUpdate={jest.fn()} /> @@ -437,4 +426,130 @@ describe("", () => { expect(betweenNode.compareDocumentPosition(foo2Node) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(foo2Node.compareDocumentPosition(afterNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + + it("should pass appropriate permalink creator for all rooms search", async () => { + const room2 = new Room("!room2:server", client, client.getSafeUserId()); + const room3 = new Room("!room3:server", client, client.getSafeUserId()); + mocked(client.getRoom).mockImplementation( + (roomId) => [room, room2, room3].find((r) => r.roomId === roomId) ?? null, + ); + + render( + + ({ + results: [ + SearchResult.fromJson( + { + rank: 1, + result: { + room_id: room.roomId, + event_id: "$2", + sender: client.getSafeUserId(), + origin_server_ts: 1, + content: { body: "Room 1", msgtype: "m.text" }, + type: EventType.RoomMessage, + }, + context: { + profile_info: {}, + events_before: [], + events_after: [], + }, + }, + eventMapper, + ), + SearchResult.fromJson( + { + rank: 2, + result: { + room_id: room2.roomId, + event_id: "$22", + sender: client.getSafeUserId(), + origin_server_ts: 1, + content: { body: "Room 2", msgtype: "m.text" }, + type: EventType.RoomMessage, + }, + context: { + profile_info: {}, + events_before: [], + events_after: [], + }, + }, + eventMapper, + ), + SearchResult.fromJson( + { + rank: 2, + result: { + room_id: room2.roomId, + event_id: "$23", + sender: client.getSafeUserId(), + origin_server_ts: 2, + content: { body: "Room 2 message 2", msgtype: "m.text" }, + type: EventType.RoomMessage, + }, + context: { + profile_info: {}, + events_before: [], + events_after: [], + }, + }, + eventMapper, + ), + SearchResult.fromJson( + { + rank: 3, + result: { + room_id: room3.roomId, + event_id: "$32", + sender: client.getSafeUserId(), + origin_server_ts: 1, + content: { body: "Room 3", msgtype: "m.text" }, + type: EventType.RoomMessage, + }, + context: { + profile_info: {}, + events_before: [], + events_after: [], + }, + }, + eventMapper, + ), + ], + highlights: [], + count: 1, + })} + resizeNotifier={resizeNotifier} + className="someClass" + onUpdate={jest.fn()} + /> + , + ); + + const event1 = await screen.findByText("Room 1"); + expect(event1.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute( + "href", + `https://matrix.to/#/${room.roomId}/$2`, + ); + + const event2 = await screen.findByText("Room 2"); + expect(event2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute( + "href", + `https://matrix.to/#/${room2.roomId}/$22`, + ); + + const event2Message2 = await screen.findByText("Room 2 message 2"); + expect(event2Message2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute( + "href", + `https://matrix.to/#/${room2.roomId}/$23`, + ); + + const event3 = await screen.findByText("Room 3"); + expect(event3.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute( + "href", + `https://matrix.to/#/${room3.roomId}/$32`, + ); + }); }); diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 5f248140c89..b81a84facaa 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -32,11 +32,9 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; import SettingsStore from "../../../src/settings/SettingsStore"; // Fake random strings to give a predictable snapshot for checkbox IDs -jest.mock("matrix-js-sdk/src/randomstring", () => { - return { - randomString: () => "abdefghi", - }; -}); +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); describe("SpaceHierarchy", () => { describe("showRoom", () => { diff --git a/test/components/structures/TabbedView-test.tsx b/test/components/structures/TabbedView-test.tsx index 922c7ccc3f1..70e1cf6c9a4 100644 --- a/test/components/structures/TabbedView-test.tsx +++ b/test/components/structures/TabbedView-test.tsx @@ -26,11 +26,11 @@ describe("", () => { const securityTab = new Tab("SECURITY", "Security", "security",
    security
    ); const defaultProps = { tabLocation: TabLocation.LEFT, - tabs: [generalTab, labsTab, securityTab] as NonEmptyArray, + tabs: [generalTab, labsTab, securityTab] as NonEmptyArray>, }; const getComponent = (props = {}): React.ReactElement => ; - const getTabTestId = (tab: Tab): string => `settings-tab-${tab.id}`; + const getTabTestId = (tab: Tab): string => `settings-tab-${tab.id}`; const getActiveTab = (container: HTMLElement): Element | undefined => container.getElementsByClassName("mx_TabbedView_tabLabel_active")[0]; const getActiveTabBody = (container: HTMLElement): Element | undefined => diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index d32c9a75d7d..b1a6c4ea5d0 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -55,14 +55,11 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
    -
    - -
    +
    @@ -149,14 +146,11 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
    -
    - -
    +
    @@ -339,14 +333,11 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
    -
    - -
    +
    @@ -606,14 +597,11 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
    -
    - -
    +
    diff --git a/test/components/structures/__snapshots__/TabbedView-test.tsx.snap b/test/components/structures/__snapshots__/TabbedView-test.tsx.snap index 77ead236a31..acd6a4bae64 100644 --- a/test/components/structures/__snapshots__/TabbedView-test.tsx.snap +++ b/test/components/structures/__snapshots__/TabbedView-test.tsx.snap @@ -6,12 +6,16 @@ exports[` renders tabs 1`] = ` class="mx_TabbedView mx_TabbedView_tabsOnLeft" >
    renders tabs 1`] = ` /> General
    Labs
    Security
    ", () => { filterConsole( // not implemented by js-dom https://github.com/jsdom/jsdom/issues/1937 "Not implemented: HTMLFormElement.prototype.requestSubmit", - // not of interested for this test - "Starting load of AsyncWrapper for modal", ); beforeEach(() => { diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index bf9e8d567ce..a84e88b17c1 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -61,7 +61,7 @@ describe("Login", function () { afterEach(function () { fetchMock.restore(); - SdkConfig.unset(); // we touch the config, so clean up + SdkConfig.reset(); // we touch the config, so clean up unmockPlatformPeg(); }); diff --git a/test/components/structures/auth/Registration-test.tsx b/test/components/structures/auth/Registration-test.tsx index 16b64bc393c..3f6f44db7ec 100644 --- a/test/components/structures/auth/Registration-test.tsx +++ b/test/components/structures/auth/Registration-test.tsx @@ -66,7 +66,7 @@ describe("Registration", function () { afterEach(function () { fetchMock.restore(); - SdkConfig.unset(); // we touch the config, so clean up + SdkConfig.reset(); // we touch the config, so clean up unmockPlatformPeg(); }); diff --git a/test/components/views/audio_messages/RecordingPlayback-test.tsx b/test/components/views/audio_messages/RecordingPlayback-test.tsx index ec20f12d637..e48570021a1 100644 --- a/test/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/components/views/audio_messages/RecordingPlayback-test.tsx @@ -26,6 +26,12 @@ import { createAudioContext } from "../../../../src/audio/compat"; import { flushPromises } from "../../../test-utils"; import { IRoomState } from "../../../../src/components/structures/RoomView"; +jest.mock("../../../../src/WorkerManager", () => ({ + WorkerManager: jest.fn(() => ({ + call: jest.fn().mockResolvedValue({ waveform: [0, 0, 1, 1] }), + })), +})); + jest.mock("../../../../src/audio/compat", () => ({ createAudioContext: jest.fn(), decodeOgg: jest.fn().mockResolvedValue({}), diff --git a/test/components/views/auth/CountryDropdown-test.tsx b/test/components/views/auth/CountryDropdown-test.tsx index a6beeda233d..95cd5abe75f 100644 --- a/test/components/views/auth/CountryDropdown-test.tsx +++ b/test/components/views/auth/CountryDropdown-test.tsx @@ -23,7 +23,7 @@ import SdkConfig from "../../../../src/SdkConfig"; describe("CountryDropdown", () => { describe("default_country_code", () => { afterEach(() => { - SdkConfig.unset(); + SdkConfig.reset(); }); it.each([ diff --git a/test/components/views/context_menus/RoomContextMenu-test.tsx b/test/components/views/context_menus/RoomContextMenu-test.tsx index 1d1dffce5f9..a1e9f58b702 100644 --- a/test/components/views/context_menus/RoomContextMenu-test.tsx +++ b/test/components/views/context_menus/RoomContextMenu-test.tsx @@ -1,6 +1,7 @@ /* Copyright 2023 Mikhail Aheichyk Copyright 2023 Nordeck IT + Consulting GmbH. +Copyright 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,6 +28,7 @@ import { shouldShowComponent } from "../../../../src/customisations/helpers/UICo import { stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -58,8 +60,8 @@ describe("RoomContextMenu", () => { onFinished = jest.fn(); }); - function getComponent(props: Partial> = {}) { - return render( + function renderComponent(props: Partial> = {}) { + render( , @@ -70,7 +72,7 @@ describe("RoomContextMenu", () => { jest.spyOn(room, "canInvite").mockReturnValue(true); mocked(shouldShowComponent).mockReturnValue(false); - getComponent(); + renderComponent(); expect(screen.queryByRole("menuitem", { name: "Invite" })).not.toBeInTheDocument(); }); @@ -79,8 +81,24 @@ describe("RoomContextMenu", () => { jest.spyOn(room, "canInvite").mockReturnValue(true); mocked(shouldShowComponent).mockReturnValue(true); - getComponent(); + renderComponent(); expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument(); }); + + it("when developer mode is disabled, it should not render the developer tools option", () => { + renderComponent(); + expect(screen.queryByText("Developer tools")).not.toBeInTheDocument(); + }); + + describe("when developer mode is enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "developerMode"); + }); + + it("should render the developer tools option", () => { + renderComponent(); + expect(screen.getByText("Developer tools")).toBeInTheDocument(); + }); + }); }); diff --git a/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx index cadf067ef55..df04e1c054f 100644 --- a/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import React from "react"; +import userEvent from "@testing-library/user-event"; import { ChevronFace } from "../../../../src/components/structures/ContextMenu"; import { @@ -34,6 +35,8 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { mkMessage, stubClient } from "../../../test-utils/test-utils"; import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../src/settings/UIFeature"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import Modal from "../../../../src/Modal"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -87,6 +90,10 @@ describe("RoomGeneralContextMenu", () => { onFinished = jest.fn(); }); + afterEach(() => { + Modal.closeCurrentModal("force"); + }); + it("renders an empty context menu for archived rooms", async () => { jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([DefaultTagID.Archived]); @@ -138,4 +145,28 @@ describe("RoomGeneralContextMenu", () => { expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(event, ReceiptType.Read, true); expect(onFinished).toHaveBeenCalled(); }); + + it("when developer mode is disabled, it should not render the developer tools option", () => { + getComponent(); + expect(screen.queryByText("Developer tools")).not.toBeInTheDocument(); + }); + + describe("when developer mode is enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "developerMode"); + getComponent(); + }); + + it("should render the developer tools option", async () => { + const developerToolsItem = screen.getByRole("menuitem", { name: "Developer tools" }); + expect(developerToolsItem).toBeInTheDocument(); + + // click open developer tools dialog + await userEvent.click(developerToolsItem); + + // assert that the dialog is displayed by searching some if its contents + expect(await screen.findByText("Toolbox")).toBeInTheDocument(); + expect(await screen.findByText(`Room ID: ${ROOM_ID}`)).toBeInTheDocument(); + }); + }); }); diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx index 7d66f39dfb5..22bcde2557f 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -30,6 +30,7 @@ describe("AccessSecretStorageDialog", () => { let mockClient: Mocked; const defaultProps: ComponentProps = { + keyInfo: {} as any, onFinished: jest.fn(), checkPrivateKey: jest.fn(), }; diff --git a/test/components/views/dialogs/FeedbackDialog-test.tsx b/test/components/views/dialogs/FeedbackDialog-test.tsx new file mode 100644 index 00000000000..73dadd00b57 --- /dev/null +++ b/test/components/views/dialogs/FeedbackDialog-test.tsx @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render } from "@testing-library/react"; + +import SdkConfig from "../../../../src/SdkConfig"; +import FeedbackDialog from "../../../../src/components/views/dialogs/FeedbackDialog"; + +describe("FeedbackDialog", () => { + it("should respect feedback config", () => { + SdkConfig.put({ + feedback: { + existing_issues_url: "http://existing?foo=bar", + new_issue_url: "https://new.issue.url?foo=bar", + }, + }); + + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index 89be4f80300..9b13d9fc77f 100644 --- a/test/components/views/dialogs/InviteDialog-test.tsx +++ b/test/components/views/dialogs/InviteDialog-test.tsx @@ -104,7 +104,6 @@ describe("InviteDialog", () => { "Error retrieving profile for userId @carol:example.com", "Error retrieving profile for userId @localpart:server.tld", "Error retrieving profile for userId @localpart:server:tld", - "Starting load of AsyncWrapper for modal", "[Invite:Recents] Excluding @alice:example.org from recents", ); diff --git a/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx b/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx new file mode 100644 index 00000000000..43912b2bc68 --- /dev/null +++ b/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Device } from "matrix-js-sdk/src/models/device"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { stubClient } from "../../../test-utils"; +import { ManualDeviceKeyVerificationDialog } from "../../../../src/components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; + +describe("ManualDeviceKeyVerificationDialog", () => { + let mockClient: MatrixClient; + + function renderDialog(userId: string, device: Device, onLegacyFinished: (confirm: boolean) => void) { + return render( + + + , + ); + } + + beforeEach(() => { + mockClient = stubClient(); + }); + + it("should display the device", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const { container } = renderDialog(mockClient.getUserId()!, device, jest.fn()); + + // Then + expect(container).toMatchSnapshot(); + }); + + it("should display the device of another user", () => { + // When + const userId = "@alice:example.com"; + const deviceId = "XYZ"; + const device = new Device({ + userId, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const { container } = renderDialog(userId, device, jest.fn()); + + // Then + expect(container).toMatchSnapshot(); + }); + + it("should call onFinished and matrixClient.setDeviceVerified", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const onFinished = jest.fn(); + renderDialog(mockClient.getUserId()!, device, onFinished); + + screen.getByRole("button", { name: "Verify session" }).click(); + + // Then + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.setDeviceVerified).toHaveBeenCalledWith(mockClient.getUserId(), deviceId, true); + }); + + it("should call onFinished and not matrixClient.setDeviceVerified", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const onFinished = jest.fn(); + renderDialog(mockClient.getUserId()!, device, onFinished); + + screen.getByRole("button", { name: "Cancel" }).click(); + + // Then + expect(onFinished).toHaveBeenCalledWith(false); + expect(mockClient.setDeviceVerified).not.toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx index 28f1aa47658..c220e5a0f4a 100644 --- a/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx +++ b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx @@ -43,7 +43,7 @@ describe("", () => { return result; } - function mockEdits(...edits: { msg: string; ts: number | undefined }[]) { + function mockEdits(...edits: { msg: string; ts?: number }[]) { client.relations.mockImplementation(() => Promise.resolve({ events: edits.map( diff --git a/test/components/views/dialogs/RoomSettingsDialog-test.tsx b/test/components/views/dialogs/RoomSettingsDialog-test.tsx index 5a4356cb334..566358de790 100644 --- a/test/components/views/dialogs/RoomSettingsDialog-test.tsx +++ b/test/components/views/dialogs/RoomSettingsDialog-test.tsx @@ -38,24 +38,46 @@ describe("", () => { const roomId = "!room:server.org"; const room = new Room(roomId, mockClient, userId); + room.name = "Test Room"; + const room2 = new Room("!room2:server.org", mockClient, userId); + room2.name = "Another Room"; jest.spyOn(SettingsStore, "getValue"); beforeEach(() => { jest.clearAllMocks(); - mockClient.getRoom.mockReturnValue(room); + mockClient.getRoom.mockImplementation((roomId) => { + if (roomId === room.roomId) return room; + if (roomId === room2.roomId) return room2; + return null; + }); jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); }); - const getComponent = (onFinished = jest.fn()) => - render(, { + const getComponent = (onFinished = jest.fn(), propRoomId = roomId) => + render(, { wrapper: ({ children }) => ( {children} ), }); + it("catches errors when room is not found", () => { + getComponent(undefined, "!room-that-does-not-exist"); + expect(screen.getByText("Something went wrong!")).toBeInTheDocument(); + }); + + it("updates when roomId prop changes", () => { + const { rerender, getByText } = getComponent(undefined, roomId); + + expect(getByText(`Room Settings - ${room.name}`)).toBeInTheDocument(); + + rerender(); + + expect(getByText(`Room Settings - ${room2.name}`)).toBeInTheDocument(); + }); + describe("Settings tabs", () => { it("renders default tabs correctly", () => { const { container } = getComponent(); diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index e587ffbe5cd..fb1fc1e65ff 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -174,7 +174,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); }); @@ -196,7 +196,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); @@ -242,7 +242,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0]!.innerHTML).toContain(testPublicRoom.name); @@ -265,7 +265,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); @@ -324,7 +324,7 @@ describe("Spotlight Dialog", () => { await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; - options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + options = content.querySelectorAll("li.mx_SpotlightDialog_option"); }); it("should find Rooms", () => { @@ -350,7 +350,7 @@ describe("Spotlight Dialog", () => { jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); - const options = document.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = document.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); @@ -372,7 +372,7 @@ describe("Spotlight Dialog", () => { await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); diff --git a/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap new file mode 100644 index 00000000000..2682f5234cc --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackDialog should respect feedback config 1`] = ` + +
    + +
    + +`; diff --git a/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap new file mode 100644 index 00000000000..f51e881e2a3 --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManualDeviceKeyVerificationDialog should display the device 1`] = ` +
    +
    + -
    +
  • Jump to room search @@ -842,8 +842,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Next unread room or DM @@ -868,8 +868,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Previous unread room or DM @@ -894,8 +894,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Next room or DM @@ -914,8 +914,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Previous room or DM @@ -934,9 +934,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    + -
    +
    Autocomplete
    -
    +
      -
      Cancel autocomplete @@ -961,8 +961,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Next autocomplete suggestion @@ -975,8 +975,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Previous autocomplete suggestion @@ -989,8 +989,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Complete @@ -1003,8 +1003,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Force complete @@ -1017,9 +1017,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    + -
    +
    diff --git a/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap new file mode 100644 index 00000000000..45340dac369 --- /dev/null +++ b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap @@ -0,0 +1,1049 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreferencesUserSettingsTab should render 1`] = ` + +
    +
    + Preferences +
    +
    + + Room list + +
    + +
    +
    +
    +
    +
    +
    + + Spaces + +
    + +
    +
    +
    +
    +
    +
    + + Keyboard shortcuts + +
    + + To view all keyboard shortcuts, + + . + +
    +
    + +
    +
    +
    +
    +
    +
    + + Displaying time + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Presence + + + Share your activity and status with others. + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Composer + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Code blocks + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Images, GIFs and videos + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Timeline + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + Room directory + +
    + +
    +
    +
    +
    +
    +
    + + General + +
    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +`; diff --git a/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx b/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx index 1d2ee066f98..a7d507e0114 100644 --- a/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx +++ b/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import { act, fireEvent, render, RenderResult } from "@testing-library/react"; import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; @@ -27,6 +28,11 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; const SpaceSettingsVisibilityTab = wrapInMatrixClientContext(_SpaceSettingsVisibilityTab); +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: jest.fn(), +})); + jest.useFakeTimers(); describe("", () => { @@ -89,13 +95,16 @@ describe("", () => { const toggleButton = getByTestId("toggle-guest-access-btn")!; fireEvent.click(toggleButton); }; - const getGuestAccessToggle = ({ container }: RenderResult) => - container.querySelector('[aria-label="Enable guest access"]'); - const getHistoryVisibilityToggle = ({ container }: RenderResult) => - container.querySelector('[aria-label="Preview Space"]'); + const getGuestAccessToggle = ({ getByLabelText }: RenderResult) => getByLabelText("Enable guest access"); + const getHistoryVisibilityToggle = ({ getByLabelText }: RenderResult) => getByLabelText("Preview Space"); const getErrorMessage = ({ getByTestId }: RenderResult) => getByTestId("space-settings-error")?.textContent; beforeEach(() => { + let i = 0; + mocked(randomString).mockImplementation(() => { + return "testid_" + i++; + }); + (mockMatrixClient.sendStateEvent as jest.Mock).mockClear().mockResolvedValue({}); MatrixClientPeg.get = jest.fn().mockReturnValue(mockMatrixClient); }); diff --git a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap index 8de0ae2c153..a93fda9d6a5 100644 --- a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap @@ -4,7 +4,7 @@ exports[` for a public space Access renders guest
    renders container 1`] = ` - Preview Space +
    + Preview Space +
    > = {}): RenderResult => { + const propsWithDefaults = { + acceptLabel: "Accept", + description:
    Description
    , + onAccept: () => {}, + onReject: () => {}, + rejectLabel: "Reject", + ...props, + }; + + return render(); +}; + +describe("GenericToast", () => { + it("should render as expected with detail content", () => { + const { asFragment } = renderGenericToast(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render as expected without detail content", () => { + const { asFragment } = renderGenericToast({ + detail: "Detail", + }); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/toasts/__snapshots__/GenericToast-test.tsx.snap b/test/components/views/toasts/__snapshots__/GenericToast-test.tsx.snap new file mode 100644 index 00000000000..ee033f70f4a --- /dev/null +++ b/test/components/views/toasts/__snapshots__/GenericToast-test.tsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenericToast should render as expected with detail content 1`] = ` + +
    +
    +
    + Description +
    +
    +
    +
    + Reject +
    +
    + Accept +
    +
    +
    +
    +`; + +exports[`GenericToast should render as expected without detail content 1`] = ` + +
    +
    +
    + Description +
    +
    + Detail +
    +
    +
    +
    + Reject +
    +
    + Accept +
    +
    +
    +
    +`; diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index 0c34041b071..a9387bfcdfb 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -15,8 +15,7 @@ limitations under the License. */ import { mocked, Mocked } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { CryptoApi, MatrixClient, Device } from "matrix-js-sdk/src/matrix"; import { RoomType } from "matrix-js-sdk/src/@types/event"; import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-utils"; @@ -151,30 +150,32 @@ describe("canEncryptToAllUsers", () => { const user2Id = "@user2:example.com"; const devices = new Map([ - ["DEV1", {} as unknown as DeviceInfo], - ["DEV2", {} as unknown as DeviceInfo], + ["DEV1", {} as unknown as Device], + ["DEV2", {} as unknown as Device], ]); let client: Mocked; + let cryptoApi: Mocked; beforeAll(() => { client = mocked(stubClient()); + cryptoApi = mocked(client.getCrypto()!); }); it("should return true if userIds is empty", async () => { - client.downloadKeys.mockResolvedValue(new Map()); + cryptoApi.getUserDeviceInfo.mockResolvedValue(new Map()); const result = await canEncryptToAllUsers(client, []); expect(result).toBe(true); }); it("should return true if download keys does not return any user", async () => { - client.downloadKeys.mockResolvedValue(new Map()); + cryptoApi.getUserDeviceInfo.mockResolvedValue(new Map()); const result = await canEncryptToAllUsers(client, [user1Id, user2Id]); expect(result).toBe(true); }); it("should return false if none of the users has a device", async () => { - client.downloadKeys.mockResolvedValue( + cryptoApi.getUserDeviceInfo.mockResolvedValue( new Map([ [user1Id, new Map()], [user2Id, new Map()], @@ -185,7 +186,7 @@ describe("canEncryptToAllUsers", () => { }); it("should return false if some of the users don't have a device", async () => { - client.downloadKeys.mockResolvedValue( + cryptoApi.getUserDeviceInfo.mockResolvedValue( new Map([ [user1Id, new Map()], [user2Id, devices], @@ -196,7 +197,7 @@ describe("canEncryptToAllUsers", () => { }); it("should return true if all users have a device", async () => { - client.downloadKeys.mockResolvedValue( + cryptoApi.getUserDeviceInfo.mockResolvedValue( new Map([ [user1Id, devices], [user2Id, devices], diff --git a/test/editor/mock.ts b/test/editor/mock.ts index 931e3cb5b3c..d1fcc45e96a 100644 --- a/test/editor/mock.ts +++ b/test/editor/mock.ts @@ -21,7 +21,7 @@ import { Caret } from "../../src/editor/caret"; import { PillPart, Part, PartCreator } from "../../src/editor/parts"; import DocumentPosition from "../../src/editor/position"; -class MockAutoComplete { +export class MockAutoComplete { public _updateCallback; public _partCreator; public _completions; @@ -44,7 +44,7 @@ class MockAutoComplete { }); if (matches.length === 1 && this._part && this._part.text.length > 1) { const match = matches[0]; - let pill; + let pill: PillPart; if (match.resourceId[0] === "@") { pill = this._partCreator.userPill(match.text, match.resourceId); } else { diff --git a/test/editor/model-test.ts b/test/editor/model-test.ts index cb475f696b2..89d88c1d38e 100644 --- a/test/editor/model-test.ts +++ b/test/editor/model-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import { createPartCreator, createRenderer } from "./mock"; +import { createPartCreator, createRenderer, MockAutoComplete } from "./mock"; import DocumentOffset from "../../src/editor/offset"; import { PillPart } from "../../src/editor/parts"; import DocumentPosition from "../../src/editor/position"; @@ -186,8 +186,7 @@ describe("editor/model", function () { expect(model.parts[1].text).toBe("@a"); // this is a hacky mock function - // @ts-ignore - model.autoComplete.tryComplete(); // see MockAutoComplete + (model.autoComplete as unknown as MockAutoComplete).tryComplete(); expect(renderer.count).toBe(2); expect((renderer.caret as DocumentPosition).index).toBe(1); @@ -216,8 +215,7 @@ describe("editor/model", function () { expect(model.parts[1].text).toBe("#r"); // this is a hacky mock function - // @ts-ignore - model.autoComplete.tryComplete(); // see MockAutoComplete + (model.autoComplete as unknown as MockAutoComplete).tryComplete(); expect(renderer.count).toBe(2); expect((renderer.caret as DocumentPosition).index).toBe(1); @@ -236,8 +234,7 @@ describe("editor/model", function () { model.update("hello #r", "insertText", new DocumentOffset(8, true)); // this is a hacky mock function - // @ts-ignore - model.autoComplete.tryComplete(); // see MockAutoComplete + (model.autoComplete as unknown as MockAutoComplete).tryComplete(); model.update("hello #riot-dev!!", "insertText", new DocumentOffset(17, true)); expect(renderer.count).toBe(3); @@ -314,6 +311,45 @@ describe("editor/model", function () { expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].text).toBe("foo@a"); }); + + it("should allow auto-completing multiple times with resets between them", () => { + const renderer = createRenderer(); + const pc = createPartCreator([{ resourceId: "#riot-dev" } as PillPart]); + const model = new EditorModel([pc.plain("")], pc, renderer); + + model.update("#r", "insertText", new DocumentOffset(8, true)); + + expect(renderer.count).toBe(1); + expect((renderer.caret as DocumentPosition).index).toBe(0); + expect((renderer.caret as DocumentPosition).offset).toBe(2); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("pill-candidate"); + expect(model.parts[0].text).toBe("#r"); + + // this is a hacky mock function + (model.autoComplete as unknown as MockAutoComplete).tryComplete(); + + expect(renderer.count).toBe(2); + expect((renderer.caret as DocumentPosition).index).toBe(0); + expect((renderer.caret as DocumentPosition).offset).toBe(9); + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("room-pill"); + expect(model.parts[0].text).toBe("#riot-dev"); + + model.reset([]); + model.update("#r", "insertText", new DocumentOffset(8, true)); + + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("pill-candidate"); + expect(model.parts[0].text).toBe("#r"); + + // this is a hacky mock function + (model.autoComplete as unknown as MockAutoComplete).tryComplete(); + + expect(model.parts.length).toBe(1); + expect(model.parts[0].type).toBe("room-pill"); + expect(model.parts[0].text).toBe("#riot-dev"); + }); }); describe("emojis", function () { it("regional emojis should be separated to prevent them to be converted to flag", () => { diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts index 556c12fe05d..0f22962831e 100644 --- a/test/languageHandler-test.ts +++ b/test/languageHandler-test.ts @@ -42,7 +42,7 @@ async function setupTranslationOverridesForTests(overrides: ICustomTranslations) describe("languageHandler", () => { afterEach(() => { - SdkConfig.unset(); + SdkConfig.reset(); CustomTranslationOptions.lookupFn = undefined; }); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index da10098e53c..43d26f0dbce 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -1050,6 +1050,12 @@ describe("ElementCall", () => { call.off(CallEvent.Destroy, onDestroy); }); + + it("clears widget persistence when destroyed", async () => { + const destroyPersistentWidgetSpy = jest.spyOn(ActiveWidgetStore.instance, "destroyPersistentWidget"); + call.destroy(); + expect(destroyPersistentWidgetSpy).toHaveBeenCalled(); + }); }); describe("instance in a video room", () => { diff --git a/test/settings/handlers/DeviceSettingsHandler-test.ts b/test/settings/handlers/DeviceSettingsHandler-test.ts new file mode 100644 index 00000000000..19d19d4c819 --- /dev/null +++ b/test/settings/handlers/DeviceSettingsHandler-test.ts @@ -0,0 +1,81 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import DeviceSettingsHandler from "../../../src/settings/handlers/DeviceSettingsHandler"; +import { CallbackFn, WatchManager } from "../../../src/settings/WatchManager"; +import { stubClient } from "../../test-utils/test-utils"; + +describe("DeviceSettingsHandler", () => { + const ROOM_ID_IS_UNUSED = ""; + + const unknownSettingKey = "unknown_setting"; + const featureKey = "my_feature"; + + let watchers: WatchManager; + let handler: DeviceSettingsHandler; + let settingListener: CallbackFn; + + beforeEach(() => { + watchers = new WatchManager(); + handler = new DeviceSettingsHandler([featureKey], watchers); + settingListener = jest.fn(); + }); + + afterEach(() => { + watchers.unwatchSetting(settingListener); + }); + + it("Returns undefined for an unknown setting", () => { + expect(handler.getValue(unknownSettingKey, ROOM_ID_IS_UNUSED)).toBeUndefined(); + }); + + it("Returns the value for a disabled feature", () => { + handler.setValue(featureKey, ROOM_ID_IS_UNUSED, false); + expect(handler.getValue(featureKey, ROOM_ID_IS_UNUSED)).toBe(false); + }); + + it("Returns the value for an enabled feature", () => { + handler.setValue(featureKey, ROOM_ID_IS_UNUSED, true); + expect(handler.getValue(featureKey, ROOM_ID_IS_UNUSED)).toBe(true); + }); + + describe("If I am a guest", () => { + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + mocked(client.isGuest).mockReturnValue(true); + }); + + afterEach(() => { + MatrixClientPeg.get = () => null; + }); + + it("Returns the value for a disabled feature", () => { + handler.setValue(featureKey, ROOM_ID_IS_UNUSED, false); + expect(handler.getValue(featureKey, ROOM_ID_IS_UNUSED)).toBe(false); + }); + + it("Returns the value for an enabled feature", () => { + handler.setValue(featureKey, ROOM_ID_IS_UNUSED, true); + expect(handler.getValue(featureKey, ROOM_ID_IS_UNUSED)).toBe(true); + }); + }); +}); diff --git a/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts new file mode 100644 index 00000000000..6db3c369fb0 --- /dev/null +++ b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts @@ -0,0 +1,298 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; +import { ImportanceAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm"; +import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models"; +import * as RoomNotifs from "../../../../../src/RoomNotifs"; +import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models"; +import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; +import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; + +describe("ImportanceAlgorithm", () => { + const userId = "@alice:server.org"; + const tagId = DefaultTagID.Favourite; + + const makeRoom = (id: string, name: string, order?: number): Room => { + const room = new Room(id, client, userId); + room.name = name; + const tagEvent = new MatrixEvent({ + type: "m.tag", + content: { + tags: { + [tagId]: { + order, + }, + }, + }, + }); + room.addTags(tagEvent); + return room; + }; + + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + }); + const roomA = makeRoom("!aaa:server.org", "Alpha", 2); + const roomB = makeRoom("!bbb:server.org", "Bravo", 5); + const roomC = makeRoom("!ccc:server.org", "Charlie", 1); + const roomD = makeRoom("!ddd:server.org", "Delta", 4); + const roomE = makeRoom("!eee:server.org", "Echo", 3); + const roomX = makeRoom("!xxx:server.org", "Xylophone", 99); + + const unreadStates: Record> = { + red: { symbol: null, count: 1, color: NotificationColor.Red }, + grey: { symbol: null, count: 1, color: NotificationColor.Grey }, + none: { symbol: null, count: 0, color: NotificationColor.None }, + }; + + beforeEach(() => { + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + }); + + const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => { + const algorithm = new ImportanceAlgorithm(tagId, sortAlgorithm); + algorithm.setRooms(rooms || [roomA, roomB, roomC]); + return algorithm; + }; + + describe("When sortAlgorithm is manual", () => { + const sortAlgorithm = SortAlgorithm.Manual; + it("orders rooms by tag order without categorizing", () => { + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState"); + const algorithm = setupAlgorithm(sortAlgorithm); + + // didn't check notif state + expect(RoomNotificationStateStore.instance.getRoomState).not.toHaveBeenCalled(); + // sorted according to room tag order + expect(algorithm.orderedRooms).toEqual([roomC, roomA, roomB]); + }); + + describe("handleRoomUpdate", () => { + // XXX: This doesn't work because manual ordered rooms dont get categoryindices + // possibly related https://github.com/vector-im/element-web/issues/25099 + it.skip("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB]); + }); + + // XXX: This doesn't work because manual ordered rooms dont get categoryindices + it.skip("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomD, roomE]); + }); + + it("does nothing and returns false for a timeline update", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + const beforeRooms = algorithm.orderedRooms; + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline); + + expect(shouldTriggerUpdate).toBe(false); + // strict equal + expect(algorithm.orderedRooms).toBe(beforeRooms); + }); + + it("does nothing and returns false for a read receipt update", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + const beforeRooms = algorithm.orderedRooms; + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.ReadReceipt); + + expect(shouldTriggerUpdate).toBe(false); + // strict equal + expect(algorithm.orderedRooms).toBe(beforeRooms); + }); + + it("throws for an unhandle update cause", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + expect(() => + algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause), + ).toThrow("Unsupported update cause: something unexpected"); + }); + }); + }); + + describe("When sortAlgorithm is alphabetical", () => { + const sortAlgorithm = SortAlgorithm.Alphabetic; + + beforeEach(async () => { + // destroy roomMap so we can start fresh + // @ts-ignore private property + RoomNotificationStateStore.instance.roomMap = new Map(); + + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + jest.spyOn(RoomNotifs, "determineUnreadState") + .mockClear() + .mockImplementation((room) => { + switch (room) { + // b and e have red notifs + case roomB: + case roomE: + return unreadStates.red; + // c is grey + case roomC: + return unreadStates.grey; + default: + return unreadStates.none; + } + }); + }); + + it("orders rooms by alpha when they have the same notif state", () => { + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to alpha + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); + }); + + it("orders rooms by notification state then alpha", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + + expect(algorithm.orderedRooms).toEqual([ + // alpha within red + roomB, + roomE, + // grey + roomC, + // alpha within none + roomA, + roomD, + ]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomB, roomC]); + // no re-sorting on a remove + expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("warns and returns without change when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // inserted according to notif state + expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId); + }); + + it("throws for an unhandled update cause", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + expect(() => + algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause), + ).toThrow("Unsupported update cause: something unexpected"); + }); + + describe("time and read receipt updates", () => { + it("throws for when a room is not indexed", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + + expect(() => algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline)).toThrow( + `Room ${roomX.roomId} has no index in ${tagId}`, + ); + }); + + it("re-sorts category when updated room has not changed category", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA, roomD]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId); + }); + + it("re-sorts category when updated room has changed category", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + // change roomE to unreadState.none + jest.spyOn(RoomNotifs, "determineUnreadState").mockImplementation((room) => { + switch (room) { + // b and e have red notifs + case roomB: + return unreadStates.red; + // c is grey + case roomC: + return unreadStates.grey; + case roomE: + default: + return unreadStates.none; + } + }); + // @ts-ignore don't bother mocking rest of emit properties + roomE.emit(RoomEvent.Timeline, new MatrixEvent({ type: "whatever", room_id: roomE.roomId })); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomB, roomC, roomA, roomD, roomE]); + + // only sorted within roomE's new category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId); + }); + }); + }); + }); +}); diff --git a/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts new file mode 100644 index 00000000000..21879586b38 --- /dev/null +++ b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts @@ -0,0 +1,136 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { NaturalAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm"; +import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models"; +import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models"; +import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; + +describe("NaturalAlgorithm", () => { + const userId = "@alice:server.org"; + const tagId = DefaultTagID.Favourite; + + const makeRoom = (id: string, name: string): Room => { + const room = new Room(id, client, userId); + room.name = name; + return room; + }; + + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + }); + const roomA = makeRoom("!aaa:server.org", "Alpha"); + const roomB = makeRoom("!bbb:server.org", "Bravo"); + const roomC = makeRoom("!ccc:server.org", "Charlie"); + const roomD = makeRoom("!ddd:server.org", "Delta"); + const roomE = makeRoom("!eee:server.org", "Echo"); + const roomX = makeRoom("!xxx:server.org", "Xylophone"); + + const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => { + const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm); + algorithm.setRooms(rooms || [roomA, roomB, roomC]); + return algorithm; + }; + + describe("When sortAlgorithm is alphabetical", () => { + const sortAlgorithm = SortAlgorithm.Alphabetic; + + beforeEach(async () => { + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + }); + + it("orders rooms by alpha", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to alpha + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomB, roomC]); + }); + + it("warns when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC, roomE]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith( + [roomA, roomB, roomC, roomE], + tagId, + ); + }); + + it("throws for an unhandled update cause", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + expect(() => + algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause), + ).toThrow("Unsupported update cause: something unexpected"); + }); + + describe("time and read receipt updates", () => { + it("handles when a room is not indexed", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline); + + // for better or worse natural alg sets this to true + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); + }); + + it("re-sorts rooms when timeline updates", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomB, roomC], tagId); + }); + }); + }); + }); +}); diff --git a/test/test-utils/pushRules.ts b/test/test-utils/pushRules.ts new file mode 100644 index 00000000000..cffc423d1c6 --- /dev/null +++ b/test/test-utils/pushRules.ts @@ -0,0 +1,367 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAnnotatedPushRule, IPushRule, IPushRules, PushRuleKind, RuleId } from "matrix-js-sdk/src/matrix"; + +/** + * Default set of push rules for a new account + * Use to mock push rule fetching, or use `getDefaultRuleWithKind` + * to use default examples of specific push rules + */ +export const DEFAULT_PUSH_RULES: IPushRules = Object.freeze({ + global: { + underride: [ + { + conditions: [{ kind: "event_match", key: "type", pattern: "m.call.invite" }], + actions: ["notify", { set_tweak: "sound", value: "ring" }, { set_tweak: "highlight", value: false }], + rule_id: ".m.rule.call", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "m.room.message" }, + { kind: "room_member_count", is: "2" }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".m.rule.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "m.room.encrypted" }, + { kind: "room_member_count", is: "2" }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".m.rule.encrypted_room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.encrypted" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.encrypted_room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.message" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.message.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.file" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.file.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.image" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.image.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.video" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.video.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "org.matrix.msc1767.audio" }, + { kind: "room_member_count", is: "2" }, + { + kind: "org.matrix.msc3931.room_version_supports", + feature: "org.matrix.msc3932.extensible_events", + }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }], + rule_id: ".org.matrix.msc3933.rule.extensible.audio.room_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "m.room.message" }], + actions: ["notify", { set_tweak: "highlight", value: false }], + rule_id: ".m.rule.message", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "m.room.encrypted" }], + actions: ["notify", { set_tweak: "highlight", value: false }], + rule_id: ".m.rule.encrypted", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "im.vector.modular.widgets" }, + { kind: "event_match", key: "content.type", pattern: "jitsi" }, + { kind: "event_match", key: "state_key", pattern: "*" }, + ], + actions: ["notify", { set_tweak: "highlight", value: false }], + rule_id: ".im.vector.jitsi", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "room_member_count", is: "2" }, + { kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.start" }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }], + rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.start" }], + actions: ["notify"], + rule_id: ".org.matrix.msc3930.rule.poll_start", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "room_member_count", is: "2" }, + { kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.end" }, + ], + actions: ["notify", { set_tweak: "sound", value: "default" }], + rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.end" }], + actions: ["notify"], + rule_id: ".org.matrix.msc3930.rule.poll_end", + default: true, + enabled: true, + }, + ], + sender: [], + room: [], + content: [ + { + actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }], + rule_id: ".m.rule.contains_user_name", + default: true, + pattern: "alice", + enabled: true, + }, + ], + override: [ + { conditions: [], actions: ["dont_notify"], rule_id: ".m.rule.master", default: true, enabled: false }, + { + conditions: [{ kind: "event_match", key: "content.msgtype", pattern: "m.notice" }], + actions: ["dont_notify"], + rule_id: ".m.rule.suppress_notices", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "m.room.member" }, + { kind: "event_match", key: "content.membership", pattern: "invite" }, + { kind: "event_match", key: "state_key", pattern: "@alice:example.org" }, + ], + actions: ["notify", { set_tweak: "highlight", value: false }, { set_tweak: "sound", value: "default" }], + rule_id: ".m.rule.invite_for_me", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "m.room.member" }], + actions: ["dont_notify"], + rule_id: ".m.rule.member_event", + default: true, + enabled: true, + }, + { + conditions: [ + { + kind: "event_property_contains", + key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids", + value_type: "user_id", + }, + ], + actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }], + rule_id: ".org.matrix.msc3952.is_user_mention", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "contains_display_name" }], + actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }], + rule_id: ".m.rule.contains_display_name", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_property_is", key: "content.org\\.matrix\\.msc3952\\.mentions.room", value: true }, + { kind: "sender_notification_permission", key: "room" }, + ], + actions: ["notify", { set_tweak: "highlight" }], + rule_id: ".org.matrix.msc3952.is_room_mention", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "sender_notification_permission", key: "room" }, + { kind: "event_match", key: "content.body", pattern: "@room" }, + ], + actions: ["notify", { set_tweak: "highlight" }], + rule_id: ".m.rule.roomnotif", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "m.room.tombstone" }, + { kind: "event_match", key: "state_key", pattern: "" }, + ], + actions: ["notify", { set_tweak: "highlight" }], + rule_id: ".m.rule.tombstone", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "m.reaction" }], + actions: [], + rule_id: ".m.rule.reaction", + default: true, + enabled: true, + }, + { + conditions: [ + { kind: "event_match", key: "type", pattern: "m.room.server_acl" }, + { kind: "event_match", key: "state_key", pattern: "" }, + ], + actions: [], + rule_id: ".m.rule.room.server_acl", + default: true, + enabled: true, + }, + { + conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.response" }], + actions: [], + rule_id: ".org.matrix.msc3930.rule.poll_response", + default: true, + enabled: true, + }, + ], + }, +} as IPushRules); + +/** + * Get rule by id from default rules + * @param ruleId + * @returns {IPushRule} matching push rule + * @returns {PushRuleKind} + * @throws when no rule is found with ruleId + */ +export const getDefaultRuleWithKind = (ruleId: RuleId | string): { rule: IPushRule; kind: PushRuleKind } => { + for (const kind of Object.keys(DEFAULT_PUSH_RULES.global)) { + const rule = DEFAULT_PUSH_RULES.global[kind as PushRuleKind]!.find((r: IPushRule) => r.rule_id === ruleId); + if (rule) { + return { rule, kind: kind as PushRuleKind }; + } + } + + throw new Error(`Could not find default rule for id ${ruleId}`); +}; + +/** + * Get rule by id from default rules as an IAnnotatedPushRule + * @param ruleId + * @returns + */ +export const getDefaultAnnotatedRule = (ruleId: RuleId | string): IAnnotatedPushRule => { + const { rule, kind } = getDefaultRuleWithKind(ruleId); + + return { + ...rule, + kind, + }; +}; + +/** + * Make a push rule with default values + * @param ruleId + * @param ruleOverrides + * @returns IPushRule + */ +export const makePushRule = (ruleId: RuleId | string, ruleOverrides: Partial = {}): IPushRule => ({ + actions: [], + enabled: true, + default: false, + ...ruleOverrides, + rule_id: ruleId, +}); + +export const makeAnnotatedPushRule = ( + kind: PushRuleKind, + ruleId: RuleId | string, + ruleOverrides: Partial = {}, +): IAnnotatedPushRule => ({ + ...makePushRule(ruleId, ruleOverrides), + kind, +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ddd6b091257..e414419359c 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -99,7 +99,6 @@ export function createTestClient(): MatrixClient { getDevice: jest.fn(), getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"), getStoredCrossSigningForUser: jest.fn(), - checkDeviceTrust: jest.fn(), getStoredDevice: jest.fn(), requestVerification: jest.fn(), deviceId: "ABCDEFGHI", @@ -121,6 +120,7 @@ export function createTestClient(): MatrixClient { downloadKeys: jest.fn(), }, }, + getCrypto: jest.fn().mockReturnValue({ getUserDeviceInfo: jest.fn() }), getPushActionsForEvent: jest.fn(), getRoom: jest.fn().mockImplementation((roomId) => mkStubRoom(roomId, "My room", client)), @@ -234,6 +234,7 @@ export function createTestClient(): MatrixClient { }), searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }), + setDeviceVerified: jest.fn(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 420e5d3bca2..514a9091f19 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -127,7 +127,7 @@ export function untilEmission( }); } -export const flushPromises = async () => await new Promise((resolve) => window.setTimeout(resolve)); +export const flushPromises = async () => await new Promise((resolve) => window.setTimeout(resolve)); // with jest's modern fake timers process.nextTick is also mocked, // flushing promises in the normal way then waits for some advancement @@ -211,6 +211,17 @@ export const clearAllModals = async (): Promise => { // of removing the same modal because the promises don't flush otherwise. // // XXX: Maybe in the future with Jest 29.5.0+, we could use `runAllTimersAsync` instead. - await flushPromisesWithFakeTimers(); + + // this is called in some places where timers are not faked + // which causes a lot of noise in the console + // to make a hack even hackier check if timers are faked using a weird trick from github + // then call the appropriate promise flusher + // https://github.com/facebook/jest/issues/10555#issuecomment-1136466942 + const jestTimersFaked = setTimeout.name === "setTimeout"; + if (jestTimersFaked) { + await flushPromisesWithFakeTimers(); + } else { + await flushPromises(); + } } }; diff --git a/test/toasts/UnverifiedSessionToast-test.tsx b/test/toasts/UnverifiedSessionToast-test.tsx index 7a6dae4079b..2799f0f7916 100644 --- a/test/toasts/UnverifiedSessionToast-test.tsx +++ b/test/toasts/UnverifiedSessionToast-test.tsx @@ -18,9 +18,8 @@ import React from "react"; import { render, RenderResult, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mocked, Mocked } from "jest-mock"; -import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { CryptoApi, DeviceVerificationStatus, IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; -import { DeviceTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; import dis from "../../src/dispatcher/dispatcher"; import { showToast } from "../../src/toasts/UnverifiedSessionToast"; @@ -37,7 +36,7 @@ describe("UnverifiedSessionToast", () => { let client: Mocked; let renderResult: RenderResult; - filterConsole("Starting load of AsyncWrapper for modal", "Dismissing unverified sessions: ABC123"); + filterConsole("Dismissing unverified sessions: ABC123"); beforeAll(() => { client = mocked(stubClient()); @@ -55,7 +54,11 @@ describe("UnverifiedSessionToast", () => { return null; }); - client.checkDeviceTrust.mockReturnValue(new DeviceTrustLevel(true, false, false, false)); + client.getCrypto.mockReturnValue({ + getDeviceVerificationStatus: jest + .fn() + .mockResolvedValue(new DeviceVerificationStatus({ crossSigningVerified: true })), + } as unknown as CryptoApi); jest.spyOn(dis, "dispatch"); jest.spyOn(DeviceListener.sharedInstance(), "dismissUnverifiedSessions"); }); diff --git a/test/utils/ErrorUtils-test.ts b/test/utils/ErrorUtils-test.ts new file mode 100644 index 00000000000..14fa70ef7ee --- /dev/null +++ b/test/utils/ErrorUtils-test.ts @@ -0,0 +1,182 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ReactElement } from "react"; +import { render } from "@testing-library/react"; +import { MatrixError, ConnectionError } from "matrix-js-sdk/src/http-api"; + +import { + adminContactStrings, + messageForConnectionError, + messageForLoginError, + messageForResourceLimitError, + messageForSyncError, + resourceLimitStrings, +} from "../../src/utils/ErrorUtils"; + +describe("messageForResourceLimitError", () => { + it("should match snapshot for monthly_active_user", () => { + const { asFragment } = render( + messageForResourceLimitError("monthly_active_user", "some@email", resourceLimitStrings) as ReactElement, + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for admin contact links", () => { + const { asFragment } = render( + messageForResourceLimitError("", "some@email", adminContactStrings) as ReactElement, + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe("messageForSyncError", () => { + it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => { + const err = new MatrixError({ + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + data: { + limit_type: "monthly_active_user", + admin_contact: "some@email", + }, + }); + const { asFragment } = render(messageForSyncError(err) as ReactElement); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for other errors", () => { + const err = new MatrixError({ + errcode: "OTHER_ERROR", + }); + const { asFragment } = render(messageForSyncError(err) as ReactElement); + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe("messageForLoginError", () => { + it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => { + const err = new MatrixError({ + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + data: { + limit_type: "monthly_active_user", + admin_contact: "some@email", + }, + }); + const { asFragment } = render( + messageForLoginError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for M_USER_DEACTIVATED", () => { + const err = new MatrixError( + { + errcode: "M_USER_DEACTIVATED", + }, + 403, + ); + const { asFragment } = render( + messageForLoginError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for 401", () => { + const err = new MatrixError( + { + errcode: "UNKNOWN", + }, + 401, + ); + const { asFragment } = render( + messageForLoginError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for unknown error", () => { + const err = new MatrixError({}, 400); + const { asFragment } = render( + messageForLoginError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe("messageForConnectionError", () => { + it("should match snapshot for ConnectionError", () => { + const err = new ConnectionError("Internal Server Error", new MatrixError({}, 500)); + const { asFragment } = render( + messageForConnectionError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for MatrixError M_NOT_FOUND", () => { + const err = new MatrixError( + { + errcode: "M_NOT_FOUND", + }, + 404, + ); + const { asFragment } = render( + messageForConnectionError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for unknown error", () => { + const err = new Error("What even"); + const { asFragment } = render( + messageForConnectionError(err, { + hsUrl: "hsUrl", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should match snapshot for mixed content error", () => { + const err = new ConnectionError("Mixed content maybe?"); + Object.defineProperty(window, "location", { value: { protocol: "https:" } }); + const { asFragment } = render( + messageForConnectionError(err, { + hsUrl: "http://server.com", + hsName: "hsName", + }) as ReactElement, + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/utils/MegolmExportEncryption-test.ts b/test/utils/MegolmExportEncryption-test.ts index 69d803073f2..e3e6d8ca24c 100644 --- a/test/utils/MegolmExportEncryption-test.ts +++ b/test/utils/MegolmExportEncryption-test.ts @@ -75,13 +75,13 @@ describe("MegolmExportEncryption", function () { let MegolmExportEncryption: typeof MegolmExportEncryptionExport; beforeEach(() => { - window.crypto = { - getRandomValues, - randomUUID: jest.fn().mockReturnValue("not-random-uuid"), - subtle: webCrypto.subtle, - }; - // @ts-ignore for some reason including it in the object above gets ignored - window.crypto.subtle = webCrypto.subtle; + Object.defineProperty(window, "crypto", { + value: { + getRandomValues, + randomUUID: jest.fn().mockReturnValue("not-random-uuid"), + subtle: webCrypto.subtle, + }, + }); MegolmExportEncryption = require("../../src/utils/MegolmExportEncryption"); }); diff --git a/test/utils/ShieldUtils-test.ts b/test/utils/ShieldUtils-test.ts index f971cea7798..8759252727b 100644 --- a/test/utils/ShieldUtils-test.ts +++ b/test/utils/ShieldUtils-test.ts @@ -22,21 +22,25 @@ import DMRoomMap from "../../src/utils/DMRoomMap"; function mkClient(selfTrust = false) { return { getUserId: () => "@self:localhost", + getCrypto: () => ({ + getDeviceVerificationStatus: (userId: string, deviceId: string) => + Promise.resolve({ + isVerified: () => (userId === "@self:localhost" ? selfTrust : userId[2] == "T"), + }), + }), checkUserTrust: (userId: string) => ({ isCrossSigningVerified: () => userId[1] == "T", wasCrossSigningVerified: () => userId[1] == "T" || userId[1] == "W", }), - checkDeviceTrust: (userId: string, deviceId: string) => ({ - isVerified: () => (userId === "@self:localhost" ? selfTrust : userId[2] == "T"), - }), getStoredDevicesForUser: (userId: string) => ["DEVICE"], } as unknown as MatrixClient; } describe("mkClient self-test", function () { - test.each([true, false])("behaves well for self-trust=%s", (v) => { + test.each([true, false])("behaves well for self-trust=%s", async (v) => { const client = mkClient(v); - expect(client.checkDeviceTrust("@self:localhost", "DEVICE").isVerified()).toBe(v); + const status = await client.getCrypto()!.getDeviceVerificationStatus("@self:localhost", "DEVICE"); + expect(status?.isVerified()).toBe(v); }); test.each([ @@ -53,8 +57,9 @@ describe("mkClient self-test", function () { ["@TF:h", false], ["@FT:h", true], ["@FF:h", false], - ])("behaves well for device trust %s", (userId, trust) => { - expect(mkClient().checkDeviceTrust(userId, "device").isVerified()).toBe(trust); + ])("behaves well for device trust %s", async (userId, trust) => { + const status = await mkClient().getCrypto()!.getDeviceVerificationStatus(userId, "device"); + expect(status?.isVerified()).toBe(trust); }); }); diff --git a/test/utils/__snapshots__/ErrorUtils-test.ts.snap b/test/utils/__snapshots__/ErrorUtils-test.ts.snap new file mode 100644 index 00000000000..bad4e0a1447 --- /dev/null +++ b/test/utils/__snapshots__/ErrorUtils-test.ts.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`messageForConnectionError should match snapshot for ConnectionError 1`] = ` + + + + Can't connect to homeserver - please check your connectivity, ensure your + + homeserver's SSL certificate + + is trusted, and that a browser extension is not blocking requests. + + + +`; + +exports[`messageForConnectionError should match snapshot for MatrixError M_NOT_FOUND 1`] = ` + + There was a problem communicating with the homeserver, please try again later.(M_NOT_FOUND) + +`; + +exports[`messageForConnectionError should match snapshot for mixed content error 1`] = ` + + + + Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or + + enable unsafe scripts + + . + + + +`; + +exports[`messageForConnectionError should match snapshot for unknown error 1`] = ` + + There was a problem communicating with the homeserver, please try again later. + +`; + +exports[`messageForLoginError should match snapshot for 401 1`] = ` + + Incorrect username and/or password. + +`; + +exports[`messageForLoginError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = ` + +
    +
    + This homeserver has exceeded one of its resource limits. +
    + +
    +
    +`; + +exports[`messageForLoginError should match snapshot for M_USER_DEACTIVATED 1`] = ` + + This account has been deactivated. + +`; + +exports[`messageForLoginError should match snapshot for unknown error 1`] = ` + + There was a problem communicating with the homeserver, please try again later. (HTTP 400) + +`; + +exports[`messageForResourceLimitError should match snapshot for admin contact links 1`] = ` + + + Please + + contact your service administrator + + to continue using this service. + + +`; + +exports[`messageForResourceLimitError should match snapshot for monthly_active_user 1`] = ` + + This homeserver has hit its Monthly Active User limit. + +`; + +exports[`messageForSyncError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = ` + +
    +
    + This homeserver has exceeded one of its resource limits. +
    +
    + Please contact your service administrator to continue using this service. +
    +
    +
    +`; + +exports[`messageForSyncError should match snapshot for other errors 1`] = ` + +
    + Unable to connect to Homeserver. Retrying… +
    +
    +`; diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index 558b85e0b5c..f843210dc4c 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -30,6 +30,7 @@ import { GroupedArray, concat, asyncEvery, + asyncSome, } from "../../src/utils/arrays"; type TestParams = { input: number[]; output: number[] }; @@ -444,4 +445,27 @@ describe("arrays", () => { expect(predicate).toHaveBeenCalledWith(2); }); }); + + describe("asyncSome", () => { + it("when called with an empty array, it should return false", async () => { + expect(await asyncSome([], jest.fn().mockResolvedValue(true))).toBe(false); + }); + + it("when called with some items and the predicate resolves to false for all of them, it should return false", async () => { + const predicate = jest.fn().mockResolvedValue(false); + expect(await asyncSome([1, 2, 3], predicate)).toBe(false); + expect(predicate).toHaveBeenCalledTimes(3); + expect(predicate).toHaveBeenCalledWith(1); + expect(predicate).toHaveBeenCalledWith(2); + expect(predicate).toHaveBeenCalledWith(3); + }); + + it("when called with some items and the predicate resolves to true, it should short-circuit and return true", async () => { + const predicate = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + expect(await asyncSome([1, 2, 3], predicate)).toBe(true); + expect(predicate).toHaveBeenCalledTimes(2); + expect(predicate).toHaveBeenCalledWith(1); + expect(predicate).toHaveBeenCalledWith(2); + }); + }); }); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts index 4133619f917..9b90d6cb104 100644 --- a/test/utils/device/clientInformation-test.ts +++ b/test/utils/device/clientInformation-test.ts @@ -20,6 +20,8 @@ import BasePlatform from "../../../src/BasePlatform"; import { IConfigOptions } from "../../../src/IConfigOptions"; import { getDeviceClientInformation, recordClientInformation } from "../../../src/utils/device/clientInformation"; import { getMockClientWithEventEmitter } from "../../test-utils"; +import { DEFAULTS } from "../../../src/SdkConfig"; +import { DeepReadonly } from "../../../src/@types/common"; describe("recordClientInformation()", () => { const deviceId = "my-device-id"; @@ -31,7 +33,8 @@ describe("recordClientInformation()", () => { setAccountData: jest.fn(), }); - const sdkConfig: IConfigOptions = { + const sdkConfig: DeepReadonly = { + ...DEFAULTS, brand: "Test Brand", element_call: { url: "", use_exclusively: false, brand: "Element Call" }, }; diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 36c8e418c94..005444a142c 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -114,31 +114,51 @@ describe("notifications", () => { let sendReadReceiptSpy: jest.SpyInstance; const ROOM_ID = "123"; const USER_ID = "@bob:example.org"; + let message: MatrixEvent; + let sendReceiptsSetting = true; beforeEach(() => { stubClient(); client = mocked(MatrixClientPeg.get()); room = new Room(ROOM_ID, client, USER_ID); + message = mkMessage({ + event: true, + room: ROOM_ID, + user: USER_ID, + msg: "Hello", + }); + room.addLiveEvents([message]); sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({}); jest.spyOn(client, "getRooms").mockReturnValue([room]); jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { - return name === "sendReadReceipts"; + return name === "sendReadReceipts" && sendReceiptsSetting; }); }); it("sends a request even if everything has been read", () => { clearRoomNotification(room, client); - expect(sendReadReceiptSpy).not.toHaveBeenCalled(); + expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.Read, true); }); it("marks the room as read even if the receipt failed", async () => { room.setUnreadNotificationCount(NotificationCountType.Total, 5); - sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockReset().mockRejectedValue({}); - try { + sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockReset().mockRejectedValue({ error: 42 }); + + await expect(async () => { await clearRoomNotification(room, client); - } finally { - expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0); - } + }).rejects.toEqual({ error: 42 }); + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0); + }); + + describe("when sendReadReceipts setting is disabled", () => { + beforeEach(() => { + sendReceiptsSetting = false; + }); + + it("should send a private read receipt", () => { + clearRoomNotification(room, client); + expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true); + }); }); }); diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx index dddaa858272..240d5732886 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx @@ -28,7 +28,7 @@ import { VoiceBroadcastRecording, VoiceBroadcastRecordingPip, } from "../../../../src/voice-broadcast"; -import { filterConsole, flushPromises, stubClient } from "../../../test-utils"; +import { flushPromises, stubClient } from "../../../test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "../../utils/test-utils"; import { requestMediaPermissions } from "../../../../src/utils/media/requestMediaPermissions"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; @@ -85,8 +85,6 @@ describe("VoiceBroadcastRecordingPip", () => { }); }; - filterConsole("Starting load of AsyncWrapper for modal"); - beforeAll(() => { client = stubClient(); mocked(requestMediaPermissions).mockResolvedValue({ diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap index c7d2d43bd2e..2850c944a25 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap @@ -112,16 +112,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a buffering voice broadcast s
    - 00:00 - - + +
    @@ -223,16 +225,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a pause/not-live broadcast sh
    - 00:00 - - + +
    @@ -346,16 +350,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a playing broadcast in pip mo
    - 00:00 - - + +
    @@ -457,16 +463,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a playing broadcast should re
    - 00:00 - - + +
    @@ -576,16 +584,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a playing/live broadcast shou
    - 00:00 - - + +
    @@ -667,16 +677,18 @@ exports[`VoiceBroadcastPlaybackBody when rendering a stopped broadcast should re
    - 00:00 - - + +
    diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap index 478a66e990f..043dd0bbc83 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap @@ -44,11 +44,12 @@ exports[`VoiceBroadcastRecordingPip when rendering a paused recording should ren
    - 4h 0m 0s left - +
    - 4h 0m 0s left - +
    { }; filterConsole( - "Starting load of AsyncWrapper for modal", // expected for some tests "Unable to load broadcast playback", ); diff --git a/test/voice-broadcast/utils/getChunkLength-test.ts b/test/voice-broadcast/utils/getChunkLength-test.ts index a046a47f760..5610bd6caf1 100644 --- a/test/voice-broadcast/utils/getChunkLength-test.ts +++ b/test/voice-broadcast/utils/getChunkLength-test.ts @@ -14,24 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked } from "jest-mock"; - -import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig"; +import SdkConfig from "../../../src/SdkConfig"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Features } from "../../../src/settings/Settings"; import SettingsStore from "../../../src/settings/SettingsStore"; import { getChunkLength } from "../../../src/voice-broadcast/utils/getChunkLength"; -jest.mock("../../../src/SdkConfig"); - describe("getChunkLength", () => { afterEach(() => { - jest.resetAllMocks(); + SdkConfig.reset(); }); describe("when there is a value provided by Sdk config", () => { beforeEach(() => { - mocked(SdkConfig.get).mockReturnValue({ chunk_length: 42 }); + SdkConfig.add({ + voice_broadcast: { + chunk_length: 42, + }, + }); }); it("should return this value", () => { @@ -41,9 +41,11 @@ describe("getChunkLength", () => { describe("when Sdk config does not provide a value", () => { beforeEach(() => { - DEFAULTS.voice_broadcast = { - chunk_length: 23, - }; + SdkConfig.add({ + voice_broadcast: { + chunk_length: 23, + }, + }); }); it("should return this value", () => { @@ -52,10 +54,6 @@ describe("getChunkLength", () => { }); describe("when there are no defaults", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = undefined; - }); - it("should return the fallback value", () => { expect(getChunkLength()).toBe(120); }); diff --git a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts index f2dd1389548..3f40dd0efc2 100644 --- a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts +++ b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked } from "jest-mock"; - import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig"; import { getMaxBroadcastLength } from "../../../src/voice-broadcast"; -jest.mock("../../../src/SdkConfig"); - describe("getMaxBroadcastLength", () => { afterEach(() => { - jest.resetAllMocks(); + SdkConfig.reset(); }); describe("when there is a value provided by Sdk config", () => { beforeEach(() => { - mocked(SdkConfig.get).mockReturnValue({ max_length: 42 }); + SdkConfig.put({ + voice_broadcast: { + max_length: 42, + }, + }); }); it("should return this value", () => { @@ -37,23 +37,14 @@ describe("getMaxBroadcastLength", () => { }); describe("when Sdk config does not provide a value", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = { - max_length: 23, - }; - }); - it("should return this value", () => { - expect(getMaxBroadcastLength()).toBe(23); + expect(getMaxBroadcastLength()).toBe(DEFAULTS.voice_broadcast!.max_length); }); }); describe("if there are no defaults", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = undefined; - }); - it("should return the fallback value", () => { + expect(DEFAULTS.voice_broadcast!.max_length).toBe(4 * 60 * 60); expect(getMaxBroadcastLength()).toBe(4 * 60 * 60); }); }); diff --git a/yarn.lock b/yarn.lock index 1fe9b50015f..82b2113b391 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1140,7 +1140,7 @@ resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-2.1.1.tgz#07ae11a0a06365d7ec686549db7b729bc036528e" integrity sha512-GbrTj2Z8MCTUv+52GE0RbFGM527xuXZ0Xa5g0Z+YN573uveS4G0qi6WNOMyz3yrFM/jaILTTwJ0+umx81EzqfA== -"@csstools/media-query-list-parser@^2.0.1": +"@csstools/media-query-list-parser@^2.0.2": version "2.0.4" resolved "https://registry.yarnpkg.com/@csstools/media-query-list-parser/-/media-query-list-parser-2.0.4.tgz#466bd254041530dfd1e88bcb1921e8ca4af75b6a" integrity sha512-GyYot6jHgcSDZZ+tLSnrzkR7aJhF2ZW6d+CXH66mjy5WpAQhZD4HDke2OQ36SivGRWlZJpAz7TzbW6OKlEpxAA== @@ -1209,10 +1209,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.37.0": - version "8.37.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.37.0.tgz#cf1b5fa24217fe007f6487a26d765274925efa7d" - integrity sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A== +"@eslint/js@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.38.0.tgz#73a8a0d8aa8a8e6fe270431c5e72ae91b5337892" + integrity sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g== "@humanwhocodes/config-array@^0.11.8": version "0.11.8" @@ -1567,10 +1567,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.5.0.tgz#38b69c4e29d243944c5712cca7b674a3432056e6" integrity sha512-uL5kf7MqC+GxsGJtimPVbFliyaFinohTHSzohz31JTysktHsjRR2SC+vV7sy2/dstTWVdG9EGOnohyPsB+oi3A== -"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.6": - version "0.1.0-alpha.6" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.6.tgz#c0bdb9ab0d30179b8ef744d1b4010b0ad0ab9c3a" - integrity sha512-7hMffzw7KijxDyyH/eUyTfrLeCQHuyU3kaPOKGhcl3DZ3vx7bCncqjGMGTnxNPoP23I6gosvKSbO+3wYOT24Xg== +"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.7": + version "0.1.0-alpha.7" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.7.tgz#136375b84fd8a7e698f70fc969f668e541a61313" + integrity sha512-sQEG9cSfNji5NYBf5h7j5IxYVO0dwtAKoetaVyR+LhIXz/Su7zyEE3EwlAWAeJOFdAV/vZ5LTNyh39xADuNlTg== "@matrix-org/matrix-wysiwyg@^2.0.0": version "2.0.0" @@ -1885,6 +1885,11 @@ resolved "https://registry.yarnpkg.com/@percy/sdk-utils/-/sdk-utils-1.23.0.tgz#7e3eb8a128952c548b3cc7213c65f620e3d09ec7" integrity sha512-FPXMOsK7SIh6NuK+wnr/O35bPN8cyYDGsXqkE5EWwf7Frs+QXFIejlYM9t1SckoxlDpS1YsPYJABv+CUsuJIlQ== +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@sentry-internal/tracing@7.47.0": version "7.47.0" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.47.0.tgz#45e92eb4c8d049d93bd4fab961eaa38a4fb680f3" @@ -2215,9 +2220,9 @@ integrity sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw== "@types/lodash@^4.14.168": - version "4.14.192" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.192.tgz#5790406361a2852d332d41635d927f1600811285" - integrity sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A== + version "4.14.194" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76" + integrity sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g== "@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.2": version "0.1.2" @@ -2262,9 +2267,9 @@ integrity sha512-xefu+RBie4xWlK8hwAzGh3npDz/4VhF6icY/shU+zv/1fNn+ZVG7T7CRwe9LId9sAYRPxI+59QBPuKL3WpyGRg== "@types/node@^16": - version "16.18.23" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.23.tgz#b6e934fe427eb7081d0015aad070acb3373c3c90" - integrity sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g== + version "16.18.25" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.25.tgz#8863940fefa1234d3fcac7a4b7a48a6c992d67af" + integrity sha512-rUDO6s9Q/El1R1I21HG4qw/LstTHCPO/oQNAwI/4b2f9EWvMnqt4d3HJwPMawfZ3UvodB8516Yg+VAq54YM+eA== "@types/normalize-package-data@^2.4.0": version "2.4.1" @@ -2339,10 +2344,10 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@17.0.55", "@types/react@^17": - version "17.0.55" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.55.tgz#f94eac1a37929cd86d1cc084c239c08dcfd10e5f" - integrity sha512-kBcAhmT8RivFDYxHdy8QfPKu+WyfiiGjdPb9pIRtd6tj05j0zRHq5DBGW5Ogxv5cwSKd93BVgUk/HZ4I9p3zNg== +"@types/react@*", "@types/react@17.0.58", "@types/react@^17": + version "17.0.58" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.58.tgz#c8bbc82114e5c29001548ebe8ed6c4ba4d3c9fb0" + integrity sha512-c1GzVY97P0fGxwGxhYq989j4XwlcHQoto6wQISOC2v6wm3h0PORRWJFHlkRjfGsiG3y1609WdQ+J+tKxvrEd6A== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -2432,14 +2437,14 @@ integrity sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w== "@typescript-eslint/eslint-plugin@^5.35.1": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.58.0.tgz#b1d4b0ad20243269d020ef9bbb036a40b0849829" - integrity sha512-vxHvLhH0qgBd3/tW6/VccptSfc8FxPQIkmNTVLWcCOVqSBvqpnKkBTYrhcGlXfSnd78azwe+PsjYFj0X34/njA== + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.0.tgz#c0e10eeb936debe5d1c3433cf36206a95befefd0" + integrity sha512-p0QgrEyrxAWBecR56gyn3wkG15TJdI//eetInP3zYRewDh0XS+DhB3VUAd3QqvziFsfaQIoIuZMxZRB7vXYaYw== dependencies: "@eslint-community/regexpp" "^4.4.0" - "@typescript-eslint/scope-manager" "5.58.0" - "@typescript-eslint/type-utils" "5.58.0" - "@typescript-eslint/utils" "5.58.0" + "@typescript-eslint/scope-manager" "5.59.0" + "@typescript-eslint/type-utils" "5.59.0" + "@typescript-eslint/utils" "5.59.0" debug "^4.3.4" grapheme-splitter "^1.0.4" ignore "^5.2.0" @@ -2448,13 +2453,13 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.6.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.58.0.tgz#2ac4464cf48bef2e3234cb178ede5af352dddbc6" - integrity sha512-ixaM3gRtlfrKzP8N6lRhBbjTow1t6ztfBvQNGuRM8qH1bjFFXIJ35XY+FC0RRBKn3C6cT+7VW1y8tNm7DwPHDQ== + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.0.tgz#0ad7cd019346cc5d150363f64869eca10ca9977c" + integrity sha512-qK9TZ70eJtjojSUMrrEwA9ZDQ4N0e/AuoOIgXuNBorXYcBDk397D2r5MIe1B3cok/oCtdNC5j+lUUpVB+Dpb+w== dependencies: - "@typescript-eslint/scope-manager" "5.58.0" - "@typescript-eslint/types" "5.58.0" - "@typescript-eslint/typescript-estree" "5.58.0" + "@typescript-eslint/scope-manager" "5.59.0" + "@typescript-eslint/types" "5.59.0" + "@typescript-eslint/typescript-estree" "5.59.0" debug "^4.3.4" "@typescript-eslint/scope-manager@5.58.0": @@ -2465,13 +2470,21 @@ "@typescript-eslint/types" "5.58.0" "@typescript-eslint/visitor-keys" "5.58.0" -"@typescript-eslint/type-utils@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.58.0.tgz#f7d5b3971483d4015a470d8a9e5b8a7d10066e52" - integrity sha512-FF5vP/SKAFJ+LmR9PENql7fQVVgGDOS+dq3j+cKl9iW/9VuZC/8CFmzIP0DLKXfWKpRHawJiG70rVH+xZZbp8w== +"@typescript-eslint/scope-manager@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.0.tgz#86501d7a17885710b6716a23be2e93fc54a4fe8c" + integrity sha512-tsoldKaMh7izN6BvkK6zRMINj4Z2d6gGhO2UsI8zGZY3XhLq1DndP3Ycjhi1JwdwPRwtLMW4EFPgpuKhbCGOvQ== dependencies: - "@typescript-eslint/typescript-estree" "5.58.0" - "@typescript-eslint/utils" "5.58.0" + "@typescript-eslint/types" "5.59.0" + "@typescript-eslint/visitor-keys" "5.59.0" + +"@typescript-eslint/type-utils@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.0.tgz#8e8d1420fc2265989fa3a0d897bde37f3851e8c9" + integrity sha512-d/B6VSWnZwu70kcKQSCqjcXpVH+7ABKH8P1KNn4K7j5PXXuycZTPXF44Nui0TEm6rbWGi8kc78xRgOC4n7xFgA== + dependencies: + "@typescript-eslint/typescript-estree" "5.59.0" + "@typescript-eslint/utils" "5.59.0" debug "^4.3.4" tsutils "^3.21.0" @@ -2480,6 +2493,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.58.0.tgz#54c490b8522c18986004df7674c644ffe2ed77d8" integrity sha512-JYV4eITHPzVQMnHZcYJXl2ZloC7thuUHrcUmxtzvItyKPvQ50kb9QXBkgNAt90OYMqwaodQh2kHutWZl1fc+1g== +"@typescript-eslint/types@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.0.tgz#3fcdac7dbf923ec5251545acdd9f1d42d7c4fe32" + integrity sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA== + "@typescript-eslint/typescript-estree@5.58.0": version "5.58.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.58.0.tgz#4966e6ff57eaf6e0fce2586497edc097e2ab3e61" @@ -2493,7 +2511,34 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.58.0", "@typescript-eslint/utils@^5.10.0": +"@typescript-eslint/typescript-estree@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.0.tgz#8869156ee1dcfc5a95be3ed0e2809969ea28e965" + integrity sha512-sUNnktjmI8DyGzPdZ8dRwW741zopGxltGs/SAPgGL/AAgDpiLsCFLcMNSpbfXfmnNeHmK9h3wGmCkGRGAoUZAg== + dependencies: + "@typescript-eslint/types" "5.59.0" + "@typescript-eslint/visitor-keys" "5.59.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.0.tgz#063d066b3bc4850c18872649ed0da9ee72d833d5" + integrity sha512-GGLFd+86drlHSvPgN/el6dRQNYYGOvRSDVydsUaQluwIW3HvbXuxyuD5JETvBt/9qGYe+lOrDk6gRrWOHb/FvA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@types/json-schema" "^7.0.9" + "@types/semver" "^7.3.12" + "@typescript-eslint/scope-manager" "5.59.0" + "@typescript-eslint/types" "5.59.0" + "@typescript-eslint/typescript-estree" "5.59.0" + eslint-scope "^5.1.1" + semver "^7.3.7" + +"@typescript-eslint/utils@^5.10.0": version "5.58.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.58.0.tgz#430d7c95f23ec457b05be5520c1700a0dfd559d5" integrity sha512-gAmLOTFXMXOC+zP1fsqm3VceKSBQJNzV385Ok3+yzlavNHZoedajjS4UyS21gabJYcobuigQPs/z71A9MdJFqQ== @@ -2515,6 +2560,14 @@ "@typescript-eslint/types" "5.58.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.0.tgz#a59913f2bf0baeb61b5cfcb6135d3926c3854365" + integrity sha512-qZ3iXxQhanchCeaExlKPV3gDQFxMUmU35xfd5eCXB6+kUw1TUAbIy2n7QIrwz9s98DQLzNWyHp61fY0da4ZcbA== + dependencies: + "@typescript-eslint/types" "5.59.0" + eslint-visitor-keys "^3.3.0" + abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -2934,6 +2987,11 @@ before-after-hook@^2.2.0: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== +big-integer@^1.6.48: + version "1.6.51" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" + integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -3236,6 +3294,15 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +cliui@^7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -3921,7 +3988,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.20.4: +es-abstract@^1.18.3, es-abstract@^1.19.0, es-abstract@^1.20.4: version "1.21.2" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.21.2.tgz#a56b9695322c8a185dc25975aa3b8ec31d0e7eff" integrity sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg== @@ -4239,15 +4306,15 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz#c7f0f956124ce677047ddbc192a68f999454dedc" integrity sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ== -eslint@8.37.0: - version "8.37.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.37.0.tgz#1f660ef2ce49a0bfdec0b0d698e0b8b627287412" - integrity sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw== +eslint@8.38.0: + version "8.38.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.38.0.tgz#a62c6f36e548a5574dd35728ac3c6209bd1e2f1a" + integrity sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.4.0" "@eslint/eslintrc" "^2.0.2" - "@eslint/js" "8.37.0" + "@eslint/js" "8.38.0" "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -4630,6 +4697,14 @@ foreachasync@^3.0.0: resolved "https://registry.yarnpkg.com/foreachasync/-/foreachasync-3.0.0.tgz#5502987dc8714be3392097f32e0071c9dee07cf6" integrity sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw== +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -4808,6 +4883,18 @@ glob-to-regexp@^0.4.0, glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== +glob@^10.0.0: + version "10.2.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.1.tgz#44288e9186b5cd5baa848728533ba21a94aa8f33" + integrity sha512-ngom3wq2UhjdbmRE/krgkD8BQyi1KZ5l+D2dVm4+Yj+jJIBp74/ZGunL6gNGc/CYuQmvUBiavWEXIotRiv5R6A== + dependencies: + foreground-child "^3.1.0" + fs.realpath "^1.0.0" + jackspeak "^2.0.3" + minimatch "^9.0.0" + minipass "^5.0.0" + path-scurry "^1.7.0" + glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -4820,16 +4907,6 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^9.2.0: - version "9.3.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" - integrity sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q== - dependencies: - fs.realpath "^1.0.0" - minimatch "^8.0.2" - minipass "^4.2.4" - path-scurry "^1.6.1" - global-dirs@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.1.tgz#0c488971f066baceda21447aecb1a8b911d22485" @@ -4998,7 +5075,7 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -html-tags@^3.2.0: +html-tags@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== @@ -5476,6 +5553,15 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +jackspeak@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.0.3.tgz#672eb397b97744a265b5862d7762b96e8dad6e61" + integrity sha512-0Jud3OMUdMbrlr3PyUMKESq51LXVAB+a239Ywdvd+Kgxj3MaBRml/nVRxf8tQFyfthMjuRkxkv7Vg58pmIMfuQ== + dependencies: + cliui "^7.0.4" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jest-canvas-mock@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341" @@ -6392,13 +6478,13 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@25.0.0: - version "25.0.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-25.0.0.tgz#a46ecb62eb96d5e93e36bf3f318ba4b4a08d56bf" - integrity sha512-eI9v0JkIYrSfsjUVk6PeZrsR7c7Qh0tirsK2biMZ4jPQo9sx/iuBjlfAEPoVlwzQKTfakGryhit15FfPhucMnw== +matrix-js-sdk@25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-25.1.0.tgz#86bfd96e70fab83dc131254dd9e9aaeb5cfafbc9" + integrity sha512-zVAg73FiFrFacS/q9hLnRaSPvg84VT/X70VIOigp2eOMUhot35UnNL99YUbfkksY95/UnvgrqJkyaZcm8lSspQ== dependencies: "@babel/runtime" "^7.12.5" - "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.6" + "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.7" another-json "^0.2.0" bs58 "^5.0.0" content-type "^1.0.4" @@ -6537,10 +6623,10 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^8.0.2: - version "8.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" - integrity sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA== +minimatch@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56" + integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w== dependencies: brace-expansion "^2.0.1" @@ -6558,11 +6644,6 @@ minimist@>=1.2.2, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -minipass@^4.2.4: - version "4.2.7" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.7.tgz#14c6fc0dcab54d9c4dd64b2b7032fef04efec218" - integrity sha512-ScVIgqHcXRMyfflqHmEW0bm8z8rb5McHyOY3ewX9JBgZaR77G7nxq9L/mtV96/QbAAwtbCAHVVLzD1kkyfFQEw== - minipass@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" @@ -6606,7 +6687,7 @@ murmurhash-js@^1.0.0: resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== -nanoid@^3.3.4: +nanoid@^3.3.4, nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== @@ -6935,10 +7016,10 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.6.1: - version "1.6.4" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.6.4.tgz#020a9449e5382a4acb684f9c7e1283bc5695de66" - integrity sha512-Qp/9IHkdNiXJ3/Kon++At2nVpnhRiPq/aSvQN+H3U1WZbvNRK0RIQK/o4HMqPoXjpuGJUEWpHSs6Mnjxqh3TQg== +path-scurry@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.7.0.tgz#99c741a2cfbce782294a39994d63748b5a24f6db" + integrity sha512-UkZUeDjczjYRE495+9thsgcVgsaCPkaw80slmfVFgllxY+IO8ubTsOpFVjDPROBqJdHfVPUFRHPBV/WciOVfWg== dependencies: lru-cache "^9.0.0" minipass "^5.0.0" @@ -7070,7 +7151,7 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.3.11, postcss@^8.4.21: +postcss@^8.3.11: version "8.4.21" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== @@ -7079,10 +7160,19 @@ postcss@^8.3.11, postcss@^8.4.21: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@1.51.5: - version "1.51.5" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.51.5.tgz#23f11f5e75690312301d596b6f1f795e5b423eb9" - integrity sha512-hhOreF51vvg97iKFZ4GFF4lwQVq1WWJXOJ59NbQVsXj+bVxDcX4vog0Yx40rfp4uWNnE/xRWQQEOwlKM2WkcjQ== +postcss@^8.4.21: + version "8.4.22" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.22.tgz#c29e6776b60ab3af602d4b513d5bd2ff9aa85dc1" + integrity sha512-XseknLAfRHzVWjCEtdviapiBtfLdgyzExD50Rg2ePaucEesyh8Wv4VPdW0nbyDa1ydbrAxV19jvMT4+LFmcNUA== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +posthog-js@1.53.2: + version "1.53.2" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.53.2.tgz#ea185f23d36e5fa0543b6e43a2df12cca4b9131e" + integrity sha512-/vSGeDEWNX8ZVvXu4DA+tdZXcc8gHjZl8Tb5cU97KXngQCOumsSimJTBeG/PI8X8R/mRWBbOmnllo72YWTrl1g== dependencies: fflate "^0.4.1" rrweb-snapshot "^1.1.14" @@ -7162,6 +7252,14 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proposal-temporal@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/proposal-temporal/-/proposal-temporal-0.9.0.tgz#4841cf83cf270f85a829e9283843ea8796d3d86f" + integrity sha512-AyNg3NmmBDCDbABQDmsnsY1B8VciwO9wZm+C3rClAgkPre+SpZDcIGje0WLZwroyqUFDySqW7VV6vcvAv8Bi+Q== + dependencies: + big-integer "^1.6.48" + es-abstract "^1.18.3" + protocol-buffers-schema@^3.3.1: version "3.6.0" resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" @@ -7602,12 +7700,12 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^4.0.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.1.tgz#bd33364f67021c5b79e93d7f4fa0568c7c21b755" - integrity sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og== +rimraf@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.0.tgz#5bda14e410d7e4dd522154891395802ce032c2cb" + integrity sha512-Jf9llaP+RvaEVS5nPShYFhtXIrb3LRKP281ib3So0KkeZKo2wIKyq0Re7TOSwanasA423PSr6CCIL4bP6T040g== dependencies: - glob "^9.2.0" + glob "^10.0.0" rrweb-snapshot@^1.1.14: version "1.1.14" @@ -7717,13 +7815,20 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: +semver@^7.3.2, semver@^7.3.5, semver@^7.3.8: version "7.4.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318" integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw== dependencies: lru-cache "^6.0.0" +semver@^7.3.4, semver@^7.3.7: + version "7.5.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" + integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA== + dependencies: + lru-cache "^6.0.0" + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -7767,6 +7872,11 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.0.1.tgz#96a61033896120ec9335d96851d902cc98f0ba2a" + integrity sha512-uUWsN4aOxJAS8KOuf3QMyFtgm1pkb6I+KRZbRF/ghdf5T7sM+B1lLLzPDxswUjkmHyxQAVzEgG35E3NzDM9GVw== + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -8024,13 +8134,13 @@ stylelint-scss@^4.2.0: postcss-value-parser "^4.2.0" stylelint@^15.0.0: - version "15.4.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.4.0.tgz#3958fff41fbcd68cf947fdecb329762d45f87013" - integrity sha512-TlOvpG3MbcFwHmK0q2ykhmpKo7Dq892beJit0NPdpyY9b1tFah/hGhqnAz/bRm2PDhDbJLKvjzkEYYBEz7Dxcg== + version "15.5.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.5.0.tgz#f16c238231f3f32e62da8a88969821d237eae8a6" + integrity sha512-jyMO3R1QtE5mUS4v40+Gg+sIQBqe7CF1xPslxycDzNVkIBCUD4O+5F1vLPq16VmunUTv4qG9o2rUKLnU5KkVeQ== dependencies: "@csstools/css-parser-algorithms" "^2.1.0" "@csstools/css-tokenizer" "^2.1.0" - "@csstools/media-query-list-parser" "^2.0.1" + "@csstools/media-query-list-parser" "^2.0.2" "@csstools/selector-specificity" "^2.2.0" balanced-match "^2.0.0" colord "^2.9.3" @@ -8044,7 +8154,7 @@ stylelint@^15.0.0: global-modules "^2.0.0" globby "^11.1.0" globjoin "^0.1.4" - html-tags "^3.2.0" + html-tags "^3.3.1" ignore "^5.2.4" import-lazy "^4.0.0" imurmurhash "^0.1.4" @@ -8379,10 +8489,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.3.tgz#fe976f0c826a88d0a382007681cbb2da44afdedf" - integrity sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA== +typescript@5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" + integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== ua-parser-js@^1.0.2: version "1.0.35"