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/_components.scss b/res/css/_components.scss index 98d1e4c655c..0d04789f313 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -80,6 +80,7 @@ @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; +@import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @@ -256,9 +257,11 @@ @import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; +@import "./views/rooms/_RoomInfoLine.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomListHeader.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; +@import "./views/rooms/_RoomPreviewCard.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index eed3d8830f6..f4d37e0e246 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -137,124 +137,6 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_preview, - .mx_SpaceRoomView_landing { - .mx_SpaceRoomView_info_memberCount { - color: inherit; - position: relative; - padding: 0 0 0 16px; - font-size: $font-15px; - display: inline; // cancel inline-flex - - &::before { - content: "·"; // visual separator - position: absolute; - left: 6px; - } - } - } - - .mx_SpaceRoomView_preview { - padding: 32px 24px !important; // override default padding from above - margin: auto; - max-width: 480px; - box-sizing: border-box; - box-shadow: 2px 15px 30px $dialog-shadow-color; - border-radius: 8px; - position: relative; - - // XXX remove this when spaces leaves Beta - .mx_BetaCard_betaPill { - position: absolute; - right: 24px; - top: 32px; - } - - // XXX remove this when spaces leaves Beta - .mx_SpaceRoomView_preview_spaceBetaPrompt { - font-weight: $font-semi-bold; - font-size: $font-14px; - line-height: $font-24px; - color: $primary-content; - margin-top: 24px; - position: relative; - padding-left: 24px; - - .mx_AccessibleButton_kind_link { - display: inline; - padding: 0; - font-size: inherit; - line-height: inherit; - } - - &::before { - content: ""; - position: absolute; - height: $font-24px; - width: 20px; - left: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - background-color: $secondary-content; - } - } - - .mx_SpaceRoomView_preview_inviter { - display: flex; - align-items: center; - margin-bottom: 20px; - font-size: $font-15px; - - > div { - margin-left: 8px; - - .mx_SpaceRoomView_preview_inviter_name { - line-height: $font-18px; - } - - .mx_SpaceRoomView_preview_inviter_mxid { - line-height: $font-24px; - color: $secondary-content; - } - } - } - - > .mx_RoomAvatar_isSpaceRoom { - &.mx_BaseAvatar_image, .mx_BaseAvatar_image { - border-radius: 12px; - } - } - - h1.mx_SpaceRoomView_preview_name { - margin: 20px 0 !important; // override default margin from above - } - - .mx_SpaceRoomView_preview_topic { - font-size: $font-14px; - line-height: $font-22px; - color: $secondary-content; - margin: 20px 0; - max-height: 160px; - overflow-y: auto; - } - - .mx_SpaceRoomView_preview_joinButtons { - margin-top: 20px; - - .mx_AccessibleButton { - width: 200px; - box-sizing: border-box; - padding: 14px 0; - - & + .mx_AccessibleButton { - margin-left: 20px; - } - } - } - } - .mx_SpaceRoomView_landing { display: flex; flex-direction: column; @@ -314,40 +196,6 @@ $SpaceRoomViewInnerWidth: 428px; flex-wrap: wrap; line-height: $font-24px; - .mx_SpaceRoomView_info { - color: $secondary-content; - font-size: $font-15px; - display: inline-block; - - .mx_SpaceRoomView_info_public, - .mx_SpaceRoomView_info_private { - padding-left: 20px; - position: relative; - - &::before { - position: absolute; - content: ""; - width: 20px; - height: 20px; - top: 0; - left: -2px; - mask-position: center; - mask-repeat: no-repeat; - background-color: $tertiary-content; - } - } - - .mx_SpaceRoomView_info_public::before { - mask-size: 12px; - mask-image: url("$(res)/img/globe.svg"); - } - - .mx_SpaceRoomView_info_private::before { - mask-size: 14px; - mask-image: url("$(res)/img/element-icons/lock.svg"); - } - } - .mx_SpaceRoomView_landing_infoBar_interactive { display: flex; flex-wrap: wrap; diff --git a/res/css/structures/_VideoRoomView.scss b/res/css/structures/_VideoRoomView.scss index d99b3f5894b..3577e7b73e1 100644 --- a/res/css/structures/_VideoRoomView.scss +++ b/res/css/structures/_VideoRoomView.scss @@ -24,8 +24,7 @@ limitations under the License. margin-right: calc($container-gap-width / 2); background-color: $header-panel-bg-color; - padding-top: 33px; // to match the right panel chat heading - border: 8px solid $header-panel-bg-color; + padding: 8px; border-radius: 8px; .mx_AppTile { diff --git a/res/css/views/context_menus/_DeviceContextMenu.scss b/res/css/views/context_menus/_DeviceContextMenu.scss new file mode 100644 index 00000000000..4b886279d7d --- /dev/null +++ b/res/css/views/context_menus/_DeviceContextMenu.scss @@ -0,0 +1,27 @@ +/* +Copyright 2021 Šimon Brandner + +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_DeviceContextMenu { + max-width: 252px; + + .mx_DeviceContextMenu_device_icon { + display: none; + } + + .mx_IconizedContextMenu_label { + padding-left: 0 !important; + } +} diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 36004af7418..84be4301ffc 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -25,6 +25,11 @@ limitations under the License. padding-right: 20px; } + .mx_IconizedContextMenu_optionList_label { + font-size: $font-15px; + font-weight: $font-semi-bold; + } + // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { // This is a bit of a hack when we could just use a simple border-top property, 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/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 90f1c590a14..e40695fcf14 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -15,6 +15,9 @@ limitations under the License. */ .mx_FacePile { + display: flex; + align-items: center; + .mx_FacePile_faces { display: inline-flex; flex-direction: row-reverse; diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 75318757a73..e270a8512fa 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -28,8 +28,9 @@ limitations under the License. top: -32px; right: 8px; user-select: none; - // Ensure the action bar appears above over things, like the read marker. - z-index: 1; + // Ensure the action bar appears above other things like the read marker + // and sender avatar (for small screens) + z-index: 10; // Adds a previous event safe area so that you can't accidentally hover the // previous event while trying to mouse into the action bar or from the 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/res/css/views/rooms/_RoomInfoLine.scss b/res/css/views/rooms/_RoomInfoLine.scss new file mode 100644 index 00000000000..5c0aea7c0bd --- /dev/null +++ b/res/css/views/rooms/_RoomInfoLine.scss @@ -0,0 +1,58 @@ +/* +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. +*/ + +.mx_RoomInfoLine { + color: $secondary-content; + display: inline-block; + + &::before { + content: ""; + display: inline-block; + height: 1.2em; + mask-position-y: center; + mask-repeat: no-repeat; + background-color: $tertiary-content; + vertical-align: text-bottom; + margin-right: 6px; + } + + &.mx_RoomInfoLine_public::before { + width: 12px; + mask-size: 12px; + mask-image: url("$(res)/img/globe.svg"); + } + + &.mx_RoomInfoLine_private::before { + width: 14px; + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/lock.svg"); + } + + &.mx_RoomInfoLine_video::before { + width: 16px; + mask-size: 16px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_RoomInfoLine_members { + color: inherit; + + &::before { + content: "·"; // visual separator + margin: 0 6px; + } + } +} diff --git a/res/css/views/rooms/_RoomPreviewCard.scss b/res/css/views/rooms/_RoomPreviewCard.scss new file mode 100644 index 00000000000..b561bf666df --- /dev/null +++ b/res/css/views/rooms/_RoomPreviewCard.scss @@ -0,0 +1,136 @@ +/* +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. +*/ + +.mx_RoomPreviewCard { + padding: $spacing-32 $spacing-24 !important; // Override SpaceRoomView's default padding + margin: auto; + flex-grow: 1; + max-width: 480px; + box-sizing: border-box; + background-color: $system; + border-radius: 8px; + position: relative; + font-size: $font-14px; + + .mx_RoomPreviewCard_notice { + font-weight: $font-semi-bold; + line-height: $font-24px; + color: $primary-content; + margin-top: $spacing-24; + position: relative; + padding-left: calc(20px + $spacing-8); + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-content; + } + } + + .mx_RoomPreviewCard_inviter { + display: flex; + align-items: center; + margin-bottom: $spacing-20; + font-size: $font-15px; + + > div { + margin-left: $spacing-8; + + .mx_RoomPreviewCard_inviter_name { + line-height: $font-18px; + } + + .mx_RoomPreviewCard_inviter_mxid { + color: $secondary-content; + } + } + } + + .mx_RoomPreviewCard_avatar { + display: flex; + align-items: center; + + .mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 12px; + } + } + + .mx_RoomPreviewCard_video { + width: 50px; + height: 50px; + border-radius: calc((50px + 2 * 3px) / 2); + background-color: $accent; + border: 3px solid $system; + + position: relative; + left: calc(-50px / 4 - 3px); + + &::before { + content: ""; + background-color: $button-primary-fg-color; + position: absolute; + width: 50px; + height: 50px; + mask-size: 22px; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + } + + h1.mx_RoomPreviewCard_name { + margin: $spacing-16 0 !important; // Override SpaceRoomView's default margins + } + + .mx_RoomPreviewCard_topic { + line-height: $font-22px; + margin-top: $spacing-16; + max-height: 160px; + overflow-y: auto; + } + + .mx_FacePile { + margin-top: $spacing-20; + } + + .mx_RoomPreviewCard_joinButtons { + margin-top: $spacing-20; + display: flex; + gap: $spacing-20; + + .mx_AccessibleButton { + max-width: 200px; + padding: 14px 0; + flex-grow: 1; + } + } +} diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss index 9305d07f3b7..4c375ee2222 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.scss +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ limitations under the License. position: absolute; display: flex; justify-content: center; - bottom: 24px; + bottom: 32px; opacity: 1; transition: opacity 0.5s; z-index: 200; // To be above _all_ feeds @@ -46,6 +46,10 @@ limitations under the License. justify-content: center; align-items: center; + position: relative; + + box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes + &::before { content: ''; display: inline-block; @@ -60,6 +64,25 @@ limitations under the License. width: 24px; } + &.mx_CallViewButtons_dropdownButton { + width: 16px; + height: 16px; + + position: absolute; + right: 0; + bottom: 0; + + &::before { + width: 14px; + height: 14px; + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + &.mx_CallViewButtons_dropdownButton_collapsed::before { + transform: rotate(180deg); + } + } + // State buttons &.mx_CallViewButtons_button_on { background-color: $call-view-button-on-background; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 9c9548444e4..9e34134a7d1 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,202 +23,176 @@ limitations under the License. padding-right: 8px; // XXX: PiPContainer sets pointer-events: none - should probably be set back in a better place pointer-events: initial; -} - -.mx_CallView_large { - padding-bottom: 10px; - margin: $container-gap-width; - // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. - margin-right: calc($container-gap-width / 2); - margin-bottom: 10px; - display: flex; - flex-direction: column; - flex: 1; - - .mx_CallView_voice { - flex: 1; - } - &.mx_CallView_belowWidget { - margin-top: 0; - } -} + .mx_CallView_toast { + position: absolute; + top: 74px; -.mx_CallView_pip { - width: 320px; - padding-bottom: 8px; - background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); - border-radius: 8px; + padding: 4px 8px; - .mx_CallView_video_hold, - .mx_CallView_voice { - height: 180px; - } + border-radius: 4px; + z-index: 50; - .mx_CallViewButtons { - bottom: 13px; + // Same on both themes + color: white; + background-color: #17191c; } - .mx_CallViewButtons_button { - width: 34px; - height: 34px; + .mx_CallView_content_wrapper { + display: flex; + justify-content: center; - &::before { - width: 22px; - height: 22px; - } - } - - .mx_CallView_holdTransferContent { - padding-top: 10px; - padding-bottom: 25px; - } -} - -.mx_CallView_content { - position: relative; - display: flex; - justify-content: center; - border-radius: 8px; - - > .mx_VideoFeed { width: 100%; height: 100%; - &.mx_VideoFeed_voice { + overflow: hidden; + + .mx_CallView_content { + position: relative; + display: flex; + flex-direction: column; justify-content: center; align-items: center; - } - .mx_VideoFeed_video { - height: 100%; - background-color: #000; + flex: 1; + overflow: hidden; + + border-radius: 10px; + + padding: 10px; + padding-right: calc(20% + 20px); // Space for the sidebar + + background-color: $call-view-content-background; + + .mx_CallView_status { + z-index: 50; + color: $accent-fg-color; + } + + .mx_CallView_avatarsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + div { + margin-left: 12px; + margin-right: 12px; + } + } + + .mx_CallView_holdBackground { + position: absolute; + left: 0; + right: 0; + + width: 100%; + height: 100%; + + background-repeat: no-repeat; + background-size: cover; + background-position: center; + filter: blur(20px); + + &::after { + content: ""; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + } + } + + &.mx_CallView_content_hold .mx_CallView_status { + font-weight: bold; + text-align: center; + + &::before { + display: block; + margin-left: auto; + margin-right: auto; + content: ""; + width: 40px; + height: 40px; + background-image: url("$(res)/img/voip/paused.svg"); + background-position: center; + background-size: cover; + } + + .mx_CallView_pip &::before { + width: 30px; + height: 30px; + } + + .mx_AccessibleButton_hasKind { + padding: 0px; + } + } } + } + + &:not(.mx_CallView_sidebar) .mx_CallView_content { + padding: 0; + width: 100%; + height: 100%; + + .mx_VideoFeed_primary { + aspect-ratio: unset; + border: 0; - .mx_VideoFeed_mic { - left: 10px; - bottom: 10px; + width: 100%; + height: 100%; } } -} -.mx_CallView_voice { - align-items: center; - justify-content: center; - flex-direction: column; - background-color: $inverted-bg-color; -} + &.mx_CallView_pip { + width: 320px; + padding-bottom: 8px; -.mx_CallView_voice_avatarsContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - div { - margin-left: 12px; - margin-right: 12px; - } -} + border-radius: 8px; -.mx_CallView_voice .mx_CallView_holdTransferContent { - // This masks the avatar image so when it's blurred, the edge is still crisp - .mx_CallView_voice_avatarContainer { - border-radius: 2000px; - overflow: hidden; - position: relative; - } -} + background-color: $system; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); + + .mx_CallViewButtons { + bottom: 13px; -.mx_CallView_holdTransferContent { - height: 20px; - padding-top: 20px; - padding-bottom: 15px; - color: $accent-fg-color; - user-select: none; + .mx_CallViewButtons_button { + width: 34px; + height: 34px; + + &::before { + width: 22px; + height: 22px; + } + } + } - .mx_AccessibleButton_hasKind { - padding: 0px; - font-weight: bold; + .mx_CallView_content { + min-height: 180px; + } } -} -.mx_CallView_video { - width: 100%; - height: 100%; - z-index: 30; - overflow: hidden; -} + &.mx_CallView_large { + display: flex; + flex-direction: column; + align-items: center; -.mx_CallView_video_hold { - overflow: hidden; + flex: 1; - // we keep these around in the DOM: it saved wiring them up again when the call - // is resumed and keeps the container the right size - .mx_VideoFeed { - visibility: hidden; - } -} + padding-bottom: 10px; -.mx_CallView_video_holdBackground { - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - filter: blur(20px); - &::after { - content: ""; - display: block; - position: absolute; - width: 100%; - height: 100%; - left: 0; - right: 0; - background-color: rgba(0, 0, 0, 0.6); + margin: $container-gap-width; + // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. + margin-right: calc($container-gap-width / 2); + margin-bottom: 10px; } -} -.mx_CallView_video .mx_CallView_holdTransferContent { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-weight: bold; - color: $accent-fg-color; - text-align: center; - - &::before { - display: block; - margin-left: auto; - margin-right: auto; - content: ""; - width: 40px; - height: 40px; - background-image: url("$(res)/img/voip/paused.svg"); - background-position: center; - background-size: cover; - } - .mx_CallView_pip &::before { - width: 30px; - height: 30px; - } - .mx_AccessibleButton_hasKind { - padding: 0px; + &.mx_CallView_belowWidget { + margin-top: 0; } } - -.mx_CallView_presenting { - position: absolute; - margin-top: 18px; - padding: 4px 8px; - border-radius: 4px; - - // Same on both themes - color: white; - background-color: #17191c; -} diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss index 358357f1343..9340dfb0401 100644 --- a/res/css/views/voip/_CallViewHeader.scss +++ b/res/css/views/voip/_CallViewHeader.scss @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +20,9 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; - justify-content: left; + justify-content: space-between; flex-shrink: 0; + width: 100%; &.mx_CallViewHeader_pip { cursor: pointer; diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 4871ccfe65e..351f4061f4b 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -1,5 +1,5 @@ /* -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,18 +16,15 @@ limitations under the License. .mx_CallViewSidebar { position: absolute; - right: 16px; - bottom: 16px; - z-index: 100; // To be above the primary feed + right: 10px; - overflow: auto; - - height: calc(100% - 32px); // Subtract the top and bottom padding width: 20%; + height: 100%; + overflow: auto; display: flex; - flex-direction: column-reverse; - justify-content: flex-start; + flex-direction: column; + justify-content: center; align-items: flex-end; gap: 12px; @@ -42,15 +39,6 @@ limitations under the License. background-color: $video-feed-secondary-background; } - - .mx_VideoFeed_video { - border-radius: 4px; - } - - .mx_VideoFeed_mic { - left: 6px; - bottom: 6px; - } } &.mx_CallViewSidebar_pipMode { diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 29dcb5cba3c..a0ab8269c0a 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -1,5 +1,6 @@ /* -Copyright 2015, 2016, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2020, 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,15 +21,32 @@ limitations under the License. box-sizing: border-box; border: transparent 2px solid; display: flex; + border-radius: 4px; + + &.mx_VideoFeed_secondary { + position: absolute; + right: 24px; + bottom: 72px; + width: 20%; + } &.mx_VideoFeed_voice { background-color: $inverted-bg-color; - aspect-ratio: 16 / 9; + + display: flex; + justify-content: center; + align-items: center; + + &:not(.mx_VideoFeed_primary) { + aspect-ratio: 16 / 9; + } } .mx_VideoFeed_video { + height: 100%; width: 100%; - background-color: transparent; + border-radius: 4px; + background-color: #000000; &.mx_VideoFeed_video_mirror { transform: scale(-1, 1); @@ -37,6 +55,8 @@ limitations under the License. .mx_VideoFeed_mic { position: absolute; + left: 6px; + bottom: 6px; display: flex; align-items: center; justify-content: center; diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index d8162c46453..4c5d50f9e1f 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -185,6 +185,7 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 681f644d0f2..8997538e0ab 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -117,6 +117,7 @@ $call-view-button-on-foreground: $primary-content; $call-view-button-on-background: $system; $call-view-button-off-foreground: $system; $call-view-button-off-background: $primary-content; +$call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 463ecffb644..49f690d6a04 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -175,6 +175,7 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #394049; // XXX: Color from dark theme diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c62baddaa36..bd7194e5fb4 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -277,6 +277,7 @@ $call-view-button-on-foreground: $secondary-content; $call-view-button-on-background: $background; $call-view-button-off-foreground: $background; $call-view-button-off-background: $secondary-content; +$call-view-content-background: #21262C; $video-feed-secondary-background: #394049; // XXX: Color from dark theme $voipcall-plinth-color: $system; diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 737e87844f5..81f0784ff96 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,6 +10,9 @@ defbranch="$3" rm -r "$defrepo" || true +PR_ORG=${PR_ORG:-"matrix-org"} +PR_REPO=${PR_REPO:-"matrix-react-sdk"} + # A function that clones a branch of a repo based on the org, repo and branch clone() { org=$1 @@ -29,8 +32,7 @@ getPRInfo() { if [ -n "$number" ]; then echo "Getting info about a PR with number $number" - apiEndpoint="https://api.github.com/repos/${REPOSITORY:-"matrix-org/matrix-react-sdk"}/pulls/" - apiEndpoint+=$number + apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$number" head=$(curl $apiEndpoint | jq -r '.head.label') fi @@ -58,7 +60,7 @@ TRY_ORG=$deforg TRY_BRANCH=${BRANCH_ARRAY[0]} if [[ "$head" == *":"* ]]; then # ... but only match that fork if it's a real fork - if [ "${BRANCH_ARRAY[0]}" != "matrix-org" ]; then + if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then TRY_ORG=${BRANCH_ARRAY[0]} fi TRY_BRANCH=${BRANCH_ARRAY[1]} diff --git a/sonar-project.properties b/sonar-project.properties index afeecf737be..47814f9d418 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,16 +1,14 @@ sonar.projectKey=matrix-react-sdk sonar.organization=matrix-org -# This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=matrix-react-sdk -#sonar.projectVersion=1.0 - -# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. - # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 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=test/**/*,cypress/**/* +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/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index 2bb522e7fe9..c56b245f259 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -31,18 +31,19 @@ export class DecryptionFailure { type ErrorCode = "OlmKeysNotSentError" | "OlmIndexError" | "UnknownError" | "OlmUnspecifiedError"; -type TrackingFn = (count: number, trackedErrCode: ErrorCode) => void; +type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void; export type ErrCodeMapFn = (errcode: string) => ErrorCode; export class DecryptionFailureTracker { - private static internalInstance = new DecryptionFailureTracker((total, errorCode) => { + private static internalInstance = new DecryptionFailureTracker((total, errorCode, rawError) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total)); for (let i = 0; i < total; i++) { PosthogAnalytics.instance.trackEvent({ eventName: "Error", domain: "E2EE", name: errorCode, + context: `mxc_crypto_error_type_${rawError}`, }); } }, (errorCode) => { @@ -236,7 +237,7 @@ export class DecryptionFailureTracker { if (this.failureCounts[errorCode] > 0) { const trackedErrorCode = this.errorCodeMapFn(errorCode); - this.fn(this.failureCounts[errorCode], trackedErrorCode); + this.fn(this.failureCounts[errorCode], trackedErrorCode, errorCode); this.failureCounts[errorCode] = 0; } } 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 { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); + await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); + await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); } public setAudioOutput(deviceId: string): void { @@ -90,9 +90,9 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setAudioInput(deviceId: string): void { + public async setAudioInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); } /** @@ -100,16 +100,16 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setVideoInput(deviceId: string): void { + public async setVideoInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); } - public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise { switch (kind) { case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; - case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; - case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break; } } @@ -124,4 +124,17 @@ export default class MediaDeviceHandler extends EventEmitter { public static getVideoInput(): string { return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); } + + /** + * Returns the current set deviceId for a device kind + * @param {MediaDeviceKindEnum} kind of the device that will be returned + * @returns {string} the deviceId + */ + public static getDevice(kind: MediaDeviceKindEnum): string { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput(); + case MediaDeviceKindEnum.AudioInput: return this.getAudioInput(); + case MediaDeviceKindEnum.VideoInput: return this.getVideoInput(); + } + } } diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 200da2f7cf8..e9204996ed2 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -19,6 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; 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 { MatrixClientPeg } from './MatrixClientPeg'; import MultiInviter, { CompletionStates } from './utils/MultiInviter'; @@ -84,12 +85,12 @@ export function showRoomInviteDialog(roomId: string, initialText = ""): void { * @returns {boolean} True if valid, false otherwise */ export function isValid3pidInvite(event: MatrixEvent): boolean { - if (!event || event.getType() !== "m.room.third_party_invite") return false; + if (!event || event.getType() !== EventType.RoomThirdPartyInvite) return false; // any events without these keys are not valid 3pid invites, so we ignore them const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; - for (let i = 0; i < requiredKeys.length; ++i) { - if (!event.getContent()[requiredKeys[i]]) return false; + if (requiredKeys.some(key => !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/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 94b9905becc..a89f205a88e 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -223,7 +223,7 @@ export default class RoomStatusBar extends React.PureComponent { "Please contact your service administrator to continue using the service.", ), 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Your message wasn't sent because this homeserver has been blocked by its administrator. " + "Please contact your service administrator to continue using the service.", ), '': _td( diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 761dd9b496f..a7a4ec2a9e0 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -65,6 +65,7 @@ import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; @@ -179,9 +180,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 +256,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 +689,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 +1399,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); + } } } } @@ -1831,6 +1832,21 @@ export class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); + if ( + this.state.room.isElementVideoRoom() && + !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + ) { + return +
+ +
; +
; + } + // SpaceRoomView handles invites itself if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { if (this.state.joining || this.state.rejecting) { @@ -2102,7 +2118,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..695fa7a749e 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -26,17 +26,13 @@ import { _t } from "../../languageHandler"; import AccessibleButton from "../views/elements/AccessibleButton"; import RoomName from "../views/elements/RoomName"; import RoomTopic from "../views/elements/RoomTopic"; -import InlineSpinner from "../views/elements/InlineSpinner"; import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; -import { useRoomMembers } from "../../hooks/useRoomMembers"; import { useFeatureEnabled } from "../../hooks/useSettings"; import createRoom, { IOpts } from "../../createRoom"; import Field from "../views/elements/Field"; -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'; @@ -57,7 +53,6 @@ import { showSpaceSettings, } from "../../utils/space"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; -import MemberAvatar from "../views/avatars/MemberAvatar"; import RoomFacePile from "../views/elements/RoomFacePile"; import { AddExistingToSpace, @@ -71,11 +66,10 @@ import IconizedContextMenu, { } from "../views/context_menus/IconizedContextMenu"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { BetaPill } from "../views/beta/BetaCard"; -import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu"; -import { useAsyncMemo } from "../../hooks/useAsyncMemo"; -import { useDispatcher } from "../../hooks/useDispatcher"; -import { useRoomState } from "../../hooks/useRoomState"; +import RoomInfoLine from "../views/rooms/RoomInfoLine"; +import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; +import { useMyRoomMembership } from "../../hooks/useRoomMembers"; import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { UIComponent } from "../../settings/UIFeature"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; @@ -107,205 +101,6 @@ enum Phase { PrivateExistingRooms, } -const RoomMemberCount = ({ room, children }) => { - const members = useRoomMembers(room); - const count = members.length; - - if (children) return children(count); - return count; -}; - -const useMyRoomMembership = (room: Room) => { - const [membership, setMembership] = useState(room.getMyMembership()); - useTypedEventEmitter(room, RoomEvent.MyMembership, () => { - setMembership(room.getMyMembership()); - }); - return membership; -}; - -const SpaceInfo = ({ space }: { space: Room }) => { - // summary will begin as undefined whilst loading and go null if it fails to load or we are not invited. - const summary = useAsyncMemo(async () => { - if (space.getMyMembership() !== "invite") return null; - try { - return space.client.getRoomSummary(space.roomId); - } catch (e) { - return null; - } - }, [space]); - const joinRule = useRoomState(space, state => state.getJoinRule()); - const membership = useMyRoomMembership(space); - - let visibilitySection; - if (joinRule === JoinRule.Public) { - visibilitySection = - { _t("Public space") } - ; - } else { - visibilitySection = - { _t("Private space") } - ; - } - - let memberSection; - if (membership === "invite" && summary) { - // Don't trust local state and instead use the summary API - memberSection = - { _t("%(count)s members", { count: summary.num_joined_members }) } - ; - } else if (summary !== undefined) { // summary is not still loading - memberSection = - { (count) => count > 0 ? ( - { - RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList }); - }} - > - { _t("%(count)s members", { count }) } - - ) : null } - ; - } - - return
- { visibilitySection } - { memberSection } -
; -}; - -interface ISpacePreviewProps { - space: Room; - onJoinButtonClicked(): void; - onRejectButtonClicked(): void; -} - -const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { - const cli = useContext(MatrixClientContext); - const myMembership = useMyRoomMembership(space); - useDispatcher(defaultDispatcher, payload => { - if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) { - setBusy(false); // stop the spinner, join failed - } - }); - - const [busy, setBusy] = useState(false); - - const joinRule = useRoomState(space, state => state.getJoinRule()); - const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave - && joinRule !== JoinRule.Public; - - let inviterSection; - let joinButtons; - if (myMembership === "join") { - // XXX remove this when spaces leaves Beta - joinButtons = ( - { - dis.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); - }} - > - { _t("Leave") } - - ); - } else if (myMembership === "invite") { - const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); - const inviter = inviteSender && space.getMember(inviteSender); - - if (inviteSender) { - inviterSection =
- -
-
- { _t(" invites you", {}, { - inviter: () => { inviter?.name || inviteSender }, - }) } -
- { inviter ?
- { inviteSender } -
: null } -
-
; - } - - joinButtons = <> - { - setBusy(true); - onRejectButtonClicked(); - }} - > - { _t("Reject") } - - { - setBusy(true); - onJoinButtonClicked(); - }} - > - { _t("Accept") } - - ; - } else { - joinButtons = ( - { - onJoinButtonClicked(); - if (!cli.isGuest()) { - // user will be shown a modal that won't fire a room join error - setBusy(true); - } - }} - disabled={cannotJoin} - > - { _t("Join") } - - ); - } - - if (busy) { - joinButtons = ; - } - - let footer; - if (cannotJoin) { - footer =
- { _t("To view %(spaceName)s, you need an invite", { - spaceName: space.name, - }) } -
; - } - - return
- { inviterSection } - -

- -

- - - { (topic, ref) => -
- { topic } -
- } -
- { space.getJoinRule() === "public" && } -
- { joinButtons } -
- { footer } -
; -}; - const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); @@ -316,8 +111,8 @@ const SpaceLandingAddButton = ({ space }) => { if (menuDisplayed) { const rect = handle.current.getBoundingClientRect(); contextMenu = { }} /> { videoRoomsEnabled && { e.preventDefault(); @@ -452,7 +247,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- +
{ inviteButton } @@ -847,8 +642,8 @@ export default class SpaceRoomView extends React.PureComponent { if (this.state.myMembership === "join") { return ; } else { - return ; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index eccb68ed997..a84013bb221 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -39,6 +39,7 @@ import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog'; import { Action } from '../../dispatcher/actions'; import { UserTab } from '../views/dialogs/UserTab'; import dis from '../../dispatcher/dispatcher'; +import Spinner from "../views/elements/Spinner"; interface IProps { roomId: string; @@ -191,7 +192,6 @@ const ThreadPanel: React.FC = ({ 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 = <> + +