diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index 1529faccaf..df737c6ab4 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -1,13 +1,33 @@ # Triggers after the layered build has finished, taking the artifact and running cypress on it +# +# Also called by a workflow in matrix-js-sdk. +# name: Cypress End to End Tests on: workflow_run: workflows: ["Element Web - Build"] types: - completed + + # support calls from other workflows + workflow_call: + inputs: + react-sdk-repository: + type: string + required: true + description: "The name of the github repository to check out and build." + rust-crypto: + type: boolean + required: false + description: "Enable Rust cryptography for the cypress run." + secrets: + CYPRESS_RECORD_KEY: + required: true + concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} + jobs: prepare: name: Prepare @@ -100,16 +120,10 @@ jobs: - uses: browser-actions/setup-chrome@c485fa3bab6be59dce18dbc18ef6ab7cbc8ff5f1 - run: echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV - - uses: tecolicom/actions-use-apt-tools@ceaf289fdbc6169fd2406a0f0365a584ffba003b # v1 - with: - # Our test suite includes some screenshot tests with unusual diacritics, which are - # supposed to be covered by STIXGeneral. - tools: fonts-stix - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2 + uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2 with: run_id: ${{ github.event.workflow_run.id }} name: previewbuild @@ -129,14 +143,23 @@ jobs: # XXX: We're checking out untrusted code in a secure context # We need to be careful to not trust anything this code outputs/may do # - # Note that we check out from the default repository, which is (for this workflow) the + # Note that (in the absence of a `react-sdk-repository` input), + # we check out from the default repository, which is (for this workflow) the # *target* repository for the pull request. + # ref: ${{ steps.sha.outputs.sha }} persist-credentials: false path: matrix-react-sdk + repository: ${{ inputs.react-sdk-repository || github.repository }} + + # Enable rust crypto if the calling workflow requests it + - name: Enable rust crypto + if: inputs.rust-crypto + run: | + echo "CYPRESS_RUST_CRYPTO=1" >> "$GITHUB_ENV" - name: Run Cypress tests - uses: cypress-io/github-action@59c3b9b4a1a6e623c29806797d849845443487d1 + uses: cypress-io/github-action@40a1a26c08d0e549e8516612ecebbd1ab5eeec8f with: working-directory: matrix-react-sdk # The built-in Electron runner seems to grind to a halt trying diff --git a/.github/workflows/element-web.yaml b/.github/workflows/element-web.yaml index 022c293abe..d369641f17 100644 --- a/.github/workflows/element-web.yaml +++ b/.github/workflows/element-web.yaml @@ -12,19 +12,37 @@ on: branches: [develop, master] repository_dispatch: types: [upstream-sdk-notify] + + # support triggering from other workflows + workflow_call: + inputs: + react-sdk-repository: + type: string + required: true + description: "The name of the github repository to check out and build." + + matrix-js-sdk-sha: + type: string + required: false + description: "The Git SHA of matrix-js-sdk to build against. By default, will use a matching branch name if it exists, or develop." + concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true + env: - # These must be set for fetchdep.sh to get the right branch - REPOSITORY: ${{ github.repository }} + # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} + jobs: build: name: "Build Element-Web" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 + with: + repository: ${{ inputs.react-sdk-repository || github.repository }} - uses: actions/setup-node@v3 with: @@ -32,6 +50,9 @@ jobs: - name: Fetch layered build id: layered_build + env: + # tell layered.sh to check out the right sha of the JS-SDK, if we were given one + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} run: | scripts/ci/layered.sh JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) @@ -43,7 +64,6 @@ jobs: run: cp element.io/develop/config.json config.json working-directory: ./element-web - # After building we write the version file and the react-sdk sha so our cypress tests are from the same sha - name: Build env: CI_PACKAGE: true @@ -51,9 +71,13 @@ jobs: run: | yarn build echo $VERSION > webapp/version - echo $GITHUB_SHA > webapp/sha working-directory: ./element-web + # Record the react-sdk sha so our cypress tests are from the same sha + - name: Record react-sdk SHA + run: | + git rev-parse HEAD > element-web/webapp/sha + - name: Upload Artifact uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index 4fd3f851dd..248fb50c9e 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -33,7 +33,7 @@ jobs: # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2 + uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2 with: run_id: ${{ github.event.workflow_run.id }} name: previewbuild diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 3714516b93..c4bf0ef3be 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -10,10 +10,11 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true + env: - # These must be set for fetchdep.sh to get the right branch - REPOSITORY: ${{ github.repository }} + # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} + jobs: ts_lint: name: "Typescript Syntax Check" @@ -59,7 +60,7 @@ jobs: - name: Get diff lines id: diff - uses: Equip-Collaboration/diff-line-numbers@df70b4b83e05105c15f20dc6cc61f1463411b2a6 # v1.0.0 + uses: Equip-Collaboration/diff-line-numbers@e752977e2cb4207d671bb9e4dad18c07c1b73d52 # v1.1.0 with: include: '["\\.tsx?$"]' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdc1badd6a..ad8fdab8c7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,11 +20,12 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true + env: ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' && inputs.disable_coverage != 'true' }} - # These must be set for fetchdep.sh to get the right branch - REPOSITORY: ${{ github.repository }} + # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} + jobs: jest: name: Jest diff --git a/.stylelintrc.js b/.stylelintrc.js index 099a12f09c..259c626dee 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -33,6 +33,11 @@ module.exports = { "import-notation": null, "value-keyword-case": null, "declaration-block-no-redundant-longhand-properties": null, + "declaration-block-no-duplicate-properties": [ + true, + // useful for fallbacks + { ignore: ["consecutive-duplicates-with-different-values"] }, + ], "shorthand-property-no-redundant-values": null, "property-no-vendor-prefix": null, "value-no-vendor-prefix": null, diff --git a/cypress/e2e/audio-player/audio-player.spec.ts b/cypress/e2e/audio-player/audio-player.spec.ts index 7a5d608cd6..a59fb64ab4 100644 --- a/cypress/e2e/audio-player/audio-player.spec.ts +++ b/cypress/e2e/audio-player/audio-player.spec.ts @@ -176,7 +176,7 @@ describe("Audio player", () => { // Enable high contrast manually cy.openUserSettings("Appearance") - .get(".mx_ThemeChoicePanel") + .findByTestId("mx_ThemeChoicePanel") .findByLabelText("Use high contrast") .click({ force: true }); // force click because the size of the checkbox is zero @@ -333,30 +333,33 @@ describe("Audio player", () => { // On a thread cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last") - .within(() => { - // Assert that the player is correctly rendered on a thread - cy.get(".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container").within(() => { - // Assert that the counter is zero before clicking the play button - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); + cy.get(".mx_EventTile_last").within(() => { + // Assert that the player is correctly rendered on a thread + cy.get(".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container").within(() => { + // 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, the wait is to make the test less flaky - cy.findByRole("button", { name: "Play" }).should("exist"); - cy.wait(500).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"); + // Assert that "Pause" button can be found + cy.findByRole("button", { name: "Pause" }).should("exist"); - // Assert that the timer is reset when the audio file finished playing - cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); + // Assert that the timer is reset when the audio file finished playing + 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").should("not.have.attr", "disabled"); - }); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); // Find and click "Reply" button + // Assert that "Play" button can be found + cy.findByRole("button", { name: "Play" }).should("exist").should("not.have.attr", "disabled"); + }); + }); + + // Find and click "Reply" button + // + // Calling cy.get(".mx_EventTile_last") again here is a workaround for + // https://github.com/matrix-org/matrix-js-sdk/issues/3394: the event tile may have been re-mounted while + // the audio was playing. + cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); cy.get(".mx_MessageComposer--compact").within(() => { // Assert that the reply preview is rendered on the message composer diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 1013885b3d..2b49b5e32e 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -225,9 +225,10 @@ describe("Composer", () => { }); // ...inserts the username into the composer cy.findByRole("textbox").within(() => { - // TODO update this test when the mentions are inserted as pills, instead - // of as text - cy.findByText(otherUserName, { exact: false }).should("exist"); + cy.findByText(otherUserName, { exact: false }) + .should("exist") + .should("have.attr", "contenteditable", "false") + .should("have.attr", "data-mention-type", "user"); }); // Send the message to clear the composer @@ -250,9 +251,10 @@ describe("Composer", () => { // Selecting the autocomplete option using Enter inserts it into the composer cy.findByRole("textbox").type(`{Enter}`); cy.findByRole("textbox").within(() => { - // TODO update this test when the mentions are inserted as pills, instead - // of as text - cy.findByText(otherUserName, { exact: false }).should("exist"); + cy.findByText(otherUserName, { exact: false }) + .should("exist") + .should("have.attr", "contenteditable", "false") + .should("have.attr", "data-mention-type", "user"); }); }); }); diff --git a/cypress/e2e/crypto/complete-security.spec.ts b/cypress/e2e/crypto/complete-security.spec.ts index 0838abd459..b598829b86 100644 --- a/cypress/e2e/crypto/complete-security.spec.ts +++ b/cypress/e2e/crypto/complete-security.spec.ts @@ -16,8 +16,9 @@ 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 { handleVerificationRequest, logIntoElement, waitForVerificationRequest } from "./utils"; import { CypressBot } from "../../support/bot"; +import { skipIfRustCrypto } from "../../support/util"; describe("Complete security", () => { let homeserver: HomeserverInstance; @@ -46,6 +47,8 @@ describe("Complete security", () => { }); it("should walk through device verification if we have a signed device", () => { + skipIfRustCrypto(); + // create a new user, and have it bootstrap cross-signing let botClient: CypressBot; cy.getBot(homeserver, { displayName: "Jeff" }) @@ -66,7 +69,6 @@ describe("Complete security", () => { // accept the verification request on the "bot" side cy.wrap(botVerificationRequestPromise).then(async (verificationRequest: VerificationRequest) => { - await verificationRequest.accept(); await handleVerificationRequest(verificationRequest); }); @@ -80,22 +82,3 @@ describe("Complete security", () => { }); }); }); - -/** - * 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 5c3bb857fb..17975e88da 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -19,7 +19,14 @@ import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/ import type { CypressBot } from "../../support/bot"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; -import { EmojiMapping, handleVerificationRequest, waitForVerificationRequest } from "./utils"; +import { + checkDeviceIsCrossSigned, + EmojiMapping, + handleVerificationRequest, + logIntoElement, + waitForVerificationRequest, +} from "./utils"; +import { skipIfRustCrypto } from "../../support/util"; interface CryptoTestContext extends Mocha.Context { homeserver: HomeserverInstance; @@ -103,6 +110,27 @@ function autoJoin(client: MatrixClient) { }); } +/** + * Given a VerificationRequest in a bot client, add cypress commands to: + * - wait for the bot to receive a 'verify by emoji' notification + * - check that the bot sees the same emoji as the application + * + * @param botVerificationRequest - a verification request in a bot client + */ +function doTwoWaySasVerification(botVerificationRequest: VerificationRequest): void { + // on the bot side, wait for the emojis, confirm they match, and return them + const emojiPromise = handleVerificationRequest(botVerificationRequest); + + // then, check that our application shows an emoji panel with the same emojis. + cy.wrap(emojiPromise).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]); + }); + }); + }); +} + const verify = function (this: CryptoTestContext) { const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob); @@ -111,21 +139,9 @@ const verify = function (this: CryptoTestContext) { cy.findByText("Bob").click(); cy.findByRole("button", { name: "Verify" }).click(); cy.findByRole("button", { name: "Start Verification" }).click(); - cy.wrap(bobsVerificationRequestPromise) - .then((verificationRequest: VerificationRequest) => { - verificationRequest.accept(); - return verificationRequest; - }) - .as("bobsVerificationRequest"); cy.findByRole("button", { name: "Verify by emoji" }).click(); - cy.get("@bobsVerificationRequest").then((request: VerificationRequest) => { - 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]); - }); - }); - }); + cy.wrap(bobsVerificationRequestPromise).then((request: VerificationRequest) => { + doTwoWaySasVerification(request); }); cy.findByRole("button", { name: "They match" }).click(); cy.findByText("You've successfully verified Bob!").should("exist"); @@ -143,7 +159,11 @@ describe("Cryptography", function () { cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { aliceCredentials = credentials; }); - cy.getBot(homeserver, { displayName: "Bob", autoAcceptInvites: false, userIdPrefix: "bob_" }).as("bob"); + cy.getBot(homeserver, { + displayName: "Bob", + autoAcceptInvites: false, + userIdPrefix: "bob_", + }).as("bob"); }); }); @@ -152,6 +172,7 @@ describe("Cryptography", function () { }); it("setting up secure key backup should work", () => { + skipIfRustCrypto(); cy.openUserSettings("Security & Privacy"); cy.findByRole("button", { name: "Set up Secure Backup" }).click(); cy.get(".mx_Dialog").within(() => { @@ -175,6 +196,7 @@ describe("Cryptography", function () { }); it("creating a DM should work, being e2e-encrypted / user verification", function (this: CryptoTestContext) { + skipIfRustCrypto(); cy.bootstrapCrossSigning(aliceCredentials); startDMWithBob.call(this); // send first message @@ -196,6 +218,7 @@ describe("Cryptography", function () { }); it("should allow verification when there is no existing DM", function (this: CryptoTestContext) { + skipIfRustCrypto(); cy.bootstrapCrossSigning(aliceCredentials); autoJoin(this.bob); @@ -214,6 +237,7 @@ describe("Cryptography", function () { }); it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { + skipIfRustCrypto(); cy.bootstrapCrossSigning(aliceCredentials); // bob has a second, not cross-signed, device @@ -300,3 +324,67 @@ describe("Cryptography", function () { }); }); }); + +describe("Verify own device", () => { + let aliceBotClient: CypressBot; + let homeserver: HomeserverInstance; + + beforeEach(() => { + cy.startHomeserver("default").then((data: HomeserverInstance) => { + 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"); + + // Create a new device for alice + cy.getBot(homeserver, { bootstrapCrossSigning: true }).then((bot) => { + aliceBotClient = bot; + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + /* Click the "Verify with another device" button, and have the bot client auto-accept it. + * + * Stores the incoming `VerificationRequest` on the bot client as `@verificationRequest`. + */ + function initiateAliceVerificationRequest() { + // alice bot waits for verification request + const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); + + // Click on "Verify with another device" + cy.get(".mx_AuthPage").within(() => { + cy.findByRole("button", { name: "Verify with another device" }).click(); + }); + + // alice bot responds yes to verification request from alice + cy.wrap(promiseVerificationRequest).as("verificationRequest"); + } + + it("with SAS", function (this: CryptoTestContext) { + logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); + + // Launch the verification request between alice and the bot + initiateAliceVerificationRequest(); + + // Handle emoji SAS verification + cy.get(".mx_InfoDialog").within(() => { + cy.get("@verificationRequest").then((request: VerificationRequest) => { + // Handle emoji request and check that emojis are matching + doTwoWaySasVerification(request); + }); + + cy.findByRole("button", { name: "They match" }).click(); + cy.findByRole("button", { name: "Got it" }).click(); + }); + + // Check that our device is now cross-signed + checkDeviceIsCrossSigned(); + }); +}); diff --git a/cypress/e2e/crypto/decryption-failure.spec.ts b/cypress/e2e/crypto/decryption-failure.spec.ts index a9ace36c22..4de2af0e81 100644 --- a/cypress/e2e/crypto/decryption-failure.spec.ts +++ b/cypress/e2e/crypto/decryption-failure.spec.ts @@ -15,11 +15,11 @@ limitations under the License. */ 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 { MatrixClient } from "matrix-js-sdk/src/matrix"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; -import Chainable = Cypress.Chainable; +import { handleVerificationRequest } from "./utils"; +import { skipIfRustCrypto } from "../../support/util"; const ROOM_NAME = "Test room"; const TEST_USER = "Alia"; @@ -39,24 +39,6 @@ const waitForVerificationRequest = (cli: MatrixClient): Promise => { - return cy.wrap( - new Promise((resolve) => { - const onShowSas = (event: ISasEvent) => { - verifier.off("show_sas", onShowSas); - event.confirm(); - resolve(event.sas.emoji); - }; - - const verifier = request.beginKeyVerification("m.sas.v1"); - verifier.on("show_sas", onShowSas); - verifier.verify(); - }), - // extra timeout, as this sometimes takes a while - { timeout: 30_000 }, - ); -}; - const checkTimelineNarrow = (button = true) => { cy.viewport(800, 600); // SVGA cy.get(".mx_LeftPanel_minimized").should("exist"); // Wait until the left panel is minimized @@ -86,6 +68,7 @@ describe("Decryption Failure Bar", () => { let roomId: string; beforeEach(function () { + skipIfRustCrypto(); cy.startHomeserver("default").then((hs: HomeserverInstance) => { homeserver = hs; cy.initTestUser(homeserver, TEST_USER) @@ -161,7 +144,11 @@ describe("Decryption Failure Bar", () => { ); cy.wrap(verificationRequestPromise).then((verificationRequest: VerificationRequest) => { cy.wrap(verificationRequest.accept()); - handleVerificationRequest(verificationRequest).then((emojis) => { + cy.wrap( + handleVerificationRequest(verificationRequest), + // extra timeout, as this sometimes takes a while + { timeout: 30_000 }, + ).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/utils.ts b/cypress/e2e/crypto/utils.ts index 6f99a23d0f..3e91d1e93d 100644 --- a/cypress/e2e/crypto/utils.ts +++ b/cypress/e2e/crypto/utils.ts @@ -21,15 +21,16 @@ import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/ export type EmojiMapping = [emoji: string, name: string]; /** - * wait for the given client to receive an incoming verification request + * wait for the given client to receive an incoming verification request, and automatically accept it * * @param cli - matrix client we expect to receive a request */ export function waitForVerificationRequest(cli: MatrixClient): Promise { return new Promise((resolve) => { - const onVerificationRequestEvent = (request: VerificationRequest) => { + const onVerificationRequestEvent = async (request: VerificationRequest) => { // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here cli.off("crypto.verification.request", onVerificationRequestEvent); + await request.accept(); resolve(request); }; // @ts-ignore @@ -38,26 +39,83 @@ export function waitForVerificationRequest(cli: MatrixClient): Promise { return new Promise((resolve) => { const onShowSas = (event: ISasEvent) => { + // @ts-ignore VerifierEvent is a pain to get at here as we don't have a reference to matrixcs; + // using the string value here verifier.off("show_sas", onShowSas); event.confirm(); - verifier.done(); resolve(event.sas.emoji); }; const verifier = request.beginKeyVerification("m.sas.v1"); + // @ts-ignore as above, avoiding reference to VerifierEvent verifier.on("show_sas", onShowSas); verifier.verify(); }); } + +/** + * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. + */ +export function checkDeviceIsCrossSigned(): void { + let userId: string; + let myDeviceId: string; + cy.window({ log: false }) + .then((win) => { + // Get the userId and deviceId of the current user + const cli = win.mxMatrixClientPeg.get(); + const accessToken = cli.getAccessToken()!; + const homeserverUrl = cli.getHomeserverUrl(); + myDeviceId = cli.getDeviceId(); + userId = cli.getUserId(); + return cy.request({ + method: "POST", + url: `${homeserverUrl}/_matrix/client/v3/keys/query`, + headers: { Authorization: `Bearer ${accessToken}` }, + body: { device_keys: { [userId]: [] } }, + }); + }) + .then((res) => { + // there should be three cross-signing keys + expect(res.body.master_keys[userId]).to.have.property("keys"); + expect(res.body.self_signing_keys[userId]).to.have.property("keys"); + expect(res.body.user_signing_keys[userId]).to.have.property("keys"); + + // and the device should be signed by the self-signing key + const selfSigningKeyId = Object.keys(res.body.self_signing_keys[userId].keys)[0]; + + expect(res.body.device_keys[userId][myDeviceId]).to.exist; + + const myDeviceSignatures = res.body.device_keys[userId][myDeviceId].signatures[userId]; + expect(myDeviceSignatures[selfSigningKeyId]).to.exist; + }); +} + +/** + * Fill in the login form in element with the given creds + */ +export 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/invite/invite-dialog.spec.ts b/cypress/e2e/invite/invite-dialog.spec.ts index 1553215ac0..80edfa411d 100644 --- a/cypress/e2e/invite/invite-dialog.spec.ts +++ b/cypress/e2e/invite/invite-dialog.spec.ts @@ -164,6 +164,14 @@ describe("Invite dialog", function () { // Assert that the invite dialog disappears cy.get(".mx_InviteDialog_other").should("not.exist"); + // Assert that the hovered user name on invitation UI does not have background color + // TODO: implement the test on room-header.spec.ts + cy.get(".mx_RoomHeader").within(() => { + cy.get(".mx_RoomHeader_name--textonly") + .realHover() + .should("have.css", "background-color", "rgba(0, 0, 0, 0)"); + }); + // Send a message to invite the bots cy.getComposer().type("Hello{enter}"); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index 7098a4ce9d..9bc6dd3f1b 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -21,10 +21,6 @@ import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Login", () => { let homeserver: HomeserverInstance; - beforeEach(() => { - cy.stubDefaultServer(); - }); - afterEach(() => { cy.stopHomeserver(homeserver); }); @@ -44,17 +40,18 @@ describe("Login", () => { it("logs in with an existing account and lands on the home screen", () => { cy.injectAxe(); - cy.findByRole("textbox", { name: "Username", timeout: 15000 }).should("be.visible"); - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 - //cy.percySnapshot("Login"); - cy.checkA11y(); - + // first pick the homeserver, as otherwise the user picker won't be visible cy.findByRole("button", { name: "Edit" }).click(); cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserver.baseUrl); 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", timeout: 15000 }).should("be.visible"); + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 + //cy.percySnapshot("Login"); + cy.checkA11y(); + cy.findByRole("textbox", { name: "Username" }).type(username); cy.findByPlaceholderText("Password").type(password); cy.findByRole("button", { name: "Sign in" }).click(); diff --git a/cypress/e2e/permalinks/permalinks.spec.ts b/cypress/e2e/permalinks/permalinks.spec.ts index 3d9fc944a1..2a61df26a0 100644 --- a/cypress/e2e/permalinks/permalinks.spec.ts +++ b/cypress/e2e/permalinks/permalinks.spec.ts @@ -126,13 +126,14 @@ describe("permalinks", () => { getPill(danielle.getSafeUserId()); }); - // clean up before taking the snapshot - cy.get(".mx_cryptoEvent").invoke("remove"); - cy.get(".mx_NewRoomIntro").invoke("remove"); - cy.get(".mx_GenericEventListSummary").invoke("remove"); + // Exclude various components from the snapshot, for consistency + const percyCSS = + ".mx_cryptoEvent, " + + ".mx_NewRoomIntro, " + + ".mx_MessageTimestamp, " + + ".mx_RoomView_myReadMarker, " + + ".mx_GenericEventListSummary { visibility: hidden !important; }"; - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/25283 - //const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - //cy.get(".mx_RoomView_timeline").percySnapshotElement("Permalink rendering", { percyCSS }); + cy.get(".mx_RoomView_timeline").percySnapshotElement("Permalink rendering", { percyCSS }); }); }); diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index 152915cc1c..5810915439 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -17,12 +17,12 @@ limitations under the License. /// import { HomeserverInstance } from "../../plugins/utils/homeserver"; +import { checkDeviceIsCrossSigned } from "../crypto/utils"; describe("Registration", () => { let homeserver: HomeserverInstance; beforeEach(() => { - cy.stubDefaultServer(); cy.visit("/#/register"); cy.startHomeserver("consent").then((data) => { homeserver = data; @@ -89,39 +89,14 @@ describe("Registration", () => { // check that the device considers itself verified cy.findByRole("button", { name: "User menu" }).click(); - cy.findByRole("menuitem", { name: "Security & Privacy" }).click(); - cy.get(".mx_DevicesPanel_myDevice .mx_DevicesPanel_deviceTrust .mx_E2EIcon").should( - "have.class", - "mx_E2EIcon_verified", - ); + cy.findByRole("menuitem", { name: "All settings" }).click(); + cy.findByRole("tab", { name: "Sessions" }).click(); + cy.findByTestId("current-session-section").within(() => { + cy.findByTestId("device-metadata-isVerified").should("have.text", "Verified"); + }); // check that cross-signing keys have been uploaded. - const myUserId = "@alice:localhost"; - let myDeviceId: string; - cy.window({ log: false }) - .then((win) => { - const cli = win.mxMatrixClientPeg.get(); - const accessToken = cli.getAccessToken()!; - myDeviceId = cli.getDeviceId(); - return cy.request({ - method: "POST", - url: `${homeserver.baseUrl}/_matrix/client/v3/keys/query`, - headers: { Authorization: `Bearer ${accessToken}` }, - body: { device_keys: { [myUserId]: [] } }, - }); - }) - .then((res) => { - // there should be three cross-signing keys - expect(res.body.master_keys[myUserId]).to.have.property("keys"); - expect(res.body.self_signing_keys[myUserId]).to.have.property("keys"); - expect(res.body.user_signing_keys[myUserId]).to.have.property("keys"); - - // and the device should be signed by the self-signing key - const selfSigningKeyId = Object.keys(res.body.self_signing_keys[myUserId].keys)[0]; - expect(res.body.device_keys[myUserId][myDeviceId]).to.exist; - const myDeviceSignatures = res.body.device_keys[myUserId][myDeviceId].signatures[myUserId]; - expect(myDeviceSignatures[selfSigningKeyId]).to.exist; - }); + checkDeviceIsCrossSigned(); }); it("should require username to fulfil requirements and be available", () => { diff --git a/cypress/e2e/room/room-header.spec.ts b/cypress/e2e/room/room-header.spec.ts index b4c73532d4..fc20dfbebe 100644 --- a/cypress/e2e/room/room-header.spec.ts +++ b/cypress/e2e/room/room-header.spec.ts @@ -16,6 +16,8 @@ limitations under the License. /// +import { IWidget } from "matrix-widget-api"; + import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; @@ -94,14 +96,8 @@ describe("Room Header", () => { // Assert the size of buttons on RoomHeader are specified and the buttons are not compressed // Note these assertions do not check the size of mx_RoomHeader_name button - // TODO: merge the assertions by using the same class name cy.get(".mx_RoomHeader_button") - .should("have.length", 3) - .should("be.visible") - .should("have.css", "height", "32px") - .should("have.css", "width", "32px"); - cy.get(".mx_RightPanel_headerButton") - .should("have.length", 3) + .should("have.length", 6) .should("be.visible") .should("have.css", "height", "32px") .should("have.css", "width", "32px"); @@ -196,4 +192,101 @@ describe("Room Header", () => { }); }); }); + + describe("with a widget", () => { + const ROOM_NAME = "Test Room with a widget"; + const WIDGET_ID = "fake-widget"; + const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + + `; + + let widgetUrl: string; + let roomId: string; + + beforeEach(() => { + cy.serveHtmlFile(WIDGET_HTML).then((url) => { + widgetUrl = url; + }); + + cy.createRoom({ name: ROOM_NAME }).then((id) => { + roomId = id; + + // setup widget via state event + cy.getClient() + .then(async (matrixClient) => { + const content: IWidget = { + id: WIDGET_ID, + creatorUserId: "somebody", + type: "widget", + name: "widget", + url: widgetUrl, + }; + await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, WIDGET_ID); + }) + .as("widgetEventSent"); + + // set initial layout + cy.getClient() + .then(async (matrixClient) => { + const content = { + widgets: { + [WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }; + await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); + }) + .as("layoutEventSent"); + }); + + cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(() => { + // open the room + cy.viewRoomByName(ROOM_NAME); + }); + }); + + it("should highlight the apps button", () => { + // Assert that AppsDrawer is rendered + cy.get(".mx_AppsDrawer").should("exist"); + + cy.get(".mx_RoomHeader").within(() => { + // Assert that "Hide Widgets" button is rendered and aria-checked is set to true + cy.findByRole("button", { name: "Hide Widgets" }) + .should("exist") + .should("have.attr", "aria-checked", "true"); + }); + + cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (highlighted)"); + }); + + it("should support hiding a widget", () => { + cy.get(".mx_AppsDrawer").should("exist"); + + cy.get(".mx_RoomHeader").within(() => { + // Click the apps button to hide AppsDrawer + cy.findByRole("button", { name: "Hide Widgets" }).should("exist").click(); + + // Assert that "Show widgets" button is rendered and aria-checked is set to false + cy.findByRole("button", { name: "Show Widgets" }) + .should("exist") + .should("have.attr", "aria-checked", "false"); + }); + + // Assert that AppsDrawer is not rendered + cy.get(".mx_AppsDrawer").should("not.exist"); + + cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (not highlighted)"); + }); + }); }); diff --git a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts b/cypress/e2e/settings/appearance-user-settings-tab.spec.ts index f566eeeda8..cb22d26b58 100644 --- a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts +++ b/cypress/e2e/settings/appearance-user-settings-tab.spec.ts @@ -36,12 +36,11 @@ describe("Appearance user settings tab", () => { it("should be rendered properly", () => { cy.openUserSettings("Appearance"); - cy.get(".mx_SettingsTab.mx_AppearanceUserSettingsTab").within(() => { - // Assert that the top heading is rendered - cy.findByTestId("appearance").should("have.text", "Customise your appearance").should("be.visible"); + cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => { + cy.get("h2").should("have.text", "Customise your appearance").should("be.visible"); }); - cy.get(".mx_SettingsTab.mx_AppearanceUserSettingsTab").percySnapshotElement( + cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement( "User settings tab - Appearance (advanced options collapsed)", { // Emulate TabbedView's actual min and max widths @@ -57,7 +56,7 @@ describe("Appearance user settings tab", () => { // Assert that "Hide advanced" link button is rendered cy.findByRole("button", { name: "Hide advanced" }).should("exist"); - cy.get(".mx_SettingsTab.mx_AppearanceUserSettingsTab").percySnapshotElement( + cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement( "User settings tab - Appearance (advanced options expanded)", { // Emulate TabbedView's actual min and max widths @@ -74,7 +73,7 @@ describe("Appearance user settings tab", () => { cy.openUserSettings("Appearance"); - cy.get(".mx_AppearanceUserSettingsTab .mx_LayoutSwitcher_RadioButtons").within(() => { + cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { // Assert that the layout selected by default is "Modern" cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => { cy.findByLabelText("Modern").should("exist"); @@ -84,7 +83,7 @@ describe("Appearance user settings tab", () => { // Assert that the room layout is set to group (modern) layout cy.get(".mx_RoomView_body[data-layout='group']").should("exist"); - cy.get(".mx_AppearanceUserSettingsTab .mx_LayoutSwitcher_RadioButtons").within(() => { + cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { // Select the first layout cy.get(".mx_LayoutSwitcher_RadioButton").first().click(); @@ -97,7 +96,7 @@ describe("Appearance user settings tab", () => { // Assert that the room layout is set to IRC layout cy.get(".mx_RoomView_body[data-layout='irc']").should("exist"); - cy.get(".mx_AppearanceUserSettingsTab .mx_LayoutSwitcher_RadioButtons").within(() => { + cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { // Select the last layout cy.get(".mx_LayoutSwitcher_RadioButton").last().click(); @@ -114,7 +113,7 @@ describe("Appearance user settings tab", () => { it("should support changing font size by clicking the font slider", () => { cy.openUserSettings("Appearance"); - cy.get(".mx_SettingsTab.mx_AppearanceUserSettingsTab").within(() => { + cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => { cy.get(".mx_FontScalingPanel_fontSlider").within(() => { cy.findByLabelText("Font size").should("exist"); }); @@ -150,7 +149,7 @@ describe("Appearance user settings tab", () => { it("should disable font size slider when custom font size is used", () => { cy.openUserSettings("Appearance"); - cy.get(".mx_FontScalingPanel").within(() => { + cy.findByTestId("mx_FontScalingPanel").within(() => { cy.findByLabelText("Use custom size").click({ force: true }); // force click as checkbox size is zero // Assert that the font slider is disabled @@ -167,10 +166,8 @@ describe("Appearance user settings tab", () => { // Click "Show advanced" link button cy.findByRole("button", { name: "Show advanced" }).click(); - cy.get(".mx_AppearanceUserSettingsTab_Advanced").within(() => { - // force click as checkbox size is zero - cy.findByLabelText("Use a more compact 'Modern' layout").click({ force: true }); - }); + // force click as checkbox size is zero + cy.findByLabelText("Use a more compact 'Modern' layout").click({ force: true }); // Assert that the room layout is set to compact group (modern) layout cy.get("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout").should("exist"); @@ -178,13 +175,7 @@ describe("Appearance user settings tab", () => { it("should disable compact group (modern) layout option on IRC layout and bubble layout", () => { const checkDisabled = () => { - cy.get(".mx_AppearanceUserSettingsTab_Advanced").within(() => { - cy.get(".mx_Checkbox") - .first() - .within(() => { - cy.get("input[type='checkbox'][disabled]").should("exist"); - }); - }); + cy.findByLabelText("Use a more compact 'Modern' layout").should("be.disabled"); }; cy.openUserSettings("Appearance"); @@ -193,7 +184,7 @@ describe("Appearance user settings tab", () => { cy.findByRole("button", { name: "Show advanced" }).click(); // Enable IRC layout - cy.get(".mx_AppearanceUserSettingsTab .mx_LayoutSwitcher_RadioButtons").within(() => { + cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { // Select the first layout cy.get(".mx_LayoutSwitcher_RadioButton").first().click(); @@ -206,7 +197,7 @@ describe("Appearance user settings tab", () => { checkDisabled(); // Enable bubble layout - cy.get(".mx_AppearanceUserSettingsTab .mx_LayoutSwitcher_RadioButtons").within(() => { + cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => { // Select the first layout cy.get(".mx_LayoutSwitcher_RadioButton").last().click(); @@ -225,10 +216,8 @@ describe("Appearance user settings tab", () => { // Click "Show advanced" link button cy.findByRole("button", { name: "Show advanced" }).click(); - cy.get(".mx_AppearanceUserSettingsTab_Advanced").within(() => { - // force click as checkbox size is zero - cy.findByLabelText("Use a system font").click({ force: true }); - }); + // force click as checkbox size is zero + cy.findByLabelText("Use a system font").click({ force: true }); // Assert that the font-family value was removed cy.get("body").should("have.css", "font-family", '""'); @@ -242,7 +231,7 @@ describe("Appearance user settings tab", () => { it("should be rendered with the light theme selected", () => { cy.openUserSettings("Appearance") - .get(".mx_ThemeChoicePanel") + .findByTestId("mx_ThemeChoicePanel") .within(() => { cy.findByTestId("checkbox-use-system-theme").within(() => { cy.findByText("Match system theme").should("be.visible"); @@ -252,7 +241,7 @@ describe("Appearance user settings tab", () => { cy.get(".mx_Checkbox_checkmark").should("not.be.visible"); }); - cy.get(".mx_ThemeSelectors").within(() => { + cy.findByTestId("theme-choice-panel-selectors").within(() => { cy.get(".mx_ThemeSelector_light").should("exist"); cy.get(".mx_ThemeSelector_dark").should("exist"); @@ -274,11 +263,11 @@ describe("Appearance user settings tab", () => { "the system theme is clicked", () => { cy.openUserSettings("Appearance") - .get(".mx_ThemeChoicePanel") + .findByTestId("mx_ThemeChoicePanel") .findByLabelText("Match system theme") .click({ force: true }); // force click because the size of the checkbox is zero - cy.get(".mx_ThemeChoicePanel").within(() => { + cy.findByTestId("mx_ThemeChoicePanel").within(() => { // Assert that the labels for the light theme and dark theme are disabled cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled").should("exist"); cy.get(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled").should("exist"); @@ -321,7 +310,7 @@ describe("Appearance user settings tab", () => { }); cy.openUserSettings("Appearance") - .get(".mx_ThemeChoicePanel") + .findByTestId("mx_ThemeChoicePanel") .findByLabelText("Use high contrast") .click({ force: true }); // force click because the size of the checkbox is zero diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts index 277fa505fc..06795b68be 100644 --- a/cypress/e2e/settings/device-management.spec.ts +++ b/cypress/e2e/settings/device-management.spec.ts @@ -24,7 +24,6 @@ describe("Device manager", () => { let user: UserCredentials | undefined; beforeEach(() => { - cy.enableLabsFeature("feature_new_device_manager"); cy.startHomeserver("default").then((data) => { homeserver = data; diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts index b78fe390d9..2879d6d930 100644 --- a/cypress/e2e/settings/general-user-settings-tab.spec.ts +++ b/cypress/e2e/settings/general-user-settings-tab.spec.ts @@ -53,7 +53,7 @@ describe("General user settings tab", () => { cy.findByTestId("mx_GeneralUserSettingsTab").within(() => { // Assert that the top heading is rendered - cy.findByTestId("general").should("have.text", "General").should("be.visible"); + cy.findByText("General").should("be.visible"); cy.get(".mx_ProfileSettings_profile") .scrollIntoView() @@ -83,10 +83,14 @@ describe("General user settings tab", () => { }); // Wait until spinners disappear - cy.get(".mx_GeneralUserSettingsTab_section--account .mx_Spinner").should("not.exist"); - cy.get(".mx_GeneralUserSettingsTab_section--discovery .mx_Spinner").should("not.exist"); + cy.findByTestId("accountSection").within(() => { + cy.get(".mx_Spinner").should("not.exist"); + }); + cy.findByTestId("discoverySection").within(() => { + cy.get(".mx_Spinner").should("not.exist"); + }); - cy.get(".mx_GeneralUserSettingsTab_section--account").within(() => { + cy.findByTestId("accountSection").within(() => { // Assert that input areas for changing a password exists cy.get("form.mx_GeneralUserSettingsTab_section--account_changePassword") .scrollIntoView() @@ -95,29 +99,28 @@ describe("General user settings tab", () => { 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 email addresses area + cy.findByTestId("mx_AccountEmailAddresses") + .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.findByTestId("mx_AccountPhoneNumbers") + .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_section_languageInput") @@ -188,7 +191,7 @@ describe("General user settings tab", () => { it("should set a country calling code based on default_country_code", () => { // Check phone numbers area - cy.get(".mx_PhoneNumbers") + cy.findByTestId("mx_AccountPhoneNumbers") .scrollIntoView() .within(() => { // Assert that an input area for a new phone number is rendered diff --git a/cypress/e2e/settings/security-user-settings-tab.spec.ts b/cypress/e2e/settings/security-user-settings-tab.spec.ts new file mode 100644 index 0000000000..341624dee3 --- /dev/null +++ b/cypress/e2e/settings/security-user-settings-tab.spec.ts @@ -0,0 +1,72 @@ +/* +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("Security user settings tab", () => { + let homeserver: HomeserverInstance; + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + describe("with posthog enabled", () => { + beforeEach(() => { + // Enable posthog + cy.intercept("/config.json?cachebuster=*", (req) => { + req.continue((res) => { + res.send(200, { + ...res.body, + posthog: { + project_api_key: "foo", + api_host: "bar", + }, + privacy_policy_url: "example.tld", // Set privacy policy URL to enable privacyPolicyLink + }); + }); + }); + + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Hanako"); + }); + + // Hide "Notification" toast on Cypress Cloud + cy.contains(".mx_Toast_toast h2", "Notifications") + .should("exist") + .closest(".mx_Toast_toast") + .within(() => { + cy.findByRole("button", { name: "Dismiss" }).click(); + }); + + cy.get(".mx_Toast_buttons").within(() => { + cy.findByRole("button", { name: "Yes" }).should("exist").click(); // Allow analytics + }); + + cy.openUserSettings("Security"); + }); + + describe("AnalyticsLearnMoreDialog", () => { + it("should be rendered properly", () => { + cy.findByRole("button", { name: "Learn more" }).click(); + + cy.get(".mx_AnalyticsLearnMoreDialog_wrapper").percySnapshotElement("AnalyticsLearnMoreDialog"); + }); + }); + }); +}); diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index 9b1fb241d0..47228e2bcd 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -140,6 +140,8 @@ describe("Spaces", () => { cy.findByPlaceholderText("Support").type("Projects"); cy.findByRole("button", { name: "Continue" }).click(); + cy.get(".mx_SpaceRoomView").percySnapshotElement("Space - 'Invite your teammates' dialog"); + cy.get(".mx_SpaceRoomView").within(() => { cy.get("h1").findByText("Invite your teammates"); cy.findByRole("button", { name: "Skip for now" }).click(); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 2aa98460b9..507fc2d75f 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -203,6 +203,10 @@ describe("Spotlight", () => { cy.get(".mx_RoomSublist_skeletonUI").should("not.exist"); }); }); + // wait for the room to have the right name + cy.get(".mx_RoomHeader").within(() => { + cy.findByText(room1Name); + }); }); afterEach(() => { @@ -212,8 +216,12 @@ describe("Spotlight", () => { it("should be able to add and remove filters via keyboard", () => { cy.openSpotlightDialog().within(() => { - cy.spotlightSearch().type("{downArrow}"); + cy.wait(1000); // wait for the dialog to settle, otherwise our keypresses might race with an update + + // initially, publicrooms should be highlighted (because there are no other suggestions) cy.get("#mx_SpotlightDialog_button_explorePublicRooms").should("have.attr", "aria-selected", "true"); + + // hitting enter should enable the publicrooms filter cy.spotlightSearch().type("{enter}"); cy.get(".mx_SpotlightDialog_filter").should("contain", "Public rooms"); cy.spotlightSearch().type("{backspace}"); @@ -233,7 +241,6 @@ describe("Spotlight", () => { cy.openSpotlightDialog() .within(() => { cy.spotlightSearch().clear().type(room1Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).click(); @@ -249,7 +256,6 @@ describe("Spotlight", () => { .within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room1Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -266,7 +272,6 @@ describe("Spotlight", () => { .within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room2Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room2Name); cy.spotlightResults().eq(0).should("contain", "Join"); @@ -284,7 +289,6 @@ describe("Spotlight", () => { .within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room3Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room3Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -326,7 +330,6 @@ describe("Spotlight", () => { .within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot1Name); cy.spotlightResults().eq(0).click(); @@ -341,7 +344,6 @@ describe("Spotlight", () => { .within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -359,7 +361,6 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 08e93af7ad..335c87bc01 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -296,7 +296,7 @@ describe("Threads", () => { }); cy.findByRole("button", { name: "Threads" }) - .should("have.class", "mx_RightPanel_headerButton_unread") // User asserts thread list unread indicator + .should("have.class", "mx_RoomHeader_button--unread") // User asserts thread list unread indicator .click(); // User opens thread list // User asserts thread with correct root & latest events & unread dot diff --git a/cypress/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts index 16bee8d222..0f18ce85c2 100644 --- a/cypress/e2e/widgets/layout.spec.ts +++ b/cypress/e2e/widgets/layout.spec.ts @@ -95,6 +95,10 @@ describe("Widget Layout", () => { cy.stopWebServers(); }); + it("should be set properly", () => { + cy.get(".mx_AppsDrawer").percySnapshotElement("Widgets drawer on the timeline (AppsDrawer)"); + }); + it("manually resize the height of the top container layout", () => { cy.get('iframe[title="widget"]').invoke("height").should("be.lessThan", 250); diff --git a/cypress/fixtures/matrix-org-client-login.json b/cypress/fixtures/matrix-org-client-login.json deleted file mode 100644 index d7c4fde1e5..0000000000 --- a/cypress/fixtures/matrix-org-client-login.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "flows": [ - { - "type": "m.login.sso", - "identity_providers": [ - { - "id": "oidc-github", - "name": "GitHub", - "icon": "mxc://matrix.org/sVesTtrFDTpXRbYfpahuJsKP", - "brand": "github" - }, - { - "id": "oidc-google", - "name": "Google", - "icon": "mxc://matrix.org/ZlnaaZNPxtUuQemvgQzlOlkz", - "brand": "google" - }, - { - "id": "oidc-gitlab", - "name": "GitLab", - "icon": "mxc://matrix.org/MCVOEmFgVieKFshPxmnejWOq", - "brand": "gitlab" - }, - { - "id": "oidc-facebook", - "name": "Facebook", - "icon": "mxc://matrix.org/nsyeLIgzxazZmJadflMAsAWG", - "brand": "facebook" - }, - { - "id": "oidc-apple", - "name": "Apple", - "icon": "mxc://matrix.org/QQKNSOdLiMHtJhzeAObmkFiU", - "brand": "apple" - } - ] - }, - { - "type": "m.login.token" - }, - { - "type": "m.login.password" - }, - { - "type": "m.login.application_service" - } - ] -} diff --git a/cypress/fixtures/matrix-org-client-well-known.json b/cypress/fixtures/matrix-org-client-well-known.json deleted file mode 100644 index ed726e2421..0000000000 --- a/cypress/fixtures/matrix-org-client-well-known.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "m.homeserver": { - "base_url": "https://matrix-client.matrix.org" - }, - "m.identity_server": { - "base_url": "https://vector.im" - } -} diff --git a/cypress/fixtures/vector-im-identity-v2.json b/cypress/fixtures/vector-im-identity-v2.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/cypress/fixtures/vector-im-identity-v2.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/cypress/support/axe.ts b/cypress/support/axe.ts index c0e7a6332a..38a297fe18 100644 --- a/cypress/support/axe.ts +++ b/cypress/support/axe.ts @@ -59,6 +59,10 @@ Cypress.Commands.overwrite( "color-contrast": { enabled: false, }, + // link-in-text-block also complains due to known contrast issues + "link-in-text-block": { + enabled: false, + }, ...options.rules, }, }, diff --git a/cypress/support/config.json.ts b/cypress/support/config.json.ts new file mode 100644 index 0000000000..874b410e88 --- /dev/null +++ b/cypress/support/config.json.ts @@ -0,0 +1,51 @@ +/* +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. +*/ + +/* Intercept requests to `config.json`, so that we can test against a known configuration. + * + * If we don't do this, we end up testing against the Element config for develop.element.io, which then means + * we make requests to the live `matrix.org`, which makes our tests dependent on matrix.org being up and responsive. + */ + +import { isRustCryptoEnabled } from "./util"; + +const CONFIG_JSON = { + // This is deliberately quite a minimal config.json, so that we can test that the default settings + // actually work. + // + // The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver. + // We point that to a guaranteed-invalid domain. + default_server_config: { + "m.homeserver": { + base_url: "https://server.invalid", + }, + }, + + // the location tests want a map style url. + map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", +}; + +beforeEach(() => { + const configJson = CONFIG_JSON; + + // configure element to use rust crypto if the env var tells us so + if (isRustCryptoEnabled()) { + configJson["features"] = { + feature_rust_crypto: true, + }; + } + cy.intercept({ method: "GET", pathname: "/config.json" }, { body: configJson }); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 2349bf350a..4f268966a3 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -20,6 +20,7 @@ import "@percy/cypress"; import "cypress-real-events"; import "@testing-library/cypress/add-commands"; +import "./config.json"; import "./homeserver"; import "./login"; import "./labs"; diff --git a/cypress/support/network.ts b/cypress/support/network.ts index 0cd38d9119..3e031099fb 100644 --- a/cypress/support/network.ts +++ b/cypress/support/network.ts @@ -68,29 +68,5 @@ Cypress.Commands.add("goOnline", (): void => { }); }); -Cypress.Commands.add("stubDefaultServer", (): void => { - cy.log("Stubbing vector.im and matrix.org network calls"); - // We intercept vector.im & matrix.org calls so that tests don't fail when it has issues - cy.intercept("GET", "https://vector.im/_matrix/identity/v2", { - fixture: "vector-im-identity-v2.json", - }); - cy.intercept("GET", "https://matrix.org/.well-known/matrix/client", { - fixture: "matrix-org-client-well-known.json", - }); - cy.intercept("GET", "https://matrix-client.matrix.org/_matrix/client/versions", { - fixture: "matrix-org-client-versions.json", - }); - cy.intercept("GET", "https://matrix-client.matrix.org/_matrix/client/r0/login", { - fixture: "matrix-org-client-login.json", - }); - cy.intercept("POST", "https://matrix-client.matrix.org/_matrix/client/r0/register?kind=guest", { - statusCode: 403, - body: { - errcode: "M_FORBIDDEN", - error: "Registration is not enabled on this homeserver.", - }, - }); -}); - // Needed to make this file a module export {}; diff --git a/cypress/support/util.ts b/cypress/support/util.ts index 6855379bda..c61a8c755b 100644 --- a/cypress/support/util.ts +++ b/cypress/support/util.ts @@ -56,5 +56,20 @@ cy.all = function all(commands): Cypress.Chainable { return cy.wrap(resultArray, { log: false }); }; -// Needed to make this file a module -export {}; +/** + * Check if Cypress has been configured to enable rust crypto, and bail out if so. + */ +export function skipIfRustCrypto() { + if (isRustCryptoEnabled()) { + cy.log("Skipping due to rust crypto"); + //@ts-ignore: 'state' is a secret internal command + cy.state("runnable").skip(); + } +} + +/** + * Determine if Cypress has been configured to enable rust crypto (by checking the environment variable) + */ +export function isRustCryptoEnabled(): boolean { + return !!Cypress.env("RUST_CRYPTO"); +} diff --git a/docs/cypress.md b/docs/cypress.md index d140a34d4f..d66c9675cf 100644 --- a/docs/cypress.md +++ b/docs/cypress.md @@ -45,6 +45,16 @@ To launch it: yarn run test:cypress:open ``` +### Running with Rust cryptography + +`matrix-js-sdk` is currently in the +[process](https://github.com/vector-im/element-web/issues/21972) of being +updated to replace its end-to-end encryption implementation to use the [Matrix +Rust SDK](https://github.com/matrix-org/matrix-rust-sdk). This is not currently +enabled by default, but it is possible to have Cypress configure Element to use +the Rust crypto implementation by setting the environment variable +`CYPRESS_RUST_CRYPTO=1`. + ## How the Tests Work Everything Cypress-related lives in the `cypress/` subdirectory of react-sdk diff --git a/package.json b/package.json index ec3b7b8d24..90823c84a9 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@testing-library/react-hooks": "^8.0.1", "await-lock": "^2.1.0", "blurhash": "^1.1.3", - "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.30.0", "counterpart": "^0.18.6", @@ -83,6 +82,7 @@ "focus-visible": "^5.2.0", "gfm.css": "^1.1.2", "glob-to-regexp": "^0.4.1", + "grapheme-splitter": "^1.0.4", "highlight.js": "^11.3.1", "html-entities": "^2.0.0", "is-ip": "^3.1.0", @@ -96,16 +96,16 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "25.2.0-rc.5", - "matrix-widget-api": "^1.3.1", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^1.4.0", "memoize-one": "^6.0.0", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.53.2", + "posthog-js": "1.57.2", "proposal-temporal": "^0.9.0", - "qrcode": "1.5.1", + "qrcode": "1.5.3", "re-resizable": "^6.9.0", "react": "17.0.2", "react-beautiful-dnd": "^13.1.0", @@ -118,7 +118,6 @@ "sanitize-html": "2.10.0", "tar-js": "^0.3.0", "ua-parser-js": "^1.0.2", - "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, @@ -175,7 +174,7 @@ "@typescript-eslint/eslint-plugin": "^5.35.1", "@typescript-eslint/parser": "^5.6.0", "allchange": "^1.1.0", - "axe-core": "4.7.0", + "axe-core": "4.7.1", "babel-jest": "^29.0.0", "blob-polyfill": "^7.0.0", "chokidar": "^3.5.1", @@ -183,7 +182,7 @@ "cypress-axe": "^1.0.0", "cypress-multi-reporters": "^1.6.1", "cypress-real-events": "^1.7.1", - "eslint": "8.38.0", + "eslint": "8.40.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-deprecate": "^0.7.0", @@ -193,7 +192,7 @@ "eslint-plugin-matrix-org": "1.1.0", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-unicorn": "^46.0.0", + "eslint-plugin-unicorn": "^47.0.0", "fetch-mock-jest": "^1.5.1", "fs-extra": "^11.0.0", "jest": "29.3.1", @@ -206,11 +205,11 @@ "mocha-junit-reporter": "^2.2.0", "node-fetch": "2", "postcss-scss": "^4.0.4", - "prettier": "2.8.7", + "prettier": "2.8.8", "raw-loader": "^4.0.2", "rimraf": "^5.0.0", "stylelint": "^15.0.0", - "stylelint-config-standard": "^32.0.0", + "stylelint-config-standard": "^33.0.0", "stylelint-scss": "^5.0.0", "ts-node": "^10.9.1", "typescript": "5.0.4", diff --git a/res/css/_animations.pcss b/res/css/_animations.pcss index fb4ce1eb57..96908097e4 100644 --- a/res/css/_animations.pcss +++ b/res/css/_animations.pcss @@ -34,6 +34,10 @@ limitations under the License. transition: opacity 300ms ease; } +:root { + --hover-transition: 0.08s cubic-bezier(0.46, 0.03, 0.52, 0.96); /* quadratic */ +} + @keyframes mx--anim-pulse { 0% { opacity: 1; diff --git a/res/css/_common.pcss b/res/css/_common.pcss index d40efc178b..6a3d9f03d4 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -23,10 +23,6 @@ limitations under the License. @import "./_spacing.pcss"; @import url("maplibre-gl/dist/maplibre-gl.css"); -$hover-transition: 0.08s cubic-bezier(0.46, 0.03, 0.52, 0.96); /* quadratic */ - -$selected-message-border-width: 4px; - :root { font-size: 10px; @@ -37,6 +33,22 @@ $selected-message-border-width: 4px; --buttons-dialog-gap-row: $spacing-8; --buttons-dialog-gap-column: $spacing-8; --MBody-border-radius: 8px; + + /* Expected z-indexes for dialogs: + 4000 - Default wrapper index + 4009 - Static dialog background + 4010 - Static dialog itself + 4011 - Standard dialog background + 4012 - Standard dialog itself + + These are set up such that the static dialog always appears + underneath the standard dialogs. + */ + --dialog-zIndex-wrapper-default: 4000; + --dialog-zIndex-static-background: 4009; + --dialog-zIndex-static: calc(var(--dialog-zIndex-static-background) + 1); /* 4010 */ + --dialog-zIndex-standard-background: calc(var(--dialog-zIndex-static) + 1); /* 4011 */ + --dialog-zIndex-standard: calc(var(--dialog-zIndex-standard-background) + 1); /* 4012 */ } @media only percy { @@ -281,20 +293,9 @@ legend { color: $secondary-accent-color; } -/* Expected z-indexes for dialogs: - 4000 - Default wrapper index - 4009 - Static dialog background - 4010 - Static dialog itself - 4011 - Standard dialog background - 4012 - Standard dialog itself - - These are set up such that the static dialog always appears - underneath the standard dialogs. - */ - .mx_Dialog_wrapper { position: fixed; - z-index: 4000; + z-index: var(--dialog-zIndex-wrapper-default); top: 0; left: 0; width: 100%; @@ -308,7 +309,7 @@ legend { .mx_Dialog { background-color: $background; color: $light-fg-color; - z-index: 4012; + z-index: var(--dialog-zIndex-standard); font-size: $font-15px; position: relative; padding: 24px; @@ -316,73 +317,89 @@ legend { box-shadow: 2px 15px 30px 0 $dialog-shadow-color; border-radius: 8px; overflow-y: auto; -} -/* Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants. */ -.mx_Dialog .markdown-body { - font-family: inherit !important; - white-space: normal !important; - line-height: inherit !important; - color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */ - font-size: $font-14px; - - pre, - code { - font-family: $monospace-font-family !important; - background-color: $codeblock-background-color; + .mx_Dialog_staticWrapper & { + z-index: var(--dialog-zIndex-static); + contain: content; } - /* this selector wrongly applies to code blocks too but we will unset it in the next one */ - code { - white-space: pre-wrap; /* don't collapse spaces in inline code blocks */ + .mx_Dialog_lightbox & { + border-radius: 0px; + background-color: transparent; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + pointer-events: none; + padding: 0; } - pre code { - white-space: pre; /* we want code blocks to be scrollable and not wrap */ + /* Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants. */ + .markdown-body { + font-family: inherit !important; + white-space: normal !important; + line-height: inherit !important; + color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */ + font-size: $font-14px; - > * { - display: inline; + pre, + code { + font-family: $monospace-font-family !important; + background-color: $codeblock-background-color; + } + + /* this selector wrongly applies to code blocks too but we will unset it in the next one */ + code { + white-space: pre-wrap; /* don't collapse spaces in inline code blocks */ + } + + pre { + /* have to use overlay rather than auto otherwise Linux and Windows */ + /* Chrome gets very confused about vertical spacing: */ + /* https://github.com/vector-im/vector-web/issues/754 */ + overflow-x: overlay; + overflow-y: visible; + + &::-webkit-scrollbar-corner { + background: transparent; + } + + code { + white-space: pre; /* we want code blocks to be scrollable and not wrap */ + + > * { + display: inline; + } + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: inherit !important; + color: inherit; + } + + /* Make h1 and h2 the same size as h3. */ + h1, + h2 { + font-size: 1.5em; + border-bottom: none !important; /* override GFM */ + } + + a { + color: $accent-alt; + } + + blockquote { + border-left: 2px solid $blockquote-bar-color; + border-radius: 2px; + padding: 0 10px; } } - - pre { - /* have to use overlay rather than auto otherwise Linux and Windows */ - /* Chrome gets very confused about vertical spacing: */ - /* https://github.com/vector-im/vector-web/issues/754 */ - overflow-x: overlay; - overflow-y: visible; - - &::-webkit-scrollbar-corner { - background: transparent; - } - } -} - -.mx_Dialog .markdown-body h1, -.mx_Dialog .markdown-body h2, -.mx_Dialog .markdown-body h3, -.mx_Dialog .markdown-body h4, -.mx_Dialog .markdown-body h5, -.mx_Dialog .markdown-body h6 { - font-family: inherit !important; - color: inherit; -} - -/* Make h1 and h2 the same size as h3. */ -.mx_Dialog .markdown-body h1, -.mx_Dialog .markdown-body h2 { - font-size: 1.5em; - border-bottom: none !important; /* override GFM */ -} - -.mx_Dialog .markdown-body a { - color: $accent-alt; -} - -.mx_Dialog .markdown-body blockquote { - border-left: 2px solid $blockquote-bar-color; - border-radius: 2px; - padding: 0 10px; } .mx_Dialog_fixedWidth { @@ -390,11 +407,6 @@ legend { max-width: 704px; } -.mx_Dialog_staticWrapper .mx_Dialog { - z-index: 4010; - contain: content; -} - .mx_Dialog_background { position: fixed; top: 0; @@ -403,41 +415,24 @@ legend { height: 100%; background-color: $dialog-backdrop-color; opacity: 0.8; - z-index: 4011; -} + z-index: var(--dialog-zIndex-standard-background); -.mx_Dialog_background.mx_Dialog_staticBackground { - z-index: 4009; -} + &.mx_Dialog_staticBackground { + z-index: var(--dialog-zIndex-static-background); + } -.mx_Dialog_wrapperWithStaticUnder .mx_Dialog_background { - /* Roughly half of what it would normally be - we don't want to black out */ - /* the app, just make it clear that the dialogs are stacked. */ - opacity: 0.4; -} + .mx_Dialog_wrapperWithStaticUnder & { + /* Roughly half of what it would normally be - we don't want to black out */ + /* the app, just make it clear that the dialogs are stacked. */ + opacity: 0.4; + } -.mx_Dialog_lightbox .mx_Dialog_background { - opacity: $lightbox-background-bg-opacity; - background-color: $lightbox-background-bg-color; - animation-name: mx_Dialog_lightbox_background_keyframes; - animation-duration: 300ms; -} - -.mx_Dialog_lightbox .mx_Dialog { - border-radius: 0px; - background-color: transparent; - width: 100%; - height: 100%; - max-width: 100%; - max-height: 100%; - pointer-events: none; - padding: 0; -} - -.mx_Dialog_header { - position: relative; - padding: 3px 0; - margin-bottom: 10px; + .mx_Dialog_lightbox & { + opacity: $lightbox-background-bg-opacity; + background-color: $lightbox-background-bg-color; + animation-name: mx_Dialog_lightbox_background_keyframes; + animation-duration: 300ms; + } } .mx_Dialog_titleImage { @@ -454,22 +449,29 @@ legend { display: inline-block; width: 100%; box-sizing: border-box; + + &.danger { + color: $alert; + } } -.mx_Dialog_header.mx_Dialog_headerWithButton > .mx_Dialog_title { - text-align: center; -} -.mx_Dialog_header.mx_Dialog_headerWithCancel { - padding-right: 20px; /* leave space for the 'X' cancel button */ -} +.mx_Dialog_header { + position: relative; + padding: 3px 0; + margin-bottom: 10px; -.mx_Dialog_header.mx_Dialog_headerWithCancelOnly { - padding: 0 20px 0 0; - margin: 0; -} + &.mx_Dialog_headerWithButton > .mx_Dialog_title { + text-align: center; + } -.mx_Dialog_title.danger { - color: $alert; + &.mx_Dialog_headerWithCancel { + padding-right: 20px; /* leave space for the 'X' cancel button */ + } + + &.mx_Dialog_headerWithCancelOnly { + padding: 0 20px 0 0; + margin: 0; + } } @define-mixin customisedCancelButton { @@ -509,21 +511,21 @@ legend { /* The consumer is responsible for positioning their elements. */ float: left; } -} -.mx_Dialog_buttons_row { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - text-align: initial; - margin-inline-start: auto; + .mx_Dialog_buttons_row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + text-align: initial; + margin-inline-start: auto; - /* default gap among elements */ - column-gap: var(--buttons-dialog-gap-column); - row-gap: var(--buttons-dialog-gap-row); + /* default gap among elements */ + column-gap: var(--buttons-dialog-gap-column); + row-gap: var(--buttons-dialog-gap-row); - button { - margin: 0 !important; /* override the margin settings */ + button { + margin: 0 !important; /* override the margin settings */ + } } } diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 808b6b024e..56628095f2 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -124,7 +124,6 @@ @import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_BulkRedactDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss"; -@import "./views/dialogs/_ChatCreateOrReuseChatDialog.pcss"; @import "./views/dialogs/_CompoundDialog.pcss"; @import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @@ -179,7 +178,6 @@ @import "./views/elements/_Dropdown.pcss"; @import "./views/elements/_EditableItemList.pcss"; @import "./views/elements/_ErrorBoundary.pcss"; -@import "./views/elements/_EventTilePreview.pcss"; @import "./views/elements/_ExternalLink.pcss"; @import "./views/elements/_FacePile.pcss"; @import "./views/elements/_Field.pcss"; @@ -198,7 +196,6 @@ @import "./views/elements/_ReplyChain.pcss"; @import "./views/elements/_ResizeHandle.pcss"; @import "./views/elements/_RichText.pcss"; -@import "./views/elements/_RoleButton.pcss"; @import "./views/elements/_RoomAliasField.pcss"; @import "./views/elements/_SSOButtons.pcss"; @import "./views/elements/_SearchWarning.pcss"; @@ -319,7 +316,6 @@ @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; -@import "./views/settings/_DevicesPanel.pcss"; @import "./views/settings/_FontScalingPanel.pcss"; @import "./views/settings/_ImageSizePanel.pcss"; @import "./views/settings/_IntegrationManager.pcss"; @@ -348,7 +344,6 @@ @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_SidebarUserSettingsTab.pcss"; -@import "./views/settings/tabs/user/_VoiceUserSettingsTab.pcss"; @import "./views/spaces/_SpaceBasicSettings.pcss"; @import "./views/spaces/_SpaceChildrenPicker.pcss"; @import "./views/spaces/_SpaceCreateMenu.pcss"; diff --git a/res/css/components/views/elements/_AppPermission.pcss b/res/css/components/views/elements/_AppPermission.pcss index b91ed2b809..be78efa43b 100644 --- a/res/css/components/views/elements/_AppPermission.pcss +++ b/res/css/components/views/elements/_AppPermission.pcss @@ -53,25 +53,25 @@ limitations under the License. mask-image: url("$(res)/img/feather-customised/help-circle.svg"); } } +} - .mx_AppPermission_tooltip { - box-shadow: none; - background-color: $tooltip-timeline-bg-color; - color: $tooltip-timeline-fg-color; - border: none; - border-radius: 3px; - padding: 6px 8px; +.mx_Tooltip.mx_Tooltip--appPermission { + box-shadow: none; + background-color: $tooltip-timeline-bg-color; + color: $tooltip-timeline-fg-color; + border: none; + border-radius: 3px; + padding: 6px 8px; - &.mx_AppPermission_tooltip--dark { - .mx_Tooltip_chevron::after { - border-right-color: $tooltip-timeline-bg-color; - } - } - - ul { - list-style-position: inside; - padding-left: 2px; - margin-left: 0; + &.mx_Tooltip--appPermission--dark { + .mx_Tooltip_chevron::after { + border-right-color: $tooltip-timeline-bg-color; } } + + ul { + list-style-position: inside; + padding-left: 2px; + margin-left: 0; + } } diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 9ab33c9353..2d8894150f 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -34,7 +34,9 @@ limitations under the License. width: 100%; display: grid; grid-gap: $spacing-8; - grid-template-columns: 1fr; + // setting minwidth 0 makes columns definitely sized + // fixing horizontal overflow + grid-template-columns: minmax(0, 1fr); justify-items: flex-start; margin-top: $spacing-24; @@ -50,4 +52,8 @@ limitations under the License. &.mx_SettingsSubsection_contentStretch { justify-items: stretch; } + + &.mx_SettingsSubsection_noHeading { + margin-top: 0; + } } diff --git a/res/css/structures/_QuickSettingsButton.pcss b/res/css/structures/_QuickSettingsButton.pcss index 128c8e0fbb..3f26e13250 100644 --- a/res/css/structures/_QuickSettingsButton.pcss +++ b/res/css/structures/_QuickSettingsButton.pcss @@ -113,9 +113,11 @@ limitations under the License. } .mx_QuickSettingsButton_icon { + // TODO remove when all icons have fill=currentColor * { fill: $secondary-content; } + color: $secondary-content; width: 16px; height: 16px; position: absolute; diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 1c34a46e07..71c9860764 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -34,40 +34,6 @@ limitations under the License. /** Fixme - factor this out with the main header **/ -/* See: mx_RoomHeader_button, of which this is a copy. - * TODO: factor out a common component to avoid this duplication. - */ -.mx_RightPanel_headerButton { - cursor: pointer; - flex: 0 0 auto; - margin-left: 1px; - margin-right: 1px; - height: 32px; - width: 32px; - position: relative; - border-radius: 100%; - - &::before { - content: ""; - position: absolute; - top: 4px; /* center with parent of 32px */ - left: 4px; /* center with parent of 32px */ - height: 24px; - width: 24px; - background-color: $icon-button-color; - mask-repeat: no-repeat; - mask-size: contain; - } - - &:hover { - background: rgba($accent, 0.1); - - &::before { - background-color: $accent; - } - } -} - .mx_RightPanel_threadsButton::before { mask-image: url("$(res)/img/element-icons/room/thread.svg"); } @@ -89,41 +55,6 @@ limitations under the License. } } -.mx_RightPanel_headerButton_unreadIndicator_bg { - position: absolute; - right: var(--RoomHeader-indicator-dot-offset); - top: var(--RoomHeader-indicator-dot-offset); - margin: 4px; - width: var(--RoomHeader-indicator-dot-size); - height: var(--RoomHeader-indicator-dot-size); - border-radius: 50%; - transform: scale(1.6); - transform-origin: center center; - background: rgba($background, 1); -} - -.mx_RightPanel_headerButton_unreadIndicator { - position: absolute; - right: var(--RoomHeader-indicator-dot-offset); - top: var(--RoomHeader-indicator-dot-offset); - margin: 4px; - - &.mx_Indicator_red { - background: rgba($alert, 1); - box-shadow: rgba($alert, 1); - } - - &.mx_Indicator_gray { - background: rgba($room-icon-unread-color, 1); - box-shadow: rgba($room-icon-unread-color, 1); - } - - &.mx_Indicator_bold { - background: rgba($primary-content, 1); - box-shadow: rgba($primary-content, 1); - } -} - .mx_RightPanel_timelineCardButton { &::before { mask-image: url("$(res)/img/element-icons/feedback.svg"); @@ -131,19 +62,6 @@ limitations under the License. } } -.mx_RightPanel_headerButton_unread { - &::before { - background-color: $room-icon-unread-color !important; - } -} - -.mx_RightPanel_headerButton_highlight, -.mx_RightPanel_headerButton:hover { - &::before { - background-color: $accent !important; - } -} - .mx_RightPanel .mx_MemberList, .mx_RightPanel .mx_MemberInfo { order: 2; diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index d3e08adfd6..60191c3452 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -160,7 +160,7 @@ limitations under the License. } } -.mx_RoomStatusBar_connectionLostBar img { +.mx_RoomStatusBar_connectionLostBar svg { padding-left: 10px; padding-right: 10px; vertical-align: middle; diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 5ddfed21ba..b340c8d994 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -46,14 +46,6 @@ limitations under the License. } } -.mx_RoomView_auxPanel { - min-width: 0px; - width: 100%; - margin: 0px auto; - - overflow: auto; -} - .mx_RoomView_auxPanel_hiddenHighlights { border-bottom: 1px solid $primary-hairline-color; padding: 10px 26px; diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 3487253ee7..5f434a2802 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -271,23 +271,6 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_inviteTeammates { - /* XXX remove this when spaces leaves Beta */ - .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { - padding: 16px; - position: relative; - border-radius: 8px; - background-color: $header-panel-bg-color; - max-width: $SpaceRoomViewInnerWidth; - margin: 20px 0 30px; - box-sizing: border-box; - - .mx_BetaCard_betaPill { - position: absolute; - left: 16px; - top: 16px; - } - } - .mx_SpaceRoomView_inviteTeammates_buttons { color: $secondary-content; margin-top: 28px; diff --git a/res/css/views/dialogs/_ChatCreateOrReuseChatDialog.pcss b/res/css/views/dialogs/_ChatCreateOrReuseChatDialog.pcss deleted file mode 100644 index 0f358a588e..0000000000 --- a/res/css/views/dialogs/_ChatCreateOrReuseChatDialog.pcss +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2016 OpenMarket 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_ChatCreateOrReuseDialog .mx_ChatCreateOrReuseDialog_tiles { - margin-top: 24px; -} - -.mx_ChatCreateOrReuseDialog .mx_Dialog_content { - margin-bottom: 24px; - - /* - To stop spinner that mx_ChatCreateOrReuseDialog_profile replaces from causing a - height change - */ - min-height: 100px; -} - -.mx_ChatCreateOrReuseDialog .mx_RoomTile_badge { - display: none; -} - -.mx_ChatCreateOrReuseDialog_profile { - display: flex; -} - -.mx_ChatCreateOrReuseDialog_profile_name { - padding: 14px; -} diff --git a/res/css/views/dialogs/_SpacePreferencesDialog.pcss b/res/css/views/dialogs/_SpacePreferencesDialog.pcss index 3cdb08cf92..eb644f802d 100644 --- a/res/css/views/dialogs/_SpacePreferencesDialog.pcss +++ b/res/css/views/dialogs/_SpacePreferencesDialog.pcss @@ -31,16 +31,6 @@ limitations under the License. .mx_SettingsTab { min-width: unset; - - .mx_SettingsTab_section { - font-size: $font-15px; - line-height: $font-24px; - - .mx_Checkbox + p { - color: $secondary-content; - margin: 0 20px 0 24px; - } - } } } } diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.pcss b/res/css/views/dialogs/_SpaceSettingsDialog.pcss index 78c4e42c07..7b7c40e268 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.pcss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.pcss @@ -37,12 +37,6 @@ limitations under the License. margin-bottom: 20px; } - & + .mx_SettingsTab_subheading { - border-top: 1px solid $menu-border-color; - margin-top: 0; - padding-top: 24px; - } - .mx_StyledRadioButton { margin-top: 8px; margin-bottom: 4px; diff --git a/res/css/views/elements/_Dropdown.pcss b/res/css/views/elements/_Dropdown.pcss index 23edac35cf..4060dab176 100644 --- a/res/css/views/elements/_Dropdown.pcss +++ b/res/css/views/elements/_Dropdown.pcss @@ -58,8 +58,8 @@ limitations under the License. .mx_Dropdown_option { height: 35px; line-height: $font-35px; - padding-left: 8px; - padding-right: 8px; + // Overwrites the default padding for any li elements + padding: 0 8px; } .mx_Dropdown_input > .mx_Dropdown_option { @@ -121,6 +121,10 @@ input.mx_Dropdown_option:focus { min-height: 35px; } +ul.mx_Dropdown_menu li.mx_Dropdown_option { + list-style: none; +} + .mx_Dropdown_menu .mx_Dropdown_option_highlight { background-color: $focus-bg-color; } diff --git a/res/css/views/elements/_EventTilePreview.pcss b/res/css/views/elements/_EventTilePreview.pcss deleted file mode 100644 index 67bed16ea8..0000000000 --- a/res/css/views/elements/_EventTilePreview.pcss +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_FontScalingPanel { - .mx_FontScalingPanel_preview.mx_EventTilePreview_loader { - padding: var(--FontScalingPanel_preview-padding-block) 0; - } -} diff --git a/res/css/views/elements/_RoleButton.pcss b/res/css/views/elements/_RoleButton.pcss deleted file mode 100644 index 41cd11d7c1..0000000000 --- a/res/css/views/elements/_RoleButton.pcss +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2017 Vector Creations 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_RoleButton { - margin-left: 4px; - margin-right: 4px; - cursor: pointer; - display: inline-block; -} - -.mx_RoleButton object { - pointer-events: none; -} - -.mx_RoleButton_tooltip { - display: inline-block; - position: relative; - top: -25px; - left: 6px; -} diff --git a/res/css/views/elements/_TagComposer.pcss b/res/css/views/elements/_TagComposer.pcss index 8c9b7b071a..51d9749e2b 100644 --- a/res/css/views/elements/_TagComposer.pcss +++ b/res/css/views/elements/_TagComposer.pcss @@ -17,16 +17,12 @@ limitations under the License. .mx_TagComposer { .mx_TagComposer_input { display: flex; - - .mx_Field { - flex: 1; - margin: 0; /* override from field styles */ - } + flex-direction: row; .mx_AccessibleButton { min-width: 70px; padding: 0 8px; /* override from button styles */ - margin-left: 16px; /* distance from */ + align-self: stretch; /* override default settingstab style */ } .mx_Field, diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 6738ed0fa4..5ec32423f9 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -222,60 +222,34 @@ limitations under the License. } .mx_AppTileMenuBar_widgets { - float: right; display: flex; - flex-direction: row; align-items: center; - } - .mx_AppTileMenuBar_iconButton { - --size: 24px; /* Size of the button. Its height and width values should be same */ + .mx_AppTileMenuBar_widgets_button { + --size: 24px; /* Size of the button. Its height and width values should be same */ - margin: 0 4px; - position: relative; - height: var(--size); - width: var(--size); - - &::before, - &:hover::after { - content: ""; - position: absolute; + margin: 0 4px; + position: relative; height: var(--size); width: var(--size); - } + display: flex; + align-items: center; + justify-content: center; - &::before { - background-color: $muted-fg-color; - mask-position: center; - mask-repeat: no-repeat; - mask-size: 12px; - } + &:hover::after { + content: ""; + position: absolute; + height: var(--size); + width: var(--size); + background-color: $panel-actions; + border-radius: 50%; + left: 0; + top: 0; + } - &:hover::after { - background-color: $panel-actions; - border-radius: 50%; - left: 0; - top: 0; - } - - &.mx_AppTileMenuBar_iconButton--collapse::before { - mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); - } - - &.mx_AppTileMenuBar_iconButton--maximise::before { - mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); - } - - &.mx_AppTileMenuBar_iconButton--minimise::before { - mask-image: url("$(res)/img/element-icons/minus-button.svg"); - } - - &.mx_AppTileMenuBar_iconButton--popout::before { - mask-image: url("$(res)/img/feather-customised/widget/external-link.svg"); - } - - &.mx_AppTileMenuBar_iconButton--menu::before { - mask-image: url("$(res)/img/element-icons/room/ellipsis.svg"); + .mx_Icon { + color: $muted-fg-color; + } } } } diff --git a/res/css/views/rooms/_AuxPanel.pcss b/res/css/views/rooms/_AuxPanel.pcss index 04d17c63b9..38b93e414f 100644 --- a/res/css/views/rooms/_AuxPanel.pcss +++ b/res/css/views/rooms/_AuxPanel.pcss @@ -14,37 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -.m_RoomView_auxPanel_stateViews { - padding: 5px; - padding-left: 19px; - border-bottom: 1px solid $primary-hairline-color; -} +.mx_AuxPanel { + min-width: 0px; + width: 100%; + margin: 0px auto; -.m_RoomView_auxPanel_stateViews_span a { - text-decoration: none; - color: inherit; -} + overflow: auto; -.m_RoomView_auxPanel_stateViews_span[data-severity="warning"] { - font-weight: bold; - color: orange; -} + .mx_AuxPanel_stateViews { + padding: 5px; + padding-left: 19px; + border-bottom: 1px solid $primary-hairline-color; + } -.m_RoomView_auxPanel_stateViews_span[data-severity="alert"] { - font-weight: bold; - color: red; -} + .mx_AuxPanel_stateViews_span { + &[data-severity="warning"] { + font-weight: bold; + color: orange; + } -.m_RoomView_auxPanel_stateViews_span[data-severity="normal"] { - font-weight: normal; -} + &[data-severity="alert"] { + font-weight: bold; + color: red; + } -.m_RoomView_auxPanel_stateViews_span[data-severity="notice"] { - font-weight: normal; - color: $settings-grey-fg-color; -} + &[data-severity="normal"] { + font-weight: normal; + } -.m_RoomView_auxPanel_stateViews_delim { - padding: 0 5px; - color: $settings-grey-fg-color; + &[data-severity="notice"] { + font-weight: normal; + color: $settings-grey-fg-color; + } + + a { + text-decoration: none; + color: inherit; + } + } + + .mx_AuxPanel_stateViews_delim { + padding: 0 5px; + color: $settings-grey-fg-color; + } } diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 7b22a116cb..fc871bbc35 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -170,8 +170,10 @@ $left-gutter: 64px; &[data-layout="irc"], &[data-layout="group"] { + --selected-message-border-width: 4px; + /* TODO: adjust the values for IRC layout */ - --EventTile-box-shadow-offset-x: calc(50px + $selected-message-border-width); + --EventTile-box-shadow-offset-x: calc(50px + var(--selected-message-border-width)); --EventTile-box-shadow-spread-radius: -50px; .mx_EventTile_e2eIcon { position: absolute; @@ -447,7 +449,9 @@ $left-gutter: 64px; &.mx_EventTile_isEditing > .mx_EventTile_line { .mx_EditMessageComposer { /* add space for the stroke on box-shadow */ - padding-inline-start: calc($selected-message-border-width + var(--EditMessageComposer-padding-inline)); + padding-inline-start: calc( + var(--selected-message-border-width) + var(--EditMessageComposer-padding-inline) + ); } } @@ -592,7 +596,7 @@ $left-gutter: 64px; &.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, &.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, &.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { - padding-inline-start: calc($left-gutter + 18px + $selected-message-border-width); + padding-inline-start: calc($left-gutter + 18px + var(--selected-message-border-width)); } } } @@ -1120,29 +1124,6 @@ $left-gutter: 64px; box-sizing: border-box; padding-bottom: 0; padding-inline-start: var(--leftOffset); - - .mx_ThreadPanel_replies { - margin-top: $spacing-8; - display: flex; - align-items: center; - position: relative; - - &::before { - @mixin ThreadSummaryIcon; - } - - .mx_ThreadPanel_replies_amount { - @mixin ThreadRepliesAmount; - line-height: var(--EventTile_ThreadSummary-line-height); - font-size: $font-12px; /* Same font size as the counter on the main panel */ - } - - .mx_ThreadSummary_content { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } } .mx_MessageTimestamp { @@ -1167,6 +1148,31 @@ $left-gutter: 64px; } } +.mx_EventTile[data-shape="ThreadsList"] { + .mx_ThreadPanel_replies { + margin-top: $spacing-8; + display: flex; + align-items: center; + position: relative; + + &::before { + @mixin ThreadSummaryIcon; + } + + .mx_ThreadPanel_replies_amount { + @mixin ThreadRepliesAmount; + line-height: var(--EventTile_ThreadSummary-line-height); + font-size: $font-12px; /* Same font size as the counter on the main panel */ + } + + .mx_ThreadSummary_content { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } +} + /* For style rules of ThreadView, see _ThreadPanel.pcss */ .mx_ThreadView { --ThreadView_group_spacing-start: 56px; /* 56px: 64px - 8px (padding) */ @@ -1449,12 +1455,14 @@ $left-gutter: 64px; 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/_MemberInfo.pcss b/res/css/views/rooms/_MemberInfo.pcss index cc5ea79f83..a1fc656598 100644 --- a/res/css/views/rooms/_MemberInfo.pcss +++ b/res/css/views/rooms/_MemberInfo.pcss @@ -76,6 +76,12 @@ limitations under the License. .mx_MemberInfo_container { margin: 0 16px 16px 16px; + + &.mx_MemberInfo_container--profile { + margin-bottom: 16px; + font-size: $font-15px; + position: relative; + } } .mx_MemberInfo_avatar { @@ -95,23 +101,11 @@ limitations under the License. } } -.mx_MemberInfo_profile { - margin-bottom: 16px; -} - -.mx_MemberInfo_profileField { - font-size: $font-15px; - position: relative; -} - -.mx_MemberInfo_buttons { - margin-bottom: 16px; -} - .mx_MemberInfo_field { cursor: pointer; font-size: $font-15px; color: $primary-content; margin-left: 8px; + margin-bottom: 16px; line-height: $font-23px; } diff --git a/res/css/views/rooms/_NotificationBadge.pcss b/res/css/views/rooms/_NotificationBadge.pcss index 4afa320dd9..6facab61f7 100644 --- a/res/css/views/rooms/_NotificationBadge.pcss +++ b/res/css/views/rooms/_NotificationBadge.pcss @@ -69,3 +69,10 @@ limitations under the License. } } } + +.mx_NotificationBadge_tooltip { + display: inline-block; + position: relative; + top: -25px; + left: 6px; +} diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index c351791fea..b12cb38ef1 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -80,6 +80,7 @@ limitations under the License. padding: 1px 4px; display: flex; user-select: none; + cursor: pointer; &:hover { background-color: $quinary-content; @@ -102,6 +103,14 @@ limitations under the License. background-color: $tertiary-content; } + &.mx_RoomHeader_name--textonly { + cursor: unset; + + &:hover { + background-color: unset; + } + } + &[aria-expanded="true"] { background-color: $quinary-content; @@ -120,11 +129,6 @@ limitations under the License. opacity: 0.6; } -.mx_RoomHeader_name:not(.mx_RoomHeader_name--textonly), -.mx_RoomHeader_avatar { - cursor: pointer; -} - .mx_RoomTopic { position: relative; cursor: pointer; @@ -157,6 +161,7 @@ limitations under the License. flex: 0; margin: 0 7px; position: relative; + cursor: pointer; } .mx_RoomHeader_avatar .mx_BaseAvatar_image { @@ -194,6 +199,54 @@ limitations under the License. } } +.mx_RoomHeader_button_unreadIndicator_bg { + position: absolute; + right: var(--RoomHeader-indicator-dot-offset); + top: var(--RoomHeader-indicator-dot-offset); + margin: 4px; + width: var(--RoomHeader-indicator-dot-size); + height: var(--RoomHeader-indicator-dot-size); + border-radius: 50%; + transform: scale(1.6); + transform-origin: center center; + background: rgba($background, 1); +} + +.mx_RoomHeader_button_unreadIndicator { + position: absolute; + right: var(--RoomHeader-indicator-dot-offset); + top: var(--RoomHeader-indicator-dot-offset); + margin: 4px; + + &.mx_Indicator_red { + background: rgba($alert, 1); + box-shadow: rgba($alert, 1); + } + + &.mx_Indicator_gray { + background: rgba($room-icon-unread-color, 1); + box-shadow: rgba($room-icon-unread-color, 1); + } + + &.mx_Indicator_bold { + background: rgba($primary-content, 1); + box-shadow: rgba($primary-content, 1); + } +} + +.mx_RoomHeader_button--unread { + &::before { + background-color: $room-icon-unread-color !important; + } +} + +.mx_RoomHeader_button--highlight, +.mx_RoomHeader_button:hover { + &::before { + background-color: $accent !important; + } +} + .mx_RoomHeader_forgetButton::before { mask-image: url("$(res)/img/element-icons/leave.svg"); width: 26px; diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index d0db686da8..8fcc4e9f7e 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -55,13 +55,18 @@ limitations under the License. flex-direction: column; justify-content: center; - .mx_RoomTile_title, .mx_RoomTile_subtitle { - width: 100%; + align-items: center; + color: $secondary-content; + display: flex; + gap: $spacing-4; + line-height: $font-18px; + } - /* Ellipsize any text overflow */ - text-overflow: ellipsis; + .mx_RoomTile_title, + .mx_RoomTile_subtitle_text { overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } @@ -74,11 +79,6 @@ limitations under the License. } } - .mx_RoomTile_subtitle { - line-height: $font-18px; - color: $secondary-content; - } - .mx_RoomTile_titleWithSubtitle { margin-top: -3px; /* shift the title up a bit more */ } diff --git a/res/css/views/settings/_AvatarSetting.pcss b/res/css/views/settings/_AvatarSetting.pcss index 1a5e9c5e30..98bf3ab9b8 100644 --- a/res/css/views/settings/_AvatarSetting.pcss +++ b/res/css/views/settings/_AvatarSetting.pcss @@ -22,7 +22,7 @@ limitations under the License. position: relative; .mx_AvatarSetting_hover { - transition: opacity $hover-transition; + transition: opacity var(--hover-transition); /* position to place the hover bg over the entire thing */ position: absolute; diff --git a/res/css/views/settings/_CryptographyPanel.pcss b/res/css/views/settings/_CryptographyPanel.pcss index 855949d013..3440ce6554 100644 --- a/res/css/views/settings/_CryptographyPanel.pcss +++ b/res/css/views/settings/_CryptographyPanel.pcss @@ -32,13 +32,9 @@ limitations under the License. } } -.mx_CryptographyPanel_importExportButtons .mx_AccessibleButton { - margin-right: 10px; -} - .mx_CryptographyPanel_importExportButtons { - margin-bottom: 15px; display: inline-flex; flex-flow: wrap; - row-gap: 10px; + row-gap: $spacing-8; + column-gap: $spacing-8; } diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss deleted file mode 100644 index 69b0d0a664..0000000000 --- a/res/css/views/settings/_DevicesPanel.pcss +++ /dev/null @@ -1,102 +0,0 @@ -/* -Copyright 2016 OpenMarket 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_DevicesPanel { - width: auto; - max-width: 880px; - - hr { - border: none; - border-bottom: 1px solid $quinary-content; - } -} - -.mx_DevicesPanel_header { - display: flex; - align-items: center; - margin-block: 10px; - - .mx_DevicesPanel_header_title { - font-size: $font-18px; - font-weight: var(--font-semi-bold); - color: $primary-content; - } - - .mx_DevicesPanel_selectButton { - padding-top: 9px; - } - - .mx_E2EIcon { - width: 24px; - height: 24px; - margin-left: 0; - margin-right: 5px; - } -} - -.mx_DevicesPanel_deleteButton { - margin-top: 10px; -} - -.mx_DevicesPanel_device { - display: flex; - align-items: flex-start; - margin-block: 10px; - min-height: 35px; - padding: 0 $spacing-8; - - .mx_DeviceTypeIcon { - /* hide the new device type in legacy device list - for backwards compat reasons */ - display: none; - } -} - -.mx_DevicesPanel_icon { - margin-left: 0px; - margin-right: $spacing-16; - margin-top: 2px; -} - -.mx_DevicesPanel_deviceInfo { - flex-grow: 1; -} - -.mx_DevicesPanel_deviceName { - color: $primary-content; -} - -.mx_DevicesPanel_lastSeen { - font-size: $font-12px; -} - -.mx_DevicesPanel_deviceButtons { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 9px; -} - -.mx_DevicesPanel_renameForm { - display: flex; - align-items: center; - gap: 5px; - - .mx_Field_input { - width: 240px; - margin: 0; - } -} diff --git a/res/css/views/settings/_FontScalingPanel.pcss b/res/css/views/settings/_FontScalingPanel.pcss index 9aadf3497d..06b248d729 100644 --- a/res/css/views/settings/_FontScalingPanel.pcss +++ b/res/css/views/settings/_FontScalingPanel.pcss @@ -14,63 +14,48 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_FontScalingPanel { - color: $primary-content; +.mx_FontScalingPanel_preview { + --FontScalingPanel_preview-padding-block: 9px; - .mx_FontScalingPanel_preview, - .mx_FontScalingPanel_fontSlider { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); + border: 1px solid $quinary-content; + border-radius: 10px; + padding: 0 $spacing-16 var(--FontScalingPanel_preview-padding-block) $spacing-16; + pointer-events: none; + display: flow-root; + + &.mx_IRCLayout { + padding-top: 9px; /* TODO: Use a spacing variable */ } - .mx_FontScalingPanel_preview { - --FontScalingPanel_preview-padding-block: 9px; - - border: 1px solid $quinary-content; - border-radius: 10px; - padding: 0 $spacing-16 var(--FontScalingPanel_preview-padding-block) $spacing-16; - pointer-events: none; - display: flow-root; - - &.mx_IRCLayout { - padding-top: 9px; /* TODO: Use a spacing variable */ - } - - .mx_EventTile[data-layout="bubble"] { - margin-top: 30px; /* TODO: Use a spacing variable */ - } - - .mx_EventTile_msgOption { - display: none; - } + .mx_EventTile[data-layout="bubble"] { + margin-top: 30px; /* TODO: Use a spacing variable */ } - .mx_FontScalingPanel_fontSlider { - display: flex; - align-items: center; - padding: 15px $spacing-20 35px; /* TODO: Use spacing variables */ - background: rgba($quinary-content, 0.2); - border-radius: 10px; - font-size: $font-10px; - margin-top: $spacing-24; - margin-bottom: $spacing-24; - - .mx_FontScalingPanel_fontSlider_smallText, - .mx_FontScalingPanel_fontSlider_largeText { - font-weight: 500; - } - - .mx_FontScalingPanel_fontSlider_smallText { - font-size: $font-15px; - padding-inline-end: $spacing-20; - } - - .mx_FontScalingPanel_fontSlider_largeText { - font-size: $font-18px; - padding-inline-start: $spacing-20; - } - } - - .mx_FontScalingPanel_customFontSizeField { - margin-inline-start: var(--AppearanceUserSettingsTab_Field-margin-inline-start); + .mx_EventTile_msgOption { + display: none; + } +} + +.mx_FontScalingPanel_fontSlider { + display: flex; + align-items: center; + padding: 15px $spacing-20 35px; /* TODO: Use spacing variables */ + background: rgba($quinary-content, 0.2); + border-radius: 10px; + font-size: $font-10px; + + .mx_FontScalingPanel_fontSlider_smallText, + .mx_FontScalingPanel_fontSlider_largeText { + font-weight: 500; + } + + .mx_FontScalingPanel_fontSlider_smallText { + font-size: $font-15px; + padding-inline-end: $spacing-20; + } + + .mx_FontScalingPanel_fontSlider_largeText { + font-size: $font-18px; + padding-inline-start: $spacing-20; } } diff --git a/res/css/views/settings/_ImageSizePanel.pcss b/res/css/views/settings/_ImageSizePanel.pcss index 19736347a2..89500c55b4 100644 --- a/res/css/views/settings/_ImageSizePanel.pcss +++ b/res/css/views/settings/_ImageSizePanel.pcss @@ -14,34 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ImageSizePanel { - color: $primary-content; +.mx_ImageSizePanel_radios { + display: flex; + flex-direction: row; + gap: $spacing-16; - .mx_ImageSizePanel_radios { - display: flex; - margin-top: 16px; /* move away from header a bit */ + > label { + margin-right: 68px; /* keep the boxes separate */ + cursor: pointer; + } - > label { - margin-right: 68px; /* keep the boxes separate */ - cursor: pointer; + .mx_ImageSizePanel_size { + background-color: $quinary-content; + mask-repeat: no-repeat; + mask-size: 221px; + mask-position: center; + width: 221px; + height: 148px; + margin-bottom: 14px; /* move radio button away from bottom edge a bit */ + + &.mx_ImageSizePanel_sizeDefault { + mask: url("$(res)/img/element-icons/settings/img-size-normal.svg"); } - .mx_ImageSizePanel_size { - background-color: $quinary-content; - mask-repeat: no-repeat; - mask-size: 221px; - mask-position: center; - width: 221px; - height: 148px; - margin-bottom: 14px; /* move radio button away from bottom edge a bit */ - - &.mx_ImageSizePanel_sizeDefault { - mask: url("$(res)/img/element-icons/settings/img-size-normal.svg"); - } - - &.mx_ImageSizePanel_sizeLarge { - mask: url("$(res)/img/element-icons/settings/img-size-large.svg"); - } + &.mx_ImageSizePanel_sizeLarge { + mask: url("$(res)/img/element-icons/settings/img-size-large.svg"); } } } diff --git a/res/css/views/settings/_JoinRuleSettings.pcss b/res/css/views/settings/_JoinRuleSettings.pcss index 18c4395efe..92721f3acc 100644 --- a/res/css/views/settings/_JoinRuleSettings.pcss +++ b/res/css/views/settings/_JoinRuleSettings.pcss @@ -57,7 +57,6 @@ limitations under the License. .mx_JoinRuleSettings_radioButton { padding-top: 16px; - margin-bottom: 8px; .mx_StyledRadioButton_content { margin-left: 14px; diff --git a/res/css/views/settings/_LayoutSwitcher.pcss b/res/css/views/settings/_LayoutSwitcher.pcss index 500519bbf5..94952e31b6 100644 --- a/res/css/views/settings/_LayoutSwitcher.pcss +++ b/res/css/views/settings/_LayoutSwitcher.pcss @@ -15,79 +15,78 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_LayoutSwitcher { - .mx_LayoutSwitcher_RadioButtons { +.mx_LayoutSwitcher_RadioButtons { + display: flex; + flex-direction: row; + gap: 24px; + width: 100%; + + color: $primary-content; + + > .mx_LayoutSwitcher_RadioButton { + flex-grow: 0; + flex-shrink: 1; display: flex; - flex-direction: row; - gap: 24px; + flex-direction: column; - color: $primary-content; + flex-basis: 33%; + min-width: 0; - > .mx_LayoutSwitcher_RadioButton { - flex-grow: 0; - flex-shrink: 1; + border: 1px solid $quinary-content; + border-radius: 10px; + + .mx_EventTile_msgOption, + .mx_MessageActionBar { + display: none; + } + + .mx_LayoutSwitcher_RadioButton_preview { + flex-grow: 1; display: flex; - flex-direction: column; + align-items: center; + padding: 10px; + pointer-events: none; - width: 300px; - min-width: 0; - - border: 1px solid $quinary-content; - border-radius: 10px; - - .mx_EventTile_msgOption, - .mx_MessageActionBar { - display: none; - } - - .mx_LayoutSwitcher_RadioButton_preview { - flex-grow: 1; - display: flex; - align-items: center; - padding: 10px; - pointer-events: none; - - .mx_EventTile[data-layout="bubble"] .mx_EventTile_line { - padding-right: 11px; - } - } - - .mx_StyledRadioButton { - flex-grow: 0; - padding: 10px; - } - - .mx_EventTile_content { - margin-right: 0; - } - - &.mx_LayoutSwitcher_RadioButton_selected { - border-color: $accent; + .mx_EventTile[data-layout="bubble"] .mx_EventTile_line { + padding-right: 11px; } } .mx_StyledRadioButton { - border-top: 1px solid $quinary-content; + flex-grow: 0; + padding: 10px; } - .mx_StyledRadioButton_checked { - background-color: rgba($accent, 0.08); + .mx_EventTile_content { + margin-right: 0; } - .mx_EventTile { - margin: 0; - &[data-layout="bubble"] { - margin-right: 40px; - flex-shrink: 1; - } - &[data-layout="irc"] { - > a { - display: none; - } - } - .mx_EventTile_line { - max-width: 90%; + &.mx_LayoutSwitcher_RadioButton_selected { + border-color: $accent; + } + } + + .mx_StyledRadioButton { + border-top: 1px solid $quinary-content; + } + + .mx_StyledRadioButton_checked { + background-color: rgba($accent, 0.08); + } + + .mx_EventTile { + margin: 0; + &[data-layout="bubble"] { + margin-right: 40px; + flex-shrink: 1; + } + &[data-layout="irc"] { + > a { + display: none; } } + .mx_EventTile_line { + max-width: 90%; + } } } diff --git a/res/css/views/settings/_Notifications.pcss b/res/css/views/settings/_Notifications.pcss index 635627e0b0..c35181ddca 100644 --- a/res/css/views/settings/_Notifications.pcss +++ b/res/css/views/settings/_Notifications.pcss @@ -20,7 +20,6 @@ limitations under the License. grid-template-columns: auto repeat(3, 62px); place-items: center center; grid-gap: 8px; - margin-top: $spacing-40; /* Override StyledRadioButton default styles */ .mx_StyledRadioButton { @@ -34,6 +33,11 @@ limitations under the License. display: none; } } + + // left align section heading + .mx_SettingsSubsectionHeading { + justify-self: start; + } } .mx_UserNotifSettings_gridRowContainer { @@ -51,10 +55,6 @@ limitations under the License. /* force it inline using float */ float: left; } -.mx_UserNotifSettings_gridRowHeading { - font-size: $font-18px; - font-weight: var(--font-semi-bold); -} .mx_UserNotifSettings_gridColumnLabel { color: $secondary-content; @@ -70,39 +70,35 @@ limitations under the License. margin-top: -$spacing-4; } -.mx_UserNotifSettings { - color: $primary-content; /* override from default settings page styles */ +.mx_UserNotifSettings_floatingSection { + margin-top: 40px; - .mx_UserNotifSettings_floatingSection { - margin-top: 40px; - - & > div:first-child { - /* section header */ - font-size: $font-18px; - font-weight: var(--font-semi-bold); - } - - > table { - border-collapse: collapse; - border-spacing: 0; - margin-top: 8px; - - tr > td:first-child { - /* Just for a bit of spacing */ - padding-right: 8px; - } - } + & > div:first-child { + /* section header */ + font-size: $font-18px; + font-weight: var(--font-semi-bold); } - .mx_UserNotifSettings_clearNotifsButton { + > table { + border-collapse: collapse; + border-spacing: 0; margin-top: 8px; - } - .mx_TagComposer { - margin-top: 35px; /* lots of distance from the last line of the table */ + tr > td:first-child { + /* Just for a bit of spacing */ + padding-right: 8px; + } } } +.mx_UserNotifSettings_clearNotifsButton { + margin-top: 8px; +} + +.mx_TagComposer { + margin-top: 35px; /* lots of distance from the last line of the table */ +} + .mx_AccessibleButton.mx_NotificationSound_browse { margin-right: 10px; } diff --git a/res/css/views/settings/_ProfileSettings.pcss b/res/css/views/settings/_ProfileSettings.pcss index 86b6835dc8..0b4c68120c 100644 --- a/res/css/views/settings/_ProfileSettings.pcss +++ b/res/css/views/settings/_ProfileSettings.pcss @@ -15,7 +15,6 @@ limitations under the License. */ .mx_ProfileSettings { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); border-bottom: 1px solid $quinary-content; .mx_ProfileSettings_avatarUpload { @@ -29,11 +28,13 @@ limitations under the License. flex-grow: 1; margin-inline-end: 54px; - .mx_Field:first-child { - margin-top: 0; + .mx_Field { + margin-top: $spacing-8; } .mx_ProfileSettings_profile_controls_topic { + margin-top: $spacing-8; + & > textarea { font-family: inherit; resize: vertical; diff --git a/res/css/views/settings/_SetIdServer.pcss b/res/css/views/settings/_SetIdServer.pcss index 8dc634400c..7813a35422 100644 --- a/res/css/views/settings/_SetIdServer.pcss +++ b/res/css/views/settings/_SetIdServer.pcss @@ -1,5 +1,5 @@ /* -Copyright 2019 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. @@ -14,8 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SetIdServer .mx_Field_input { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); +.mx_SetIdServer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $spacing-8; + + .mx_Field { + width: 100%; + margin: 0; + } } .mx_SetIdServer_tooltip { diff --git a/res/css/views/settings/_SettingsFieldset.pcss b/res/css/views/settings/_SettingsFieldset.pcss index 556fcdf8eb..3443ba7970 100644 --- a/res/css/views/settings/_SettingsFieldset.pcss +++ b/res/css/views/settings/_SettingsFieldset.pcss @@ -15,7 +15,6 @@ limitations under the License. */ .mx_SettingsFieldset { - margin: 10px 80px 10px 0; box-sizing: content-box; } @@ -31,8 +30,6 @@ limitations under the License. } .mx_SettingsFieldset_description { - color: $settings-subsection-fg-color; - font-size: $font-14px; display: block; margin-top: 0; margin-bottom: 10px; @@ -46,3 +43,9 @@ limitations under the License. } } } + +.mx_SettingsFieldset_content { + display: flex; + flex-direction: column; + gap: $spacing-8; +} diff --git a/res/css/views/settings/_SpellCheckLanguages.pcss b/res/css/views/settings/_SpellCheckLanguages.pcss index 0b5e140bd2..4c88ae8d26 100644 --- a/res/css/views/settings/_SpellCheckLanguages.pcss +++ b/res/css/views/settings/_SpellCheckLanguages.pcss @@ -17,7 +17,6 @@ limitations under the License. .mx_ExistingSpellCheckLanguage { display: flex; align-items: center; - margin-bottom: 5px; } .mx_ExistingSpellCheckLanguage_language { @@ -26,10 +25,5 @@ limitations under the License. } .mx_GeneralUserSettingsTab_spellCheckLanguageInput { - margin-top: 1em; - margin-bottom: 1em; -} - -.mx_SpellCheckLanguages { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); + margin-bottom: $spacing-8; } diff --git a/res/css/views/settings/_ThemeChoicePanel.pcss b/res/css/views/settings/_ThemeChoicePanel.pcss index 367191430a..7d3a87cd12 100644 --- a/res/css/views/settings/_ThemeChoicePanel.pcss +++ b/res/css/views/settings/_ThemeChoicePanel.pcss @@ -14,53 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ThemeChoicePanel { +.mx_ThemeChoicePanel_themeSelectors { color: $primary-content; + display: flex; + flex-direction: row; + flex-wrap: wrap; - > .mx_ThemeSelectors { - display: flex; - flex-direction: row; - flex-wrap: wrap; + > .mx_StyledRadioButton { + padding: $font-16px; + box-sizing: border-box; + border-radius: 10px; + width: 180px; - margin-top: 4px; - margin-bottom: 30px; + background: $quinary-content; + opacity: 0.4; - > .mx_StyledRadioButton { - padding: $font-16px; - box-sizing: border-box; - border-radius: 10px; - width: 180px; + flex-shrink: 1; + flex-grow: 0; - background: $quinary-content; - opacity: 0.4; + margin-right: 15px; + margin-top: 10px; - flex-shrink: 1; - flex-grow: 0; + font-weight: var(--font-semi-bold); - margin-right: 15px; - margin-top: 10px; + > span { + justify-content: center; + } + } - font-weight: var(--font-semi-bold); + > .mx_StyledRadioButton_enabled { + opacity: 1; - > span { - justify-content: center; - } + /* These colors need to be hardcoded because they don't change with the theme */ + &.mx_ThemeSelector_light { + background-color: #f3f8fd; + color: #2e2f32; } - > .mx_StyledRadioButton_enabled { - opacity: 1; - - /* These colors need to be hardcoded because they don't change with the theme */ - &.mx_ThemeSelector_light { - background-color: #f3f8fd; - color: #2e2f32; - } - - &.mx_ThemeSelector_dark { - /* 5% lightened version of 181b21 */ - background-color: #25282e; - color: #f3f8fd; - } + &.mx_ThemeSelector_dark { + /* 5% lightened version of 181b21 */ + background-color: #25282e; + color: #f3f8fd; } } } diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index b0388a1d7a..5f4fd6f024 100644 --- a/res/css/views/settings/tabs/_SettingsSection.pcss +++ b/res/css/views/settings/tabs/_SettingsSection.pcss @@ -17,7 +17,6 @@ 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; diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index fac189e858..064bd457d9 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -15,9 +15,6 @@ limitations under the License. */ .mx_SettingsTab { - --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; @@ -25,35 +22,30 @@ limitations under the License. a { color: $links; } + + form { + display: flex; + flex-direction: column; + gap: $spacing-8; + flex-grow: 1; + } + // never want full width buttons + // event when other content is 100% width + .mx_AccessibleButton { + align-self: flex-start; + justify-self: flex-start; + } + + .mx_Field { + margin: 0; + flex: 1; + } } .mx_SettingsTab_warningText { color: $alert; } -.mx_SettingsTab_heading { - font-size: $font-20px; - 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 */ - margin-right: 100px; /* TODO: Use a spacing variable */ - - &:nth-child(n + 2) { - margin-top: var(--SettingsTab_heading_nth_child-margin-top); - } -} - -.mx_SettingsTab_subheading { - font-size: $font-16px; - display: block; - font-weight: var(--font-semi-bold); - color: $primary-content; - margin-top: $spacing-12; - margin-bottom: 10px; /* TODO: Use a spacing variable */ - margin-right: 100px; /* TODO: Use a spacing variable */ -} - .mx_SettingsTab_subsectionText { color: $secondary-content; font-size: $font-14px; diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss index 658f91ed4f..e27751ebe1 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.pcss @@ -14,25 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_AppearanceUserSettingsTab { - --AppearanceUserSettingsTab_Field-margin-inline-start: calc($font-16px + 10px); - - .mx_SettingsTab_subsectionText { - margin-block: $spacing-12 $spacing-32; - color: $primary-content; /* Same as mx_SettingsTab */ - } - - .mx_Field { - width: 256px; - } - - .mx_AppearanceUserSettingsTab_Advanced { - .mx_Checkbox { - margin-block: $spacing-16; - } - - .mx_AppearanceUserSettingsTab_systemFont { - margin-inline-start: var(--AppearanceUserSettingsTab_Field-margin-inline-start); - } - } +.mx_Field.mx_AppearanceUserSettingsTab_checkboxControlledField { + width: 256px; + // matches checkbox box + padding + // to align with checkbox label + margin-inline-start: calc($font-16px + 10px); } diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss index eed53cb6b9..76c5834fa8 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss @@ -14,42 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_GeneralUserSettingsTab_section--account_changePassword { - .mx_Field { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); - - &:first-child { - margin-top: 0; - } - } -} - -/* TODO: Make this selector less painful */ -.mx_GeneralUserSettingsTab_section--account .mx_SettingsTab_subheading:nth-child(n + 1), -.mx_GeneralUserSettingsTab_section--discovery .mx_SettingsTab_subheading:nth-child(n + 2), -.mx_SetIdServer .mx_SettingsTab_subheading { - margin-top: 24px; -} - -.mx_GeneralUserSettingsTab_section--account, -.mx_GeneralUserSettingsTab_section--discovery { - .mx_Spinner { - /* Move the spinner to the left side of the container (default center) */ - justify-content: flex-start; - } -} - -.mx_GeneralUserSettingsTab_section--account .mx_EmailAddresses, -.mx_GeneralUserSettingsTab_section--account .mx_PhoneNumbers, -.mx_GeneralUserSettingsTab_section--discovery .mx_GeneralUserSettingsTab_section--discovery_existing, -.mx_GeneralUserSettingsTab_section_languageInput { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); -} - .mx_GeneralUserSettingsTab_section--discovery_existing { display: flex; align-items: center; - margin-bottom: 5px; } .mx_GeneralUserSettingsTab_section--discovery_existing_address, @@ -62,10 +29,8 @@ limitations under the License. margin-left: 5px; } -.mx_GeneralUserSettingsTab_section--spellcheck .mx_ToggleSwitch { - float: right; -} - -.mx_GeneralUserSettingsTab_heading_warningIcon { +.mx_GeneralUserSettingsTab_warningIcon { vertical-align: middle; + margin-right: $spacing-8; + margin-bottom: 2px; } diff --git a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss index 6f387380f2..c4e5b5c0aa 100644 --- a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss @@ -15,31 +15,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_KeyboardUserSettingsTab .mx_SettingsTab_section { - ul { - margin: 0; - padding: 0; - } - - .mx_KeyboardShortcut_shortcutRow, - .mx_KeyboardShortcut { - display: flex; - justify-content: space-between; - align-items: center; - } - - .mx_KeyboardShortcut_shortcutRow { - column-gap: $spacing-8; - margin-bottom: $spacing-4; - - /* TODO: Use flexbox */ - &:last-of-type { - margin-bottom: 0; - } - - .mx_KeyboardShortcut { - flex-wrap: nowrap; - column-gap: 5px; /* TODO: Use a spacing variable */ - } - } +.mx_KeyboardShortcut_shortcutList { + margin: 0; + padding: 0; + width: 100%; + display: grid; + grid-gap: $spacing-4; +} + +.mx_KeyboardShortcut_shortcutRow, +.mx_KeyboardShortcut { + display: flex; + justify-content: space-between; + align-items: center; +} + +.mx_KeyboardShortcut_shortcutRow { + column-gap: $spacing-8; +} + +.mx_KeyboardShortcut { + flex-wrap: nowrap; + column-gap: $spacing-4; } diff --git a/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.pcss index 26d0b00080..160abc9e3e 100644 --- a/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.pcss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MjolnirUserSettingsTab .mx_Field { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); -} - .mx_MjolnirUserSettingsTab_listItem { margin-bottom: 2px; } diff --git a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss index 81777dc2a6..ad5b3f8a11 100644 --- a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss @@ -14,43 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SecurityUserSettingsTab_bulkOptions .mx_AccessibleButton { - margin-right: 10px; +.mx_SecurityUserSettingsTab_bulkOptions { + display: flex; + flex-direction: row; + column-gap: $spacing-8; } .mx_SecurityUserSettingsTab_ignoredUser { - margin-bottom: 5px; + margin-bottom: $spacing-4; } .mx_SecurityUserSettingsTab_ignoredUser .mx_AccessibleButton { - margin-right: 10px; + margin-right: $spacing-8; } -.mx_SecurityUserSettingsTab { - .mx_SettingsTab_section { - .mx_AccessibleButton_kind_link { - font-size: inherit; - } - } +.mx_SecurityUserSettingsTab_warning { + color: $alert; + position: relative; + padding-left: 40px; - .mx_SecurityUserSettingsTab_warning { - color: $alert; - position: relative; - padding-left: 40px; - margin-top: 30px; - - &::before { - mask-repeat: no-repeat; - mask-position: 0 center; - mask-size: $font-24px; - position: absolute; - width: $font-24px; - height: $font-24px; - content: ""; - top: 0; - left: 0; - background-color: $alert; - mask-image: url("$(res)/img/feather-customised/alert-triangle.svg"); - } + &::before { + mask-repeat: no-repeat; + mask-position: 0 center; + mask-size: $font-24px; + position: absolute; + width: $font-24px; + height: $font-24px; + content: ""; + top: 0; + left: 0; + background-color: $alert; + mask-image: url("$(res)/img/feather-customised/alert-triangle.svg"); } } diff --git a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss index 5bcb6b1602..cb90c0c15f 100644 --- a/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_SidebarUserSettingsTab.pcss @@ -14,67 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SidebarUserSettingsTab { - .mx_Checkbox { - margin-top: 12px; - font-size: $font-15px; - line-height: $font-24px; - color: $secondary-content; - } +.mx_SidebarUserSettingsTab_homeAllRoomsCheckbox { + margin-left: 24px; - .mx_SidebarUserSettingsTab_checkboxMicrocopy { - margin-bottom: 12px; - margin-left: 24px; - font-size: $font-15px; - line-height: $font-24px; - color: $secondary-content; - } - - .mx_SidebarUserSettingsTab_homeAllRoomsCheckbox { - margin-left: 24px; - - & + div { - margin-left: 48px; - } - } - - .mx_SidebarUserSettingsTab_homeCheckbox, - .mx_SidebarUserSettingsTab_favouritesCheckbox, - .mx_SidebarUserSettingsTab_peopleCheckbox, - .mx_SidebarUserSettingsTab_orphansCheckbox { - .mx_Checkbox_background + div { - padding-left: 20px; - position: relative; - - &::before { - background-color: $secondary-content; - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - width: 16px; - height: 16px; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - } - } - } - - .mx_SidebarUserSettingsTab_homeCheckbox .mx_Checkbox_background + div::before { - mask-image: url("$(res)/img/element-icons/home.svg"); - } - - .mx_SidebarUserSettingsTab_favouritesCheckbox .mx_Checkbox_background + div::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); - } - - .mx_SidebarUserSettingsTab_peopleCheckbox .mx_Checkbox_background + div::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); - } - - .mx_SidebarUserSettingsTab_orphansCheckbox .mx_Checkbox_background + div::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); + & + div { + margin-left: 48px; + } +} + +.mx_SidebarUserSettingsTab_checkbox { + margin-bottom: $spacing-8; + // override checkbox styles˚ + label { + align-items: flex-start !important; + } + + svg { + height: 16px; + width: 16px; + margin-right: $spacing-8; + margin-bottom: -1px; } } diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.pcss deleted file mode 100644 index 138ca6f9de..0000000000 --- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.pcss +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2019 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_VoiceUserSettingsTab .mx_Field { - margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); -} - -.mx_VoiceUserSettingsTab_missingMediaPermissions { - margin-bottom: 15px; -} diff --git a/res/img/compound/thread-16px.svg b/res/img/compound/thread-16px.svg new file mode 100644 index 0000000000..f1a678ebc9 --- /dev/null +++ b/res/img/compound/thread-16px.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/home.svg b/res/img/element-icons/home.svg index 9ca0b82716..ae5aceaec2 100644 --- a/res/img/element-icons/home.svg +++ b/res/img/element-icons/home.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/maximise-expand.svg b/res/img/element-icons/maximise-expand.svg index 06c44e2acd..a63f7e0022 100644 --- a/res/img/element-icons/maximise-expand.svg +++ b/res/img/element-icons/maximise-expand.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/minimise-collapse.svg b/res/img/element-icons/minimise-collapse.svg index e941d41276..535c56a36b 100644 --- a/res/img/element-icons/minimise-collapse.svg +++ b/res/img/element-icons/minimise-collapse.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/room/ellipsis.svg b/res/img/element-icons/room/ellipsis.svg index db1db6ec8b..e7fcca8f94 100644 --- a/res/img/element-icons/room/ellipsis.svg +++ b/res/img/element-icons/room/ellipsis.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/res/img/element-icons/room/members.svg b/res/img/element-icons/room/members.svg index 03aba81ad4..50aa0aa466 100644 --- a/res/img/element-icons/room/members.svg +++ b/res/img/element-icons/room/members.svg @@ -2,6 +2,6 @@ - - + + diff --git a/res/img/element-icons/roomlist/favorite.svg b/res/img/element-icons/roomlist/favorite.svg index 0c33999ea3..c601b69808 100644 --- a/res/img/element-icons/roomlist/favorite.svg +++ b/res/img/element-icons/roomlist/favorite.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/roomlist/hash-circle.svg b/res/img/element-icons/roomlist/hash-circle.svg index 924b22cf32..a6bf54fb23 100644 --- a/res/img/element-icons/roomlist/hash-circle.svg +++ b/res/img/element-icons/roomlist/hash-circle.svg @@ -2,6 +2,6 @@ - - + + diff --git a/res/img/feather-customised/warning-triangle.svg b/res/img/feather-customised/warning-triangle.svg index 3d18e30fb2..29a9f85b5a 100644 --- a/res/img/feather-customised/warning-triangle.svg +++ b/res/img/feather-customised/warning-triangle.svg @@ -3,7 +3,7 @@ - + diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 310ef4e59e..9ce9c28d31 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -1,23 +1,17 @@ /* Nunito lacks combining diacritics, so these will fall through to the next font. Helevetica's diacritics sometimes do not combine nicely (on OSX, at least) and result in a huge horizontal mess. - Arial empirically gets it right, hence prioritising Arial here. - We also include STIXGeneral explicitly to support a wider range - of combining diacritics (Chrome fails without it, as per - https://bugs.chromium.org/p/chromium/issues/detail?id=1328898). - We should never actively *prefer* STIXGeneral over the default font though, - since it looks pretty rough and implements some non-LGC scripts only - partially, making, for example, Japanese text look patchy and sad. */ + Arial empirically gets it right, hence prioritising Arial here. */ /* We fall through to Twemoji for emoji rather than falling through to native Emoji fonts (if any) to ensure cross-browser consistency */ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ $font-family: "Nunito", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, - "STIXGeneral", "Noto Color Emoji"; + "Noto Color Emoji"; $monospace-font-family: "Inconsolata", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, - "STIXGeneral", "Noto Color Emoji"; + "Noto Color Emoji"; /* unified palette */ /* try to use these colors when possible */ diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss index aec8489af3..e1b79a7bf3 100644 --- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -82,11 +82,11 @@ $roomtopic-color: $secondary-content; background-color: $panel-actions !important; } -.mx_ThemeChoicePanel > .mx_ThemeSelectors > .mx_StyledRadioButton input[type="radio"]:disabled + div { +.mx_ThemeChoicePanel_themeSelectors > .mx_StyledRadioButton input[type="radio"]:disabled + div { border-color: $primary-content; } -.mx_ThemeChoicePanel > .mx_ThemeSelectors > .mx_StyledRadioButton.mx_StyledRadioButton_disabled { +.mx_ThemeChoicePanel_themeSelectors > .mx_StyledRadioButton.mx_StyledRadioButton_disabled { color: $primary-content; } diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 60fd0196d1..58ab893058 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -1,23 +1,17 @@ /* Nunito and Inter lacks combining diacritics, so these will fall through to the next font. Helevetica's diacritics sometimes do not combine nicely (on OSX, at least) and result in a huge horizontal mess. - Arial empirically gets it right, hence prioritising Arial here. - We also include STIXGeneral explicitly to support a wider range - of combining diacritics (Chrome fails without it, as per - https://bugs.chromium.org/p/chromium/issues/detail?id=1328898). - We should never actively *prefer* STIXGeneral over the default font though, - since it looks pretty rough and implements some non-LGC scripts only - partially, making, for example, Japanese text look patchy and sad. */ + Arial empirically gets it right, hence prioritising Arial here. */ /* We fall through to Twemoji for emoji rather than falling through to native Emoji fonts (if any) to ensure cross-browser consistency */ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: "Inter", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, "STIXGeneral", +$font-family: "Inter", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, "Noto Color Emoji"; $monospace-font-family: "Inconsolata", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, - "STIXGeneral", "Noto Color Emoji"; + "Noto Color Emoji"; /* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120 */ /* ******************** */ diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 81f0784ff9..3a8b9be4dd 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,8 +10,15 @@ defbranch="$3" rm -r "$defrepo" || true -PR_ORG=${PR_ORG:-"matrix-org"} -PR_REPO=${PR_REPO:-"matrix-react-sdk"} +# figure out where to look for pull requests: +# - We may have been told an explicit repo via the PR_ORG/PR_REPO/PR_NUMBER env vars +# - otherwise, check the $GITHUB_ env vars which are set by Github Actions +# - failing that, fall back to the matrix-org/matrix-react-sdk repo. +# +# in ether case, the PR_NUMBER variable must be set explicitly. +default_org_repo=${GITHUB_REPOSITORY:-"matrix-org/matrix-react-sdk"} +PR_ORG=${PR_ORG:-${default_org_repo%%/*}} +PR_REPO=${PR_REPO:-${default_org_repo##*/}} # A function that clones a branch of a repo based on the org, repo and branch clone() { diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index fdf0fc65f1..42c6068b87 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -16,18 +16,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IAuthData, IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk/src/matrix"; +import { IAuthData, IRequestMsisdnTokenResponse, IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixError, HTTPError } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import Modal from "./Modal"; import { _t, UserFriendlyError } from "./languageHandler"; import IdentityAuthClient from "./IdentityAuthClient"; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; -function getIdServerDomain(): string { - const idBaseUrl = MatrixClientPeg.get().getIdentityServerUrl(true); +function getIdServerDomain(matrixClient: MatrixClient): string { + const idBaseUrl = matrixClient.getIdentityServerUrl(true); if (!idBaseUrl) { throw new UserFriendlyError("Identity server not set"); } @@ -55,11 +54,11 @@ export type Binding = { export default class AddThreepid { private sessionId: string; private submitUrl?: string; - private clientSecret: string; - private bind: boolean; + private bind = false; + private readonly clientSecret: string; - public constructor() { - this.clientSecret = MatrixClientPeg.get().generateClientSecret(); + public constructor(private readonly matrixClient: MatrixClient) { + this.clientSecret = matrixClient.generateClientSecret(); } /** @@ -70,7 +69,7 @@ export default class AddThreepid { */ public async addEmailAddress(emailAddress: string): Promise { try { - const res = await MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1); + const res = await this.matrixClient.requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1); this.sessionId = res.sid; return res; } catch (err) { @@ -90,12 +89,12 @@ export default class AddThreepid { */ public async bindEmailAddress(emailAddress: string): Promise { this.bind = true; - if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { + if (await this.matrixClient.doesServerSupportSeparateAddAndBind()) { // For separate bind, request a token directly from the IS. const authClient = new IdentityAuthClient(); const identityAccessToken = (await authClient.getAccessToken()) ?? undefined; try { - const res = await MatrixClientPeg.get().requestEmailToken( + const res = await this.matrixClient.requestEmailToken( emailAddress, this.clientSecret, 1, @@ -126,7 +125,7 @@ export default class AddThreepid { */ public async addMsisdn(phoneCountry: string, phoneNumber: string): Promise { try { - const res = await MatrixClientPeg.get().requestAdd3pidMsisdnToken( + const res = await this.matrixClient.requestAdd3pidMsisdnToken( phoneCountry, phoneNumber, this.clientSecret, @@ -153,12 +152,12 @@ export default class AddThreepid { */ public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise { this.bind = true; - if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { + if (await this.matrixClient.doesServerSupportSeparateAddAndBind()) { // For separate bind, request a token directly from the IS. const authClient = new IdentityAuthClient(); const identityAccessToken = (await authClient.getAccessToken()) ?? undefined; try { - const res = await MatrixClientPeg.get().requestMsisdnToken( + const res = await this.matrixClient.requestMsisdnToken( phoneCountry, phoneNumber, this.clientSecret, @@ -189,17 +188,17 @@ export default class AddThreepid { */ public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> { try { - if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { + if (await this.matrixClient.doesServerSupportSeparateAddAndBind()) { if (this.bind) { const authClient = new IdentityAuthClient(); const identityAccessToken = await authClient.getAccessToken(); if (!identityAccessToken) { throw new UserFriendlyError("No identity access token found"); } - await MatrixClientPeg.get().bindThreePid({ + await this.matrixClient.bindThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: getIdServerDomain(), + id_server: getIdServerDomain(this.matrixClient), id_access_token: identityAccessToken, }); } else { @@ -233,7 +232,7 @@ export default class AddThreepid { }; const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("Add Email Address"), - matrixClient: MatrixClientPeg.get(), + matrixClient: this.matrixClient, authData: err.data, makeRequest: this.makeAddThreepidOnlyRequest, aestheticsForStagePhases: { @@ -245,11 +244,11 @@ export default class AddThreepid { } } } else { - await MatrixClientPeg.get().addThreePid( + await this.matrixClient.addThreePid( { sid: this.sessionId, client_secret: this.clientSecret, - id_server: getIdServerDomain(), + id_server: getIdServerDomain(this.matrixClient), }, this.bind, ); @@ -272,7 +271,7 @@ export default class AddThreepid { * @return {Promise} Response from /3pid/add call (in current spec, an empty object) */ private makeAddThreepidOnlyRequest = (auth?: { type: string; session?: string }): Promise<{}> => { - return MatrixClientPeg.get().addThreePidOnly({ + return this.matrixClient.addThreePidOnly({ sid: this.sessionId, client_secret: this.clientSecret, auth, @@ -291,18 +290,18 @@ export default class AddThreepid { msisdnToken: string, ): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> { const authClient = new IdentityAuthClient(); - const supportsSeparateAddAndBind = await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind(); + const supportsSeparateAddAndBind = await this.matrixClient.doesServerSupportSeparateAddAndBind(); let result: { success: boolean } | MatrixError; if (this.submitUrl) { - result = await MatrixClientPeg.get().submitMsisdnTokenOtherUrl( + result = await this.matrixClient.submitMsisdnTokenOtherUrl( this.submitUrl, this.sessionId, this.clientSecret, msisdnToken, ); } else if (this.bind || !supportsSeparateAddAndBind) { - result = await MatrixClientPeg.get().submitMsisdnToken( + result = await this.matrixClient.submitMsisdnToken( this.sessionId, this.clientSecret, msisdnToken, @@ -317,10 +316,10 @@ export default class AddThreepid { if (supportsSeparateAddAndBind) { if (this.bind) { - await MatrixClientPeg.get().bindThreePid({ + await this.matrixClient.bindThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: getIdServerDomain(), + id_server: getIdServerDomain(this.matrixClient), id_access_token: await authClient.getAccessToken(), }); } else { @@ -354,7 +353,7 @@ export default class AddThreepid { }; const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("Add Phone Number"), - matrixClient: MatrixClientPeg.get(), + matrixClient: this.matrixClient, authData: err.data, makeRequest: this.makeAddThreepidOnlyRequest, aestheticsForStagePhases: { @@ -366,11 +365,11 @@ export default class AddThreepid { } } } else { - await MatrixClientPeg.get().addThreePid( + await this.matrixClient.addThreePid( { sid: this.sessionId, client_secret: this.clientSecret, - id_server: getIdServerDomain(), + id_server: getIdServerDomain(this.matrixClient), }, this.bind, ); diff --git a/src/Avatar.ts b/src/Avatar.ts index 79254ef1b5..3873a1a59d 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -18,11 +18,11 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { User } from "matrix-js-sdk/src/models/user"; import { Room } from "matrix-js-sdk/src/models/room"; import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; -import { split } from "lodash"; import DMRoomMap from "./utils/DMRoomMap"; import { mediaFromMxc } from "./customisations/Media"; import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; +import { getFirstGrapheme } from "./utils/strings"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( @@ -133,8 +133,7 @@ export function getInitialLetter(name: string): string | undefined { name = name.substring(1); } - // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis - return split(name, "", 1)[0].toUpperCase(); + return getFirstGrapheme(name).toUpperCase(); } export function avatarUrlForRoom( diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 44eecacb7f..f7f6b148e2 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -436,15 +436,18 @@ export default class ContentMessages { } } - promBefore = doMaybeLocalRoomAction(roomId, (actualRoomId) => - this.sendContentToRoom( - file, - actualRoomId, - relation, - matrixClient, - replyToEvent ?? undefined, - loopPromiseBefore, - ), + promBefore = doMaybeLocalRoomAction( + roomId, + (actualRoomId) => + this.sendContentToRoom( + file, + actualRoomId, + relation, + matrixClient, + replyToEvent ?? undefined, + loopPromiseBefore, + ), + matrixClient, ); } @@ -580,13 +583,13 @@ export default class ContentMessages { } catch (error) { // 413: File was too big or upset the server in some way: // clear the media size limit so we fetch it again next time we try to upload - if (error?.httpStatus === 413) { + if (error instanceof HTTPError && error.httpStatus === 413) { this.mediaConfig = null; } if (!upload.cancelled) { let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); - if (error.httpStatus === 413) { + if (error instanceof HTTPError && error.httpStatus === 413) { desc = _t("The file '%(fileName)s' exceeds this homeserver's size limit for uploads", { fileName: upload.fileName, }); diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e01bc54b60..40ce534ce0 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -17,11 +17,10 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; -import { ClientEvent, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, EventType, MatrixClient, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { SyncState } from "matrix-js-sdk/src/sync"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -67,6 +66,8 @@ export default class DeviceListener { // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; + // The client with which the instance is running. Only set if `running` is true, otherwise undefined. + private client?: MatrixClient; private shouldRecordClientInformation = false; private enableBulkUnverifiedSessionsReminder = true; private deviceClientInformationSettingWatcherRef: string | undefined; @@ -76,16 +77,17 @@ export default class DeviceListener { return window.mxDeviceListener; } - public start(): void { + public start(matrixClient: MatrixClient): void { this.running = true; - MatrixClientPeg.get().on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); - MatrixClientPeg.get().on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); - MatrixClientPeg.get().on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - MatrixClientPeg.get().on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); - MatrixClientPeg.get().on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); - MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData); - MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync); - MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client = matrixClient; + this.client.on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); + this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); + this.client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); + this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); + this.client.on(ClientEvent.AccountData, this.onAccountData); + this.client.on(ClientEvent.Sync, this.onSync); + this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn"); // only configurable in config, so we don't need to watch the value this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder); @@ -101,18 +103,15 @@ export default class DeviceListener { public stop(): void { this.running = false; - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); - MatrixClientPeg.get().removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); - MatrixClientPeg.get().removeListener( - CryptoEvent.DeviceVerificationChanged, - this.onDeviceVerificationChanged, - ); - MatrixClientPeg.get().removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); - MatrixClientPeg.get().removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); - MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.onAccountData); - MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync); - MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + if (this.client) { + this.client.removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); + this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); + this.client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); + this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); + this.client.removeListener(ClientEvent.AccountData, this.onAccountData); + this.client.removeListener(ClientEvent.Sync, this.onSync); + this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); } if (this.deviceClientInformationSettingWatcherRef) { SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); @@ -128,6 +127,7 @@ export default class DeviceListener { this.keyBackupStatusChecked = false; this.ourDeviceIdsAtStart = null; this.displayingToastsForDeviceIds = new Set(); + this.client = undefined; } /** @@ -160,22 +160,23 @@ export default class DeviceListener { * @returns the set of device IDs */ private async getDeviceIds(): Promise> { - const cli = MatrixClientPeg.get(); - const crypto = cli.getCrypto(); + const cli = this.client; + const crypto = cli?.getCrypto(); if (crypto === undefined) return new Set(); - const userId = cli.getSafeUserId(); + const userId = cli!.getSafeUserId(); const devices = await crypto.getUserDeviceInfo([userId]); return new Set(devices.get(userId)?.keys() ?? []); } private onWillUpdateDevices = async (users: string[], initialFetch?: boolean): Promise => { + if (!this.client) return; // If we didn't know about *any* devices before (ie. it's fresh login), // then they are all pre-existing devices, so ignore this and set the // devicesAtStart list to the devices that we see after the fetch. if (initialFetch) return; - const myUserId = MatrixClientPeg.get().getUserId()!; + const myUserId = this.client.getSafeUserId(); if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated(); // No need to do a recheck here: we just need to get a snapshot of our devices @@ -183,17 +184,20 @@ export default class DeviceListener { }; private onDevicesUpdated = (users: string[]): void => { - if (!users.includes(MatrixClientPeg.get().getUserId()!)) return; + if (!this.client) return; + if (!users.includes(this.client.getSafeUserId())) return; this.recheck(); }; private onDeviceVerificationChanged = (userId: string): void => { - if (userId !== MatrixClientPeg.get().getUserId()) return; + if (!this.client) return; + if (userId !== this.client.getUserId()) return; this.recheck(); }; private onUserTrustStatusChanged = (userId: string): void => { - if (userId !== MatrixClientPeg.get().getUserId()) return; + if (!this.client) return; + if (userId !== this.client.getUserId()) return; this.recheck(); }; @@ -239,13 +243,14 @@ export default class DeviceListener { // The server doesn't tell us when key backup is set up, so we poll // & cache the result private async getKeyBackupInfo(): Promise { + if (!this.client) return null; const now = new Date().getTime(); if ( !this.keyBackupInfo || !this.keyBackupFetchedAt || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL ) { - this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + this.keyBackupInfo = await this.client.getKeyBackupVersion(); this.keyBackupFetchedAt = now; } return this.keyBackupInfo; @@ -256,13 +261,13 @@ export default class DeviceListener { // modifying the state involved here, so don't add new toasts to setup. if (isSecretStorageBeingAccessed()) return false; // Show setup toasts once the user is in at least one encrypted room. - const cli = MatrixClientPeg.get(); - return cli && cli.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)); + const cli = this.client; + return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false; } private async recheck(): Promise { - if (!this.running) return; // we have been stopped - const cli = MatrixClientPeg.get(); + if (!this.running || !this.client) return; // we have been stopped + const cli = this.client; // cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1 if (!(await cli.isVersionSupported("v1.1"))) return; @@ -285,11 +290,11 @@ export default class DeviceListener { this.checkKeyBackupStatus(); } else if (this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading - await crypto.getUserDeviceInfo([cli.getUserId()!]); + await crypto.getUserDeviceInfo([cli.getSafeUserId()]); // cross signing isn't enabled - nag to enable it // There are 3 different toasts for: - if (!(await crypto.getCrossSigningKeyId()) && cli.getStoredCrossSigningForUser(cli.getUserId()!)) { + if (!(await crypto.getCrossSigningKeyId()) && cli.getStoredCrossSigningForUser(cli.getSafeUserId())) { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); this.checkKeyBackupStatus(); @@ -301,7 +306,7 @@ export default class DeviceListener { } else { // No cross-signing or key backup on account (set up encryption) await cli.waitForClientWellKnown(); - if (isSecureBackupRequired() && isLoggedIn()) { + if (isSecureBackupRequired(cli) && isLoggedIn()) { // If we're meant to set up, and Secure Backup is required, // trigger the flow directly without a toast once logged in. hideSetupEncryptionToast(); @@ -327,7 +332,9 @@ export default class DeviceListener { const isCurrentDeviceTrusted = crossSigningReady && - Boolean((await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.deviceId!))?.crossSigningVerified); + Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts @@ -336,7 +343,7 @@ export default class DeviceListener { for (const deviceId of devices) { if (deviceId === cli.deviceId) continue; - const deviceTrust = await crypto.getDeviceVerificationStatus(cli.getUserId()!, deviceId); + const deviceTrust = await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), deviceId); if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) { if (this.ourDeviceIdsAtStart?.has(deviceId)) { oldUnverifiedDeviceIds.add(deviceId); @@ -383,11 +390,11 @@ export default class DeviceListener { } private checkKeyBackupStatus = async (): Promise => { - if (this.keyBackupStatusChecked) { + if (this.keyBackupStatusChecked || !this.client) { return; } // returns null when key backup status hasn't finished being checked - const isKeyBackupEnabled = MatrixClientPeg.get().getKeyBackupEnabled(); + const isKeyBackupEnabled = this.client.getKeyBackupEnabled(); this.keyBackupStatusChecked = isKeyBackupEnabled !== null; if (isKeyBackupEnabled === false) { @@ -412,11 +419,12 @@ export default class DeviceListener { }; private updateClientInformation = async (): Promise => { + if (!this.client) return; try { if (this.shouldRecordClientInformation) { - await recordClientInformation(MatrixClientPeg.get(), SdkConfig.get(), PlatformPeg.get() ?? undefined); + await recordClientInformation(this.client, SdkConfig.get(), PlatformPeg.get() ?? undefined); } else { - await removeClientInformation(MatrixClientPeg.get()); + await removeClientInformation(this.client); } } catch (error) { // this is a best effort operation diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index f8dd52a4f0..866c1d0a0c 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -19,16 +19,16 @@ limitations under the License. import React, { LegacyRef, ReactElement, ReactNode } from "react"; import sanitizeHtml from "sanitize-html"; -import { load as cheerio } from "cheerio"; import classNames from "classnames"; import EMOJIBASE_REGEX from "emojibase-regex"; -import { merge, split } from "lodash"; +import { merge } from "lodash"; import katex from "katex"; import { decode } from "html-entities"; import { IContent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; import _Linkify from "linkify-react"; import escapeHtml from "escape-html"; +import GraphemeSplitter from "grapheme-splitter"; import { _linkifyElement, @@ -464,14 +464,18 @@ const emojiToJsxSpan = (emoji: string, key: number): JSX.Element => ( * @returns if isHtmlMessage is true, returns an array of strings, otherwise return an array of React Elements for emojis * and plain text for everything else */ -function formatEmojis(message: string | undefined, isHtmlMessage: boolean): (JSX.Element | string)[] { +export function formatEmojis(message: string | undefined, isHtmlMessage?: false): JSX.Element[]; +export function formatEmojis(message: string | undefined, isHtmlMessage: true): string[]; +export function formatEmojis(message: string | undefined, isHtmlMessage: boolean): (JSX.Element | string)[] { const emojiToSpan = isHtmlMessage ? emojiToHtmlSpan : emojiToJsxSpan; const result: (JSX.Element | string)[] = []; + if (!message) return result; + let text = ""; let key = 0; - // We use lodash's grapheme splitter to avoid breaking apart compound emojis - for (const char of split(message, "")) { + const splitter = new GraphemeSplitter(); + for (const char of splitter.iterateGraphemes(message)) { if (EMOJIBASE_REGEX.test(char)) { if (text) { result.push(text); @@ -549,30 +553,19 @@ export function bodyToHtml(content: IContent, highlights: Optional, op } safeBody = sanitizeHtml(formattedBody!, sanitizeParams); - const phtml = cheerio(safeBody, { - // @ts-ignore: The `_useHtmlParser2` internal option is the - // simplest way to both parse and render using `htmlparser2`. - _useHtmlParser2: true, - decodeEntities: false, - }); - const isPlainText = phtml.html() === phtml.root().text(); + const phtml = new DOMParser().parseFromString(safeBody, "text/html"); + const isPlainText = phtml.body.innerHTML === phtml.body.textContent; isHtmlMessage = !isPlainText; if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { - // @ts-ignore - The types for `replaceWith` wrongly expect - // Cheerio instance to be returned. - phtml('div, span[data-mx-maths!=""]').replaceWith(function (i, e) { - return katex.renderToString(decode(phtml(e).attr("data-mx-maths")), { + [...phtml.querySelectorAll("div, span[data-mx-maths]")].forEach((e) => { + e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), { throwOnError: false, - // @ts-ignore - `e` can be an Element, not just a Node - displayMode: e.name == "div", + displayMode: e.tagName == "DIV", output: "htmlAndMathml", }); }); - safeBody = phtml.html(); - } - if (bodyHasEmoji) { - safeBody = formatEmojis(safeBody, true).join(""); + safeBody = phtml.body.innerHTML; } } else if (highlighter) { safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join(""); @@ -581,13 +574,9 @@ export function bodyToHtml(content: IContent, highlights: Optional, op delete sanitizeParams.textFilter; } - const contentBody = safeBody ?? strippedBody; - if (opts.returnString) { - return contentBody; - } - let emojiBody = false; if (!opts.disableBigEmoji && bodyHasEmoji) { + const contentBody = safeBody ?? strippedBody; let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : ""; // Remove zero width joiner, zero width spaces and other spaces in body @@ -607,6 +596,15 @@ export function bodyToHtml(content: IContent, highlights: Optional, op (!content.formatted_body.includes("http:") && !content.formatted_body.includes("https:"))); } + if (isFormattedBody && bodyHasEmoji && safeBody) { + // This has to be done after the emojiBody check above as to not break big emoji on replies + safeBody = formatEmojis(safeBody, true).join(""); + } + + if (opts.returnString) { + return safeBody ?? strippedBody; + } + const className = classNames({ "mx_EventTile_body": true, "mx_EventTile_bigEmoji": emojiBody, @@ -668,7 +666,7 @@ export function topicToHtml( isFormattedTopic = false; // Fall back to plain-text topic } - let emojiBodyElements: ReturnType | undefined; + let emojiBodyElements: JSX.Element[] | undefined; if (!isFormattedTopic && topicHasEmoji) { emojiBodyElements = formatEmojis(topic, false); } diff --git a/src/IdentityAuthClient.tsx b/src/IdentityAuthClient.tsx index 5ad918d0a3..28e88a49f7 100644 --- a/src/IdentityAuthClient.tsx +++ b/src/IdentityAuthClient.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; -import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { createClient, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "./MatrixClientPeg"; @@ -64,7 +64,11 @@ export default class IdentityAuthClient { private writeToken(): void { if (this.tempClient) return; // temporary client: ignore - window.localStorage.setItem("mx_is_access_token", this.accessToken); + if (this.accessToken) { + window.localStorage.setItem("mx_is_access_token", this.accessToken); + } else { + window.localStorage.removeItem("mx_is_access_token"); + } } private readToken(): string | null { @@ -123,9 +127,9 @@ export default class IdentityAuthClient { try { await this.matrixClient.getIdentityAccount(token); } catch (e) { - if (e.errcode === "M_TERMS_NOT_SIGNED") { + if (e instanceof MatrixError && e.errcode === "M_TERMS_NOT_SIGNED") { logger.log("Identity server requires new terms to be agreed to"); - await startTermsFlow([new Service(SERVICE_TYPES.IS, identityServerUrl, token)]); + await startTermsFlow(this.matrixClient, [new Service(SERVICE_TYPES.IS, identityServerUrl, token)]); return; } throw e; @@ -133,8 +137,8 @@ export default class IdentityAuthClient { if ( !this.tempClient && - !doesAccountDataHaveIdentityServer() && - !(await doesIdentityServerHaveTerms(identityServerUrl)) + !doesAccountDataHaveIdentityServer(this.matrixClient) && + !(await doesIdentityServerHaveTerms(this.matrixClient, identityServerUrl)) ) { const { finished } = Modal.createDialog(QuestionDialog, { title: _t("Identity server has no terms of service"), @@ -158,7 +162,7 @@ export default class IdentityAuthClient { }); const [confirmed] = await finished; if (confirmed) { - setToDefaultIdentityServer(); + setToDefaultIdentityServer(this.matrixClient); } else { throw new AbortedIdentityActionError("User aborted identity server action without terms"); } diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index f8096ae561..3dc7fcc483 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -1209,7 +1209,7 @@ export default class LegacyCallHandler extends EventEmitter { } try { - await WidgetUtils.addJitsiWidget(roomId, type, "Jitsi", false); + await WidgetUtils.addJitsiWidget(client, roomId, type, "Jitsi", false); logger.log("Jitsi widget added"); } catch (e) { if (e instanceof MatrixError && e.errcode === "M_FORBIDDEN") { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 763cfa1e87..6f49a0a0b6 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -553,7 +553,7 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise { +async function startMatrixClient(client: MatrixClient, startSyncing = true): Promise { logger.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -796,10 +797,10 @@ async function startMatrixClient(startSyncing = true): Promise { SdkContextClass.instance.typingStore.reset(); ToastStore.sharedInstance().reset(); - DialogOpener.instance.prepare(); + DialogOpener.instance.prepare(client); Notifier.start(); UserActivity.sharedInstance().start(); - DMRoomMap.makeShared().start(); + DMRoomMap.makeShared(client).start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.instance.start(); LegacyCallHandler.instance.start(); @@ -824,7 +825,7 @@ async function startMatrixClient(startSyncing = true): Promise { SettingsStore.runMigrations(); // This needs to be started after crypto is set up - DeviceListener.sharedInstance().start(); + DeviceListener.sharedInstance().start(client); // Similarly, don't start sending presence updates until we've started // the client if (!SettingsStore.getValue("lowBandwidth")) { @@ -940,6 +941,7 @@ export function stopMatrixClient(unsetClient = true): void { if (unsetClient) { MatrixClientPeg.unset(); EventIndexPeg.unset(); + cli.store.destroy(); } } } diff --git a/src/Livestream.ts b/src/Livestream.ts index 563136983b..77668011af 100644 --- a/src/Livestream.ts +++ b/src/Livestream.ts @@ -15,8 +15,8 @@ limitations under the License. */ import { ClientWidgetApi } from "matrix-widget-api"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import SdkConfig from "./SdkConfig"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; @@ -27,8 +27,8 @@ export function getConfigLivestreamUrl(): string | undefined { // Dummy rtmp URL used to signal that we want a special audio-only stream const AUDIOSTREAM_DUMMY_URL = "rtmp://audiostream.dummy/"; -async function createLiveStream(roomId: string): Promise { - const openIdToken = await MatrixClientPeg.get().getOpenIdToken(); +async function createLiveStream(matrixClient: MatrixClient, roomId: string): Promise { + const openIdToken = await matrixClient.getOpenIdToken(); const url = getConfigLivestreamUrl() + "/createStream"; @@ -47,8 +47,12 @@ async function createLiveStream(roomId: string): Promise { return respBody["stream_id"]; } -export async function startJitsiAudioLivestream(widgetMessaging: ClientWidgetApi, roomId: string): Promise { - const streamId = await createLiveStream(roomId); +export async function startJitsiAudioLivestream( + matrixClient: MatrixClient, + widgetMessaging: ClientWidgetApi, + roomId: string, +): Promise { + const streamId = await createLiveStream(matrixClient, roomId); await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, { rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId, diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 52d91dc9d1..c318f5e4d0 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -194,6 +194,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { private onUnexpectedStoreClose = async (): Promise => { if (!this.matrixClient) return; this.matrixClient.stopClient(); // stop the client as the database has failed + this.matrixClient.store.destroy(); if (!this.matrixClient.isGuest()) { // If the user is not a guest then prompt them to reload rather than doing it for them diff --git a/src/Notifier.ts b/src/Notifier.ts index d47a9c871b..96abb960ad 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -132,7 +132,7 @@ class NotifierClass { let msg = this.notificationMessageForEvent(ev); if (!msg) return; - let title; + let title: string | undefined; if (!ev.sender || room.name === ev.sender.name) { title = room.name; // notificationMessageForEvent includes sender, but we already have the sender here @@ -153,6 +153,8 @@ class NotifierClass { } } + if (!title) return; + if (!this.isBodyEnabled()) { msg = ""; } diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 2a7e24294e..9e76ebb097 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -254,7 +254,7 @@ export class PosthogAnalytics { }; } - // eslint-disable-nextline no-unused-varsx + // eslint-disable-nextline no-unused-vars private capture(eventName: string, properties: Properties, options?: IPostHogEventOptions): void { if (!this.enabled) { return; @@ -375,12 +375,12 @@ export class PosthogAnalytics { this.registerSuperProperties(this.platformSuperProperties); } - public async updateAnonymityFromSettings(pseudonymousOptIn: boolean): Promise { + public async updateAnonymityFromSettings(client: MatrixClient, pseudonymousOptIn: boolean): Promise { // Update this.anonymity based on the user's analytics opt-in settings const anonymity = pseudonymousOptIn ? Anonymity.Pseudonymous : Anonymity.Disabled; this.setAnonymity(anonymity); if (anonymity === Anonymity.Pseudonymous) { - await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId); + await this.identifyUser(client, PosthogAnalytics.getRandomAnalyticsId); if (MatrixClientPeg.currentUserIsJustRegistered()) { this.trackNewUserEvent(); } @@ -391,7 +391,7 @@ export class PosthogAnalytics { } } - public startListeningToSettingsChanges(): void { + public startListeningToSettingsChanges(client: MatrixClient): void { // Listen to account data changes from sync so we can observe changes to relevant flags and update. // This is called - // * On page load, when the account data is first received by sync @@ -404,7 +404,7 @@ export class PosthogAnalytics { "pseudonymousAnalyticsOptIn", null, (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { - this.updateAnonymityFromSettings(!!newValue); + this.updateAnonymityFromSettings(client, !!newValue); }, ); } diff --git a/src/Resend.ts b/src/Resend.ts index 17e39a7e29..cbd43661c4 100644 --- a/src/Resend.ts +++ b/src/Resend.ts @@ -17,8 +17,8 @@ limitations under the License. import { MatrixEvent, EventStatus } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; export default class Resend { @@ -30,7 +30,7 @@ export default class Resend { return ev.status === EventStatus.NOT_SENT; }) .map(function (event: MatrixEvent) { - return Resend.resend(event); + return Resend.resend(room.client, event); }), ); } @@ -41,30 +41,28 @@ export default class Resend { return ev.status === EventStatus.NOT_SENT; }) .forEach(function (event: MatrixEvent) { - Resend.removeFromQueue(event); + Resend.removeFromQueue(room.client, event); }); } - public static resend(event: MatrixEvent): Promise { - const room = MatrixClientPeg.get().getRoom(event.getRoomId())!; - return MatrixClientPeg.get() - .resendEvent(event, room) - .then( - function (res) { - dis.dispatch({ - action: "message_sent", - event: event, - }); - }, - function (err: Error) { - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/element-web/issues/3148 - logger.log("Resend got send failure: " + err.name + "(" + err + ")"); - }, - ); + public static resend(client: MatrixClient, event: MatrixEvent): Promise { + const room = client.getRoom(event.getRoomId())!; + return client.resendEvent(event, room).then( + function (res) { + dis.dispatch({ + action: "message_sent", + event: event, + }); + }, + function (err: Error) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/element-web/issues/3148 + logger.log("Resend got send failure: " + err.name + "(" + err + ")"); + }, + ); } - public static removeFromQueue(event: MatrixEvent): void { - MatrixClientPeg.get().cancelPendingEvent(event); + public static removeFromQueue(client: MatrixClient, event: MatrixEvent): void { + client.cancelPendingEvent(event); } } diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 0d2b64f244..6c49de2090 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -20,8 +20,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { User } from "matrix-js-sdk/src/models/user"; import { logger } from "matrix-js-sdk/src/logger"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import MultiInviter, { CompletionStates } from "./utils/MultiInviter"; import Modal from "./Modal"; import { _t } from "./languageHandler"; @@ -49,12 +49,13 @@ export interface IInviteResult { * @returns {Promise} Promise */ export function inviteMultipleToRoom( + client: MatrixClient, roomId: string, addresses: string[], sendSharedHistoryKeys = false, progressCallback?: () => void, ): Promise { - const inviter = new MultiInviter(roomId, progressCallback); + const inviter = new MultiInviter(client, roomId, progressCallback); return inviter .invite(addresses, undefined, sendSharedHistoryKeys) .then((states) => Promise.resolve({ states, inviter })); @@ -105,14 +106,15 @@ export function isValid3pidInvite(event: MatrixEvent): boolean { } export function inviteUsersToRoom( + client: MatrixClient, roomId: string, userIds: string[], sendSharedHistoryKeys = false, progressCallback?: () => void, ): Promise { - return inviteMultipleToRoom(roomId, userIds, sendSharedHistoryKeys, progressCallback) + return inviteMultipleToRoom(client, roomId, userIds, sendSharedHistoryKeys, progressCallback) .then((result) => { - const room = MatrixClientPeg.get().getRoom(roomId)!; + const room = client.getRoom(roomId)!; showAnyInviteErrors(result.states, room, result.inviter); }) .catch((err) => { @@ -150,7 +152,7 @@ export function showAnyInviteErrors( } } - const cli = MatrixClientPeg.get(); + const cli = room.client; if (errorList.length > 0) { // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution const description = ( diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index f386f50ada..c484d68f18 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -21,7 +21,6 @@ import { ConditionKind, PushRuleActionName, PushRuleKind, TweakName } from "matr import type { IPushRule } from "matrix-js-sdk/src/@types/PushRules"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import { NotificationColor } from "./stores/notifications/NotificationColor"; import { getUnsentMessages } from "./components/structures/RoomStatusBar"; import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; @@ -40,7 +39,7 @@ export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNo // look through the override rules for a rule affecting this room: // if one exists, it will take precedence. - const muteRule = findOverrideMuteRule(roomId); + const muteRule = findOverrideMuteRule(client, roomId); if (muteRule) { return RoomNotifState.Mute; } @@ -70,11 +69,11 @@ export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNo return null; } -export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise { +export function setRoomNotifsState(client: MatrixClient, roomId: string, newState: RoomNotifState): Promise { if (newState === RoomNotifState.Mute) { - return setRoomNotifsStateMuted(roomId); + return setRoomNotifsStateMuted(client, roomId); } else { - return setRoomNotifsStateUnmuted(roomId, newState); + return setRoomNotifsStateUnmuted(client, roomId, newState); } } @@ -91,7 +90,7 @@ export function getUnreadNotificationCount(room: Room, type: NotificationCountTy // Exclude threadId, as the same thread can't continue over a room upgrade if (!threadId && predecessor?.roomId) { const oldRoomId = predecessor.roomId; - const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); + const oldRoom = room.client.getRoom(oldRoomId); if (oldRoom) { // We only ever care if there's highlights in the old room. No point in // notifying the user for unread messages because they would have extreme @@ -104,8 +103,7 @@ export function getUnreadNotificationCount(room: Room, type: NotificationCountTy return notificationCount; } -function setRoomNotifsStateMuted(roomId: string): Promise { - const cli = MatrixClientPeg.get(); +function setRoomNotifsStateMuted(cli: MatrixClient, roomId: string): Promise { const promises: Promise[] = []; // delete the room rule @@ -135,11 +133,10 @@ function setRoomNotifsStateMuted(roomId: string): Promise { return Promise.all(promises); } -function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise { - const cli = MatrixClientPeg.get(); +function setRoomNotifsStateUnmuted(cli: MatrixClient, roomId: string, newState: RoomNotifState): Promise { const promises: Promise[] = []; - const overrideMuteRule = findOverrideMuteRule(roomId); + const overrideMuteRule = findOverrideMuteRule(cli, roomId); if (overrideMuteRule) { promises.push(cli.deletePushRule("global", PushRuleKind.Override, overrideMuteRule.rule_id)); } @@ -176,8 +173,7 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr return Promise.all(promises); } -function findOverrideMuteRule(roomId: string): IPushRule | null { - const cli = MatrixClientPeg.get(); +function findOverrideMuteRule(cli: MatrixClient | undefined, roomId: string): IPushRule | null { if (!cli?.pushRules?.global?.override) { return null; } diff --git a/src/Rooms.ts b/src/Rooms.ts index a79204995f..e5b38f5ba9 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -17,8 +17,8 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import AliasCustomisations from "./customisations/Alias"; /** @@ -46,26 +46,27 @@ export function getDisplayAliasForAliasSet(canonicalAlias: string | null, altAli export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise { let newTarget; if (isDirect) { - const guessedUserId = guessDMRoomTargetId(room, MatrixClientPeg.get().getUserId()!); + const guessedUserId = guessDMRoomTargetId(room, room.client.getSafeUserId()); newTarget = guessedUserId; } else { newTarget = null; } - return setDMRoom(room.roomId, newTarget); + return setDMRoom(room.client, room.roomId, newTarget); } /** * Marks or unmarks the given room as being as a DM room. + * @param client the Matrix Client instance of the logged-in user * @param {string} roomId The ID of the room to modify * @param {string | null} userId The user ID of the desired DM room target user or * null to un-mark this room as a DM room * @returns {object} A promise */ -export async function setDMRoom(roomId: string, userId: string | null): Promise { - if (MatrixClientPeg.get().isGuest()) return; +export async function setDMRoom(client: MatrixClient, roomId: string, userId: string | null): Promise { + if (client.isGuest()) return; - const mDirectEvent = MatrixClientPeg.get().getAccountData(EventType.Direct); + const mDirectEvent = client.getAccountData(EventType.Direct); const currentContent = mDirectEvent?.getContent() || {}; const dmRoomMap = new Map(Object.entries(currentContent)); @@ -98,7 +99,7 @@ export async function setDMRoom(roomId: string, userId: string | null): Promise< // prevent unnecessary calls to setAccountData if (!modified) return; - await MatrixClientPeg.get().setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap)); + await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap)); } /** diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 5a57729e53..ca181ebb8f 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import url from "url"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; @@ -25,6 +24,7 @@ import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError import { MatrixClientPeg } from "./MatrixClientPeg"; import SdkConfig from "./SdkConfig"; import { WidgetType } from "./widgets/WidgetType"; +import { parseUrl } from "./utils/UrlUtils"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -154,11 +154,11 @@ export default class ScalarAuthClient { // Once we've fully transitioned to _matrix URLs, we can give people // a grace period to update their configs, then use the rest url as // a regular base url. - const parsedImRestUrl = url.parse(this.apiUrl); - parsedImRestUrl.path = ""; + const parsedImRestUrl = parseUrl(this.apiUrl); parsedImRestUrl.pathname = ""; return startTermsFlow( - [new Service(SERVICE_TYPES.IM, url.format(parsedImRestUrl), token)], + MatrixClientPeg.get(), + [new Service(SERVICE_TYPES.IM, parsedImRestUrl.toString(), token)], this.termsInteractionCallback, ).then(() => { return token; diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 9afa10a90a..fa0849bf30 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -411,6 +411,7 @@ function kickUser(event: MessageEvent, roomId: string, userId: string): voi } function setWidget(event: MessageEvent, roomId: string | null): void { + const client = MatrixClientPeg.get(); const widgetId = event.data.widget_id; let widgetType = event.data.type; const widgetUrl = event.data.url; @@ -458,7 +459,7 @@ function setWidget(event: MessageEvent, roomId: string | null): void { widgetType = WidgetType.fromString(widgetType); if (userWidget) { - WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData) + WidgetUtils.setUserWidget(client, widgetId, widgetType, widgetUrl, widgetName, widgetData) .then(() => { sendResponse(event, { success: true, @@ -476,6 +477,7 @@ function setWidget(event: MessageEvent, roomId: string | null): void { return; } WidgetUtils.setRoomWidget( + client, roomId, widgetId, widgetType, @@ -516,7 +518,7 @@ function getWidgets(event: MessageEvent, roomId: string | null): void { } // Add user widgets (not linked to a specific room) - const userWidgets = WidgetUtils.getUserWidgetsArray(); + const userWidgets = WidgetUtils.getUserWidgetsArray(client); widgetStateEvents = widgetStateEvents.concat(userWidgets); sendResponse(event, widgetStateEvents); @@ -874,7 +876,7 @@ const onMessage = function (event: MessageEvent): void { // No integrations UI URL, ignore silently. return; } - let eventOriginUrl; + let eventOriginUrl: URL; try { eventOriginUrl = new URL(event.origin); } catch (e) { diff --git a/src/Searching.ts b/src/Searching.ts index 25800c8e06..5ec128b0aa 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -25,20 +25,19 @@ import { import { IRoomEventFilter } from "matrix-js-sdk/src/filter"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { SearchResult } from "matrix-js-sdk/src/models/search-result"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { ISearchArgs } from "./indexing/BaseEventIndexManager"; import EventIndexPeg from "./indexing/EventIndexPeg"; -import { MatrixClientPeg } from "./MatrixClientPeg"; const SEARCH_LIMIT = 10; async function serverSideSearch( + client: MatrixClient, term: string, roomId?: string, abortSignal?: AbortSignal, ): Promise<{ response: ISearchResponse; query: ISearchRequestBody }> { - const client = MatrixClientPeg.get(); - const filter: IRoomEventFilter = { limit: SEARCH_LIMIT, }; @@ -66,12 +65,12 @@ async function serverSideSearch( } async function serverSideSearchProcess( + client: MatrixClient, term: string, roomId?: string, abortSignal?: AbortSignal, ): Promise { - const client = MatrixClientPeg.get(); - const result = await serverSideSearch(term, roomId, abortSignal); + const result = await serverSideSearch(client, term, roomId, abortSignal); // The js-sdk method backPaginateRoomEventsSearch() uses _query internally // so we're reusing the concept here since we want to delegate the @@ -96,12 +95,14 @@ function compareEvents(a: ISearchResult, b: ISearchResult): number { return 0; } -async function combinedSearch(searchTerm: string, abortSignal?: AbortSignal): Promise { - const client = MatrixClientPeg.get(); - +async function combinedSearch( + client: MatrixClient, + searchTerm: string, + abortSignal?: AbortSignal, +): Promise { // Create two promises, one for the local search, one for the // server-side search. - const serverSidePromise = serverSideSearch(searchTerm, undefined, abortSignal); + const serverSidePromise = serverSideSearch(client, searchTerm, undefined, abortSignal); const localPromise = localSearch(searchTerm); // Wait for both promises to resolve. @@ -198,7 +199,11 @@ export interface ISeshatSearchResults extends ISearchResults { serverSideNextBatch?: string; } -async function localSearchProcess(searchTerm: string, roomId?: string): Promise { +async function localSearchProcess( + client: MatrixClient, + searchTerm: string, + roomId?: string, +): Promise { const emptyResult = { results: [], highlights: [], @@ -216,14 +221,17 @@ async function localSearchProcess(searchTerm: string, roomId?: string): Promise< }, }; - const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response); + const processedResult = client.processRoomEventsSearch(emptyResult, response); // Restore our encryption info so we can properly re-verify the events. restoreEncryptionInfo(processedResult.results); return processedResult; } -async function localPagination(searchResult: ISeshatSearchResults): Promise { +async function localPagination( + client: MatrixClient, + searchResult: ISeshatSearchResults, +): Promise { const eventIndex = EventIndexPeg.get(); const searchArgs = searchResult.seshatQuery; @@ -245,7 +253,7 @@ async function localPagination(searchResult: ISeshatSearchResults): Promise { +async function combinedPagination( + client: MatrixClient, + searchResult: ISeshatSearchResults, +): Promise { const eventIndex = EventIndexPeg.get(); - const client = MatrixClientPeg.get(); const searchArgs = searchResult.seshatQuery; const oldestEventFrom = searchResult.oldestEventFrom; @@ -588,31 +598,37 @@ async function combinedPagination(searchResult: ISeshatSearchResults): Promise { +function eventIndexSearch( + client: MatrixClient, + term: string, + roomId?: string, + abortSignal?: AbortSignal, +): Promise { let searchPromise: Promise; if (roomId !== undefined) { - if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { + if (client.isRoomEncrypted(roomId)) { // The search is for a single encrypted room, use our local // search method. - searchPromise = localSearchProcess(term, roomId); + searchPromise = localSearchProcess(client, term, roomId); } else { // The search is for a single non-encrypted room, use the // server-side search. - searchPromise = serverSideSearchProcess(term, roomId, abortSignal); + searchPromise = serverSideSearchProcess(client, term, roomId, abortSignal); } } else { // Search across all rooms, combine a server side search and a // local search. - searchPromise = combinedSearch(term, abortSignal); + searchPromise = combinedSearch(client, term, abortSignal); } return searchPromise; } -function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise { - const client = MatrixClientPeg.get(); - +function eventIndexSearchPagination( + client: MatrixClient, + searchResult: ISeshatSearchResults, +): Promise { const seshatQuery = searchResult.seshatQuery; const serverQuery = searchResult._query; @@ -622,36 +638,40 @@ function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise return client.backPaginateRoomEventsSearch(searchResult); } else if (!serverQuery) { // This is a search in a encrypted room. Do a local pagination. - const promise = localPagination(searchResult); + const promise = localPagination(client, searchResult); searchResult.pendingRequest = promise; return promise; } else { // We have both queries around, this is a search across all rooms so a // combined pagination needs to be done. - const promise = combinedPagination(searchResult); + const promise = combinedPagination(client, searchResult); searchResult.pendingRequest = promise; return promise; } } -export function searchPagination(searchResult: ISearchResults): Promise { +export function searchPagination(client: MatrixClient, searchResult: ISearchResults): Promise { const eventIndex = EventIndexPeg.get(); - const client = MatrixClientPeg.get(); if (searchResult.pendingRequest) return searchResult.pendingRequest; if (eventIndex === null) return client.backPaginateRoomEventsSearch(searchResult); - else return eventIndexSearchPagination(searchResult); + else return eventIndexSearchPagination(client, searchResult); } -export default function eventSearch(term: string, roomId?: string, abortSignal?: AbortSignal): Promise { +export default function eventSearch( + client: MatrixClient, + term: string, + roomId?: string, + abortSignal?: AbortSignal, +): Promise { const eventIndex = EventIndexPeg.get(); if (eventIndex === null) { - return serverSideSearchProcess(term, roomId, abortSignal); + return serverSideSearchProcess(client, term, roomId, abortSignal); } else { - return eventIndexSearch(term, roomId, abortSignal); + return eventIndexSearch(client, term, roomId, abortSignal); } } diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 34bfc44f07..c1a59bce74 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -344,7 +344,7 @@ export async function accessSecretStorage(func = async (): Promise => {}, onBeforeClose: async (reason): Promise => { // If Secure Backup is required, you cannot leave the modal. if (reason === "backgroundClick") { - return !isSecureBackupRequired(); + return !isSecureBackupRequired(cli); } return true; }, diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 3615e1a8a3..f8a3885ab6 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -26,8 +26,8 @@ import { logger } from "matrix-js-sdk/src/logger"; import { IContent } from "matrix-js-sdk/src/models/event"; import { MRoomTopicEventContent } from "matrix-js-sdk/src/@types/topic"; import { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/types/typescript/SlashCommand"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import { _t, _td, UserFriendlyError } from "./languageHandler"; import Modal from "./Modal"; @@ -75,7 +75,7 @@ interface HTMLInputEvent extends Event { target: HTMLInputElement & EventTarget; } -const singleMxcUpload = async (): Promise => { +const singleMxcUpload = async (cli: MatrixClient): Promise => { return new Promise((resolve) => { const fileSelector = document.createElement("input"); fileSelector.setAttribute("type", "file"); @@ -87,7 +87,7 @@ const singleMxcUpload = async (): Promise => { file, onFinished: async (shouldContinue): Promise => { if (shouldContinue) { - const { content_uri: uri } = await MatrixClientPeg.get().uploadContent(file); + const { content_uri: uri } = await cli.uploadContent(file); resolve(uri); } else { resolve(null); @@ -111,7 +111,7 @@ export const CommandCategories = { export type RunResult = XOR<{ error: Error }, { promise: Promise }>; -type RunFn = (this: Command, roomId: string, args?: string) => RunResult; +type RunFn = (this: Command, matrixClient: MatrixClient, roomId: string, args?: string) => RunResult; interface ICommandOpts { command: string; @@ -122,7 +122,7 @@ interface ICommandOpts { runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; - isEnabled?(): boolean; + isEnabled?(matrixClient?: MatrixClient): boolean; renderingTypes?: TimelineRenderingType[]; } @@ -136,7 +136,7 @@ export class Command { public readonly hideCompletionAfterSpace: boolean; public readonly renderingTypes?: TimelineRenderingType[]; public readonly analyticsName?: SlashCommandEvent["command"]; - private readonly _isEnabled?: () => boolean; + private readonly _isEnabled?: (matrixClient?: MatrixClient) => boolean; public constructor(opts: ICommandOpts) { this.command = opts.command; @@ -159,7 +159,7 @@ export class Command { return this.getCommand() + " " + this.args; } - public run(roomId: string, threadId: string | null, args?: string): RunResult { + public run(matrixClient: MatrixClient, roomId: string, threadId: string | null, args?: string): RunResult { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) { return reject(new UserFriendlyError("Command error: Unable to handle slash command.")); @@ -182,15 +182,15 @@ export class Command { }); } - return this.runFn(roomId, args); + return this.runFn(matrixClient, roomId, args); } public getUsage(): string { return _t("Usage") + ": " + this.getCommandWithArgs(); } - public isEnabled(): boolean { - return this._isEnabled ? this._isEnabled() : true; + public isEnabled(cli?: MatrixClient): boolean { + return this._isEnabled?.(cli) ?? true; } } @@ -206,15 +206,21 @@ function successSync(value: any): RunResult { return success(Promise.resolve(value)); } -const isCurrentLocalRoom = (): boolean => { - const cli = MatrixClientPeg.get(); +const isCurrentLocalRoom = (cli?: MatrixClient): boolean => { const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!roomId) return false; - const room = cli.getRoom(roomId); + const room = cli?.getRoom(roomId); if (!room) return false; return isLocalRoom(room); }; +const canAffectPowerlevels = (cli?: MatrixClient): boolean => { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!cli || !roomId) return false; + const room = cli?.getRoom(roomId); + return !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getSafeUserId()) && !isLocalRoom(room); +}; + /* Disable the "unexpected this" error for these commands - all of the run * functions are called with `this` bound to the Command instance. */ @@ -224,7 +230,7 @@ export const Commands = [ command: "spoiler", args: "", description: _td("Sends the given message as a spoiler"), - runFn: function (roomId, message = "") { + runFn: function (cli, roomId, message = "") { return successSync(ContentHelpers.makeHtmlMessage(message, `${message}`)); }, category: CommandCategories.messages, @@ -233,7 +239,7 @@ export const Commands = [ command: "shrug", args: "", description: _td("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "¯\\_(ツ)_/¯"; if (args) { message = message + " " + args; @@ -246,7 +252,7 @@ export const Commands = [ command: "tableflip", args: "", description: _td("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "(╯°□°)╯︵ ┻━┻"; if (args) { message = message + " " + args; @@ -259,7 +265,7 @@ export const Commands = [ command: "unflip", args: "", description: _td("Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "┬──┬ ノ( ゜-゜ノ)"; if (args) { message = message + " " + args; @@ -272,7 +278,7 @@ export const Commands = [ command: "lenny", args: "", description: _td("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let message = "( ͡° ͜ʖ ͡°)"; if (args) { message = message + " " + args; @@ -285,7 +291,7 @@ export const Commands = [ command: "plain", args: "", description: _td("Sends a message as plain text, without interpreting it as markdown"), - runFn: function (roomId, messages = "") { + runFn: function (cli, roomId, messages = "") { return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, @@ -294,7 +300,7 @@ export const Commands = [ command: "html", args: "", description: _td("Sends a message as html, without interpreting it as markdown"), - runFn: function (roomId, messages = "") { + runFn: function (cli, roomId, messages = "") { return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, @@ -303,10 +309,9 @@ export const Commands = [ command: "upgraderoom", args: "", description: _td("Upgrades a room to a new version"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { return reject( @@ -339,7 +344,7 @@ export const Commands = [ args: "", description: _td("Jump to the given date in the timeline"), isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { return success( (async (): Promise => { @@ -352,7 +357,6 @@ export const Commands = [ ); } - const cli = MatrixClientPeg.get(); const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent( roomId, unixTimestamp, @@ -381,9 +385,9 @@ export const Commands = [ command: "nick", args: "", description: _td("Changes your display nickname"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - return success(MatrixClientPeg.get().setDisplayName(args)); + return success(cli.setDisplayName(args)); } return reject(this.getUsage()); }, @@ -395,16 +399,15 @@ export const Commands = [ aliases: ["roomnick"], args: "", description: _td("Changes your display nickname in the current room only"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getUserId()!); + const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId()); const content = { ...(ev ? ev.getContent() : { membership: "join" }), displayname: args, }; - return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getUserId()!)); + return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getSafeUserId())); } return reject(this.getUsage()); }, @@ -415,17 +418,17 @@ export const Commands = [ command: "roomavatar", args: "[]", description: _td("Changes the avatar of the current room"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().sendStateEvent(roomId, "m.room.avatar", { url }, ""); + return cli.sendStateEvent(roomId, "m.room.avatar", { url }, ""); }), ); }, @@ -436,15 +439,14 @@ export const Commands = [ command: "myroomavatar", args: "[]", description: _td("Changes your avatar in this current room only"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const room = cli.getRoom(roomId); - const userId = cli.getUserId()!; + const userId = cli.getSafeUserId(); let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( @@ -466,16 +468,16 @@ export const Commands = [ command: "myavatar", args: "[]", description: _td("Changes your avatar in all rooms"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let promise = Promise.resolve(args ?? null); if (!args) { - promise = singleMxcUpload(); + promise = singleMxcUpload(cli); } return success( promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().setAvatarUrl(url); + return cli.setAvatarUrl(url); }), ); }, @@ -486,9 +488,8 @@ export const Commands = [ command: "topic", args: "[]", description: _td("Gets or sets the room topic"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false }); return success(cli.setRoomTopic(roomId, args, html)); @@ -525,10 +526,10 @@ export const Commands = [ command: "roomname", args: "", description: _td("Sets the room name"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { - return success(MatrixClientPeg.get().setRoomName(roomId, args)); + return success(cli.setRoomName(roomId, args)); } return reject(this.getUsage()); }, @@ -540,8 +541,8 @@ export const Commands = [ args: " []", description: _td("Invites user with given id to current room"), analyticsName: "Invite", - isEnabled: () => !isCurrentLocalRoom() && shouldShowComponent(UIComponent.InviteUsers), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers), + runFn: function (cli, roomId, args) { if (args) { const [address, reason] = args.split(/\s+(.+)/); if (address) { @@ -551,10 +552,7 @@ export const Commands = [ // get a bit more complex here, but we try to show something // meaningful. let prom = Promise.resolve(); - if ( - getAddressType(address) === AddressType.Email && - !MatrixClientPeg.get().getIdentityServerUrl() - ) { + if (getAddressType(address) === AddressType.Email && !cli.getIdentityServerUrl()) { const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); if (defaultIdentityServerUrl) { const { finished } = Modal.createDialog(QuestionDialog, { @@ -576,7 +574,7 @@ export const Commands = [ prom = finished.then(([useDefault]) => { if (useDefault) { - setToDefaultIdentityServer(); + setToDefaultIdentityServer(cli); return; } throw new UserFriendlyError( @@ -589,7 +587,7 @@ export const Commands = [ ); } } - const inviter = new MultiInviter(roomId); + const inviter = new MultiInviter(cli, roomId); return success( prom .then(() => { @@ -621,7 +619,7 @@ export const Commands = [ aliases: ["j", "goto"], args: "", description: _td("Joins room with given address"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -654,7 +652,7 @@ export const Commands = [ if (params[0][0] === "#") { let roomAlias = params[0]; if (!roomAlias.includes(":")) { - roomAlias += ":" + MatrixClientPeg.get().getDomain(); + roomAlias += ":" + cli.getDomain(); } dis.dispatch({ @@ -733,10 +731,8 @@ export const Commands = [ args: "[]", description: _td("Leave room"), analyticsName: "Part", - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const cli = MatrixClientPeg.get(); - + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { let targetRoomId: string | undefined; if (args) { const matches = args.match(/^(\S+)$/); @@ -765,7 +761,7 @@ export const Commands = [ } if (!targetRoomId) targetRoomId = roomId; - return success(leaveRoomBehaviour(targetRoomId)); + return success(leaveRoomBehaviour(cli, targetRoomId)); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], @@ -775,12 +771,12 @@ export const Commands = [ aliases: ["kick"], args: " [reason]", description: _td("Removes user with given id from this room"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { - return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3])); + return success(cli.kick(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); @@ -792,12 +788,12 @@ export const Commands = [ command: "ban", args: " [reason]", description: _td("Bans user with given id"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { - return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3])); + return success(cli.ban(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); @@ -809,13 +805,13 @@ export const Commands = [ command: "unban", args: "", description: _td("Unbans user with given ID"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { // Reset the user membership to "leave" to unban him - return success(MatrixClientPeg.get().unban(roomId, matches[1])); + return success(cli.unban(roomId, matches[1])); } } return reject(this.getUsage()); @@ -827,10 +823,8 @@ export const Commands = [ command: "ignore", args: "", description: _td("Ignores a user, hiding their messages from you"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const matches = args.match(/^(@[^:]+:\S+)$/); if (matches) { const userId = matches[1]; @@ -858,10 +852,8 @@ export const Commands = [ command: "unignore", args: "", description: _td("Stops ignoring a user, showing their messages going forward"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { - const cli = MatrixClientPeg.get(); - const matches = args.match(/(^@[^:]+:\S+$)/); if (matches) { const userId = matches[1]; @@ -890,17 +882,8 @@ export const Commands = [ command: "op", args: " []", description: _td("Define the power level of a user"), - isEnabled(): boolean { - const cli = MatrixClientPeg.get(); - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return false; - const room = cli.getRoom(roomId); - return ( - !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()!) && - !isLocalRoom(room) - ); - }, - runFn: function (roomId, args) { + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op @@ -910,7 +893,6 @@ export const Commands = [ powerLevel = parseInt(matches[3], 10); } if (!isNaN(powerLevel)) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) { return reject( @@ -941,21 +923,11 @@ export const Commands = [ command: "deop", args: "", description: _td("Deops user with given id"), - isEnabled(): boolean { - const cli = MatrixClientPeg.get(); - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return false; - const room = cli.getRoom(roomId); - return ( - !!room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()!) && - !isLocalRoom(room) - ); - }, - runFn: function (roomId, args) { + isEnabled: canAffectPowerlevels, + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { - const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) { return reject( @@ -981,7 +953,7 @@ export const Commands = [ new Command({ command: "devtools", description: _td("Opens the Developer Tools dialog"), - runFn: function (roomId) { + runFn: function (cli, roomId) { Modal.createDialog(DevtoolsDialog, { roomId }, "mx_DevtoolsDialog_wrapper"); return success(); }, @@ -991,11 +963,11 @@ export const Commands = [ command: "addwidget", args: "", description: _td("Adds a custom widget by URL to the room"), - isEnabled: () => + isEnabled: (cli) => SettingsStore.getValue(UIFeature.Widgets) && shouldShowComponent(UIComponent.AddIntegrations) && - !isCurrentLocalRoom(), - runFn: function (roomId, widgetUrl) { + !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, widgetUrl) { if (!widgetUrl) { return reject(new UserFriendlyError("Please supply a widget URL or embed code")); } @@ -1005,12 +977,12 @@ export const Commands = [ const embed = new DOMParser().parseFromString(widgetUrl, "text/html").body; if (embed?.childNodes?.length === 1) { const iframe = embed.firstElementChild; - if (iframe.tagName.toLowerCase() === "iframe") { + if (iframe?.tagName.toLowerCase() === "iframe") { logger.log("Pulling URL out of iframe (embed code)"); if (!iframe.hasAttribute("src")) { return reject(new UserFriendlyError("iframe has no src attribute")); } - widgetUrl = iframe.getAttribute("src"); + widgetUrl = iframe.getAttribute("src")!; } } } @@ -1018,8 +990,8 @@ export const Commands = [ if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) { return reject(new UserFriendlyError("Please supply a https:// or http:// widget URL")); } - if (WidgetUtils.canUserModifyWidgets(roomId)) { - const userId = MatrixClientPeg.get().getUserId(); + if (WidgetUtils.canUserModifyWidgets(cli, roomId)) { + const userId = cli.getUserId(); const nowMs = new Date().getTime(); const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`); let type = WidgetType.CUSTOM; @@ -1036,7 +1008,7 @@ export const Commands = [ widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl(); } - return success(WidgetUtils.setRoomWidget(roomId, widgetId, type, widgetUrl, name, data)); + return success(WidgetUtils.setRoomWidget(cli, roomId, widgetId, type, widgetUrl, name, data)); } else { return reject(new UserFriendlyError("You cannot modify widgets in this room.")); } @@ -1048,12 +1020,10 @@ export const Commands = [ command: "verify", args: " ", description: _td("Verifies a user, session, and pubkey tuple"), - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); if (matches) { - const cli = MatrixClientPeg.get(); - const userId = matches[1]; const deviceId = matches[2]; const fingerprint = matches[3]; @@ -1128,10 +1098,10 @@ export const Commands = [ new Command({ command: "discardsession", description: _td("Forces the current outbound group session in an encrypted room to be discarded"), - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId) { try { - MatrixClientPeg.get().forceDiscardSession(roomId); + cli.forceDiscardSession(roomId); } catch (e) { return reject(e.message); } @@ -1143,19 +1113,19 @@ export const Commands = [ new Command({ command: "remakeolm", description: _td("Developer command: Discards the current outbound group session and sets up new Olm sessions"), - isEnabled: () => { - return SettingsStore.getValue("developerMode") && !isCurrentLocalRoom(); + isEnabled: (cli) => { + return SettingsStore.getValue("developerMode") && !isCurrentLocalRoom(cli); }, - runFn: (roomId) => { + runFn: (cli, roomId) => { try { - const room = MatrixClientPeg.get().getRoom(roomId); + const room = cli.getRoom(roomId); - MatrixClientPeg.get().forceDiscardSession(roomId); + cli.forceDiscardSession(roomId); return success( room?.getEncryptionTargetMembers().then((members) => { // noinspection JSIgnoredPromiseFromCall - MatrixClientPeg.get().crypto?.ensureOlmSessionsForUsers( + cli.crypto?.ensureOlmSessionsForUsers( members.map((m) => m.userId), true, ); @@ -1172,7 +1142,7 @@ export const Commands = [ command: "rainbow", description: _td("Sends the given message coloured as a rainbow"), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, @@ -1182,7 +1152,7 @@ export const Commands = [ command: "rainbowme", description: _td("Sends the given emote coloured as a rainbow"), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, @@ -1201,13 +1171,13 @@ export const Commands = [ command: "whois", description: _td("Displays information about a user"), args: "", - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, userId) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, userId) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) { return reject(this.getUsage()); } - const member = MatrixClientPeg.get().getRoom(roomId)?.getMember(userId); + const member = cli.getRoom(roomId)?.getMember(userId); dis.dispatch({ action: Action.ViewUser, // XXX: We should be using a real member object and not assuming what the receiver wants. @@ -1223,7 +1193,7 @@ export const Commands = [ description: _td("Send a bug report with logs"), isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { return success( Modal.createDialog(BugReportDialog, { initialText: args, @@ -1236,10 +1206,10 @@ export const Commands = [ command: "tovirtual", description: _td("Switches to this room's virtual room, if it has one"), category: CommandCategories.advanced, - isEnabled(): boolean { - return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(); + isEnabled(cli): boolean { + return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli); }, - runFn: (roomId) => { + runFn: (cli, roomId) => { return success( (async (): Promise => { const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId); @@ -1258,7 +1228,7 @@ export const Commands = [ command: "query", description: _td("Opens chat with the given user"), args: "", - runFn: function (roomId, userId) { + runFn: function (cli, roomId, userId) { // easter-egg for now: look up phone numbers through the thirdparty API // (very dumb phone number detection...) const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); @@ -1276,7 +1246,7 @@ export const Commands = [ userId = results[0].userid; } - const roomId = await ensureDMExists(MatrixClientPeg.get(), userId); + const roomId = await ensureDMExists(cli, userId); if (!roomId) throw new Error("Failed to ensure DM exists"); dis.dispatch({ @@ -1294,7 +1264,7 @@ export const Commands = [ command: "msg", description: _td("Sends a message to the given user"), args: " []", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { if (args) { // matches the first whitespace delimited group and then the rest of the string const matches = args.match(/^(\S+?)(?: +(.*))?$/s); @@ -1303,7 +1273,6 @@ export const Commands = [ if (userId && userId.startsWith("@") && userId.includes(":")) { return success( (async (): Promise => { - const cli = MatrixClientPeg.get(); const roomId = await ensureDMExists(cli, userId); if (!roomId) throw new Error("Failed to ensure DM exists"); @@ -1330,8 +1299,8 @@ export const Commands = [ command: "holdcall", description: _td("Places the call in the current room on hold"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1345,8 +1314,8 @@ export const Commands = [ command: "unholdcall", description: _td("Takes the call in the current room off hold"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1360,9 +1329,9 @@ export const Commands = [ command: "converttodm", description: _td("Converts the room to a DM"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const room = MatrixClientPeg.get().getRoom(roomId); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { + const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, true)); }, @@ -1372,9 +1341,9 @@ export const Commands = [ command: "converttoroom", description: _td("Converts the DM to a room"), category: CommandCategories.other, - isEnabled: () => !isCurrentLocalRoom(), - runFn: function (roomId, args) { - const room = MatrixClientPeg.get().getRoom(roomId); + isEnabled: (cli) => !isCurrentLocalRoom(cli), + runFn: function (cli, roomId, args) { + const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, false)); }, @@ -1396,7 +1365,7 @@ export const Commands = [ command: effect.command, description: effect.description(), args: "", - runFn: function (roomId, args) { + runFn: function (cli, roomId, args) { let content: IContent; if (!args) { content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage()); diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index 4a6c113253..9af442932e 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -118,7 +118,7 @@ export class SlidingSyncManager { private static readonly internalInstance = new SlidingSyncManager(); public slidingSync: SlidingSync; - private client: MatrixClient; + private client?: MatrixClient; private configureDefer: IDeferred; @@ -242,8 +242,8 @@ export class SlidingSyncManager { } else { subscriptions.delete(roomId); } - const room = this.client.getRoom(roomId); - let shouldLazyLoad = !this.client.isRoomEncrypted(roomId); + const room = this.client?.getRoom(roomId); + let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId); if (!room) { // default to safety: request all state if we can't work it out. This can happen if you // refresh the app whilst viewing a room: we call setRoomVisible before we know anything diff --git a/src/Terms.ts b/src/Terms.ts index b986a5e83f..d31b6357f8 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -17,8 +17,8 @@ limitations under the License. import classNames from "classnames"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import Modal from "./Modal"; import TermsDialog from "./components/views/dialogs/TermsDialog"; @@ -66,6 +66,7 @@ export type TermsInteractionCallback = ( /** * Start a flow where the user is presented with terms & conditions for some services * + * @param client The Matrix Client instance of the logged-in user * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' * @param {function} interactionCallback Function called with: * * an array of { service: {Service}, policies: {terms response from API} } @@ -75,10 +76,11 @@ export type TermsInteractionCallback = ( * if they cancel. */ export async function startTermsFlow( + client: MatrixClient, services: Service[], interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback, ): Promise { - const termsPromises = services.map((s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl)); + const termsPromises = services.map((s) => client.getTerms(s.serviceType, s.baseUrl)); /* * a /terms response looks like: @@ -105,7 +107,7 @@ export async function startTermsFlow( }); // fetch the set of agreed policy URLs from account data - const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData("m.accepted_terms"); + const currentAcceptedTerms = await client.getAccountData("m.accepted_terms"); let agreedUrlSet: Set; if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { agreedUrlSet = new Set(); @@ -152,7 +154,7 @@ export async function startTermsFlow( // We only ever add to the set of URLs, so if anything has changed then we'd see a different length if (agreedUrlSet.size !== numAcceptedBeforeAgreement) { const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) }; - await MatrixClientPeg.get().setAccountData("m.accepted_terms", newAcceptedTerms); + await client.setAccountData("m.accepted_terms", newAcceptedTerms); } const agreePromises = policiesAndServicePairs.map((policiesAndService) => { @@ -171,7 +173,7 @@ export async function startTermsFlow( if (urlsForService.length === 0) return Promise.resolve(); - return MatrixClientPeg.get().agreeToTerms( + return client.agreeToTerms( policiesAndService.service.serviceType, policiesAndService.service.baseUrl, policiesAndService.service.accessToken, diff --git a/src/Typeguards.ts b/src/Typeguards.ts index 78869097c6..397cbbd29b 100644 --- a/src/Typeguards.ts +++ b/src/Typeguards.ts @@ -17,3 +17,7 @@ limitations under the License. export function isNotNull(arg: T): arg is Exclude { return arg !== null; } + +export function isNotUndefined(arg: T): arg is Exclude { + return arg !== undefined; +} diff --git a/src/Unread.ts b/src/Unread.ts index 7b86a013a5..3b1826b3a6 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -20,8 +20,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from "./shouldHideEvent"; import { haveRendererForEvent } from "./events/EventTileFactory"; import SettingsStore from "./settings/SettingsStore"; @@ -30,11 +30,12 @@ import SettingsStore from "./settings/SettingsStore"; * Returns true if this event arriving in a room should affect the room's * count of unread messages * + * @param client The Matrix Client instance of the logged-in user * @param {Object} ev The event * @returns {boolean} True if the given event should affect the unread message count */ -export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { - if (ev.getSender() === MatrixClientPeg.get().credentials.userId) { +export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): boolean { + if (ev.getSender() === client.getSafeUserId()) { return false; } @@ -72,13 +73,18 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { } export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean { + // NOTE: this shares logic with hasUserReadEvent in + // matrix-js-sdk/src/models/read-receipt.ts. They are not combined (yet) + // because hasUserReadEvent is focussed on a single event, and this is + // focussed on the whole room/thread. + // If there are no messages yet in the timeline then it isn't fully initialised // and cannot be unread. if (!roomOrThread || roomOrThread.timeline.length === 0) { return false; } - const myUserId = MatrixClientPeg.get().getUserId(); + const myUserId = roomOrThread.client.getSafeUserId(); // as we don't send RRs for our own messages, make sure we special case that // if *we* sent the last message into the room, we consider it not unread! @@ -90,34 +96,26 @@ export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): return false; } - // get the most recent read receipt sent by our account. - // N.B. this is NOT a read marker (RM, aka "read up to marker"), - // despite the name of the method :(( - const readUpToId = roomOrThread.getEventReadUpTo(myUserId!); - - // this just looks at whatever history we have, which if we've only just started - // up probably won't be very much, so if the last couple of events are ones that - // don't count, we don't know if there are any events that do count between where - // we have and the read receipt. We could fetch more history to try & find out, - // but currently we just guess. + const readUpToId = roomOrThread.getEventReadUpTo(myUserId); + const hasReceipt = makeHasReceipt(roomOrThread, readUpToId, myUserId); // Loop through messages, starting with the most recent... for (let i = roomOrThread.timeline.length - 1; i >= 0; --i) { const ev = roomOrThread.timeline[i]; - if (ev.getId() == readUpToId) { + if (hasReceipt(ev)) { // If we've read up to this event, there's nothing more recent // that counts and we can stop looking because the user's read // this and everything before. return false; - } else if (!shouldHideEvent(ev) && eventTriggersUnreadCount(ev)) { + } else if (isImportantEvent(roomOrThread.client, ev)) { // We've found a message that counts before we hit // the user's read receipt, so this room is definitely unread. return true; } } - // If we got here, we didn't find a message that counted but didn't find + // If we got here, we didn't find a message was important but didn't find // the user's read receipt either, so we guess and say that the room is // unread on the theory that false positives are better than false // negatives here. @@ -127,3 +125,49 @@ export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): }); return true; } + +/** + * Given this event does not have a receipt, is it important enough to make + * this room unread? + */ +function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean { + return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event); +} + +/** + * @returns a function that tells us whether a given event matches our read + * receipt. + * + * We have the ID of an event based on a read receipt. If we can find the + * corresponding event, then it's easy - our returned function just decides + * whether the receipt refers to the event we are asking about. + * + * If we can't find the event, we guess by saying of the receipt's timestamp is + * after this event's timestamp, then it's probably saying this event is read. + */ +function makeHasReceipt( + roomOrThread: Room | Thread, + readUpToId: string | null, + myUserId: string, +): (event: MatrixEvent) => boolean { + // get the most recent read receipt sent by our account. + // N.B. this is NOT a read marker (RM, aka "read up to marker"), + // despite the name of the method :(( + const readEvent = readUpToId ? roomOrThread.findEventById(readUpToId) : null; + + if (readEvent) { + // If we found an event matching our receipt, then it's easy: this event + // has a receipt if its ID is the same as the one in the receipt. + return (ev) => ev.getId() == readUpToId; + } else { + // If we didn't, we have to guess by saying if this event is before the + // receipt's ts, then it we pretend it has a receipt. + const receipt = roomOrThread.getReadReceiptForUserId(myUserId); + if (receipt) { + const receiptTimestamp = receipt.data.ts; + return (ev) => ev.getTs() < receiptTimestamp; + } else { + return (_ev) => false; + } + } +} diff --git a/src/WhoIsTyping.ts b/src/WhoIsTyping.ts index d4a43636ce..500d60e0b9 100644 --- a/src/WhoIsTyping.ts +++ b/src/WhoIsTyping.ts @@ -17,15 +17,14 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from "./languageHandler"; export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { - return usersTyping(room, [MatrixClientPeg.get().getUserId()!].concat(MatrixClientPeg.get().getIgnoredUsers())); + return usersTyping(room, [room.client.getSafeUserId()].concat(room.client.getIgnoredUsers())); } export function usersTypingApartFromMe(room: Room): RoomMember[] { - return usersTyping(room, [MatrixClientPeg.get().getUserId()!]); + return usersTyping(room, [room.client.getSafeUserId()]); } /** diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 8ba866be3f..acbf14d756 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -95,8 +95,8 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => { export const getKeyboardShortcuts = (): IKeyboardShortcuts => { const overrideBrowserShortcuts = PlatformPeg.get()?.overrideBrowserShortcuts(); - return Object.keys(KEYBOARD_SHORTCUTS) - .filter((k: KeyBindingAction) => { + return (Object.keys(KEYBOARD_SHORTCUTS) as KeyBindingAction[]) + .filter((k) => { if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false; if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false; if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false; diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 1963459835..5f3901a391 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -78,16 +78,40 @@ export enum Type { Register = "REGISTER", Unregister = "UNREGISTER", SetFocus = "SET_FOCUS", + Update = "UPDATE", } export interface IAction { - type: Type; + type: Exclude; payload: { ref: Ref; }; } -export const reducer: Reducer = (state: IState, action: IAction) => { +interface UpdateAction { + type: Type.Update; + payload?: undefined; +} + +type Action = IAction | UpdateAction; + +const refSorter = (a: Ref, b: Ref): number => { + if (a === b) { + return 0; + } + + const position = a.current!.compareDocumentPosition(b.current!); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1; + } else { + return 0; + } +}; + +export const reducer: Reducer = (state: IState, action: Action) => { switch (action.type) { case Type.Register: { if (!state.activeRef) { @@ -97,21 +121,7 @@ export const reducer: Reducer = (state: IState, action: IAction // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert state.refs.push(action.payload.ref); - state.refs.sort((a, b) => { - if (a === b) { - return 0; - } - - const position = a.current!.compareDocumentPosition(b.current!); - - if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return -1; - } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { - return 1; - } else { - return 0; - } - }); + state.refs.sort(refSorter); return { ...state }; } @@ -150,6 +160,11 @@ export const reducer: Reducer = (state: IState, action: IAction return { ...state }; } + case Type.Update: { + state.refs.sort(refSorter); + return { ...state }; + } + default: return state; } @@ -160,7 +175,7 @@ interface IProps { handleHomeEnd?: boolean; handleUpDown?: boolean; handleLeftRight?: boolean; - children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; + children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode; onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } @@ -199,7 +214,7 @@ export const RovingTabIndexProvider: React.FC = ({ handleLoop, onKeyDown, }) => { - const [state, dispatch] = useReducer>(reducer, { + const [state, dispatch] = useReducer>(reducer, { refs: [], }); @@ -301,9 +316,15 @@ export const RovingTabIndexProvider: React.FC = ({ [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop], ); + const onDragEndHandler = useCallback(() => { + dispatch({ + type: Type.Update, + }); + }, []); + return ( - {children({ onKeyDownHandler })} + {children({ onKeyDownHandler, onDragEndHandler })} ); }; diff --git a/src/actions/handlers/viewUserDeviceSettings.ts b/src/actions/handlers/viewUserDeviceSettings.ts index 4525ba104d..8af795e51f 100644 --- a/src/actions/handlers/viewUserDeviceSettings.ts +++ b/src/actions/handlers/viewUserDeviceSettings.ts @@ -19,12 +19,11 @@ import { Action } from "../../dispatcher/actions"; import defaultDispatcher from "../../dispatcher/dispatcher"; /** - * Redirect to the correct device manager section - * Based on the labs setting + * Open user device manager settings */ -export const viewUserDeviceSettings = (isNewDeviceManagerEnabled: boolean): void => { +export const viewUserDeviceSettings = (): void => { defaultDispatcher.dispatch({ action: Action.ViewUserSettings, - initialTabId: isNewDeviceManagerEnabled ? UserTab.SessionManager : UserTab.Security, + initialTabId: UserTab.SessionManager, }); }; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 3e48739826..5c401122b5 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -20,7 +20,7 @@ import FileSaver from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; -import { CrossSigningKeys, UIAFlow } from "matrix-js-sdk/src/matrix"; +import { CrossSigningKeys, MatrixError, UIAFlow } from "matrix-js-sdk/src/matrix"; import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import classNames from "classnames"; @@ -103,15 +103,17 @@ export default class CreateSecretStorageDialog extends React.PureComponent(); private passphraseField = createRef(); public constructor(props: IProps) { super(props); - let passPhraseKeySelected; - const setupMethods = getSecureBackupSetupMethods(); + const cli = MatrixClientPeg.get(); + + let passPhraseKeySelected: SecureBackupSetupMethod; + const setupMethods = getSecureBackupSetupMethods(cli); if (setupMethods.includes(SecureBackupSetupMethod.Key)) { passPhraseKeySelected = SecureBackupSetupMethod.Key; } else { @@ -143,13 +145,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent>; - public constructor(room: Room, renderingType?: TimelineRenderingType) { + public constructor(private readonly room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: ROOM_REGEX, renderingType }); this.matcher = new QueryMatcher>([], { keys: ["displayedAlias", "matchName"], @@ -119,7 +119,7 @@ export default class RoomProvider extends AutocompleteProvider { completionId: room.room.roomId, type: "room", suffix: " ", - href: makeRoomPermalink(room.displayedAlias), + href: makeRoomPermalink(this.room.client, room.displayedAlias), component: ( diff --git a/src/boundThreepids.ts b/src/boundThreepids.ts index c5c87a7953..9fed6c46bd 100644 --- a/src/boundThreepids.ts +++ b/src/boundThreepids.ts @@ -16,6 +16,7 @@ limitations under the License. import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; import IdentityAuthClient from "./IdentityAuthClient"; @@ -57,7 +58,7 @@ export async function getThreepidsWithBindStatus( } } catch (e) { // Ignore terms errors here and assume other flows handle this - if (e.errcode !== "M_TERMS_NOT_SIGNED") { + if (!(e instanceof MatrixError) || e.errcode !== "M_TERMS_NOT_SIGNED") { throw e; } } diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 49c7dfebae..34b517b99c 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -56,7 +56,7 @@ class FilePanel extends React.Component { // This is used to track if a decrypted event was a live event and should be // added to the timeline. private decryptingEvents = new Set(); - public noRoom: boolean; + public noRoom = false; private card = createRef(); public state: IState = { diff --git a/src/components/structures/GenericDropdownMenu.tsx b/src/components/structures/GenericDropdownMenu.tsx index 27dd60e107..0a38db3dbe 100644 --- a/src/components/structures/GenericDropdownMenu.tsx +++ b/src/components/structures/GenericDropdownMenu.tsx @@ -125,7 +125,7 @@ export function GenericDropdownMenu({ }: IProps): JSX.Element { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const selected: GenericDropdownMenuItem | null = options + const selected: GenericDropdownMenuItem | undefined = options .flatMap((it) => (isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it])) .find((option) => (toKey ? toKey(option.key) === toKey(value) : option.key === value)); let contextMenuOptions: JSX.Element; diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index bb2fbc068d..115da05118 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -28,7 +28,7 @@ import { OwnProfileStore } from "../../stores/OwnProfileStore"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { useEventEmitter } from "../../hooks/useEventEmitter"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext"; import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader"; import PosthogTrackers from "../../PosthogTrackers"; import EmbeddedPage from "./EmbeddedPage"; @@ -97,8 +97,9 @@ const UserWelcomeTop: React.FC = () => { }; const HomePage: React.FC = ({ justRegistered = false }) => { + const cli = useMatrixClientContext(); const config = SdkConfig.get(); - const pageUrl = getHomePageUrl(config); + const pageUrl = getHomePageUrl(config, cli); if (pageUrl) { return ; diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index b99e25a8ac..46b8de426a 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -119,6 +119,17 @@ export default class InteractiveAuthComponent extends React.Component { this.notifyNewScreen("forgot_password"); break; case "start_chat": - createRoom({ + createRoom(MatrixClientPeg.get(), { dmUserId: payload.user_id, }); break; @@ -700,7 +700,7 @@ export default class MatrixChat extends React.PureComponent { break; } case Action.ViewUserDeviceSettings: { - viewUserDeviceSettings(SettingsStore.getValue("feature_new_device_manager")); + viewUserDeviceSettings(); break; } case Action.ViewUserSettings: { @@ -1062,7 +1062,7 @@ export default class MatrixChat extends React.PureComponent { const [shouldCreate, opts] = await modal.finished; if (shouldCreate) { - createRoom(opts!); + createRoom(MatrixClientPeg.get(), opts!); } } @@ -1154,7 +1154,8 @@ export default class MatrixChat extends React.PureComponent { } private leaveRoom(roomId: string): void { - const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const cli = MatrixClientPeg.get(); + const roomToLeave = cli.getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); const isSpace = roomToLeave?.isSpaceRoom(); @@ -1173,9 +1174,9 @@ export default class MatrixChat extends React.PureComponent { ), button: _t("Leave"), - onFinished: (shouldLeave) => { + onFinished: async (shouldLeave) => { if (shouldLeave) { - leaveRoomBehaviour(roomId); + await leaveRoomBehaviour(cli, roomId); dis.dispatch({ action: Action.AfterLeaveRoom, @@ -1211,7 +1212,7 @@ export default class MatrixChat extends React.PureComponent { } private async copyRoom(roomId: string): Promise { - const roomLink = makeRoomPermalink(roomId); + const roomLink = makeRoomPermalink(MatrixClientPeg.get(), roomId); const success = await copyPlaintext(roomLink); if (!success) { Modal.createDialog(ErrorDialog, { @@ -1245,7 +1246,7 @@ export default class MatrixChat extends React.PureComponent { const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(welcomeUserId); if (welcomeUserRooms.length === 0) { - const roomId = await createRoom({ + const roomId = await createRoom(MatrixClientPeg.get(), { dmUserId: snakedConfig.get("welcome_user_id"), // Only view the welcome user if we're NOT looking at a room andView: !this.state.currentRoomId, diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 6981b9bc31..9ed934d6ca 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -743,7 +743,7 @@ export default class MessagePanel extends React.Component { lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEv.getSender() || - getEventDisplayInfo(nextEv, this.showHiddenEvents).isInfoMessage || + getEventDisplayInfo(MatrixClientPeg.get(), nextEv, this.showHiddenEvents).isInfoMessage || !shouldFormContinuation(mxEv, nextEv, this.showHiddenEvents, this.context.timelineRenderingType); } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index b918c2c0c6..23bf75a33d 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -44,26 +44,37 @@ import TimelineCard from "../views/right_panel/TimelineCard"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState"; import { Action } from "../../dispatcher/actions"; +import { XOR } from "../../@types/common"; -interface IProps { - room?: Room; // if showing panels for a given room, this is set +interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) resizeNotifier: ResizeNotifier; - permalinkCreator?: RoomPermalinkCreator; e2eStatus?: E2EStatus; } +interface RoomlessProps extends BaseProps { + room?: undefined; + permalinkCreator?: undefined; +} + +interface RoomProps extends BaseProps { + room: Room; + permalinkCreator: RoomPermalinkCreator; +} + +type Props = XOR; + interface IState { phase?: RightPanelPhases; searchQuery: string; cardState?: IRightPanelCardState; } -export default class RightPanel extends React.Component { +export default class RightPanel extends React.Component { public static contextType = MatrixClientContext; public context!: React.ContextType; - public constructor(props: IProps, context: React.ContextType) { + public constructor(props: Props, context: React.ContextType) { super(props, context); this.state = { @@ -89,7 +100,7 @@ export default class RightPanel extends React.Component { RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); } - public static getDerivedStateFromProps(props: IProps): Partial { + public static getDerivedStateFromProps(props: Props): Partial { let currentCard: IRightPanelCard | undefined; if (props.room) { currentCard = RightPanelStore.instance.currentCardForRoom(props.room.roomId); @@ -169,32 +180,36 @@ export default class RightPanel extends React.Component { } break; case RightPanelPhases.SpaceMemberList: - card = ( - - ); + if (!!cardState?.spaceId || !!roomId) { + card = ( + + ); + } break; case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: { - const roomMember = cardState?.member instanceof RoomMember ? cardState.member : undefined; - card = ( - - ); + if (!!cardState?.member) { + const roomMember = cardState.member instanceof RoomMember ? cardState.member : undefined; + card = ( + + ); + } break; } case RightPanelPhases.Room3pidMemberInfo: @@ -261,10 +276,10 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.ThreadPanel: - if (!!roomId) { + if (!!this.props.room) { card = ( ( } debuglog("requesting more search results"); - const searchPromise = searchPagination(results); + const searchPromise = searchPagination(client, results); return handleSearchResult(searchPromise); }; diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 7afc9025e2..5759289c7d 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -20,6 +20,7 @@ import { SyncState, ISyncStateData } from "matrix-js-sdk/src/sync"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixError } from "matrix-js-sdk/src/matrix"; +import { Icon as WarningIcon } from "../../../res/img/feather-customised/warning-triangle.svg"; import { _t, _td } from "../../languageHandler"; import Resend from "../../Resend"; import dis from "../../dispatcher/dispatcher"; @@ -279,12 +280,7 @@ export default class RoomStatusBar extends React.PureComponent {
- +
{_t("Connectivity to the server has been lost.")} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b4a38ee2eb..9c7cd645d6 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -561,7 +561,7 @@ export class RoomView extends React.Component { createdByCurrentUserTs - lastCreatedByOtherTs < PREVENT_MULTIPLE_JITSI_WITHIN ) { // more than one Jitsi widget with the last one from the current user → remove it - WidgetUtils.setRoomWidget(this.state.roomId, createdByCurrentUser.id); + WidgetUtils.setRoomWidget(this.context.client, this.state.roomId, createdByCurrentUser.id); } } @@ -1488,6 +1488,7 @@ export class RoomView extends React.Component { // rate limited because a power level change will emit an event for every member in the room. private updateRoomMembers = throttle( () => { + if (!this.state.room) return; this.updateDMState(); this.updateE2EStatus(this.state.room); }, @@ -1511,7 +1512,7 @@ export class RoomView extends React.Component { } const dmInviter = room.getDMInviter(); if (dmInviter) { - Rooms.setDMRoom(room.roomId, dmInviter); + Rooms.setDMRoom(room.client, room.roomId, dmInviter); } } @@ -1620,7 +1621,7 @@ export class RoomView extends React.Component { const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined; debuglog("sending search request"); const abortController = new AbortController(); - const promise = eventSearch(term, roomId, abortController.signal); + const promise = eventSearch(this.context.client!, term, roomId, abortController.signal); this.setState({ search: { diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 61279cffb3..63022d80c3 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -181,19 +181,19 @@ export default class ScrollPanel extends React.Component { private unmounted = false; private scrollTimeout?: Timer; // Are we currently trying to backfill? - private isFilling: boolean; + private isFilling = false; // Is the current fill request caused by a props update? private isFillingDueToPropsUpdate = false; // Did another request to check the fill state arrive while we were trying to backfill? - private fillRequestWhileRunning: boolean; + private fillRequestWhileRunning = false; // Is that next fill request scheduled because of a props update? - private pendingFillDueToPropsUpdate: boolean; - private scrollState: IScrollState; + private pendingFillDueToPropsUpdate = false; + private scrollState!: IScrollState; private preventShrinkingState: IPreventShrinkingState | null = null; private unfillDebouncer: number | null = null; - private bottomGrowth: number; - private minListHeight: number; - private heightUpdateInProgress: boolean; + private bottomGrowth!: number; + private minListHeight!: number; + private heightUpdateInProgress = false; private divScroll: HTMLDivElement | null = null; public constructor(props: IProps) { diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index fe246f6b35..77d85bf55b 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -32,7 +32,7 @@ import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; -import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; @@ -101,7 +101,7 @@ const Tile: React.FC = ({ children, }) => { const cli = useContext(MatrixClientContext); - const [joinedRoom, setJoinedRoom] = useState(() => { + const joinedRoom = useTypedEventEmitterState(cli, ClientEvent.Room, () => { const cliRoom = cli?.getRoom(room.room_id); return cliRoom?.getMyMembership() === "join" ? cliRoom : undefined; }); @@ -128,7 +128,6 @@ const Tile: React.FC = ({ ev.stopPropagation(); onJoinRoomClick() .then(() => awaitRoomDownSync(cli, room.room_id)) - .then(setJoinedRoom) .finally(() => { setBusy(false); }); @@ -429,7 +428,7 @@ interface IHierarchyLevelProps { parents: Set; selectedMap?: Map>; onViewRoomClick(roomId: string, roomType?: RoomType): void; - onJoinRoomClick(roomId: string): Promise; + onJoinRoomClick(roomId: string, parents: Set): Promise; onToggleClick?(parentId: string, childId: string): void; } @@ -511,7 +510,7 @@ export const HierarchyLevel: React.FC = ({ suggested={hierarchy.isSuggested(root.room_id, room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)} onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)} - onJoinRoomClick={() => onJoinRoomClick(room.room_id)} + onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} /> @@ -532,7 +531,7 @@ export const HierarchyLevel: React.FC = ({ suggested={hierarchy.isSuggested(root.room_id, space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)} onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)} - onJoinRoomClick={() => onJoinRoomClick(space.room_id)} + onJoinRoomClick={() => onJoinRoomClick(space.room_id, newParents)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} > @@ -839,7 +838,14 @@ const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, a selectedMap={selected} onToggleClick={hasPermissions ? onToggleClick : undefined} onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)} - onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)} + onJoinRoomClick={async (roomId, parents) => { + for (const parent of parents) { + if (cli.getRoom(parent)?.getMyMembership() !== "join") { + await joinRoom(cli, hierarchy, parent); + } + } + await joinRoom(cli, hierarchy, roomId); + }} /> ); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index c1614b4e0e..aee94a1d8a 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -77,7 +77,6 @@ import MainSplit from "./MainSplit"; import RightPanel from "./RightPanel"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; -import ExternalLink from "../views/elements/ExternalLink"; interface IProps { space: Room; @@ -337,7 +336,7 @@ const SpaceSetupFirstRooms: React.FC<{ const filteredRoomNames = roomNames.map((name) => name.trim()).filter(Boolean); const roomIds = await Promise.all( filteredRoomNames.map((name) => { - return createRoom({ + return createRoom(space.client, { createOpts: { preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, name, @@ -549,7 +548,7 @@ const SpaceSetupPrivateInvite: React.FC<{ setBusy(true); const targetIds = emailAddresses.map((name) => name.trim()).filter(Boolean); try { - const result = await inviteMultipleToRoom(space.roomId, targetIds); + const result = await inviteMultipleToRoom(space.client, space.roomId, targetIds); const failedUsers = Object.keys(result.states).filter((a) => result.states[a] === "error"); if (failedUsers.length > 0) { @@ -586,22 +585,6 @@ const SpaceSetupPrivateInvite: React.FC<{ {_t("Make sure the right people have access. You can invite more later.")}
-
- {_t( - "This is an experimental feature. For now, " + - "new users receiving an invite will have to open the invite on to actually join.", - {}, - { - b: (sub) => {sub}, - link: () => ( - - app.element.io - - ), - }, - )} -
- {error &&
{error}
}
{fields} diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index b00b6ed132..5b5e0365ef 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -740,7 +740,7 @@ class TimelinePanel extends React.Component { if (!this.messagePanel.current?.getScrollState()) return; - if (!this.messagePanel.current.getScrollState().stuckAtBottom) { + if (!this.messagePanel.current.getScrollState()?.stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. @@ -981,7 +981,7 @@ class TimelinePanel extends React.Component { { leading: true, trailing: true }, ); - private readMarkerTimeout(readMarkerPosition: number): number { + private readMarkerTimeout(readMarkerPosition: number | null): number { return readMarkerPosition === 0 ? this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs : this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 8a2d08f6cc..99325da99b 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -110,7 +110,7 @@ export default class UserMenu extends React.Component { } private get hasHomePage(): boolean { - return !!getHomePageUrl(SdkConfig.get()); + return !!getHomePageUrl(SdkConfig.get(), this.context.client!); } private onCurrentVoiceBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => { diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index a0a500810f..a4e10e12e0 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -153,7 +153,9 @@ export default class ViewSource extends React.Component { const isEditing = this.state.isEditing; const roomId = mxEvent.getRoomId()!; const eventId = mxEvent.getId()!; - const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent); + const canEdit = mxEvent.isState() + ? this.canSendStateEvent(mxEvent) + : canEditContent(MatrixClientPeg.get(), this.props.mxEvent); return (
diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index 3171a0ec88..23fcffa145 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -28,7 +28,7 @@ interface IProps { } interface IState { - phase: Phase; + phase?: Phase; lostKeys: boolean; } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 015ed6bbee..3299cae3fc 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -19,7 +19,7 @@ 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 { _t, _td, UserFriendlyError } from "../../../languageHandler"; import Login from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; @@ -110,7 +110,7 @@ type OnPasswordLogin = { */ export default class LoginComponent extends React.PureComponent { private unmounted = false; - private loginLogic: Login; + private loginLogic!: Login; private readonly stepRendererMap: Record ReactNode>; @@ -265,7 +265,7 @@ export default class LoginComponent extends React.PureComponent logger.error("Problem parsing URL or unhandled error doing .well-known discovery:", e); let message = _t("Failed to perform homeserver discovery"); - if (e.translatedMessage) { + if (e instanceof UserFriendlyError && e.translatedMessage) { message = e.translatedMessage; } @@ -313,6 +313,7 @@ export default class LoginComponent extends React.PureComponent this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin, + undefined, SSOAction.REGISTER, ); } else { diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index d94718ab60..4be5efa5c9 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -38,7 +38,7 @@ interface IProps { } interface IState { - phase: Phase; + phase?: Phase; verificationRequest: VerificationRequest | null; backupInfo: IKeyBackupInfo | null; lostKeys: boolean; diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index a7a377e1d1..f8a058fc7b 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -18,6 +18,7 @@ import React, { ChangeEvent, SyntheticEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -164,7 +165,11 @@ export default class SoftLogout extends React.Component { credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); } catch (e) { let errorText = _t("Failed to re-authenticate due to a homeserver problem"); - if (e.errcode === "M_FORBIDDEN" && (e.httpStatus === 401 || e.httpStatus === 403)) { + if ( + e instanceof MatrixError && + e.errcode === "M_FORBIDDEN" && + (e.httpStatus === 401 || e.httpStatus === 403) + ) { errorText = _t("Incorrect password"); } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index cefdecab5e..94180f40eb 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -214,7 +214,7 @@ export class RecaptchaAuthEntry extends React.Component @@ -235,7 +235,9 @@ export class RecaptchaAuthEntry extends React.Component - + {sitePublicKey && ( + + )} {errorSection}
); diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 3f232923cc..599723aba7 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -22,6 +22,7 @@ import SdkConfig from "../../../SdkConfig"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { _t, _td } from "../../../languageHandler"; import Field, { IInputProps } from "../elements/Field"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps extends Omit { autoFocus?: boolean; @@ -56,7 +57,7 @@ class PassphraseField extends PureComponent { deriveData: async ({ value }): Promise => { if (!value) return null; const { scorePassword } = await import("../../../utils/PasswordScorer"); - return scorePassword(value); + return scorePassword(MatrixClientPeg.get(), value); }, rules: [ { diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 3756e7c41a..508285d07e 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -165,7 +165,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent public componentDidMount(): void { MatrixClientPeg.get().on(RoomMemberEvent.PowerLevel, this.checkPermissions); + + // re-check the permissions on send progress (`maySendRedactionForEvent` only returns true for events that have + // been fully sent and echoed back, and we want to ensure the "Remove" option is added once that happens.) + this.props.mxEvent.on(MatrixEventEvent.Status, this.checkPermissions); + this.checkPermissions(); } @@ -153,6 +158,7 @@ export default class MessageContextMenu extends React.Component if (cli) { cli.removeListener(RoomMemberEvent.PowerLevel, this.checkPermissions); } + this.props.mxEvent.removeListener(MatrixEventEvent.Status, this.checkPermissions); } private checkPermissions = (): void => { @@ -195,7 +201,7 @@ export default class MessageContextMenu extends React.Component private onResendReactionsClick = (): void => { for (const reaction of this.getUnsentReactions()) { - Resend.resend(reaction); + Resend.resend(MatrixClientPeg.get(), reaction); } this.closeMenu(); }; @@ -311,7 +317,12 @@ export default class MessageContextMenu extends React.Component }; private onEditClick = (): void => { - editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent); + editEvent( + MatrixClientPeg.get(), + this.props.mxEvent, + this.context.timelineRenderingType, + this.props.getRelationsForEvent, + ); this.closeMenu(); }; @@ -611,7 +622,7 @@ export default class MessageContextMenu extends React.Component } let editButton: JSX.Element | undefined; - if (rightClick && canEditContent(mxEvent)) { + if (rightClick && canEditContent(cli, mxEvent)) { editButton = ( = ({ const { room, roomId } = useContext(RoomContext); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); - const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, roomId); let streamAudioStreamButton: JSX.Element | undefined; if (roomId && getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type)) { const onStreamAudioClick = async (): Promise => { try { - await startJitsiAudioLivestream(widgetMessaging!, roomId); + await startJitsiAudioLivestream(cli, widgetMessaging!, roomId); } catch (err) { logger.error("Failed to start livestream", err); // XXX: won't i18n well, but looks like widget api only support 'message'? @@ -138,7 +138,7 @@ export const WidgetContextMenu: React.FC = ({ button: _t("Delete widget"), onFinished: (confirmed) => { if (!confirmed) return; - WidgetUtils.setRoomWidget(roomId, app.id); + WidgetUtils.setRoomWidget(cli, roomId, app.id); }, }); } diff --git a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx index e4d1783429..59597ad66a 100644 --- a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx @@ -28,7 +28,7 @@ interface IProps extends Omit = ({ return ( { + onFinished={(success?: boolean, reason?: string) => { onFinished(success, reason, roomsToLeave); }} className="mx_ConfirmSpaceUserActionDialog" diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 9f46328396..2484d5fd7e 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -75,9 +75,10 @@ export default class CreateRoomDialog extends React.Component { joinRule = JoinRule.Restricted; } + const cli = MatrixClientPeg.get(); this.state = { isPublic: this.props.defaultPublic || false, - isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(), + isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli), joinRule, name: this.props.defaultName || "", topic: "", @@ -88,9 +89,9 @@ export default class CreateRoomDialog extends React.Component { canChangeEncryption: true, }; - MatrixClientPeg.get() - .doesServerForceEncryptionForPreset(Preset.PrivateChat) - .then((isForced) => this.setState({ canChangeEncryption: !isForced })); + cli.doesServerForceEncryptionForPreset(Preset.PrivateChat).then((isForced) => + this.setState({ canChangeEncryption: !isForced }), + ); } private roomCreateOptions(): IOpts { @@ -284,7 +285,7 @@ export default class CreateRoomDialog extends React.Component { let e2eeSection: JSX.Element | undefined; if (this.state.joinRule !== JoinRule.Public) { let microcopy: string; - if (privateShouldBeEncrypted()) { + if (privateShouldBeEncrypted(MatrixClientPeg.get())) { if (this.state.canChangeEncryption) { microcopy = isVideoRoom ? _t("You can't disable this later. The room will be encrypted but the embedded call will not.") diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 19a4778914..ac152f1da6 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -79,7 +79,16 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick } try { - await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule }); + await createSpace( + space.client, + name, + joinRule === JoinRule.Public, + alias, + topic, + avatar, + {}, + { parentSpace, joinRule }, + ); onFinished(true); } catch (e) { diff --git a/src/components/views/dialogs/IncomingSasDialog.tsx b/src/components/views/dialogs/IncomingSasDialog.tsx index b0bb9cbc81..3772b689fa 100644 --- a/src/components/views/dialogs/IncomingSasDialog.tsx +++ b/src/components/views/dialogs/IncomingSasDialog.tsx @@ -15,8 +15,8 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { IGeneratedSas, ISasEvent, SasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import { VerificationBase, VerificationEvent } from "matrix-js-sdk/src/crypto/verification/Base"; +import { VerificationBase } from "matrix-js-sdk/src/crypto/verification/Base"; +import { GeneratedSas, ShowSasCallbacks, VerifierEvent } from "matrix-js-sdk/src/crypto-api/verification"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -37,7 +37,7 @@ const PHASE_VERIFIED = 3; const PHASE_CANCELLED = 4; interface IProps { - verifier: VerificationBase; + verifier: VerificationBase; onFinished(verified?: boolean): void; } @@ -50,11 +50,11 @@ interface IState { displayname?: string; } | null; opponentProfileError: Error | null; - sas: IGeneratedSas | null; + sas: GeneratedSas | null; } export default class IncomingSasDialog extends React.Component { - private showSasEvent: ISasEvent | null; + private showSasEvent: ShowSasCallbacks | null; public constructor(props: IProps) { super(props); @@ -73,8 +73,8 @@ export default class IncomingSasDialog extends React.Component { opponentProfileError: null, sas: null, }; - this.props.verifier.on(SasEvent.ShowSas, this.onVerifierShowSas); - this.props.verifier.on(VerificationEvent.Cancel, this.onVerifierCancel); + this.props.verifier.on(VerifierEvent.ShowSas, this.onVerifierShowSas); + this.props.verifier.on(VerifierEvent.Cancel, this.onVerifierCancel); this.fetchOpponentProfile(); } @@ -82,7 +82,7 @@ export default class IncomingSasDialog extends React.Component { if (this.state.phase !== PHASE_CANCELLED && this.state.phase !== PHASE_VERIFIED) { this.props.verifier.cancel(new Error("User cancel")); } - this.props.verifier.removeListener(SasEvent.ShowSas, this.onVerifierShowSas); + this.props.verifier.removeListener(VerifierEvent.ShowSas, this.onVerifierShowSas); } private async fetchOpponentProfile(): Promise { @@ -118,7 +118,7 @@ export default class IncomingSasDialog extends React.Component { }); }; - private onVerifierShowSas = (e: ISasEvent): void => { + private onVerifierShowSas = (e: ShowSasCallbacks): void => { this.showSasEvent = e; this.setState({ phase: PHASE_SHOW_SAS, diff --git a/src/components/views/dialogs/InfoDialog.tsx b/src/components/views/dialogs/InfoDialog.tsx index 790c77d418..d218438f62 100644 --- a/src/components/views/dialogs/InfoDialog.tsx +++ b/src/components/views/dialogs/InfoDialog.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, KeyboardEvent } from "react"; +import React, { ReactNode } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; @@ -30,7 +30,7 @@ interface IProps { button?: boolean | string; hasCloseButton?: boolean; fixedWidth?: boolean; - onKeyDown?(event: KeyboardEvent): void; + onKeyDown?(event: KeyboardEvent | React.KeyboardEvent): void; onFinished(): void; } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 6a50955073..30dc15e5cc 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -407,7 +407,7 @@ export default class InviteDialog extends React.PureComponent ), a: (sub) => ( - + {sub} ), diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 197709b8b7..a8b446df09 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -76,7 +76,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent reject(error)); diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 5a06db2da6..9641bd7715 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import BaseDialog from "./BaseDialog"; -import { _t } from "../../../languageHandler"; +import { _t, UserFriendlyError } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import SdkConfig from "../../../SdkConfig"; import Field from "../elements/Field"; @@ -113,7 +113,7 @@ export default class ServerPickerDialog extends React.PureComponent => { ev.preventDefault(); + if (this.state.defaultChosen) { + this.props.onFinished(this.defaultServer); + } + const valid = await this.fieldRef.current?.validate({ allowEmpty: false }); - if (!valid && !this.state.defaultChosen) { + if (!valid) { this.fieldRef.current?.focus(); this.fieldRef.current?.validate({ allowEmpty: false, focused: true }); return; } - this.props.onFinished(this.state.defaultChosen ? this.defaultServer : this.validatedConf); + this.props.onFinished(this.validatedConf); }; public render(): React.ReactNode { @@ -202,6 +206,7 @@ export default class ServerPickerDialog extends React.PureComponent {defaultServerName} diff --git a/src/components/views/dialogs/SetEmailDialog.tsx b/src/components/views/dialogs/SetEmailDialog.tsx index bec380e66a..56c386b330 100644 --- a/src/components/views/dialogs/SetEmailDialog.tsx +++ b/src/components/views/dialogs/SetEmailDialog.tsx @@ -28,6 +28,7 @@ import ErrorDialog, { extractErrorMessageFromError } from "./ErrorDialog"; import QuestionDialog from "./QuestionDialog"; import BaseDialog from "./BaseDialog"; import EditableText from "../elements/EditableText"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps { title: string; @@ -71,7 +72,7 @@ export default class SetEmailDialog extends React.Component { }); return; } - this.addThreepid = new AddThreepid(); + this.addThreepid = new AddThreepid(MatrixClientPeg.get()); this.addThreepid.addEmailAddress(emailAddress).then( () => { Modal.createDialog(QuestionDialog, { diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx index 0963a53118..c0e7e6ea33 100644 --- a/src/components/views/dialogs/SpacePreferencesDialog.tsx +++ b/src/components/views/dialogs/SpacePreferencesDialog.tsx @@ -27,6 +27,9 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import RoomName from "../elements/RoomName"; import { SpacePreferenceTab } from "../../../dispatcher/payloads/OpenSpacePreferencesPayload"; import { NonEmptyArray } from "../../../@types/common"; +import SettingsTab from "../settings/tabs/SettingsTab"; +import { SettingsSection } from "../settings/shared/SettingsSection"; +import SettingsSubsection, { SettingsSubsectionText } from "../settings/shared/SettingsSubsection"; interface IProps { space: Room; @@ -38,34 +41,34 @@ const SpacePreferencesAppearanceTab: React.FC> = ({ space const showPeople = useSettingValue("Spaces.showPeopleInSpace", space.roomId); return ( -
-
{_t("Sections to show")}
- -
- ) => { - SettingsStore.setValue( - "Spaces.showPeopleInSpace", - space.roomId, - SettingLevel.ROOM_ACCOUNT, - !showPeople, - ); - }} - > - {_t("People")} - -

- {_t( - "This groups your chats with members of this space. " + - "Turning this off will hide those chats from your view of %(spaceName)s.", - { - spaceName: space.name, - }, - )} -

-
-
+ + + + ) => { + SettingsStore.setValue( + "Spaces.showPeopleInSpace", + space.roomId, + SettingLevel.ROOM_ACCOUNT, + !showPeople, + ); + }} + > + {_t("People")} + + + {_t( + "This groups your chats with members of this space. " + + "Turning this off will hide those chats from your view of %(spaceName)s.", + { + spaceName: space.name, + }, + )} + + + + ); }; diff --git a/src/components/views/dialogs/TermsDialog.tsx b/src/components/views/dialogs/TermsDialog.tsx index ca2f674cb6..3de62188e1 100644 --- a/src/components/views/dialogs/TermsDialog.tsx +++ b/src/components/views/dialogs/TermsDialog.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import url from "url"; import React from "react"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; @@ -23,6 +22,7 @@ import DialogButtons from "../elements/DialogButtons"; import BaseDialog from "./BaseDialog"; import { ServicePolicyPair } from "../../../Terms"; import ExternalLink from "../elements/ExternalLink"; +import { parseUrl } from "../../../utils/UrlUtils"; interface ITermsCheckboxProps { onChange: (url: string, checked: boolean) => void; @@ -130,7 +130,7 @@ export default class TermsDialog extends React.PureComponent { @@ -56,15 +55,11 @@ export default class UserSettingsDialog extends React.Component this.state = { mjolnirEnabled: SettingsStore.getValue("feature_mjolnir"), - newSessionManagerEnabled: SettingsStore.getValue("feature_new_device_manager"), }; } public componentDidMount(): void { - this.settingsWatchers = [ - SettingsStore.watchSetting("feature_mjolnir", null, this.mjolnirChanged), - SettingsStore.watchSetting("feature_new_device_manager", null, this.sessionManagerChanged), - ]; + this.settingsWatchers = [SettingsStore.watchSetting("feature_mjolnir", null, this.mjolnirChanged)]; } public componentWillUnmount(): void { @@ -76,11 +71,6 @@ export default class UserSettingsDialog extends React.Component this.setState({ mjolnirEnabled: newValue }); }; - private sessionManagerChanged: CallbackFn = (settingName, roomId, atLevel, newValue) => { - // We can cheat because we know what levels a feature is tracked at, and how it is tracked - this.setState({ newSessionManagerEnabled: newValue }); - }; - private getTabs(): NonEmptyArray> { const tabs: Tab[] = []; @@ -160,18 +150,16 @@ export default class UserSettingsDialog extends React.Component "UserSettingsSecurityPrivacy", ), ); - if (this.state.newSessionManagerEnabled) { - tabs.push( - new Tab( - UserTab.SessionManager, - _td("Sessions"), - "mx_UserSettingsDialog_sessionsIcon", - , - // don't track with posthog while under construction - undefined, - ), - ); - } + tabs.push( + new Tab( + UserTab.SessionManager, + _td("Sessions"), + "mx_UserSettingsDialog_sessionsIcon", + , + // don't track with posthog while under construction + undefined, + ), + ); // Show the Labs tab if enabled or if there are any active betas if ( SdkConfig.get("show_labs_settings") || diff --git a/src/components/views/dialogs/VerificationRequestDialog.tsx b/src/components/views/dialogs/VerificationRequestDialog.tsx index 53ac14f6c0..540cb3190e 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.tsx +++ b/src/components/views/dialogs/VerificationRequestDialog.tsx @@ -51,6 +51,8 @@ export default class VerificationRequestDialog extends React.Component ({ }); const validateEventContent = withValidation({ - deriveData({ value }) { + async deriveData({ value }) { try { JSON.parse(value!); } catch (e) { - return e; + return e as Error; } + return undefined; }, rules: [ { diff --git a/src/components/views/dialogs/security/SetupEncryptionDialog.tsx b/src/components/views/dialogs/security/SetupEncryptionDialog.tsx index 659b9cc9e7..a69a178e66 100644 --- a/src/components/views/dialogs/security/SetupEncryptionDialog.tsx +++ b/src/components/views/dialogs/security/SetupEncryptionDialog.tsx @@ -21,7 +21,7 @@ import BaseDialog from "../BaseDialog"; import { _t } from "../../../../languageHandler"; import { SetupEncryptionStore, Phase } from "../../../../stores/SetupEncryptionStore"; -function iconFromPhase(phase: Phase): string { +function iconFromPhase(phase?: Phase): string { if (phase === Phase.Done) { return require("../../../../../res/img/e2e/verified-deprecated.svg").default; } else { diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 94db40847b..759250651a 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -21,17 +21,7 @@ import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { IPublicRoomsChunkRoom, MatrixClient, RoomMember, RoomType } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/models/room"; import { normalize } from "matrix-js-sdk/src/utils"; -import React, { - ChangeEvent, - KeyboardEvent, - RefObject, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import sanitizeHtml from "sanitize-html"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; @@ -1067,7 +1057,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ); } - const onDialogKeyDown = (ev: KeyboardEvent): void => { + const onDialogKeyDown = (ev: KeyboardEvent | React.KeyboardEvent): void => { const navigationAction = getKeyBindingsManager().getNavigationAction(ev); switch (navigationAction) { case KeyBindingAction.FilterRooms: @@ -1139,7 +1129,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } }; - const onKeyDown = (ev: KeyboardEvent): void => { + const onKeyDown = (ev: React.KeyboardEvent): void => { const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { diff --git a/src/components/views/elements/AppPermission.tsx b/src/components/views/elements/AppPermission.tsx index 2c1015bd1a..e73c8ddf8a 100644 --- a/src/components/views/elements/AppPermission.tsx +++ b/src/components/views/elements/AppPermission.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from "react"; -import url from "url"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { _t } from "../../../languageHandler"; @@ -28,6 +27,7 @@ import MemberAvatar from "../avatars/MemberAvatar"; import BaseAvatar from "../avatars/BaseAvatar"; import AccessibleButton from "./AccessibleButton"; import TextWithTooltip from "./TextWithTooltip"; +import { parseUrl } from "../../../utils/UrlUtils"; interface IProps { url: string; @@ -38,7 +38,7 @@ interface IProps { } interface IState { - roomMember: RoomMember; + roomMember: RoomMember | null; isWrapped: boolean; widgetDomain: string | null; } @@ -56,7 +56,7 @@ export default class AppPermission extends React.Component { // The second step is to find the user's profile so we can show it on the prompt const room = MatrixClientPeg.get().getRoom(this.props.roomId); - let roomMember; + let roomMember: RoomMember | null = null; if (room) roomMember = room.getMember(this.props.creatorUserId); // Set all this into the initial state @@ -67,13 +67,12 @@ export default class AppPermission extends React.Component { } private parseWidgetUrl(): { isWrapped: boolean; widgetDomain: string | null } { - const widgetUrl = url.parse(this.props.url); - const params = new URLSearchParams(widgetUrl.search ?? undefined); + const widgetUrl = parseUrl(this.props.url); // HACK: We're relying on the query params when we should be relying on the widget's `data`. // This is a workaround for Scalar. - if (WidgetUtils.isScalarUrl(this.props.url) && params?.get("url")) { - const unwrappedUrl = url.parse(params.get("url")!); + if (WidgetUtils.isScalarUrl(this.props.url) && widgetUrl.searchParams.has("url")) { + const unwrappedUrl = parseUrl(widgetUrl.searchParams.get("url")!); return { widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname, isWrapped: true, @@ -105,7 +104,9 @@ export default class AppPermission extends React.Component {
  • {_t("Your display name")}
  • {_t("Your avatar URL")}
  • {_t("Your user ID")}
  • +
  • {_t("Your device ID")}
  • {_t("Your theme")}
  • +
  • {_t("Your language")}
  • {_t("%(brand)s URL", { brand })}
  • {_t("Room ID")}
  • {_t("Widget ID")}
  • @@ -115,7 +116,7 @@ export default class AppPermission extends React.Component { const warningTooltip = ( diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index a90e01f317..6f153a2b75 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -17,7 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import url from "url"; import React, { ContextType, createRef, CSSProperties, MutableRefObject, ReactNode } from "react"; import classNames from "classnames"; import { IWidget, MatrixCapabilities } from "matrix-widget-api"; @@ -41,6 +40,11 @@ import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; import LegacyCallHandler from "../../../LegacyCallHandler"; import { IApp, isAppWidget } from "../../../stores/WidgetStore"; +import { Icon as CollapseIcon } from "../../../../res/img/element-icons/minimise-collapse.svg"; +import { Icon as MaximiseIcon } from "../../../../res/img/element-icons/maximise-expand.svg"; +import { Icon as MinimiseIcon } from "../../../../res/img/element-icons/minus-button.svg"; +import { Icon as PopoutIcon } from "../../../../res/img/feather-customised/widget/external-link.svg"; +import { Icon as MenuIcon } from "../../../../res/img/element-icons/room/ellipsis.svg"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; @@ -52,6 +56,7 @@ import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidget import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { ModuleRunner } from "../../../modules/ModuleRunner"; +import { parseUrl } from "../../../utils/UrlUtils"; interface IProps { app: IWidget | IApp; @@ -126,7 +131,7 @@ export default class AppTile extends React.Component { private persistKey: string; private sgWidget: StopGapWidget | null; private dispatcherRef?: string; - private unmounted: boolean; + private unmounted = false; public constructor(props: IProps) { super(props); @@ -265,7 +270,7 @@ export default class AppTile extends React.Component { private isMixedContent(): boolean { const parentContentProtocol = window.location.protocol; - const u = url.parse(this.props.app.url); + const u = parseUrl(this.props.app.url); const childContentProtocol = u.protocol; if (parentContentProtocol === "https:" && childContentProtocol !== "https:") { logger.warn( @@ -590,7 +595,11 @@ export default class AppTile extends React.Component { const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;"; - const appTileBodyClass = "mx_AppTileBody" + (this.props.miniMode ? "_mini " : " "); + const appTileBodyClass = classNames({ + mx_AppTileBody: !this.props.miniMode, + mx_AppTileBody_mini: this.props.miniMode, + mx_AppTile_loading: this.state.loading, + }); const appTileBodyStyles: CSSProperties = {}; if (this.props.pointerEvents) { appTileBodyStyles.pointerEvents = this.props.pointerEvents; @@ -626,10 +635,7 @@ export default class AppTile extends React.Component { ); } else if (this.state.initialising || !this.state.isUserProfileReady) { appTileBody = ( -
    +
    {loadingElement}
    ); @@ -642,10 +648,7 @@ export default class AppTile extends React.Component { ); } else { appTileBody = ( -
    +
    {this.state.loading && loadingElement} '); + expect(spy).toHaveBeenCalledWith( + client, + roomId, + expect.any(String), + WidgetType.CUSTOM, + "https://element.io", + "Custom", + {}, + ); }); }); }); diff --git a/test/Terms-test.tsx b/test/Terms-test.tsx index c04ddd03dd..772bb5da3d 100644 --- a/test/Terms-test.tsx +++ b/test/Terms-test.tsx @@ -66,7 +66,7 @@ describe("Terms", function () { }, }); const interactionCallback = jest.fn().mockResolvedValue([]); - await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback); expect(interactionCallback).toHaveBeenCalledWith( [ @@ -97,7 +97,7 @@ describe("Terms", function () { mockClient.agreeToTerms; const interactionCallback = jest.fn(); - await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback); expect(interactionCallback).not.toHaveBeenCalled(); expect(mockClient.agreeToTerms).toHaveBeenCalledWith(SERVICE_TYPES.IM, "https://imone.test", "a token token", [ @@ -122,7 +122,7 @@ describe("Terms", function () { }); const interactionCallback = jest.fn().mockResolvedValue(["http://example.com/one", "http://example.com/two"]); - await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback); expect(interactionCallback).toHaveBeenCalledWith( [ @@ -168,7 +168,7 @@ describe("Terms", function () { }); const interactionCallback = jest.fn().mockResolvedValue(["http://example.com/one", "http://example.com/two"]); - await startTermsFlow([IM_SERVICE_ONE, IM_SERVICE_TWO], interactionCallback); + await startTermsFlow(mockClient, [IM_SERVICE_ONE, IM_SERVICE_TWO], interactionCallback); expect(interactionCallback).toHaveBeenCalledWith( [ diff --git a/test/Unread-test.ts b/test/Unread-test.ts index 998448d886..63f3e6e54b 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -67,32 +67,32 @@ describe("Unread", () => { }); it("returns false when the event was sent by the current user", () => { - expect(eventTriggersUnreadCount(ourMessage)).toBe(false); + expect(eventTriggersUnreadCount(client, ourMessage)).toBe(false); // returned early before checking renderer expect(haveRendererForEvent).not.toHaveBeenCalled(); }); it("returns false for a redacted event", () => { - expect(eventTriggersUnreadCount(redactedEvent)).toBe(false); + expect(eventTriggersUnreadCount(client, redactedEvent)).toBe(false); // returned early before checking renderer expect(haveRendererForEvent).not.toHaveBeenCalled(); }); it("returns false for an event without a renderer", () => { mocked(haveRendererForEvent).mockReturnValue(false); - expect(eventTriggersUnreadCount(alicesMessage)).toBe(false); + expect(eventTriggersUnreadCount(client, alicesMessage)).toBe(false); expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); }); it("returns true for an event with a renderer", () => { mocked(haveRendererForEvent).mockReturnValue(true); - expect(eventTriggersUnreadCount(alicesMessage)).toBe(true); + expect(eventTriggersUnreadCount(client, alicesMessage)).toBe(true); expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); }); it("returns false for beacon locations", () => { const beaconLocationEvent = makeBeaconEvent(aliceId); - expect(eventTriggersUnreadCount(beaconLocationEvent)).toBe(false); + expect(eventTriggersUnreadCount(client, beaconLocationEvent)).toBe(false); expect(haveRendererForEvent).not.toHaveBeenCalled(); }); @@ -112,7 +112,7 @@ describe("Unread", () => { type: eventType, sender: aliceId, }); - expect(eventTriggersUnreadCount(event)).toBe(false); + expect(eventTriggersUnreadCount(client, event)).toBe(false); expect(haveRendererForEvent).not.toHaveBeenCalled(); }, ); @@ -321,6 +321,88 @@ describe("Unread", () => { expect(doesRoomHaveUnreadMessages(room)).toBe(true); }); + + it("returns false when the event for a thread receipt can't be found, but the receipt ts is late", () => { + // Given a room that is read + let receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // And a thread + const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); + + // When we provide a receipt that points at an unknown event, + // but its timestamp is after all events in the thread + // + // (This could happen if we mis-filed a reaction into the main + // thread when it should actually have gone into this thread, or + // maybe the event is just not loaded for some reason.) + const receiptTs = Math.max(...events.map((e) => e.getTs())) + 100; + receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + ["UNKNOWN_EVENT_ID"]: { + [ReceiptType.Read]: { + [myId]: { ts: receiptTs, threadId: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + expect(doesRoomHaveUnreadMessages(room)).toBe(false); + }); + + it("returns true when the event for a thread receipt can't be found, and the receipt ts is early", () => { + // Given a room that is read + let receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // And a thread + const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); + + // When we provide a receipt that points at an unknown event, + // but its timestamp is before some of the events in the thread + // + // (This could happen if we mis-filed a reaction into the main + // thread when it should actually have gone into this thread, or + // maybe the event is just not loaded for some reason.) + const receiptTs = (events.at(-1)?.getTs() ?? 0) - 100; + receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + ["UNKNOWN_EVENT_ID"]: { + [ReceiptType.Read]: { + [myId]: { ts: receiptTs, threadId: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + expect(doesRoomHaveUnreadMessages(room)).toBe(true); + }); }); it("returns true for a room that only contains a hidden event", () => { diff --git a/test/__snapshots__/HtmlUtils-test.tsx.snap b/test/__snapshots__/HtmlUtils-test.tsx.snap new file mode 100644 index 0000000000..c4d91467c0 --- /dev/null +++ b/test/__snapshots__/HtmlUtils-test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`bodyToHtml feature_latex_maths should not mangle code blocks 1`] = `"

    hello

    $\\xi$

    world

    "`; + +exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"

    hello

    ξ\\xi

    world

    "`; + +exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello ξ\\xi world"`; + +exports[`bodyToHtml should generate big emoji for an emoji-only reply to a message 1`] = ` + + + + 🥰 + + + +`; diff --git a/test/__snapshots__/SlashCommands-test.tsx.snap b/test/__snapshots__/SlashCommands-test.tsx.snap index 925f5e878b..08d3bdcc47 100644 --- a/test/__snapshots__/SlashCommands-test.tsx.snap +++ b/test/__snapshots__/SlashCommands-test.tsx.snap @@ -1,5 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SlashCommands /lenny should match snapshot with args 1`] = ` +{ + "body": "( ͡° ͜ʖ ͡°) this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /lenny should match snapshot with no args 1`] = ` +{ + "body": "( ͡° ͜ʖ ͡°)", + "msgtype": "m.text", +} +`; + exports[`SlashCommands /rainbow should make things rainbowy 1`] = ` { "body": "this is a test message", @@ -17,3 +31,45 @@ exports[`SlashCommands /rainbowme should make things rainbowy 1`] = ` "msgtype": "m.emote", } `; + +exports[`SlashCommands /shrug should match snapshot with args 1`] = ` +{ + "body": "¯\\_(ツ)_/¯ this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /shrug should match snapshot with no args 1`] = ` +{ + "body": "¯\\_(ツ)_/¯", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /tableflip should match snapshot with args 1`] = ` +{ + "body": "(╯°□°)╯︵ ┻━┻ this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /tableflip should match snapshot with no args 1`] = ` +{ + "body": "(╯°□°)╯︵ ┻━┻", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /unflip should match snapshot with args 1`] = ` +{ + "body": "┬──┬ ノ( ゜-゜ノ) this is a test message", + "msgtype": "m.text", +} +`; + +exports[`SlashCommands /unflip should match snapshot with no args 1`] = ` +{ + "body": "┬──┬ ノ( ゜-゜ノ)", + "msgtype": "m.text", +} +`; diff --git a/test/actions/handlers/viewUserDeviceSettings-test.ts b/test/actions/handlers/viewUserDeviceSettings-test.ts index 41fcc60fdf..d3cdab5fc7 100644 --- a/test/actions/handlers/viewUserDeviceSettings-test.ts +++ b/test/actions/handlers/viewUserDeviceSettings-test.ts @@ -26,23 +26,12 @@ describe("viewUserDeviceSettings()", () => { dispatchSpy.mockClear(); }); - it("dispatches action to view new session manager when enabled", () => { - const isNewDeviceManagerEnabled = true; - viewUserDeviceSettings(isNewDeviceManagerEnabled); + it("dispatches action to view session manager", () => { + viewUserDeviceSettings(); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewUserSettings, initialTabId: UserTab.SessionManager, }); }); - - it("dispatches action to view old session manager when disabled", () => { - const isNewDeviceManagerEnabled = false; - viewUserDeviceSettings(isNewDeviceManagerEnabled); - - expect(dispatchSpy).toHaveBeenCalledWith({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Security, - }); - }); }); diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx new file mode 100644 index 0000000000..cc80057cf8 --- /dev/null +++ b/test/components/structures/MatrixChat-test.tsx @@ -0,0 +1,307 @@ +/* +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, { ComponentProps } from "react"; +import { fireEvent, render, RenderResult, screen, within } from "@testing-library/react"; +import fetchMockJest from "fetch-mock-jest"; +import { ClientEvent } from "matrix-js-sdk/src/client"; +import { SyncState } from "matrix-js-sdk/src/sync"; +import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import MatrixChat from "../../../src/components/structures/MatrixChat"; +import * as StorageManager from "../../../src/utils/StorageManager"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { UserTab } from "../../../src/components/views/dialogs/UserTab"; +import { clearAllModals, flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils"; +import * as leaveRoomUtils from "../../../src/utils/leave-behaviour"; + +describe("", () => { + const userId = "@alice:server.org"; + const deviceId = "qwertyui"; + const accessToken = "abc123"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + startClient: jest.fn(), + stopClient: jest.fn(), + setCanResetTimelineCallback: jest.fn(), + isInitialSyncComplete: jest.fn(), + getSyncState: jest.fn(), + getSyncStateData: jest.fn().mockReturnValue(null), + getThirdpartyProtocols: jest.fn().mockResolvedValue({}), + getClientWellKnown: jest.fn().mockReturnValue({}), + isVersionSupported: jest.fn().mockResolvedValue(false), + isCryptoEnabled: jest.fn().mockReturnValue(false), + getRoom: jest.fn(), + getMediaHandler: jest.fn().mockReturnValue({ + setVideoInput: jest.fn(), + setAudioInput: jest.fn(), + setAudioSettings: jest.fn(), + stopAllStreams: jest.fn(), + } as unknown as MediaHandler), + setAccountData: jest.fn(), + store: { + destroy: jest.fn(), + }, + }); + const serverConfig = { + hsUrl: "https://test.com", + hsName: "Test Server", + hsNameIsDifferent: false, + isUrl: "https://is.com", + isDefault: true, + isNameResolvable: true, + warning: "", + }; + const defaultProps: ComponentProps = { + config: { + brand: "Test", + element_call: {}, + feedback: { + existing_issues_url: "https://feedback.org/existing", + new_issue_url: "https://feedback.org/new", + }, + validated_server_config: serverConfig, + }, + onNewScreen: jest.fn(), + onTokenLoginCompleted: jest.fn(), + makeRegistrationUrl: jest.fn(), + realQueryParams: {}, + }; + const getComponent = (props: Partial> = {}) => + render(); + const localStorageSpy = jest.spyOn(localStorage.__proto__, "getItem").mockReturnValue(undefined); + + beforeEach(() => { + fetchMockJest.get("https://test.com/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + localStorageSpy.mockClear(); + jest.spyOn(StorageManager, "idbLoad").mockRestore(); + jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined); + jest.spyOn(defaultDispatcher, "dispatch").mockClear(); + }); + + it("should render spinner while app is loading", () => { + const { container } = getComponent(); + + expect(container).toMatchSnapshot(); + }); + + describe("with an existing session", () => { + const mockidb: Record> = { + acccount: { + mx_access_token: accessToken, + }, + }; + const mockLocalStorage: Record = { + mx_hs_url: serverConfig.hsUrl, + mx_is_url: serverConfig.isUrl, + mx_access_token: accessToken, + mx_user_id: userId, + mx_device_id: deviceId, + }; + + beforeEach(() => { + localStorageSpy.mockImplementation((key: unknown) => mockLocalStorage[key as string] || ""); + + jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => { + const safeKey = Array.isArray(key) ? key[0] : key; + return mockidb[table]?.[safeKey]; + }); + }); + + const getComponentAndWaitForReady = async (): Promise => { + const renderResult = getComponent(); + // we think we are logged in, but are still waiting for the /sync to complete + await screen.findByText("Logout"); + // initial sync + mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null); + // wait for logged in view to load + await screen.findByLabelText("User menu"); + // let things settle + await flushPromises(); + // and some more for good measure + // this proved to be a little flaky + await flushPromises(); + + return renderResult; + }; + + it("should render welcome page after login", async () => { + getComponent(); + + // we think we are logged in, but are still waiting for the /sync to complete + const logoutButton = await screen.findByText("Logout"); + + expect(logoutButton).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + + // initial sync + mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null); + + // wait for logged in view to load + await screen.findByLabelText("User menu"); + // let things settle + await flushPromises(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByText(`Welcome ${userId}`)).toBeInTheDocument(); + }); + + describe("onAction()", () => { + it("should open user device settings", async () => { + await getComponentAndWaitForReady(); + + defaultDispatcher.dispatch({ + action: Action.ViewUserDeviceSettings, + }); + + await flushPromises(); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ViewUserSettings, + initialTabId: UserTab.SessionManager, + }); + }); + + describe("room actions", () => { + const roomId = "!room:server.org"; + const spaceId = "!spaceRoom:server.org"; + const room = new Room(roomId, mockClient, userId); + const spaceRoom = new Room(spaceId, mockClient, userId); + jest.spyOn(spaceRoom, "isSpaceRoom").mockReturnValue(true); + + beforeEach(() => { + mockClient.getRoom.mockImplementation( + (id) => [room, spaceRoom].find((room) => room.roomId === id) || null, + ); + jest.spyOn(defaultDispatcher, "dispatch").mockClear(); + }); + + describe("leave_room", () => { + beforeEach(async () => { + await clearAllModals(); + await getComponentAndWaitForReady(); + // this is thoroughly unit tested elsewhere + jest.spyOn(leaveRoomUtils, "leaveRoomBehaviour").mockClear().mockResolvedValue(undefined); + }); + const dispatchAction = () => + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: roomId, + }); + const publicJoinRule = new MatrixEvent({ + type: "m.room.join_rules", + content: { + join_rule: "public", + }, + }); + const inviteJoinRule = new MatrixEvent({ + type: "m.room.join_rules", + content: { + join_rule: "invite", + }, + }); + describe("for a room", () => { + beforeEach(() => { + jest.spyOn(room.currentState, "getJoinedMemberCount").mockReturnValue(2); + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(publicJoinRule); + }); + it("should launch a confirmation modal", async () => { + dispatchAction(); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toMatchSnapshot(); + }); + it("should warn when room has only one joined member", async () => { + jest.spyOn(room.currentState, "getJoinedMemberCount").mockReturnValue(1); + dispatchAction(); + await screen.findByRole("dialog"); + expect( + screen.getByText( + "You are the only person here. If you leave, no one will be able to join in the future, including you.", + ), + ).toBeInTheDocument(); + }); + it("should warn when room is not public", async () => { + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(inviteJoinRule); + dispatchAction(); + await screen.findByRole("dialog"); + expect( + screen.getByText( + "This room is not public. You will not be able to rejoin without an invite.", + ), + ).toBeInTheDocument(); + }); + it("should do nothing on cancel", async () => { + dispatchAction(); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByText("Cancel")); + + await flushPromises(); + + expect(leaveRoomUtils.leaveRoomBehaviour).not.toHaveBeenCalled(); + expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ + action: Action.AfterLeaveRoom, + room_id: roomId, + }); + }); + it("should leave room and dispatch after leave action", async () => { + dispatchAction(); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByText("Leave")); + + await flushPromises(); + + expect(leaveRoomUtils.leaveRoomBehaviour).toHaveBeenCalled(); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.AfterLeaveRoom, + room_id: roomId, + }); + }); + }); + + describe("for a space", () => { + const dispatchAction = () => + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: spaceId, + }); + beforeEach(() => { + jest.spyOn(spaceRoom.currentState, "getStateEvents").mockReturnValue(publicJoinRule); + }); + it("should launch a confirmation modal", async () => { + dispatchAction(); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toMatchSnapshot(); + }); + it("should warn when space is not public", async () => { + jest.spyOn(spaceRoom.currentState, "getStateEvents").mockReturnValue(inviteJoinRule); + dispatchAction(); + await screen.findByRole("dialog"); + expect( + screen.getByText( + "This space is not public. You will not be able to rejoin without an invite.", + ), + ).toBeInTheDocument(); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/components/structures/MessagePanel-test.tsx b/test/components/structures/MessagePanel-test.tsx index feef37bb78..ec7382f5e7 100644 --- a/test/components/structures/MessagePanel-test.tsx +++ b/test/components/structures/MessagePanel-test.tsx @@ -112,7 +112,7 @@ describe("MessagePanel", function () { return arg === "showDisplaynameChanges"; }); - DMRoomMap.makeShared(); + DMRoomMap.makeShared(client); }); afterEach(function () { diff --git a/test/components/structures/PipContainer-test.tsx b/test/components/structures/PipContainer-test.tsx index 5ca118c451..90b3477027 100644 --- a/test/components/structures/PipContainer-test.tsx +++ b/test/components/structures/PipContainer-test.tsx @@ -91,7 +91,7 @@ describe("PipContainer", () => { stubClient(); client = mocked(MatrixClientPeg.get()); - DMRoomMap.makeShared(); + DMRoomMap.makeShared(client); room = new Room("!1:example.org", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, diff --git a/test/components/structures/RightPanel-test.tsx b/test/components/structures/RightPanel-test.tsx index 03b0a1cc08..6be6693ed4 100644 --- a/test/components/structures/RightPanel-test.tsx +++ b/test/components/structures/RightPanel-test.tsx @@ -48,7 +48,7 @@ describe("RightPanel", () => { beforeEach(() => { stubClient(); cli = mocked(MatrixClientPeg.get()); - DMRoomMap.makeShared(); + DMRoomMap.makeShared(cli); context = new SdkContextClass(); context.client = cli; RightPanel = wrapInSdkContext(RightPanelBase, context); diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index bad6f27c66..8670d06885 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -56,6 +56,11 @@ import WidgetUtils from "../../../src/utils/WidgetUtils"; import { WidgetType } from "../../../src/widgets/WidgetType"; import WidgetStore from "../../../src/stores/WidgetStore"; +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + const RoomView = wrapInMatrixClientContext(_RoomView); describe("RoomView", () => { @@ -83,7 +88,7 @@ describe("RoomView", () => { room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); - DMRoomMap.makeShared(); + DMRoomMap.makeShared(cli); stores = new SdkContextClass(); stores.client = cli; stores.rightPanelStore.useUnitTestClient(cli); @@ -457,7 +462,7 @@ describe("RoomView", () => { }); it("the last Jitsi widget should be removed", () => { - expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(room.roomId, widget2Id); + expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id); }); }); diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index b81a84faca..95842df7d6 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; @@ -25,7 +25,7 @@ import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { mkStubRoom, stubClient } from "../../test-utils"; import dispatcher from "../../../src/dispatcher/dispatcher"; -import { HierarchyLevel, showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy"; +import SpaceHierarchy, { showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy"; import { Action } from "../../../src/dispatcher/actions"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import DMRoomMap from "../../../src/utils/DMRoomMap"; @@ -158,7 +158,18 @@ describe("SpaceHierarchy", () => { }); }); - describe("", () => { + describe("", () => { + beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; + }); + stubClient(); const client = MatrixClientPeg.get(); @@ -167,55 +178,123 @@ describe("SpaceHierarchy", () => { } as unknown as DMRoomMap; jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); - const root = mkStubRoom("room-id-1", "Room 1", client); - const room1 = mkStubRoom("room-id-2", "Room 2", client); - const room2 = mkStubRoom("room-id-3", "Room 3", client); + const root = mkStubRoom("space-id-1", "Space 1", client); + const room1 = mkStubRoom("room-id-2", "Room 1", client); + const room2 = mkStubRoom("room-id-3", "Room 2", client); + const space1 = mkStubRoom("space-id-4", "Space 2", client); + const room3 = mkStubRoom("room-id-5", "Room 3", client); + mocked(client.getRooms).mockReturnValue([root]); + mocked(client.getRoom).mockImplementation( + (roomId) => client.getRooms().find((room) => room.roomId === roomId) ?? null, + ); + [room1, room2, space1, room3].forEach((r) => mocked(r.getMyMembership).mockReturnValue("leave")); - const hierarchyRoot = { + const hierarchyRoot: IHierarchyRoom = { room_id: root.roomId, num_joined_members: 1, + room_type: "m.space", children_state: [ { state_key: room1.roomId, content: { order: "1" }, + origin_server_ts: 111, + type: "m.space.child", + sender: "@other:server", }, { state_key: room2.roomId, content: { order: "2" }, + origin_server_ts: 111, + type: "m.space.child", + sender: "@other:server", + }, + { + state_key: space1.roomId, + content: { order: "3" }, + origin_server_ts: 111, + type: "m.space.child", + sender: "@other:server", }, ], - } as IHierarchyRoom; - const hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; - const hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; + world_readable: true, + guest_can_join: true, + }; + const hierarchyRoom1: IHierarchyRoom = { + room_id: room1.roomId, + num_joined_members: 2, + children_state: [], + world_readable: true, + guest_can_join: true, + }; + const hierarchyRoom2: IHierarchyRoom = { + room_id: room2.roomId, + num_joined_members: 3, + children_state: [], + world_readable: true, + guest_can_join: true, + }; + const hierarchyRoom3: IHierarchyRoom = { + name: "Nested room", + room_id: room3.roomId, + num_joined_members: 3, + children_state: [], + world_readable: true, + guest_can_join: true, + }; + const hierarchySpace1: IHierarchyRoom = { + room_id: space1.roomId, + name: "Nested space", + num_joined_members: 1, + room_type: "m.space", + children_state: [ + { + state_key: room3.roomId, + content: { order: "1" }, + origin_server_ts: 111, + type: "m.space.child", + sender: "@other:server", + }, + ], + world_readable: true, + guest_can_join: true, + }; - const roomHierarchy = { - roomMap: new Map([ - [root.roomId, hierarchyRoot], - [room1.roomId, hierarchyRoom1], - [room2.roomId, hierarchyRoom2], - ]), - isSuggested: jest.fn(), - } as unknown as RoomHierarchy; + mocked(client.getRoomHierarchy).mockResolvedValue({ + rooms: [hierarchyRoot, hierarchyRoom1, hierarchyRoom2, hierarchySpace1, hierarchyRoom3], + }); - it("renders", () => { - const defaultProps = { - root: hierarchyRoot, - roomSet: new Set([hierarchyRoom1, hierarchyRoom2]), - hierarchy: roomHierarchy, - parents: new Set(), - selectedMap: new Map>(), - onViewRoomClick: jest.fn(), - onJoinRoomClick: jest.fn(), - onToggleClick: jest.fn(), - }; - const getComponent = (props = {}): React.ReactElement => ( - - ; - - ); + const defaultProps = { + space: root, + showRoom: jest.fn(), + }; + const getComponent = (props = {}): React.ReactElement => ( + + ; + + ); - const { container } = render(getComponent()); - expect(container).toMatchSnapshot(); + it("renders", async () => { + const { asFragment } = render(getComponent()); + // Wait for spinners to go away + await waitForElementToBeRemoved(screen.getAllByRole("progressbar")); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should join subspace when joining nested room", async () => { + mocked(client.joinRoom).mockResolvedValue({} as Room); + + const { getByText } = render(getComponent()); + // Wait for spinners to go away + await waitForElementToBeRemoved(screen.getAllByRole("progressbar")); + const button = getByText("Nested room")!.closest("li")!.querySelector(".mx_AccessibleButton_kind_primary")!; + fireEvent.click(button); + + await waitFor(() => { + expect(client.joinRoom).toHaveBeenCalledTimes(2); + }); + // Joins subspace + expect(client.joinRoom).toHaveBeenCalledWith(space1.roomId, expect.any(Object)); + expect(client.joinRoom).toHaveBeenCalledWith(room3.roomId, expect.any(Object)); }); }); }); diff --git a/test/components/structures/ThreadView-test.tsx b/test/components/structures/ThreadView-test.tsx index 94cfc66096..7ca5ca9d75 100644 --- a/test/components/structures/ThreadView-test.tsx +++ b/test/components/structures/ThreadView-test.tsx @@ -131,7 +131,7 @@ describe("ThreadView", () => { rootEvent = res.rootEvent; - DMRoomMap.makeShared(); + DMRoomMap.makeShared(mockClient); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(SENDER); }); diff --git a/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap new file mode 100644 index 0000000000..8b3a6dbdeb --- /dev/null +++ b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render spinner while app is loading 1`] = ` +
    +
    +
    +
    +
    +
    +
    +`; + +exports[` with an existing session onAction() room actions leave_room for a room should launch a confirmation modal 1`] = ` +
    renders 1`] = ` -
    -
  • renders 1`] = ` + + -
  • -
  • -
    +
  • - -
    -
    - Unnamed Room
    - Joined + + + + +
    +
    + Unnamed Room +
    +
    + 3 members
    - 3 members - · - -
    -
    -
    -
    - View -
    - - -
    -
  • - + +
  • +
    +
    +
    + + + + +
    +
    + Nested space +
    +
    + 1 member · 1 room +
    +
    +
    +
    + Join +
    + + +
  • +
  • +
    +
    +
    + + + + +
    +
    + Nested room +
    +
    + 3 members +
    +
    +
    +
    + Join +
    +
    + + +
    +
    +
  • + ; -
    + `; diff --git a/test/components/views/beacon/BeaconListItem-test.tsx b/test/components/views/beacon/BeaconListItem-test.tsx index cab8da30a0..aa3d4a183b 100644 --- a/test/components/views/beacon/BeaconListItem-test.tsx +++ b/test/components/views/beacon/BeaconListItem-test.tsx @@ -28,6 +28,11 @@ import { makeRoomWithBeacons, } from "../../../test-utils"; +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + describe("", () => { // 14.03.2022 16:15 const now = 1647270879403; diff --git a/test/components/views/beacon/DialogSidebar-test.tsx b/test/components/views/beacon/DialogSidebar-test.tsx index a0def1f445..6e8a0d8999 100644 --- a/test/components/views/beacon/DialogSidebar-test.tsx +++ b/test/components/views/beacon/DialogSidebar-test.tsx @@ -27,6 +27,11 @@ import { mockClientMethodsUser, } from "../../../test-utils"; +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + describe("", () => { const defaultProps: ComponentProps = { beacons: [], diff --git a/test/components/views/beacon/ShareLatestLocation-test.tsx b/test/components/views/beacon/ShareLatestLocation-test.tsx index 654b3dc73a..995fe7d3e3 100644 --- a/test/components/views/beacon/ShareLatestLocation-test.tsx +++ b/test/components/views/beacon/ShareLatestLocation-test.tsx @@ -21,6 +21,11 @@ import ShareLatestLocation from "../../../../src/components/views/beacon/ShareLa import { copyPlaintext } from "../../../../src/utils/strings"; import { flushPromises } from "../../../test-utils"; +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn().mockResolvedValue(undefined), })); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap index dd1d607dd4..9b924345aa 100644 --- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -32,6 +32,7 @@ exports[` when a beacon is live and has locations renders beac class="mx_BeaconListItem_interactions" >
    renders sidebar correctly with beacons 1`] = ` class="mx_BeaconListItem_interactions" >
    renders share buttons when there is a location 1`] = `
    { }; beforeEach(() => { - DMRoomMap.makeShared(); + DMRoomMap.makeShared(mockClient); jest.clearAllMocks(); mockClient.getUserId.mockReturnValue("@bob:example.org"); mockClient.getSafeUserId.mockReturnValue("@bob:example.org"); diff --git a/test/components/views/dialogs/IncomingSasDialog-test.tsx b/test/components/views/dialogs/IncomingSasDialog-test.tsx new file mode 100644 index 0000000000..61b9f21e03 --- /dev/null +++ b/test/components/views/dialogs/IncomingSasDialog-test.tsx @@ -0,0 +1,83 @@ +/* +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 { act, render } from "@testing-library/react"; +import React from "react"; +import { Mocked } from "jest-mock"; +import { VerificationBase } from "matrix-js-sdk/src/crypto/verification/Base"; +import { + EmojiMapping, + ShowSasCallbacks, + VerifierEvent, + VerifierEventHandlerMap, +} from "matrix-js-sdk/src/crypto-api/verification"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import IncomingSasDialog from "../../../../src/components/views/dialogs/IncomingSasDialog"; +import { stubClient } from "../../../test-utils"; + +describe("IncomingSasDialog", () => { + beforeEach(() => { + stubClient(); + }); + + it("shows a spinner at first", () => { + const mockVerifier = makeMockVerifier(); + const { container } = renderComponent(mockVerifier); + expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); + }); + + it("should show some emojis once keys are exchanged", () => { + const mockVerifier = makeMockVerifier(); + const { container } = renderComponent(mockVerifier); + + // fire the ShowSas event + const sasEvent = makeMockSasCallbacks(); + act(() => { + mockVerifier.emit(VerifierEvent.ShowSas, sasEvent); + }); + + const emojis = container.getElementsByClassName("mx_VerificationShowSas_emojiSas_block"); + expect(emojis.length).toEqual(7); + for (const emoji of emojis) { + expect(emoji).toHaveTextContent("🦄Unicorn"); + } + }); +}); + +function renderComponent(verifier: VerificationBase, onFinished = () => true) { + return render(); +} + +function makeMockVerifier(): Mocked { + const verifier = new TypedEventEmitter(); + Object.assign(verifier, { + cancel: jest.fn(), + }); + return verifier as unknown as Mocked; +} + +function makeMockSasCallbacks(): ShowSasCallbacks { + const unicorn: EmojiMapping = ["🦄", "unicorn"]; + return { + sas: { + emoji: new Array(7).fill(unicorn), + }, + cancel: jest.fn(), + confirm: jest.fn(), + mismatch: jest.fn(), + }; +} diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index 9b13d9fc77..61718fcd3c 100644 --- a/test/components/views/dialogs/InviteDialog-test.tsx +++ b/test/components/views/dialogs/InviteDialog-test.tsx @@ -143,7 +143,7 @@ describe("InviteDialog", () => { getClientWellKnown: jest.fn().mockResolvedValue({}), }); SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions); - DMRoomMap.makeShared(); + DMRoomMap.makeShared(mockClient); jest.clearAllMocks(); room = new Room(roomId, mockClient, mockClient.getSafeUserId()); diff --git a/test/components/views/dialogs/ServerPickerDialog-test.tsx b/test/components/views/dialogs/ServerPickerDialog-test.tsx new file mode 100644 index 0000000000..f54ed1beb5 --- /dev/null +++ b/test/components/views/dialogs/ServerPickerDialog-test.tsx @@ -0,0 +1,235 @@ +/* +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 { fireEvent, render, screen } from "@testing-library/react"; +import fetchMock from "fetch-mock-jest"; + +import ServerPickerDialog from "../../../../src/components/views/dialogs/ServerPickerDialog"; +import SdkConfig from "../../../../src/SdkConfig"; +import { flushPromises } from "../../../test-utils"; +import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; + +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + +describe("", () => { + const defaultServerConfig = { + hsUrl: "https://matrix.org", + hsName: "matrix.org", + hsNameIsDifferent: true, + isUrl: "https://is.org", + isDefault: true, + isNameResolvable: true, + warning: "", + }; + const wkHsUrl = "https://hsbaseurlfrom.wk"; + const wkIsUrl = "https://isbaseurlfrom.wk"; + const validWellKnown = { + "m.homeserver": { + base_url: wkHsUrl, + }, + "m.identity_server": { + base_url: wkIsUrl, + }, + }; + const defaultProps = { + serverConfig: defaultServerConfig, + onFinished: jest.fn(), + }; + const getComponent = ( + props: Partial<{ + onFinished: any; + serverConfig: ValidatedServerConfig; + }> = {}, + ) => render(); + + beforeEach(() => { + SdkConfig.add({ + validated_server_config: defaultServerConfig, + }); + + fetchMock.resetHistory(); + }); + + it("should render dialog", () => { + const { container } = getComponent(); + expect(container).toMatchSnapshot(); + }); + + // checkbox and text input have the same aria-label + const getOtherHomeserverCheckBox = () => + screen.getAllByLabelText("Other homeserver").find((node) => node.getAttribute("type") === "radio")!; + const getOtherHomeserverInput = () => + screen.getAllByLabelText("Other homeserver").find((node) => node.getAttribute("type") === "text")!; + + describe("when default server config is selected", () => { + it("should select other homeserver field on open", () => { + getComponent(); + expect(getOtherHomeserverCheckBox()).toBeChecked(); + // empty field + expect(getOtherHomeserverInput()).toHaveDisplayValue(""); + }); + + it("should display an error when trying to continue with an empty homeserver field", async () => { + const onFinished = jest.fn(); + const { container } = getComponent({ onFinished }); + + fireEvent.click(screen.getByText("Continue")); + + await flushPromises(); + + // error on field + expect(container.querySelector(".mx_ServerPickerDialog_otherHomeserver.mx_Field_invalid")).toBeTruthy(); + + // didn't close dialog + expect(onFinished).not.toHaveBeenCalled(); + }); + + it("should close when selecting default homeserver and clicking continue", async () => { + const onFinished = jest.fn(); + getComponent({ onFinished }); + + fireEvent.click(screen.getByTestId("defaultHomeserver")); + expect(screen.getByTestId("defaultHomeserver")).toBeChecked(); + + fireEvent.click(screen.getByText("Continue")); + + // closed dialog with default server + expect(onFinished).toHaveBeenCalledWith(defaultServerConfig); + }); + + it("should submit successfully with a valid custom homeserver", async () => { + const homeserver = "https://myhomeserver.site"; + fetchMock.get(`${homeserver}/_matrix/client/versions`, { + unstable_features: {}, + versions: [], + }); + const onFinished = jest.fn(); + getComponent({ onFinished }); + + fireEvent.change(getOtherHomeserverInput(), { target: { value: homeserver } }); + expect(getOtherHomeserverInput()).toHaveDisplayValue(homeserver); + + fireEvent.click(screen.getByText("Continue")); + + // validation on submit is async + await flushPromises(); + + // closed dialog with validated custom server + expect(onFinished).toHaveBeenCalledWith({ + hsName: "myhomeserver.site", + hsUrl: homeserver, + hsNameIsDifferent: false, + warning: null, + isDefault: false, + isNameResolvable: false, + isUrl: defaultServerConfig.isUrl, + }); + }); + + describe("validates custom homeserver", () => { + it("should lookup .well-known for homeserver without protocol", async () => { + const homeserver = "myhomeserver1.site"; + const wellKnownUrl = `https://${homeserver}/.well-known/matrix/client`; + fetchMock.get(wellKnownUrl, {}); + getComponent(); + + fireEvent.change(getOtherHomeserverInput(), { target: { value: homeserver } }); + expect(getOtherHomeserverInput()).toHaveDisplayValue(homeserver); + // trigger validation + fireEvent.blur(getOtherHomeserverInput()); + + // validation on submit is async + await flushPromises(); + + expect(fetchMock).toHaveFetched(wellKnownUrl); + }); + + it("should submit using validated config from a valid .well-known", async () => { + const homeserver = "myhomeserver2.site"; + const wellKnownUrl = `https://${homeserver}/.well-known/matrix/client`; + + // urls from homeserver well-known + const versionsUrl = `${wkHsUrl}/_matrix/client/versions`; + const isWellKnownUrl = `${wkIsUrl}/_matrix/identity/v2`; + + fetchMock.getOnce(wellKnownUrl, validWellKnown); + fetchMock.getOnce(versionsUrl, { + versions: [], + }); + fetchMock.getOnce(isWellKnownUrl, {}); + const onFinished = jest.fn(); + getComponent({ onFinished }); + + fireEvent.change(getOtherHomeserverInput(), { target: { value: homeserver } }); + fireEvent.click(screen.getByText("Continue")); + + // validation on submit is async + await flushPromises(); + + expect(fetchMock).toHaveFetched(wellKnownUrl); + // fetched using urls from .well-known + expect(fetchMock).toHaveFetched(versionsUrl); + expect(fetchMock).toHaveFetched(isWellKnownUrl); + + expect(onFinished).toHaveBeenCalledWith({ + hsName: homeserver, + hsUrl: wkHsUrl, + hsNameIsDifferent: true, + warning: null, + isDefault: false, + isNameResolvable: true, + isUrl: wkIsUrl, + }); + + await flushPromises(); + }); + + it("should fall back to static config when well-known lookup fails", async () => { + const homeserver = "myhomeserver3.site"; + // this server returns 404 for well-known + const wellKnownUrl = `https://${homeserver}/.well-known/matrix/client`; + fetchMock.get(wellKnownUrl, { status: 404 }); + // but is otherwise live (happy versions response) + fetchMock.get(`https://${homeserver}/_matrix/client/versions`, { versions: ["1"] }); + const onFinished = jest.fn(); + getComponent({ onFinished }); + + fireEvent.change(getOtherHomeserverInput(), { target: { value: homeserver } }); + fireEvent.click(screen.getByText("Continue")); + + // validation on submit is async + await flushPromises(); + + expect(fetchMock).toHaveFetched(wellKnownUrl); + expect(fetchMock).toHaveFetched(`https://${homeserver}/_matrix/client/versions`); + + expect(onFinished).toHaveBeenCalledWith({ + hsName: homeserver, + hsUrl: "https://" + homeserver, + hsNameIsDifferent: false, + warning: null, + isDefault: false, + isNameResolvable: false, + isUrl: defaultServerConfig.isUrl, + }); + }); + }); + }); +}); diff --git a/test/components/views/dialogs/UserSettingsDialog-test.tsx b/test/components/views/dialogs/UserSettingsDialog-test.tsx index 5e610b87ba..cba01bc1e1 100644 --- a/test/components/views/dialogs/UserSettingsDialog-test.tsx +++ b/test/components/views/dialogs/UserSettingsDialog-test.tsx @@ -75,7 +75,6 @@ describe("", () => { const getActiveTabLabel = (container: Element) => container.querySelector(".mx_TabbedView_tabLabel_active")?.textContent; const getActiveTabHeading = (container: Element) => - container.querySelector(".mx_SettingsTab_heading")?.textContent || container.querySelector(".mx_SettingsSection .mx_Heading_h2")?.textContent; it("should render general settings tab when no initialTabId", () => { @@ -117,10 +116,7 @@ describe("", () => { expect(getByTestId(`settings-tab-${UserTab.Voice}`)).toBeTruthy(); }); - it("renders session manager tab when enabled", () => { - mockSettingsStore.getValue.mockImplementation((settingName): any => { - return settingName === "feature_new_device_manager"; - }); + it("renders session manager tab", () => { const { getByTestId } = render(getComponent()); expect(getByTestId(`settings-tab-${UserTab.SessionManager}`)).toBeTruthy(); }); @@ -153,28 +149,15 @@ describe("", () => { expect(queryByTestId(`settings-tab-${UserTab.Mjolnir}`)).toBeFalsy(); expect(mockSettingsStore.watchSetting.mock.calls[0][0]).toEqual("feature_mjolnir"); - expect(mockSettingsStore.watchSetting.mock.calls[1][0]).toEqual("feature_new_device_manager"); // call the watch setting callback watchSettingCallbacks["feature_mjolnir"]("feature_mjolnir", "", SettingLevel.ACCOUNT, true, true); // tab is rendered now expect(queryByTestId(`settings-tab-${UserTab.Mjolnir}`)).toBeTruthy(); - // call the watch setting callback - watchSettingCallbacks["feature_new_device_manager"]( - "feature_new_device_manager", - "", - SettingLevel.ACCOUNT, - true, - true, - ); - // tab is rendered now - expect(queryByTestId(`settings-tab-${UserTab.SessionManager}`)).toBeTruthy(); - unmount(); // unwatches settings on unmount expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-watcher-id-feature_mjolnir"); - expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-watcher-id-feature_new_device_manager"); }); }); diff --git a/test/components/views/dialogs/__snapshots__/ServerPickerDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ServerPickerDialog-test.tsx.snap new file mode 100644 index 0000000000..4690792fac --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/ServerPickerDialog-test.tsx.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render dialog 1`] = ` +
    +
    +