diff --git a/.github/codecov.yml b/.github/codecov.yml deleted file mode 100644 index 449fa0a733a..00000000000 --- a/.github/codecov.yml +++ /dev/null @@ -1,12 +0,0 @@ -codecov: - allow_coverage_offsets: True -coverage: - status: - project: off - patch: off -comment: - layout: "diff, files" - behavior: default - require_changes: false - require_base: no - require_head: no diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index 260f8f130c5..bada40e077e 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -84,6 +84,7 @@ jobs: if: always() with: step: finish + override: false token: ${{ secrets.GITHUB_TOKEN }} status: ${{ job.status }} env: ${{ steps.deployment.outputs.env }} diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml deleted file mode 100644 index 786d828d419..00000000000 --- a/.github/workflows/preview_changelog.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: Preview Changelog -on: - pull_request_target: - types: [ opened, edited, labeled ] -jobs: - changelog: - runs-on: ubuntu-latest - steps: - - name: Preview Changelog - uses: matrix-org/allchange@main - with: - ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml new file mode 100644 index 00000000000..22a92bf0b56 --- /dev/null +++ b/.github/workflows/pull_request.yaml @@ -0,0 +1,24 @@ +name: Pull Request +on: + pull_request_target: + types: [ opened, edited, labeled, unlabeled ] +jobs: + changelog: + name: Preview Changelog + runs-on: ubuntu-latest + steps: + - uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} + + enforce-label: + name: Enforce Labels + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: yogevbd/enforce-label-action@2.1.0 + with: + REQUIRED_LABELS_ANY: "T-Defect,T-Enhancement,T-Task" + BANNED_LABELS: "X-Blocked" + BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 00000000000..7029be97f3b --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,47 @@ +name: SonarQube +on: + workflow_run: + workflows: [ "Tests" ] + types: + - completed +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + # 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 Coverage Report + uses: actions/github-script@v3.1.0 + with: + script: | + const artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "coverage" + })[0]; + const download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data)); + + - name: Extract Coverage Report + run: unzip -d coverage coverage.zip && rm coverage.zip + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 63e939f7f9a..266f7c728ad 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -38,11 +38,32 @@ jobs: run: "yarn run lint:types" i18n_lint: - name: "i18n Diff Check" + name: "i18n Check" runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - uses: actions/checkout@v2 + - name: "Get modified files" + id: changed_files + if: github.event_name == 'pull_request' && github.actor != 'RiotTranslateBot' + uses: tj-actions/changed-files@v19 + with: + files: | + src/i18n/strings/* + files_ignore: | + src/i18n/strings/en_EN.json + + - name: "Assert only en_EN was modified" + if: | + github.event_name == 'pull_request' && + github.actor != 'RiotTranslateBot' && + steps.changed_files.outputs.any_modified == 'true' + run: | + echo "You can only modify en_EN.json, do not touch any of the other i18n files as Weblate will be confused" + exit 1 + - uses: actions/setup-node@v3 with: cache: 'yarn' @@ -87,16 +108,3 @@ jobs: - name: Run Linter run: "yarn run lint:style" - - sonarqube: - name: "SonarQube" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f160e42844d..9fa7a6f7cf1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,16 +11,11 @@ env: PR_NUMBER: ${{ github.event.pull_request.number }} jobs: jest: - name: Jest with Codecov + name: Jest runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - with: - # If this is a pull request, make sure we check out its head rather than the - # automatically generated merge commit, so that the coverage diff excludes - # unrelated changes in the base branch - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} - name: Yarn cache uses: actions/setup-node@v3 @@ -31,11 +26,12 @@ jobs: run: "./scripts/ci/install-deps.sh --ignore-scripts" - name: Run tests with coverage - run: "yarn coverage" + run: "yarn coverage --ci" - - name: Upload coverage - uses: codecov/codecov-action@v2 + - name: Upload Artifact + uses: actions/upload-artifact@v2 with: - fail_ci_if_error: false - verbose: true - override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} + name: coverage + path: | + coverage + !coverage/lcov-report diff --git a/.github/workflows/upgrade_dependencies.yml b/.github/workflows/upgrade_dependencies.yml new file mode 100644 index 00000000000..a4a0fedc0d9 --- /dev/null +++ b/.github/workflows/upgrade_dependencies.yml @@ -0,0 +1,8 @@ +name: Upgrade Dependencies +on: + workflow_dispatch: { } +jobs: + upgrade: + uses: matrix-org/matrix-js-sdk/.github/workflows/upgrade_dependencies.yml@develop + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/README.md b/README.md index 4664887360a..1312e56a5b2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +[![npm](https://img.shields.io/npm/v/matrix-react-sdk)](https://www.npmjs.com/package/matrix-react-sdk) +![Tests](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/tests.yml/badge.svg) +![Static Analysis](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/static_analysis.yaml/badge.svg) +[![Weblate](https://translate.element.io/widgets/element-web/-/matrix-react-sdk/svg-badge.svg)](https://translate.element.io/engage/element-web/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=bugs)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk) + matrix-react-sdk ================ diff --git a/cypress/integration/1-register/register.spec.ts b/cypress/integration/1-register/register.spec.ts index f719da55477..f61a10e3046 100644 --- a/cypress/integration/1-register/register.spec.ts +++ b/cypress/integration/1-register/register.spec.ts @@ -16,34 +16,34 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker/index"; +import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Registration", () => { - let synapseId; - let synapsePort; + let synapse: SynapseInstance; beforeEach(() => { - cy.task("synapseStart", "consent").then(result => { - synapseId = result.synapseId; - synapsePort = result.port; - }); cy.visit("/#/register"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); }); afterEach(() => { - cy.task("synapseStop", synapseId); + cy.stopSynapse(synapse); }); it("registers an account and lands on the home screen", () => { cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(`http://localhost:${synapsePort}`); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get('.mx_ServerPickerDialog').should('not.exist'); + cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); cy.get(".mx_Login_submit").click(); + cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); diff --git a/cypress/integration/2-login/login.spec.ts b/cypress/integration/2-login/login.spec.ts new file mode 100644 index 00000000000..9fb7ba4792b --- /dev/null +++ b/cypress/integration/2-login/login.spec.ts @@ -0,0 +1,57 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Login", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.visit("/#/login"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("m.login.password", () => { + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.registerUser(synapse, username, password); + }); + + it("logs in with an existing account and lands on the home screen", () => { + cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_continue").click(); + // wait for the dialog to go away + cy.get('.mx_ServerPickerDialog').should('not.exist'); + + cy.get("#mx_LoginForm_username").type(username); + cy.get("#mx_LoginForm_password").type(password); + cy.get(".mx_Login_submit").click(); + + cy.url().should('contain', '/#/home'); + }); + }); +}); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts new file mode 100644 index 00000000000..b3c482d9f19 --- /dev/null +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import type { UserCredentials } from "../../support/login"; + +describe("UserMenu", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("consent").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jeff").then(credentials => { + user = credentials; + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should contain our name & userId", () => { + cy.get('[aria-label="User menu"]', { timeout: 15000 }).click(); + cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); + cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index db01ceceb4f..9438d136064 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -16,8 +16,13 @@ limitations under the License. /// -import { synapseDocker } from "./synapsedocker/index"; +import { synapseDocker } from "./synapsedocker"; +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; -export default function(on, config) { +/** + * @type {Cypress.PluginConfig} + */ +export default function(on: PluginEvents, config: PluginConfigOptions) { synapseDocker(on, config); } diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 0f029e7b2ed..af8ddac73c6 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -21,6 +21,10 @@ import * as os from "os"; import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; +import * as net from "net"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; // A cypress plugins to add command to start & stop synapses in // docker with preset templates. @@ -28,11 +32,13 @@ import * as fse from "fs-extra"; interface SynapseConfig { configDir: string; registrationSecret: string; + // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage + baseUrl: string; + port: number; } export interface SynapseInstance extends SynapseConfig { synapseId: string; - port: number; } const synapses = new Map(); @@ -41,6 +47,16 @@ function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } +async function getFreePort(): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = (srv.address()).port; + srv.close(() => resolve(port)); + }); + }); +} + async function cfgDirFromTemplate(template: string): Promise { const templateDir = path.join(__dirname, "templates", template); @@ -61,12 +77,16 @@ async function cfgDirFromTemplate(template: string): Promise { const macaroonSecret = randB64Bytes(16); const formSecret = randB64Bytes(16); - // now copy homeserver.yaml, applying sustitutions + const port = await getFreePort(); + const baseUrl = `http://localhost:${port}`; + + // now copy homeserver.yaml, applying substitutions console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8"); hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); + hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml); // now generate a signing key (we could use synapse's config generation for @@ -77,6 +97,8 @@ async function cfgDirFromTemplate(template: string): Promise { await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`); return { + port, + baseUrl, configDir: tempDir, registrationSecret, }; @@ -98,7 +120,7 @@ async function synapseStart(template: string): Promise { "--name", containerName, "-d", "-v", `${synCfg.configDir}:/data`, - "-p", "8008/tcp", + "-p", `${synCfg.port}:8008/tcp`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -107,30 +129,31 @@ async function synapseStart(template: string): Promise { }); }); - // Get the port that docker allocated: specifying only one - // port above leaves docker to just grab a free one, although - // in hindsight we need to put the port in public_baseurl in the - // config really, so this will probably need changing to use a fixed - // / configured port. - const port = await new Promise((resolve, reject) => { - childProcess.execFile('docker', [ - "port", synapseId, "8008", + synapses.set(synapseId, { synapseId, ...synCfg }); + + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + + // Await Synapse healthcheck + await new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "exec", synapseId, + "curl", + "--connect-timeout", "30", + "--retry", "30", + "--retry-delay", "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/health", ], { encoding: 'utf8' }, (err, stdout) => { if (err) reject(err); - resolve(Number(stdout.trim().split(":")[1])); + else resolve(); }); }); - synapses.set(synapseId, Object.assign({ - port, - synapseId, - }, synCfg)); - - console.log(`Started synapse with id ${synapseId} on port ${port}.`); return synapses.get(synapseId); } -async function synapseStop(id) { +async function synapseStop(id: string): Promise { const synCfg = synapses.get(id); if (!synCfg) throw new Error("Unknown synapse ID"); @@ -186,10 +209,10 @@ async function synapseStop(id) { /** * @type {Cypress.PluginConfig} */ -// eslint-disable-next-line no-unused-vars -export function synapseDocker(on, config) { +export function synapseDocker(on: PluginEvents, config: PluginConfigOptions) { on("task", { - synapseStart, synapseStop, + synapseStart, + synapseStop, }); on("after:spec", async (spec) => { @@ -197,7 +220,7 @@ export function synapseDocker(on, config) { // This is on the theory that we should avoid re-using synapse // instances between spec runs: they should be cheap enough to // start that we can have a separate one for each spec run or even - // test. If we accidentally re-use synapses, we could inadvertantly + // test. If we accidentally re-use synapses, we could inadvertently // make our tests depend on each other. for (const synId of synapses.keys()) { console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`); diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml index e26133f6d11..6decaeb5a0b 100644 --- a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml @@ -1,6 +1,6 @@ server_name: "localhost" pid_file: /data/homeserver.pid -public_baseurl: http://localhost:5005/ +public_baseurl: "{{PUBLIC_BASEURL}}" listeners: - port: 8008 tls: false diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 9901ef4cb80..598cc4de7ed 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,3 +1,20 @@ -// Empty file to prevent cypress from recreating a helpful example -// file on every run (their example file doesn't use semicolons and -// so fails our lint rules). +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import "./synapse"; +import "./login"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts new file mode 100644 index 00000000000..2d7d3ef84af --- /dev/null +++ b/cypress/support/login.ts @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; +import { SynapseInstance } from "../plugins/synapsedocker"; + +export interface UserCredentials { + accessToken: string; + userId: string; + deviceId: string; + password: string; + homeServer: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Generates a test user and instantiates an Element session with that user. + * @param synapse the synapse returned by startSynapse + * @param displayName the displayName to give the test user + */ + initTestUser(synapse: SynapseInstance, displayName: string): Chainable; + } + } +} + +Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable => { + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(() => { + const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + return cy.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>({ + url, + method: "POST", + body: { + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + }, + }); + }).then(response => { + return cy.window().then(win => { + // Seed the localStorage with the required credentials + win.localStorage.setItem("mx_hs_url", synapse.baseUrl); + win.localStorage.setItem("mx_user_id", response.body.user_id); + win.localStorage.setItem("mx_access_token", response.body.access_token); + win.localStorage.setItem("mx_device_id", response.body.device_id); + win.localStorage.setItem("mx_is_guest", "false"); + win.localStorage.setItem("mx_has_pickle_key", "false"); + win.localStorage.setItem("mx_has_access_token", "true"); + + return cy.visit("/").then(() => ({ + password, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); + }); + }); +}); diff --git a/cypress/support/synapse.ts b/cypress/support/synapse.ts new file mode 100644 index 00000000000..1571ddef366 --- /dev/null +++ b/cypress/support/synapse.ts @@ -0,0 +1,121 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import * as crypto from 'crypto'; + +import Chainable = Cypress.Chainable; +import AUTWindow = Cypress.AUTWindow; +import { SynapseInstance } from "../plugins/synapsedocker"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Start a synapse instance with a given config template. + * @param template path to template within cypress/plugins/synapsedocker/template/ directory. + */ + startSynapse(template: string): Chainable; + + /** + * Custom command wrapping task:synapseStop whilst preventing uncaught exceptions + * for if Synapse stopping races with the app's background sync loop. + * @param synapse the synapse instance returned by startSynapse + */ + stopSynapse(synapse: SynapseInstance): Chainable; + + /** + * Register a user on the given Synapse using the shared registration secret. + * @param synapse the synapse instance returned by startSynapse + * @param username the username of the user to register + * @param password the password of the user to register + * @param displayName optional display name to set on the newly registered user + */ + registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, + ): Chainable; + } + } +} + +function startSynapse(template: string): Chainable { + return cy.task("synapseStart", template); +} + +function stopSynapse(synapse: SynapseInstance): Chainable { + // Navigate away from app to stop the background network requests which will race with Synapse shutting down + return cy.window().then((win) => { + win.location.href = 'about:blank'; + cy.task("synapseStop", synapse.synapseId); + }); +} + +interface Credentials { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; +} + +function registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, +): Chainable { + const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + return cy.then(() => { + // get a nonce + return cy.request<{ nonce: string }>({ url }); + }).then(response => { + const { nonce } = response.body; + const mac = crypto.createHmac('sha1', synapse.registrationSecret).update( + `${nonce}\0${username}\0${password}\0notadmin`, + ).digest('hex'); + + return cy.request<{ + access_token: string; + user_id: string; + home_server: string; + device_id: string; + }>({ + url, + method: "POST", + body: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + }, + }); + }).then(response => ({ + homeServer: response.body.home_server, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + })); +} + +Cypress.Commands.add("startSynapse", startSynapse); +Cypress.Commands.add("stopSynapse", stopSynapse); +Cypress.Commands.add("registerUser", registerUser); diff --git a/docs/cypress.md b/docs/cypress.md new file mode 100644 index 00000000000..95b9b330d11 --- /dev/null +++ b/docs/cypress.md @@ -0,0 +1,163 @@ +# Cypress in Element Web + +## Scope of this Document +This doc is about our Cypress tests in Element Web and how we use Cypress to write tests. +It aims to cover: + * How to run the tests yourself + * How the tests work + * How to write great Cypress tests + +## Running the Tests +Our Cypress tests run automatically as part of our CI along with our other tests, +on every pull request and on every merge to develop & master. + +However the Cypress tests are run, an element-web must be running on +http://localhost:8080 (this is configured in `cypress.json`) - this is what will +be tested. When running Cypress tests yourself, the standard `yarn start` from the +element-web project is fine: leave it running it a different terminal as you would +when developing. + +The tests use Docker to launch Synapse instances to test against, so you'll also +need to have Docker installed and working in order to run the Cypress tests. + +There are a few different ways to run the tests yourself. The simplest is to run: + +``` +yarn run test:cypress +``` + +This will run the Cypress tests once, non-interactively. + +You can also run individual tests this way too, as you'd expect: + +``` +yarn run test:cypress cypress/integration/1-register/register.spec.ts +``` + +Cypress also has its own UI that you can use to run and debug the tests. +To launch it: + +``` +yarn run test:cypress:open +``` + +## How the Tests Work +Everything Cypress-related lives in the `cypress/` subdirectory of react-sdk +as is typical for Cypress tests. Likewise, tests live in `cypress/integration`. + +`cypress/plugins/synapsedocker` contains a Cypress plugin that starts instances +of Synapse in Docker containers. These synapses are what Element-web runs against +in the Cypress tests. + +Synapse can be launched with different configurations in order to test element +in different configurations. `cypress/plugins/synapsedocker/templates` contains +template configuration files for each different configuration. + +Each test suite can then launch whatever Synapse instances it needs it whatever +configurations. + +Note that although tests should stop the Synapse instances after running and the +plugin also stop any remaining instances after all tests have run, it is possible +to be left with some stray containers if, for example, you terminate a test such +that the `after()` does not run and also exit Cypress uncleanly. All the containers +it starts are prefixed, so they are easy to recognise. They can be removed safely. + +After each test run, logs from the Synapse instances are saved in `cypress/synapselogs` +with each instance in a separate directory named after its ID. These logs are removed +at the start of each test run. + +## Writing Tests +Mostly this is the same advice as for writing any other Cypress test: the Cypress +docs are well worth a read if you're not already familiar with Cypress testing, eg. +https://docs.cypress.io/guides/references/best-practices . + +### Getting a Synapse +The key difference is in starting Synapse instances. Tests use this plugin via +`cy.startSynapse()` to provide a Synapse instance to log into: + +```javascript +cy.startSynapse("consent").then(result => { + synapse = result; +}); +``` + +This returns an object with information about the Synapse instance, including what port +it was started on and the ID that needs to be passed to shut it down again. It also +returns the registration shared secret (`registrationSecret`) that can be used to +register users via the REST API. The Synapse has been ensured ready to go by awaiting +its internal health-check. + +Synapse instances should be reasonably cheap to start (you may see the first one take a +while as it pulls the Docker image), so it's generally expected that tests will start a +Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. + +To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance +object you received when starting it. +```javascript +cy.stopSynapse(synapse); +``` + +### Synapse Config Templates +When a Synapse instance is started, it's given a config generated from one of the config +templates in `cypress/plugins/synapsedocker/templates`. There are a couple of special files +in these templates: + * `homeserver.yaml`: + Template substitution happens in this file. Template variables are: + * `REGISTRATION_SECRET`: The secret used to register users via the REST API. + * `MACAROON_SECRET_KEY`: Generated each time for security + * `FORM_SECRET`: Generated each time for security + * `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at + * `localhost.signing.key`: A signing key is auto-generated and saved to this file. + Config templates should not contain a signing key and instead assume that one will exist + in this file. + +All other files in the template are copied recursively to `/data/`, so the file `foo.html` +in a template can be referenced in the config as `/data/foo.html`. + +### Logging In +There exists a basic utility to start the app with a random user already logged in: +```javascript +cy.initTestUser(synapse, "Jeff"); +``` +It takes the SynapseInstance you received from `startSynapse` and a display name for your test user. +This custom command will register a random userId using the registrationSecret with a random password +and the given display name. The returned Chainable will contain details about the credentials for if +they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them +and the app loaded (path `/`). + +The internals of how this custom command run may be swapped out later, +but the signature can be maintained for simpler maintenance. + +### Joining a Room +Many tests will also want to start with the client in a room, ready to send & receive messages. Best +way to do this may be to get an access token for the user and use this to create a room with the REST +API before logging the user in. + +### Convenience APIs +We should probably end up with convenience APIs that wrap the synapse creation, logging in and room +creation that can be called to set up tests. + +## Good Test Hygiene +This section mostly summarises general good Cypress testing practice, and should not be news to anyone +already familiar with Cypress. + +1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's + wrong when they fail. +1. Don't depend on state from other tests: any given test should be able to run in isolation. +1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're + testing that the user can send a reaction to a message, it's best to send a message using a REST + API, then react to it using the UI, rather than using the element-web UI to send the message. +1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and + all assertions are retired until they either pass or time out, so you should never need to + manually wait for an element. + * For example, for asserting about editing an already-edited message, you can't wait for the + 'edited' element to appear as there was already one there, but you can assert that the body + of the message is what is should be after the second edit and this assertion will pass once + it becomes true. You can then assert that the 'edited' element is still in the DOM. + * You can also wait for other things like network requests in the + browser to complete (https://docs.cypress.io/guides/guides/network-requests#Waiting). + Needing to wait for things can also be because of race conditions in the app itself, which ideally + shouldn't be there! + +This is a small selection - the Cypress best practices guide, linked above, has more good advice, and we +should generally try to adhere to them. diff --git a/package.json b/package.json index c62184810c5..a1dfd9d340a 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ "is-ip": "^3.1.0", "jszip": "^3.7.0", "katex": "^0.12.0", - "linkify-element": "^4.0.0-beta.4", - "linkify-string": "^4.0.0-beta.4", - "linkifyjs": "^4.0.0-beta.4", + "linkify-element": "4.0.0-beta.4", + "linkify-string": "4.0.0-beta.4", + "linkifyjs": "4.0.0-beta.4", "lodash": "^4.17.20", "maplibre-gl": "^1.15.2", "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#4aef17b56798639906f26a8739043a3c5c5fde7e", @@ -184,6 +184,7 @@ "jest-fetch-mock": "^3.0.3", "jest-mock": "^27.5.1", "jest-raw-loader": "^1.0.1", + "jest-sonar-reporter": "^2.0.0", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "^1.2.0", @@ -233,9 +234,14 @@ "/src/**/*.{js,ts,tsx}" ], "coverageReporters": [ - "text", - "json" - ] + "text-summary", + "lcov" + ], + "testResultsProcessor": "jest-sonar-reporter" + }, + "jestSonar": { + "reportPath": "coverage", + "sonar56x": true }, "typings": "./lib/index.d.ts" } diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss index ad7bf9a8167..2cdec19ebfb 100644 --- a/res/css/views/dialogs/_ForwardDialog.scss +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -85,6 +85,10 @@ limitations under the License. margin-top: 24px; } + .mx_ForwardList_resultsList { + padding-right: 8px; + } + .mx_ForwardList_entry { display: flex; justify-content: space-between; diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 9e9c59d2cbb..9947a7575f0 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -104,11 +104,13 @@ limitations under the License. } } - .mx_AutoHideScrollbar { + .mx_AutoHideScrollbar, + .mx_RoomView_messagePanelSpinner { background-color: $background; border-radius: 8px; padding-inline-end: 0; overflow-y: scroll; // set gap between the thread tile and the right border + height: 100%; } // Override _GroupLayout.scss for the thread panel diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 3b7a51797f9..15cf0cdc1ec 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -52,6 +52,10 @@ limitations under the License. .mx_UserInfo_container { padding: 8px 16px; + + .mx_UserInfo_container_verifyButton { + margin-top: $spacing-8; + } } .mx_UserInfo_separator { @@ -193,10 +197,7 @@ limitations under the License. } .mx_UserInfo_field { - cursor: pointer; - color: $accent; line-height: $font-16px; - margin: 8px 0; &.mx_UserInfo_destructive { color: $alert; @@ -228,14 +229,18 @@ limitations under the License. padding-bottom: 0; > :not(h3) { - margin-left: 8px; + margin-inline-start: $spacing-8; + display: flex; + flex-flow: column; + align-items: flex-start; + row-gap: $spacing-8; } } .mx_UserInfo_devices { .mx_UserInfo_device { display: flex; - margin: 8px 0; + margin: $spacing-8 0; &.mx_UserInfo_device_verified { .mx_UserInfo_device_trusted { @@ -250,7 +255,7 @@ limitations under the License. .mx_UserInfo_device_name { flex: 1; - margin-right: 5px; + margin: 0 5px; word-break: break-word; } } @@ -259,20 +264,16 @@ limitations under the License. .mx_E2EIcon { // don't squeeze flex: 0 0 auto; - margin: 2px 5px 0 0; + margin: 0; width: 12px; height: 12px; } .mx_UserInfo_expand { - display: flex; - margin-top: 11px; + column-gap: 5px; // cf: mx_UserInfo_device_name + margin-bottom: 11px; } } - - .mx_AccessibleButton.mx_AccessibleButton_hasKind { - padding: 8px 18px; - } } .mx_UserInfo.mx_UserInfo_smallAvatar { diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 4104ae42733..29888908fa8 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -96,7 +96,11 @@ limitations under the License. line-height: $font-18px; } - > .mx_DisambiguatedProfile { + // inside mx_RoomView_MessageList, outside of mx_ReplyTile + // (on the main panel and the chat panel with a maximized widget) + > .mx_DisambiguatedProfile, + // inside a thread, outside of mx_ReplyTile + .mx_EventTile_senderDetails > .mx_DisambiguatedProfile { position: relative; top: -2px; left: 2px; @@ -406,6 +410,7 @@ limitations under the License. .mx_MPollBody { width: 550px; // to prevent timestamp overlapping summary text + max-width: 100%; // prevent overflowing a reply tile .mx_MPollBody_totalVotes { // align summary text with corner timestamp diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index a849c5fedb1..d802c4f9fb6 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -886,7 +886,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss width: 100%; .mx_EventTile_content, - .mx_EventTile_body, .mx_HiddenBody, .mx_RedactedBody, .mx_UnknownBody, diff --git a/sonar-project.properties b/sonar-project.properties index afeecf737be..b6516cb92ac 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -14,3 +14,8 @@ sonar.organization=matrix-org sonar.sources=src,res sonar.tests=test,cypress sonar.exclusions=__mocks__,docs + +sonar.typescript.tsconfigPath=./tsconfig.json +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.coverage.exclusions=spec/*.ts +sonar.testExecutionReportPaths=coverage/test-report.xml diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1d54b1adc3a..7cb0ad1db9c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -380,11 +380,11 @@ export default class ContentMessages { const tooBigFiles = []; const okFiles = []; - for (let i = 0; i < files.length; ++i) { - if (this.isFileSizeAcceptable(files[i])) { - okFiles.push(files[i]); + for (const file of files) { + if (this.isFileSizeAcceptable(file)) { + okFiles.push(file); } else { - tooBigFiles.push(files[i]); + tooBigFiles.push(file); } } @@ -450,13 +450,7 @@ export default class ContentMessages { } public cancelUpload(promise: Promise, matrixClient: MatrixClient): void { - let upload: IUpload; - for (let i = 0; i < this.inprogress.length; ++i) { - if (this.inprogress[i].promise === promise) { - upload = this.inprogress[i]; - break; - } - } + const upload = this.inprogress.find(item => item.promise === promise); if (upload) { upload.canceled = true; matrixClient.cancelUpload(upload.promise); diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ac26eccc718..7f92653c30b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,13 +27,17 @@ import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; import { IContent } from 'matrix-js-sdk/src/models/event'; -import { _linkifyElement, _linkifyString } from './linkify-matrix'; +import { + _linkifyElement, + _linkifyString, + ELEMENT_URL_PATTERN, + options as linkifyMatrixOptions, +} from './linkify-matrix'; import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import SettingsStore from './settings/SettingsStore'; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { getEmojiFromUnicode } from "./emoji"; import { mediaFromMxc } from "./customisations/Media"; -import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix'; import { stripHTMLReply, stripPlainReply } from './utils/Reply'; // Anything outside the basic multilingual plane will be a surrogate pair @@ -45,10 +49,10 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SYMBOL_PATTERN = /([\u2100-\u2bff])/; // Regex pattern for Zero-Width joiner unicode characters -const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g"); +const ZWJ_REGEX = /[\u200D\u2003]/g; // Regex pattern for whitespace characters -const WHITESPACE_REGEX = new RegExp("\\s", "g"); +const WHITESPACE_REGEX = /\s/g; const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index d4f4ffc6811..8ce30252f92 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -15,14 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isMac, Key } from "./Keyboard"; +import { IS_MAC, Key } from "./Keyboard"; import SettingsStore from "./settings/SettingsStore"; import SdkConfig from "./SdkConfig"; -import { - IKeyBindingsProvider, - KeyBinding, - KeyCombo, -} from "./KeyBindingsManager"; +import { IKeyBindingsProvider, KeyBinding } from "./KeyBindingsManager"; import { CATEGORIES, CategoryName, @@ -31,13 +27,10 @@ import { import { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils"; export const getBindingsByCategory = (category: CategoryName): KeyBinding[] => { - return CATEGORIES[category].settingNames.reduce((bindings, name) => { - const value = getKeyboardShortcuts()[name]?.default; - if (value) { - bindings.push({ - action: name as KeyBindingAction, - keyCombo: value as KeyCombo, - }); + return CATEGORIES[category].settingNames.reduce((bindings, action) => { + const keyCombo = getKeyboardShortcuts()[action]?.default; + if (keyCombo) { + bindings.push({ action, keyCombo }); } return bindings; }, []); @@ -81,7 +74,7 @@ const messageComposerBindings = (): KeyBinding[] => { shiftKey: true, }, }); - if (isMac) { + if (IS_MAC) { bindings.push({ action: KeyBindingAction.NewLine, keyCombo: { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 7a79a69ce87..aee403e31d1 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -17,7 +17,7 @@ limitations under the License. import { KeyBindingAction } from "./accessibility/KeyboardShortcuts"; import { defaultBindingsProvider } from './KeyBindingsDefaults'; -import { isMac } from './Keyboard'; +import { IS_MAC } from './Keyboard'; /** * Represent a key combination. @@ -127,7 +127,7 @@ export class KeyBindingsManager { ): KeyBindingAction | undefined { for (const getter of getters) { const bindings = getter(); - const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, IS_MAC)); if (binding) { return binding.action; } diff --git a/src/Keyboard.ts b/src/Keyboard.ts index 8d7d39fc190..efecd791fd8 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -74,10 +74,10 @@ export const Key = { Z: "z", }; -export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; +export const IS_MAC = navigator.platform.toUpperCase().includes('MAC'); export function isOnlyCtrlOrCmdKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; @@ -85,7 +85,7 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) { } export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { - if (isMac) { + if (IS_MAC) { return ev.metaKey && !ev.altKey && !ev.ctrlKey; } else { return ev.ctrlKey && !ev.altKey && !ev.metaKey; diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index f91158c38aa..516e18ddc73 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -61,6 +61,7 @@ import { setSentryUser } from "./sentry"; import SdkConfig from "./SdkConfig"; import { DialogOpener } from "./utils/DialogOpener"; import { Action } from "./dispatcher/actions"; +import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -832,7 +833,7 @@ async function startMatrixClient(startSyncing = true): Promise { } // Now that we have a MatrixClientPeg, update the Jitsi info - await Jitsi.getInstance().start(); + Jitsi.getInstance().start(); // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. @@ -878,6 +879,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise !event.getContent()[key])) { + return false; } // Valid enough by our standards diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index f3d254d0590..c67e8ec8d96 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -83,9 +83,11 @@ async function confirmToDismiss(): Promise { return !sure; } +type KeyParams = { passphrase: string, recoveryKey: string }; + function makeInputToKey( keyInfo: ISecretStorageKeyInfo, -): (keyParams: { passphrase: string, recoveryKey: string }) => Promise { +): (keyParams: KeyParams) => Promise { return async ({ passphrase, recoveryKey }) => { if (passphrase) { return deriveKey( @@ -101,11 +103,10 @@ function makeInputToKey( async function getSecretStorageKey( { keys: keyInfos }: { keys: Record }, - ssssItemName, ): Promise<[string, Uint8Array]> { const cli = MatrixClientPeg.get(); let keyId = await cli.getDefaultSecretStorageKeyId(); - let keyInfo; + let keyInfo: ISecretStorageKeyInfo; if (keyId) { // use the default SSSS key if set keyInfo = keyInfos[keyId]; @@ -154,9 +155,9 @@ async function getSecretStorageKey( /* props= */ { keyInfo, - checkPrivateKey: async (input) => { + checkPrivateKey: async (input: KeyParams) => { const key = await inputToKey(input); - return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); + return MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); }, }, /* className= */ null, @@ -171,11 +172,11 @@ async function getSecretStorageKey( }, }, ); - const [input] = await finished; - if (!input) { + const [keyParams] = await finished; + if (!keyParams) { throw new AccessCancelledError(); } - const key = await inputToKey(input); + const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session cacheSecretStorageKey(keyId, keyInfo, key); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index bb6d9eab3c7..04d1726f3d5 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -224,7 +224,7 @@ const onViewJoinRuleSettingsClick = () => { }); }; -function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => Renderable { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); switch (ev.getContent().join_rule) { case JoinRule.Public: @@ -281,7 +281,7 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null { const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), + allow_ip_literals: prevContent.allow_ip_literals !== false, }; let getText = null; @@ -372,13 +372,15 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { addresses: addedAltAliases.join(", "), count: addedAltAliases.length, }); - } if (removedAltAliases.length && !addedAltAliases.length) { + } + if (removedAltAliases.length && !addedAltAliases.length) { return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { senderName, addresses: removedAltAliases.join(", "), count: removedAltAliases.length, }); - } if (removedAltAliases.length && addedAltAliases.length) { + } + if (removedAltAliases.length && addedAltAliases.length) { return () => _t('%(senderName)s changed the alternative addresses for this room.', { senderName, }); @@ -504,7 +506,7 @@ const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); }; -function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => Renderable { if (!SettingsStore.getValue("feature_pinning")) return null; const senderName = getSenderName(event); const roomId = event.getRoomId(); @@ -758,10 +760,12 @@ function textForPollEndEvent(event: MatrixEvent): () => string | null { }); } +type Renderable = string | JSX.Element | null; + interface IHandlers { [type: string]: (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) => - (() => string | JSX.Element | null); + (() => Renderable); } const handlers: IHandlers = { diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 434116d4303..1dff38cde34 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { KeyCombo } from "../KeyBindingsManager"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { _t, _td } from "../languageHandler"; import PlatformPeg from "../PlatformPeg"; import SettingsStore from "../settings/SettingsStore"; @@ -96,7 +96,7 @@ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { return Object.keys(KEYBOARD_SHORTCUTS).filter((k: KeyBindingAction) => { if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false; - if (MAC_ONLY_SHORTCUTS.includes(k) && !isMac) return false; + if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false; if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false; return true; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 97e428d2a0f..50992eb299a 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -16,7 +16,7 @@ limitations under the License. */ import { _td } from "../languageHandler"; -import { isMac, Key } from "../Keyboard"; +import { IS_MAC, Key } from "../Keyboard"; import { IBaseSetting } from "../settings/Settings"; import IncompatibleController from "../settings/controllers/IncompatibleController"; import { KeyCombo } from "../KeyBindingsManager"; @@ -200,7 +200,7 @@ export const KEY_ICON: Record = { [Key.ARROW_LEFT]: "←", [Key.ARROW_RIGHT]: "→", }; -if (isMac) { +if (IS_MAC) { KEY_ICON[Key.META] = "⌘"; KEY_ICON[Key.ALT] = "⌥"; } @@ -528,8 +528,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.GoToHome]: { default: { ctrlOrCmdKey: true, - altKey: !isMac, - shiftKey: isMac, + altKey: !IS_MAC, + shiftKey: IS_MAC, key: Key.H, }, displayName: _td("Go to Home View"), @@ -621,25 +621,25 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, [KeyBindingAction.EditRedo]: { default: { - key: isMac ? Key.Z : Key.Y, + key: IS_MAC ? Key.Z : Key.Y, ctrlOrCmdKey: true, - shiftKey: isMac, + shiftKey: IS_MAC, }, displayName: _td("Redo edit"), }, [KeyBindingAction.PreviousVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT, }, displayName: _td("Previous recently visited room or space"), }, [KeyBindingAction.NextVisitedRoomOrSpace]: { default: { - metaKey: isMac, - altKey: !isMac, - key: isMac ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, + metaKey: IS_MAC, + altKey: !IS_MAC, + key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT, }, displayName: _td("Next recently visited room or space"), }, diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index c4d75cc854a..b8893763361 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -282,7 +282,8 @@ function addMatrixClientListener( const listener: Listener = (...args) => { const payload = actionCreator(matrixClient, ...args); if (payload) { - dis.dispatch(payload, true); + // Consumers shouldn't have to worry about calling js-sdk methods mid-dispatch, so make this dispatch async + dis.dispatch(payload, false); } }; matrixClient.on(eventName, listener); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 53df137f6d6..190e683cf2b 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -276,7 +276,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 880f4e68732..0c7ef1afb2e 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -95,7 +95,7 @@ export default class Autocompleter { */ // list of results from each provider, each being a list of completions or null if it times out const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { - return await timeout( + return timeout( provider.getCompletions(query, selection, force, limit), null, PROVIDER_COMPLETION_TIMEOUT, diff --git a/src/boundThreepids.ts b/src/boundThreepids.ts index a703d10fd78..6421c1309aa 100644 --- a/src/boundThreepids.ts +++ b/src/boundThreepids.ts @@ -53,7 +53,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.errcode !== "M_TERMS_NOT_SIGNED") { throw e; } } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 187e55cc392..695d6ec2a7b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -157,12 +157,14 @@ export default class ContextMenu extends React.PureComponent { // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst // a context menu and its click-guard are up without completely rewriting how the context menus work. setImmediate(() => { - const clickEvent = document.createEvent('MouseEvents'); - clickEvent.initMouseEvent( - 'contextmenu', true, true, window, 0, - 0, 0, x, y, false, false, - false, false, 0, null, - ); + const clickEvent = new MouseEvent("contextmenu", { + clientX: x, + clientY: y, + screenX: 0, + screenY: 0, + button: 0, // Left + relatedTarget: null, + }); document.elementFromPoint(x, y).dispatchEvent(clickEvent); }); } @@ -417,8 +419,8 @@ export type ToRightOf = { // Placement method for to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect: Pick, chevronOffset = 12): ToRightOf => { - const left = elementRect.right + window.pageXOffset + 3; - let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + const left = elementRect.right + window.scrollX + 3; + let top = elementRect.top + (elementRect.height / 2) + window.scrollY; top -= chevronOffset + 8; // where 8 is half the height of the chevron return { left, top, chevronOffset }; }; @@ -436,9 +438,9 @@ export const aboveLeftOf = ( ): AboveLeftOf => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -460,9 +462,9 @@ export const aboveRightOf = ( ): AboveLeftOf => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonLeft = elementRect.left + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonLeft = elementRect.left + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically on whichever side of the button has more space available. @@ -484,9 +486,9 @@ export const alwaysAboveLeftOf = ( ) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonRight = elementRect.right + window.pageXOffset; - const buttonBottom = elementRect.bottom + window.pageYOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonRight = elementRect.right + window.scrollX; + const buttonBottom = elementRect.bottom + window.scrollY; + const buttonTop = elementRect.top + window.scrollY; // Align the right edge of the menu to the right edge of the button menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. @@ -508,8 +510,8 @@ export const alwaysAboveRightOf = ( ) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; - const buttonLeft = elementRect.left + window.pageXOffset; - const buttonTop = elementRect.top + window.pageYOffset; + const buttonLeft = elementRect.left + window.scrollX; + const buttonTop = elementRect.top + window.scrollY; // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically above the menu diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 38d18e92f76..5dd5ea01936 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -23,6 +23,7 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Relations } from "matrix-js-sdk/src/models/relations"; import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -1079,7 +1080,7 @@ abstract class BaseGrouper { // Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until -// the first non-state event or membership event which is not regarding the sender of the `m.room.create` event +// the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event class CreationGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { return ev.getType() === EventType.RoomCreate; @@ -1098,9 +1099,15 @@ class CreationGrouper extends BaseGrouper { && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } + // beacons are not part of room creation configuration + // should be shown in timeline + if (M_BEACON_INFO.matches(ev.getType())) { + return false; + } if (ev.isState() && ev.getSender() === createEvent.getSender()) { return true; } + return false; } diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 99aeb6f5478..aa8f38556a7 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -26,7 +26,7 @@ import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; -import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; +import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; import NetworkDropdown from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; @@ -43,7 +43,6 @@ import PosthogTrackers from "../../PosthogTrackers"; import { PublicRoomTile } from "../views/rooms/PublicRoomTile"; import { getFieldsForThirdPartyLocation, joinRoomByAlias, showRoom } from "../../utils/rooms"; import { GenericError } from "../../utils/error"; -import { ALL_ROOMS, Protocols } from "../../utils/DirectoryUtils"; const LAST_SERVER_KEY = "mx_last_room_directory_server"; const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 94212927641..77faf0f9298 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -28,7 +28,7 @@ import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCo import { getKeyBindingsManager } from "../../KeyBindingsManager"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; -import { isMac, Key } from "../../Keyboard"; +import { IS_MAC, Key } from "../../Keyboard"; import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import SpotlightDialog from "../views/dialogs/SpotlightDialog"; @@ -206,7 +206,7 @@ export default class RoomSearch extends React.PureComponent { ); let shortcutPrompt =
- { isMac ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" } + { IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" }
; if (this.props.isMinimized) { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 761dd9b496f..2f55f1b217a 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -179,9 +179,7 @@ export interface IRoomState { // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. It has the effect of hiding the // 'scroll to bottom' knob, among a couple of other things. - atEndOfLiveTimeline: boolean; - // used by componentDidUpdate to avoid unnecessary checks - atEndOfLiveTimelineInit: boolean; + atEndOfLiveTimeline?: boolean; showTopUnreadMessagesBar: boolean; statusBarVisible: boolean; // We load this later by asking the js-sdk to suggest a version for us. @@ -257,8 +255,6 @@ export class RoomView extends React.Component { isPeeking: false, showRightPanel: false, joining: false, - atEndOfLiveTimeline: true, - atEndOfLiveTimelineInit: false, showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, @@ -692,9 +688,8 @@ export class RoomView extends React.Component { // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this.messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this.messagePanel && this.state.atEndOfLiveTimeline === undefined) { this.setState({ - atEndOfLiveTimelineInit: true, atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), }); } @@ -1403,7 +1398,12 @@ export class RoomView extends React.Component { .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (!bundledRelationship || event.getThread()) continue; const room = this.context.getRoom(event.getRoomId()); - event.setThread(room.findThreadForEvent(event) ?? room.createThread(event, [], true)); + const thread = room.findThreadForEvent(event); + if (thread) { + event.setThread(thread); + } else { + room.createThread(event.getId(), event, [], true); + } } } } @@ -2102,7 +2102,7 @@ export class RoomView extends React.Component { } let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense - if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { + if (this.state.atEndOfLiveTimeline === false && !this.state.searchResults) { jumpToBottom = ( 0} numUnreadMessages={this.state.numUnreadMessages} diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 756cacab1fe..89790e46746 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -36,7 +36,6 @@ import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; -import dis from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; @@ -330,13 +329,13 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } } const roomAlias = getDisplayAliasForRoom(room) || undefined; - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.ViewRoom, should_peek: true, room_alias: roomAlias, @@ -356,7 +355,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st // Don't let the user view a room they won't be able to either peek or join: // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { - dis.dispatch({ action: "require_registration" }); + defaultDispatcher.dispatch({ action: "require_registration" }); return; } @@ -365,7 +364,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st }); prom.then(() => { - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: "SpaceHierarchy", @@ -569,7 +568,7 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [ ...selected.get(parentId).values(), - ].map(childId => [parentId, childId]) as [string, string][]; + ].map(childId => [parentId, childId]); }); const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1e9d5caa0cf..4e258f5258b 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -36,7 +36,6 @@ import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; import withValidation from "../views/elements/Validation"; import * as Email from "../../email"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import ResizeNotifier from "../../utils/ResizeNotifier"; import MainSplit from './MainSplit'; @@ -204,7 +203,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp { - dis.dispatch({ + defaultDispatcher.dispatch({ action: "leave_room", room_id: space.roomId, }); @@ -316,8 +315,8 @@ const SpaceLandingAddButton = ({ space }) => { if (menuDisplayed) { const rect = handle.current.getBoundingClientRect(); contextMenu = = ({ const [filterOption, setFilterOption] = useState(ThreadFilterType.All); const [room, setRoom] = useState(null); - const [threadCount, setThreadCount] = useState(0); const [timelineSet, setTimelineSet] = useState(null); const [narrow, setNarrow] = useState(false); @@ -206,23 +206,13 @@ const ThreadPanel: React.FC = ({ }, [mxClient, roomId]); useEffect(() => { - function onNewThread(): void { - setThreadCount(room.threads.size); - } - function refreshTimeline() { - if (timelineSet) timelinePanel.current.refreshTimeline(); + timelinePanel?.current.refreshTimeline(); } - if (room) { - setThreadCount(room.threads.size); - - room.on(ThreadEvent.New, onNewThread); - room.on(ThreadEvent.Update, refreshTimeline); - } + room?.on(ThreadEvent.Update, refreshTimeline); return () => { - room?.removeListener(ThreadEvent.New, onNewThread); room?.removeListener(ThreadEvent.Update, refreshTimeline); }; }, [room, mxClient, timelineSet]); @@ -260,7 +250,7 @@ const ThreadPanel: React.FC = ({ header={} footer={<> = ({ permalinkCreator={permalinkCreator} disableGrouping={true} /> - :
+ :
+ +
} diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 12dd84685d9..2eb4af94fcd 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { createRef, KeyboardEvent } from 'react'; -import { Thread, ThreadEvent, THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; +import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { Room } from 'matrix-js-sdk/src/models/room'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; @@ -51,6 +51,7 @@ import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import { RoomViewStore } from '../../stores/RoomViewStore'; +import Spinner from "../views/elements/Spinner"; interface IProps { room: Room; @@ -66,7 +67,6 @@ interface IProps { interface IState { thread?: Thread; - lastThreadReply?: MatrixEvent; layout: Layout; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; @@ -104,7 +104,6 @@ export default class ThreadView extends React.Component { } public componentWillUnmount(): void { - this.teardownThread(); if (this.dispatcherRef) dis.unregister(this.dispatcherRef); const roomId = this.props.mxEvent.getRoomId(); const room = MatrixClientPeg.get().getRoom(roomId); @@ -123,7 +122,6 @@ export default class ThreadView extends React.Component { public componentDidUpdate(prevProps) { if (prevProps.mxEvent !== this.props.mxEvent) { - this.teardownThread(); this.setupThread(this.props.mxEvent); } @@ -134,7 +132,6 @@ export default class ThreadView extends React.Component { private onAction = (payload: ActionPayload): void => { if (payload.phase == RightPanelPhases.ThreadView && payload.event) { - this.teardownThread(); this.setupThread(payload.event); } switch (payload.action) { @@ -164,23 +161,15 @@ export default class ThreadView extends React.Component { }; private setupThread = (mxEv: MatrixEvent) => { - let thread = this.props.room.threads?.get(mxEv.getId()); + let thread = this.props.room.getThread(mxEv.getId()); if (!thread) { - thread = this.props.room.createThread(mxEv, [mxEv], true); + thread = this.props.room.createThread(mxEv.getId(), mxEv, [mxEv], true); } - thread.on(ThreadEvent.Update, this.updateLastThreadReply); this.updateThread(thread); }; - private teardownThread = () => { - if (this.state.thread) { - this.state.thread.removeListener(ThreadEvent.Update, this.updateLastThreadReply); - } - }; - private onNewThread = (thread: Thread) => { if (thread.id === this.props.mxEvent.getId()) { - this.teardownThread(); this.setupThread(this.props.mxEvent); } }; @@ -189,33 +178,15 @@ export default class ThreadView extends React.Component { if (thread && this.state.thread !== thread) { this.setState({ thread, - lastThreadReply: thread.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }), }, async () => { thread.emit(ThreadEvent.ViewThread); - if (!thread.initialEventsFetched) { - const response = await thread.fetchInitialEvents(); - if (response?.nextBatch) { - this.nextBatch = response.nextBatch; - } - } - + await thread.fetchInitialEvents(); + this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); this.timelinePanel.current?.refreshTimeline(); }); } }; - private updateLastThreadReply = () => { - if (this.state.thread) { - this.setState({ - lastThreadReply: this.state.thread.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }), - }); - } - }; - private resetJumpToEvent = (event?: string): void => { if (this.props.initialEvent && this.props.initialEventScrollIntoView && event === this.props.initialEvent?.getId()) { @@ -298,12 +269,16 @@ export default class ThreadView extends React.Component { }; private get threadRelation(): IEventRelation { + const lastThreadReply = this.state.thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }); + return { "rel_type": THREAD_RELATION_TYPE.name, "event_id": this.state.thread?.id, "is_falling_back": true, "m.in_reply_to": { - "event_id": this.state.lastThreadReply?.getId() ?? this.state.thread?.id, + "event_id": lastThreadReply?.getId() ?? this.state.thread?.id, }, }; } @@ -324,11 +299,45 @@ export default class ThreadView extends React.Component { const threadRelation = this.threadRelation; - const messagePanelClassNames = classNames( - "mx_RoomView_messagePanel", - { - "mx_GroupLayout": this.state.layout === Layout.Group, - }); + const messagePanelClassNames = classNames("mx_RoomView_messagePanel", { + "mx_GroupLayout": this.state.layout === Layout.Group, + }); + + let timeline: JSX.Element; + if (this.state.thread) { + timeline = <> + +