diff --git a/.eslintrc.js b/.eslintrc.js index 67e6ab1e642..f3e38f94c3c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,6 +86,8 @@ module.exports = { "jsx-a11y/no-static-element-interactions": "off", "jsx-a11y/role-supports-aria-props": "off", "jsx-a11y/tabindex-no-positive": "off", + + "matrix-org/require-copyright-header": "error", }, overrides: [ { 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..808ede86b9d 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", @@ -173,7 +173,7 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "^0.4.0", + "eslint-plugin-matrix-org": "^0.5.2", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", "fs-extra": "^10.0.1", @@ -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 3f371478d12..6988c017895 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"; @@ -125,7 +126,6 @@ @import "./views/dialogs/_SpacePreferencesDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpotlightDialog.scss"; -@import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @@ -162,6 +162,7 @@ @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; +@import "./views/elements/_Pill.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -255,9 +256,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/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 0137db7ebf2..2b8def01d03 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -155,7 +155,7 @@ limitations under the License. line-height: $font-20px; padding: 0 5px; color: $accent-fg-color; - background-color: $rte-room-pill-color; + background-color: $pill-bg-color; } .mx_RoomDirectory_topic { 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/src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts b/res/css/views/context_menus/_DeviceContextMenu.scss similarity index 53% rename from src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts rename to res/css/views/context_menus/_DeviceContextMenu.scss index 891d1261694..4b886279d7d 100644 --- a/src/dispatcher/payloads/OpenTabbedIntegrationManagerDialogPayload.ts +++ b/res/css/views/context_menus/_DeviceContextMenu.scss @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. @@ -14,16 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/models/room"; -import { Optional } from "matrix-events-sdk"; +.mx_DeviceContextMenu { + max-width: 252px; -import { ActionPayload } from "../payloads"; -import { Action } from "../actions"; + .mx_DeviceContextMenu_device_icon { + display: none; + } -export interface OpenTabbedIntegrationManagerDialogPayload extends ActionPayload { - action: Action.OpenTabbedIntegrationManagerDialog; - - room: Optional; - screen: Optional; - integrationId: Optional; + .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/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index 5838939d9bf..1d7759fe2bb 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -55,6 +55,12 @@ limitations under the License. text-decoration: underline; } + .mx_EventTile { + .mx_MessageTimestamp { + position: absolute; + } + } + .mx_EventTile_line, .mx_EventTile_content { margin-right: 0px; } diff --git a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss b/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss deleted file mode 100644 index 6385dd76f52..00000000000 --- a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2019 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_TabbedIntegrationManagerDialog .mx_Dialog { - width: 60%; - height: 70%; - overflow: hidden; - padding: 0; - max-width: initial; - max-height: initial; - position: relative; -} - -.mx_TabbedIntegrationManagerDialog_container { - // Full size of the dialog, whatever it is - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - - .mx_TabbedIntegrationManagerDialog_currentManager { - width: 100%; - height: 100%; - border-top: 1px solid $accent; - - iframe { - background-color: #fff; - border: 0; - width: 100%; - height: 100%; - } - } -} - -.mx_TabbedIntegrationManagerDialog_tab { - display: inline-block; - border: 1px solid $accent; - border-bottom: 0; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - padding: 10px 8px; - margin-right: 5px; -} - -.mx_TabbedIntegrationManagerDialog_currentTab { - background-color: $accent; - color: $accent-fg-color; -} 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/elements/_Pill.scss b/res/css/views/elements/_Pill.scss new file mode 100644 index 00000000000..15511601ffe --- /dev/null +++ b/res/css/views/elements/_Pill.scss @@ -0,0 +1,63 @@ +/* +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_Pill { + padding: $font-1px 0.4em $font-1px 0; + line-height: $font-17px; + border-radius: $font-16px; + vertical-align: text-top; + display: inline-flex; + align-items: center; + + cursor: pointer; + + color: $accent-fg-color !important; // To override .markdown-body + background-color: $pill-bg-color !important; // To override .markdown-body + + &.mx_UserPill_me, + &.mx_AtRoomPill { + background-color: $alert !important; // To override .markdown-body + } + + &:hover { + background-color: $pill-hover-bg-color !important; // To override .markdown-body + } + + &.mx_UserPill_me:hover { + background-color: #ff6b75 !important; // To override .markdown-body | same on both themes + } + + // We don't want to indicate clickability + &.mx_AtRoomPill:hover { + background-color: $alert !important; // To override .markdown-body + cursor: unset; + } + + .mx_BaseAvatar { + position: relative; + display: inline-flex; + align-items: center; + border-radius: 10rem; + margin-right: 0.24rem; + } + + a& { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-decoration: none !important; // To override .markdown-body + } +} diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 4615f5da23f..4f0b532c6b4 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -2,86 +2,6 @@ // naming scheme; it's completely unclear where or how they're being used // --Matthew -.mx_UserPill, -.mx_RoomPill, -.mx_AtRoomPill { - display: inline-flex; - align-items: center; - vertical-align: middle; - border-radius: $font-16px; - line-height: $font-15px; - padding-left: 0; -} - -a.mx_Pill { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 100%; -} - -.mx_Pill { - padding: $font-1px; - padding-right: 0.4em; - vertical-align: text-top; - line-height: $font-17px; -} - -/* More specific to override `.markdown-body a` text-decoration */ -.mx_EventTile_content .markdown-body a.mx_Pill { - text-decoration: none; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_UserPill, -.mx_UserPill { - color: $primary-content; - background-color: $other-user-pill-bg-color; -} - -.mx_UserPill_selected { - background-color: $accent !important; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, -.mx_EventTile_content .markdown-body a.mx_AtRoomPill, -.mx_EventTile_content .mx_AtRoomPill, -.mx_MessageComposer_input .mx_AtRoomPill { - color: $accent-fg-color; - background-color: $alert; -} - -/* More specific to override `.markdown-body a` color */ -.mx_EventTile_content .markdown-body a.mx_RoomPill, -.mx_RoomPill { - color: $accent-fg-color; - background-color: $rte-room-pill-color; -} - -.mx_EventTile_body .mx_UserPill, -.mx_EventTile_body .mx_RoomPill { - cursor: pointer; -} - -.mx_UserPill .mx_BaseAvatar, -.mx_RoomPill .mx_BaseAvatar, -.mx_AtRoomPill .mx_BaseAvatar { - position: relative; - display: inline-flex; - align-items: center; - border-radius: 10rem; - margin-right: 0.24rem; - pointer-events: none; -} - -.mx_Emoji { - // Should be 1.8rem for our default 1.4rem message bodies, - // and scale with the size of the surrounding text - font-size: calc(18 / 14 * 1em); - vertical-align: bottom; -} - .mx_Markdown_BOLD { font-weight: bold; } @@ -115,3 +35,10 @@ a.mx_Pill { .mx_Markdown_STRIKETHROUGH { text-decoration: line-through; } + +.mx_Emoji { + // Should be 1.8rem for our default message bodies, and scale with the + // surrounding text + font-size: max($font-18px, 1em); + vertical-align: bottom; +} diff --git a/res/css/views/messages/_MLocationBody.scss b/res/css/views/messages/_MLocationBody.scss index 72202ca6e1b..adca4ae4ba6 100644 --- a/res/css/views/messages/_MLocationBody.scss +++ b/res/css/views/messages/_MLocationBody.scss @@ -15,7 +15,10 @@ limitations under the License. */ .mx_MLocationBody { + max-width: 100%; + .mx_MLocationBody_map { + max-width: 100%; width: 450px; height: 300px; z-index: 0; // keeps the entire map under the message action bar @@ -27,15 +30,11 @@ limitations under the License. /* In the timeline, we fit the width of the container */ .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { - width: 100%; max-width: 450px; + width: 100%; } -.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody { +.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MLocationBody .mx_MLocationBody_map { max-width: 100%; - - .mx_MLocationBody_map { - max-width: 100%; - width: 450px; - } + width: 450px; } 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/messages/_MessageTimestamp.scss b/res/css/views/messages/_MessageTimestamp.scss index 85c910296af..d496ff82c8c 100644 --- a/res/css/views/messages/_MessageTimestamp.scss +++ b/res/css/views/messages/_MessageTimestamp.scss @@ -18,4 +18,5 @@ limitations under the License. color: $event-timestamp-color; font-size: $font-10px; font-variant-numeric: tabular-nums; + width: $MessageTimestamp_width; } diff --git a/res/css/views/messages/_TextualEvent.scss b/res/css/views/messages/_TextualEvent.scss index 607ed9cecba..530ad50587b 100644 --- a/res/css/views/messages/_TextualEvent.scss +++ b/res/css/views/messages/_TextualEvent.scss @@ -17,6 +17,7 @@ limitations under the License. .mx_TextualEvent { opacity: 0.5; overflow-y: hidden; + line-height: normal; a { color: $accent; 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/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 5a121a8f61c..13dd5af6af0 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -43,7 +43,8 @@ limitations under the License. } .mx_NewRoomIntro { - margin-left: 36px; + margin-inline-start: 36px; // TODO: Use a variable + margin-inline-end: 36px; // TODO: Use a variable } .mx_EventTile_content { @@ -51,31 +52,39 @@ limitations under the License. } .mx_EventTile:not([data-layout="bubble"]) { + $left-gutter: 36px; + .mx_EventTile_line { - padding-left: 36px; - padding-right: 36px; + padding-inline-start: $left-gutter; + padding-inline-end: 36px; + } + + .mx_DisambiguatedProfile, + .mx_ReactionsRow, + .mx_ThreadSummary { + margin-inline-start: $left-gutter; } .mx_ReactionsRow { padding: 0; // See margin setting of ReactionsRow on _EventTile.scss - margin-left: 36px; margin-right: 8px; } .mx_ThreadSummary { - margin-left: 36px; margin-right: 0; max-width: min(calc(100% - 36px), 600px); } .mx_EventTile_avatar { + position: absolute; // for IRC layout top: 12px; left: -3px; } .mx_MessageTimestamp { + position: absolute; // for modern layout and IRC layout right: -4px; left: auto; } @@ -86,7 +95,7 @@ limitations under the License. &.mx_EventTile_info { .mx_EventTile_line { - padding-left: 36px; + padding-left: $left-gutter; } .mx_EventTile_avatar { 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/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 73ff9f048b2..c89fa6028b6 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -51,9 +51,15 @@ limitations under the License. } &.mx_BasicMessageComposer_input_shouldShowPillAvatar { - span.mx_UserPill, span.mx_RoomPill { - position: relative; + span.mx_UserPill, span.mx_RoomPill, span.mx_SpacePill { user-select: all; + position: relative; + cursor: unset; // We don't want indicate clickability + + &:hover { + // We don't want indicate clickability | To override the overriding of .markdown-body + background-color: $pill-bg-color !important; + } // avatar psuedo element &::before { @@ -72,14 +78,6 @@ limitations under the License. font-size: $font-10-4px; } } - - span.mx_UserPill { - cursor: pointer; - } - - span.mx_RoomPill { - cursor: default; - } } &.mx_BasicMessageComposer_input_disabled { 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..6f2fb704983 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -704,7 +704,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); // See: _commons.scss .mx_MessagePanel_narrow .mx_ThreadSummary { min-width: initial; - max-width: initial; + max-width: 100%; // prevent overflow width: initial; } @@ -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/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index b1d6e8b535b..f08fa1512e4 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -30,8 +30,7 @@ $left-gutter: 64px; } .mx_MessageTimestamp { - position: absolute; - width: $MessageTimestamp_width; + position: absolute; // for modern layout } .mx_EventTile_line, .mx_EventTile_reply { diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index c09ee476767..d689d152348 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -15,7 +15,6 @@ limitations under the License. */ $icon-width: 14px; -$timestamp-width: 45px; $right-padding: 5px; $irc-line-height: $font-18px; @@ -28,7 +27,7 @@ $irc-line-height: $font-18px; // timestamps are links which shouldn't be underlined > a { text-decoration: none; - min-width: 45px; + min-width: $MessageTimestamp_width; } display: flex; @@ -85,7 +84,6 @@ $irc-line-height: $font-18px; .mx_MessageTimestamp { font-size: $font-10px; - width: $timestamp-width; text-align: right; } @@ -141,7 +139,7 @@ $irc-line-height: $font-18px; .mx_GenericEventListSummary { > .mx_EventTile_line { - padding-left: calc(var(--name-width) + $icon-width + $timestamp-width + 3 * $right-padding); // 15 px of padding + padding-left: calc(var(--name-width) + $icon-width + $MessageTimestamp_width + 3 * $right-padding); // 15 px of padding } .mx_GenericEventListSummary_avatars { diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index eb0233108b8..598371bd368 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -25,30 +25,31 @@ limitations under the License. .mx_ReplyPreview_section { border-bottom: 1px solid $primary-hairline-color; + display: flex; + flex-flow: column; + row-gap: $spacing-8; + padding: $spacing-8 $spacing-8 0 $spacing-8; .mx_ReplyPreview_header { - margin: 8px; + display: flex; + justify-content: space-between; + column-gap: 8px; + color: $primary-content; font-weight: 400; opacity: 0.4; - } - - .mx_ReplyPreview_tile { - margin: 0 8px; - } - - .mx_ReplyPreview_title { - float: left; - } - - .mx_ReplyPreview_cancel { - float: right; - cursor: pointer; - display: flex; - } - .mx_ReplyPreview_clear { - clear: both; + .mx_ReplyPreview_header_cancel { + background-color: $primary-content; + mask: url('$(res)/img/cancel.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 18px; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + } } } } 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..6280da8cbb7 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; @@ -43,6 +45,8 @@ limitations under the License. .mx_CallViewHeader_controls { margin-left: auto; + display: flex; + gap: 5px; } .mx_CallViewHeader_button { @@ -61,17 +65,23 @@ limitations under the License. mask-size: contain; mask-position: center; } -} -.mx_CallViewHeader_button_fullscreen { - &::before { - mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + &.mx_CallViewHeader_button_fullscreen { + &::before { + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + } } -} -.mx_CallViewHeader_button_expand { - &::before { - mask-image: url('$(res)/img/element-icons/call/expand.svg'); + &.mx_CallViewHeader_button_pin { + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + &.mx_CallViewHeader_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } } } 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 38fd3a58da4..4c5d50f9e1f 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -94,8 +94,8 @@ $roomheader-addroom-fg-color: $primary-content; // Rich-text-editor // ******************** -$rte-room-pill-color: $room-highlight-color; -$other-user-pill-bg-color: $room-highlight-color; +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // ******************** // Inputs @@ -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 6f958c08fdb..8997538e0ab 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -27,8 +27,8 @@ $light-fg-color: $header-panel-text-secondary-color; // used for focusing form controls $focus-bg-color: $room-highlight-color; -$other-user-pill-bg-color: $room-highlight-color; -$rte-room-pill-color: $room-highlight-color; +$pill-bg-color: $room-highlight-color; +$pill-hover-bg-color: #545a66; // informational plinth $info-plinth-bg-color: $header-panel-bg-color; @@ -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 e1da4d277da..49f690d6a04 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -37,8 +37,6 @@ $selection-fg-color: $primary-bg-color; $focus-brightness: 105%; -$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); - // informational plinth $info-plinth-bg-color: #f7f7f7; $info-plinth-fg-color: #888; @@ -117,10 +115,12 @@ $settings-subsection-fg-color: #61708b; $rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); -$rte-room-pill-color: #aaa; $header-panel-text-primary-color: #91a1c0; +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; + $topleftmenu-color: #212121; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: #91a1c0; @@ -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-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index e2d4fef8fe1..54d3dfd15e7 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -142,5 +142,6 @@ $eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-colo $reaction-row-button-selected-bg-color: var(--reaction-row-button-selected-bg-color, $reaction-row-button-selected-bg-color); $menu-selected-color: var(--menu-selected-color, $menu-selected-color); -$other-user-pill-bg-color: var(--other-user-pill-bg-color, $other-user-pill-bg-color); +$pill-bg-color: var(--other-user-pill-bg-color, $pill-bg-color); +$pill-hover-bg-color: var(--other-user-pill-bg-color, $pill-hover-bg-color); $icon-button-color: var(--icon-button-color, $icon-button-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 14ed62f7266..bd7194e5fb4 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -147,8 +147,8 @@ $roomheader-addroom-fg-color: #5c6470; // Rich-text-editor // ******************** -$rte-room-pill-color: #aaa; -$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); +$pill-bg-color: #aaa; +$pill-hover-bg-color: #ccc; $rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); // ******************** @@ -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/NodeAnimator.tsx b/src/NodeAnimator.tsx index 1a8942f5f53..2bb79542404 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from "react"; import ReactDom from "react-dom"; diff --git a/src/Notifier.ts b/src/Notifier.ts index 62e2f093703..892d5bc19cc 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -408,10 +408,6 @@ export const Notifier = { // don't bother notifying as user was recently active in this room return; } - if (SettingsStore.getValue("doNotDisturb")) { - // Don't bother the user if they didn't ask to be bothered - return; - } if (this.isEnabled()) { this._displayPopupNotification(ev, room); 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/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 328af853142..b7e89b08a40 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -129,7 +129,6 @@ import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyn import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStartChatOrReusePayload'; import { IConfigOptions } from "../../IConfigOptions"; import { SnakedObject } from "../../utils/SnakedObject"; -import InfoDialog from '../views/dialogs/InfoDialog'; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; @@ -1412,36 +1411,6 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(false); } - if (!localStorage.getItem("mx_seen_feature_thread_experimental")) { - setTimeout(() => { - if (SettingsStore.getValue("feature_thread") && SdkConfig.get("show_labs_settings")) { - Modal.createDialog(InfoDialog, { - title: _t("Threads Approaching Beta 🎉"), - description: <> -

- { _t("We're getting closer to releasing a public Beta for Threads.") } -

-

- { _t("As we prepare for it, we need to make some changes: threads created " - + "before this point will be displayed as regular replies.", - {}, { - "strong": sub => { sub }, - }) } -

-

- { _t("This will be a one-off transition, as threads are now part " - + "of the Matrix specification.") } -

- , - button: _t("Got it"), - onFinished: () => { - localStorage.setItem("mx_seen_feature_thread_experimental", "true"); - }, - }); - } - }, 1 * 60 * 1000); // show after 1 minute to not overload user on launch - } - if (!localStorage.getItem("mx_seen_feature_spotlight_toast")) { setTimeout(() => { // Skip the toast if the beta is already enabled or the user has changed the setting from default diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 38d18e92f76..1a403ba5065 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -23,6 +23,8 @@ 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 { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -846,7 +848,7 @@ export default class MessagePanel extends React.Component { } const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { - if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { + if (!r.userId || ![ReceiptType.Read, ReceiptType.ReadPrivate].includes(r.type) || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. } if (MatrixClientPeg.get().isUserIgnored(r.userId)) { @@ -1079,7 +1081,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 +1100,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/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index f2595471c96..5db5e6daad9 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -17,14 +17,13 @@ limitations under the License. import React, { createRef, CSSProperties, ReactNode, KeyboardEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import SettingsStore from '../../settings/SettingsStore'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import ResizeNotifier from "../../utils/ResizeNotifier"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; -const DEBUG_SCROLL = false; - // The amount of extra scroll distance to allow prior to unfilling. // See getExcessHeight. const UNPAGINATION_PADDING = 6000; @@ -36,13 +35,11 @@ const UNFILL_REQUEST_DEBOUNCE_MS = 200; // much while the content loads. const PAGE_SIZE = 400; -let debuglog; -if (DEBUG_SCROLL) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(console, "ScrollPanel debuglog:"); -} else { - debuglog = function() {}; -} +const debuglog = (...args: any[]) => { + if (SettingsStore.getValue("debug_scroll_panel")) { + logger.log.call(console, "ScrollPanel debuglog:", ...args); + } +}; interface IProps { /* stickyBottom: if set to true, then once the user hits the bottom of 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 = <> + +
- { customStatusSection } { topSection } { primaryOptionList } ; @@ -516,11 +407,6 @@ export default class UserMenu extends React.Component { const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); - let badge: JSX.Element; - if (this.state.dndEnabled) { - badge =
; - } - let name: JSX.Element; if (!this.props.isPanelCollapsed) { name =
@@ -535,9 +421,6 @@ export default class UserMenu extends React.Component { label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} - className={classNames({ - mx_UserMenu_cutout: badge, - })} >
{ resizeMethod="crop" className="mx_UserMenu_userAvatar_BaseAvatar" /> - { badge }
{ name } diff --git a/src/components/structures/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx index 2695dafa798..e2cd62e08d0 100644 --- a/src/components/structures/VideoRoomView.tsx +++ b/src/components/structures/VideoRoomView.tsx @@ -20,28 +20,47 @@ import { Room } from "matrix-js-sdk/src/models/room"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useEventEmitter } from "../../hooks/useEventEmitter"; -import { getVideoChannel } from "../../utils/VideoChannelUtils"; -import WidgetStore from "../../stores/WidgetStore"; +import WidgetUtils from "../../utils/WidgetUtils"; +import { addVideoChannel, getVideoChannel } from "../../utils/VideoChannelUtils"; +import WidgetStore, { IApp } from "../../stores/WidgetStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore"; import AppTile from "../views/elements/AppTile"; import VideoLobby from "../views/voip/VideoLobby"; -const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing }) => { +interface IProps { + room: Room; + resizing: boolean; +} + +const VideoRoomView: FC = ({ room, resizing }) => { const cli = useContext(MatrixClientContext); const store = VideoChannelStore.instance; // In case we mount before the WidgetStore knows about our Jitsi widget + const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient)); const [widgetLoaded, setWidgetLoaded] = useState(false); useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => { - if (roomId === null || roomId === room.roomId) setWidgetLoaded(true); + if (roomId === null) setWidgetStoreReady(true); + if (roomId === null || roomId === room.roomId) { + setWidgetLoaded(Boolean(getVideoChannel(room.roomId))); + } }); - const app = useMemo(() => { - const app = getVideoChannel(room.roomId); - if (!app) logger.warn(`No video channel for room ${room.roomId}`); - return app; - }, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + const app: IApp = useMemo(() => { + if (widgetStoreReady) { + const app = getVideoChannel(room.roomId); + if (!app) { + logger.warn(`No video channel for room ${room.roomId}`); + // Since widgets in video rooms are mutable, we'll take this opportunity to + // reinstate the Jitsi widget in case another client removed it + if (WidgetUtils.canUserModifyWidgets(room.roomId)) { + addVideoChannel(room.roomId, room.name); + } + } + return app; + } + }, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId); useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId)); diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 297e233444e..e38fdb4180b 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -222,7 +222,7 @@ export default class LoginComponent extends React.PureComponent "This homeserver has hit its Monthly Active User limit.", ), 'hs_blocked': _td( - "This homeserver has been blocked by it's administrator.", + "This homeserver has been blocked by its administrator.", ), '': _td( "This homeserver has exceeded one of its resource limits.", diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index ff7e41b9d58..e694fdee40b 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -295,7 +295,7 @@ export default class Registration extends React.Component { response.data.admin_contact, { 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), - 'hs_blocked': _td("This homeserver has been blocked by it's administrator."), + 'hs_blocked': _td("This homeserver has been blocked by its administrator."), '': _td("This homeserver has exceeded one of its resource limits."), }, ); diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 43b00a8fa76..b93d8de1ae7 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -15,8 +15,8 @@ limitations under the License. */ import classNames from 'classnames'; -import React from 'react'; -import { BeaconIdentifier, Room } from 'matrix-js-sdk/src/matrix'; +import React, { useEffect } from 'react'; +import { Beacon, BeaconIdentifier, Room } from 'matrix-js-sdk/src/matrix'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; @@ -61,6 +61,25 @@ const getLabel = (hasStoppingErrors: boolean, hasLocationErrors: boolean): strin return _t('You are sharing your live location'); }; +const useLivenessMonitor = (liveBeaconIds: BeaconIdentifier[], beacons: Map): void => { + useEffect(() => { + // chromium sets the minimum timer interval to 1000ms + // for inactive tabs + // refresh beacon monitors when the tab becomes active again + const onPageVisibilityChanged = () => { + if (document.visibilityState === 'visible') { + liveBeaconIds.map(identifier => beacons.get(identifier)?.monitorLiveness()); + } + }; + if (liveBeaconIds.length) { + document.addEventListener("visibilitychange", onPageVisibilityChanged); + } + () => { + document.removeEventListener("visibilitychange", onPageVisibilityChanged); + }; + }, [liveBeaconIds, beacons]); +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -91,6 +110,8 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const hasLocationPublishErrors = !!beaconIdsWithLocationPublishError.length; const hasStoppingErrors = !!beaconIdsWithStoppingError.length; + useLivenessMonitor(liveBeaconIds, OwnBeaconStore.instance.beacons); + if (!isMonitoringLiveLocation) { return null; } diff --git a/src/components/views/context_menus/DeviceContextMenu.tsx b/src/components/views/context_menus/DeviceContextMenu.tsx new file mode 100644 index 00000000000..04463e81ff0 --- /dev/null +++ b/src/components/views/context_menus/DeviceContextMenu.tsx @@ -0,0 +1,89 @@ +/* +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. +*/ + +import React, { useEffect, useState } from "react"; + +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu"; +import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { _t, _td } from "../../../languageHandler"; + +const SECTION_NAMES: Record = { + [MediaDeviceKindEnum.AudioInput]: _td("Input devices"), + [MediaDeviceKindEnum.AudioOutput]: _td("Output devices"), + [MediaDeviceKindEnum.VideoInput]: _td("Cameras"), +}; + +interface IDeviceContextMenuDeviceProps { + label: string; + selected: boolean; + onClick: () => void; +} + +const DeviceContextMenuDevice: React.FC = ({ label, selected, onClick }) => { + return ; +}; + +interface IDeviceContextMenuSectionProps { + deviceKind: MediaDeviceKindEnum; +} + +const DeviceContextMenuSection: React.FC = ({ deviceKind }) => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(MediaDeviceHandler.getDevice(deviceKind)); + + useEffect(() => { + const getDevices = async () => { + return setDevices((await MediaDeviceHandler.getDevices())[deviceKind]); + }; + getDevices(); + }, [deviceKind]); + + const onDeviceClick = (deviceId: string): void => { + MediaDeviceHandler.instance.setDevice(deviceId, deviceKind); + setSelectedDevice(deviceId); + }; + + return + { devices.map(({ label, deviceId }) => { + return onDeviceClick(deviceId)} + />; + }) } + ; +}; + +interface IProps extends IContextMenuProps { + deviceKinds: MediaDeviceKind[]; +} + +const DeviceContextMenu: React.FC = ({ deviceKinds, ...props }) => { + return + { deviceKinds.map((kind) => { + return ; + }) } + ; +}; + +export default DeviceContextMenu; diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 2c6bdb3776b..9b7896790ef 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -33,6 +33,7 @@ interface IProps extends IContextMenuProps { interface IOptionListProps { first?: boolean; red?: boolean; + label?: string; className?: string; } @@ -126,13 +127,20 @@ export const IconizedContextMenuOption: React.FC = ({ ; }; -export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { +export const IconizedContextMenuOptionList: React.FC = ({ + first, + red, + className, + label, + children, +}) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, }); return
+ { label &&
{ label }
} { children }
; }; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 917091ece83..cf61ee5bfdd 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -37,12 +37,11 @@ import { Action } from "../../../dispatcher/actions"; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { ButtonEvent } from '../elements/AccessibleButton'; import { copyPlaintext, getSelectedText } from '../../../utils/strings'; -import ContextMenu, { toRightOf } from '../../structures/ContextMenu'; +import ContextMenu, { toRightOf, IPosition, ChevronFace } from '../../structures/ContextMenu'; import ReactionPicker from '../emojipicker/ReactionPicker'; import ViewSource from '../../structures/ViewSource'; import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; import ShareDialog from '../dialogs/ShareDialog'; -import { IPosition, ChevronFace } from '../../structures/ContextMenu'; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import EndPollDialog from '../dialogs/EndPollDialog'; diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d9286c618b4..5d045900453 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { useContext } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { EventType } from "matrix-js-sdk/src/@types/event"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; @@ -136,6 +136,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms); + const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms"); const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces); let newRoomSection: JSX.Element; @@ -149,6 +150,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onFinished(); }; + const onNewVideoRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space, RoomType.ElementVideo); + onFinished(); + }; + const onNewSubspaceClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -169,6 +178,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = onClick={onNewRoomClick} /> } + { canAddVideoRooms && + + } { canAddSubSpaces && { // align the context menu's icons with the icon which opened the context menu - const left = elementRect.left + window.pageXOffset + elementRect.width; - const top = elementRect.bottom + window.pageYOffset; + const left = elementRect.left + window.scrollX + elementRect.width; + const top = elementRect.bottom + window.scrollY; const chevronFace = ChevronFace.None; return { left, top, chevronFace }; }; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 1aeeccc724b..b6a46bd2aca 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -110,7 +110,8 @@ const WidgetContextMenu: React.FC = ({ } let snapshotButton; - if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const screenshotsEnabled = SettingsStore.getValue("enableWidgetScreenshots"); + if (screenshotsEnabled && widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { const onSnapshotClick = () => { widgetMessaging?.takeScreenshot().then(data => { dis.dispatch({ diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 14fb69b1563..fb2f7113dd8 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -101,6 +101,7 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => {

{ _t("Options") }

+
; } diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index 6881c1c52d8..5c1bf48cac2 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -200,7 +200,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 2000)(parsedSize); }, invalid: () => { @@ -238,7 +238,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { }, { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value as string, 10); + const parsedSize = parseInt(value, 10); return validateNumberInRange(1, 10 ** 8)(parsedSize); }, invalid: () => { diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 0a063df9c7b..100b04bc5ac 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -131,7 +131,7 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, @@ -261,6 +261,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr { rooms.length > 0 ? (
rooms.slice(start, end).map(room => diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 2dedfb52937..0f51530a128 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -138,7 +138,8 @@ export default class MessageEditHistoryDialog extends React.PureComponent)); + /> + )); lastEvent = e; }); return nodes; diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx index f579e262875..f5efc0b8dc8 100644 --- a/src/components/views/dialogs/SpotlightDialog.tsx +++ b/src/components/views/dialogs/SpotlightDialog.tsx @@ -74,6 +74,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../RoomAliasCache"; import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers"; +import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -210,6 +211,8 @@ type Result = IRoomResult | IResult; const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const recentAlgorithm = new RecentAlgorithm(); + export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { useEffect(() => { if (!queryLength) return; @@ -280,6 +283,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => const results: [Result[], Result[], Result[]] = [[], [], []]; + // Group results in their respective sections possibleResults.forEach(entry => { if (isRoomResult(entry)) { if (!entry.room.normalizedName.includes(normalizedQuery) && @@ -295,8 +299,25 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => results[entry.section].push(entry); }); + // Sort results by most recent activity + + const myUserId = cli.getUserId(); + for (const resultArray of results) { + resultArray.sort((a: Result, b: Result) => { + // This is not a room result, it should appear at the bottom of + // the list + if (!(a as IRoomResult).room) return 1; + if (!(b as IRoomResult).room) return -1; + + const roomA = (a as IRoomResult).room; + const roomB = (b as IRoomResult).room; + + return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId); + }); + } + return results; - }, [possibleResults, trimmedQuery]); + }, [possibleResults, trimmedQuery, cli]); const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0; useWebSearchMetrics(numResults, query.length, true); @@ -367,16 +388,16 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => ); } - const otherResult = (result as IResult); + // IResult case return ( ); }; diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx b/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx deleted file mode 100644 index 5a5d6e38229..00000000000 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { Room } from "matrix-js-sdk/src/models/room"; -import classNames from 'classnames'; -import { logger } from "matrix-js-sdk/src/logger"; - -import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import { dialogTermsInteractionCallback, TermsNotSignedError } from "../../../Terms"; -import * as ScalarMessaging from "../../../ScalarMessaging"; -import { IntegrationManagerInstance } from "../../../integrations/IntegrationManagerInstance"; -import ScalarAuthClient from "../../../ScalarAuthClient"; -import AccessibleButton from "../elements/AccessibleButton"; -import IntegrationManager from "../settings/IntegrationManager"; -import { IDialogProps } from "./IDialogProps"; - -interface IProps extends IDialogProps { - /** - * Optional room where the integration manager should be open to - */ - room?: Room; - - /** - * Optional screen to open on the integration manager - */ - screen?: string; - - /** - * Optional integration ID to open in the integration manager - */ - integrationId?: string; -} - -interface IState { - managers: IntegrationManagerInstance[]; - busy: boolean; - currentIndex: number; - currentConnected: boolean; - currentLoading: boolean; - currentScalarClient: ScalarAuthClient; -} - -export default class TabbedIntegrationManagerDialog extends React.Component { - constructor(props: IProps) { - super(props); - - this.state = { - managers: IntegrationManagers.sharedInstance().getOrderedManagers(), - busy: true, - currentIndex: 0, - currentConnected: false, - currentLoading: true, - currentScalarClient: null, - }; - } - - public componentDidMount(): void { - this.openManager(0, true); - } - - private openManager = async (i: number, force = false): Promise => { - if (i === this.state.currentIndex && !force) return; - - const manager = this.state.managers[i]; - const client = manager.getScalarClient(); - this.setState({ - busy: true, - currentIndex: i, - currentLoading: true, - currentConnected: false, - currentScalarClient: client, - }); - - ScalarMessaging.setOpenManagerUrl(manager.uiUrl); - - client.setTermsInteractionCallback((policyInfo, agreedUrls) => { - // To avoid visual glitching of two modals stacking briefly, we customise the - // terms dialog sizing when it will appear for the integration manager so that - // it gets the same basic size as the IM's own modal. - return dialogTermsInteractionCallback( - policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationManager', - ); - }); - - try { - await client.connect(); - if (!client.hasCredentials()) { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } else { - this.setState({ - busy: false, - currentLoading: false, - currentConnected: true, - }); - } - } catch (e) { - if (e instanceof TermsNotSignedError) { - return; - } - - logger.error(e); - this.setState({ - busy: false, - currentLoading: false, - currentConnected: false, - }); - } - }; - - private renderTabs(): JSX.Element[] { - return this.state.managers.map((m, i) => { - const classes = classNames({ - 'mx_TabbedIntegrationManagerDialog_tab': true, - 'mx_TabbedIntegrationManagerDialog_currentTab': this.state.currentIndex === i, - }); - return ( - this.openManager(i)} - key={`tab_${i}`} - disabled={this.state.busy} - > - { m.name } - - ); - }); - } - - public renderTab(): JSX.Element { - let uiUrl = null; - if (this.state.currentScalarClient) { - uiUrl = this.state.currentScalarClient.getScalarInterfaceUrlForRoom( - this.props.room, - this.props.screen, - this.props.integrationId, - ); - } - return {/* no-op */}} - />; - } - - public render(): JSX.Element { - return ( -
-
- { this.renderTabs() } -
-
- { this.renderTab() } -
-
- ); - } -} diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx index ca5b1db9fbc..4b1928c3a73 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx @@ -93,7 +93,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ + makeRequest({ type: 'm.login.password', identifier: { type: 'm.id.user', @@ -106,7 +106,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent { forceHide?: boolean; yOffset?: number; alignment?: Alignment; + onHover?: (hovering: boolean) => void; onHideTooltip?(ev: SyntheticEvent): void; } @@ -52,6 +53,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(true); if (this.props.forceHide) return; this.setState({ hover: true, @@ -59,6 +61,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(false); this.setState({ hover: false, }); diff --git a/src/components/views/elements/AppWarning.tsx b/src/components/views/elements/AppWarning.tsx index 352c5990680..b3dfae99118 100644 --- a/src/components/views/elements/AppWarning.tsx +++ b/src/components/views/elements/AppWarning.tsx @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from 'react'; interface IProps { diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 566eddbe07e..c9b9f634d2f 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -31,10 +31,16 @@ interface IProps extends HTMLAttributes { const FacePile: FC = ({ members, faceSize, overflow, tooltip, children, ...props }) => { const faces = members.map( - tooltip ? - m => : - m => - + tooltip + ? m => + : m => + , ); diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index c0e37d93d74..61fee35bdd7 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -44,7 +44,7 @@ export default class IRCTimelineProfileResizer extends React.Component this.updateCSSWidth(this.state.width)); } diff --git a/src/components/views/elements/InteractiveTooltip.tsx b/src/components/views/elements/InteractiveTooltip.tsx index 62d0c43d06a..ca8ae4c8fd5 100644 --- a/src/components/views/elements/InteractiveTooltip.tsx +++ b/src/components/views/elements/InteractiveTooltip.tsx @@ -352,10 +352,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Left) { - const targetLeft = targetRect.left + window.pageXOffset; + const targetLeft = targetRect.left + window.scrollX; return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetRight = targetRect.right + window.pageXOffset; + const targetRight = targetRect.right + window.scrollX; const spaceOnRight = UIStore.instance.windowWidth - targetRight; return contentRect && (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -366,10 +366,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); if (this.props.direction === Direction.Top) { - const targetTop = targetRect.top + window.pageYOffset; + const targetTop = targetRect.top + window.scrollY; return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } else { - const targetBottom = targetRect.bottom + window.pageYOffset; + const targetBottom = targetRect.bottom + window.scrollY; const spaceBelow = UIStore.instance.windowHeight - targetBottom; return contentRect && (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE); } @@ -429,10 +429,10 @@ export default class InteractiveTooltip extends React.Component const targetRect = this.target.getBoundingClientRect(); // The window X and Y offsets are to adjust position when zoomed in to page - const targetLeft = targetRect.left + window.pageXOffset; - const targetRight = targetRect.right + window.pageXOffset; - const targetBottom = targetRect.bottom + window.pageYOffset; - const targetTop = targetRect.top + window.pageYOffset; + const targetLeft = targetRect.left + window.scrollX; + const targetRight = targetRect.right + window.scrollX; + const targetBottom = targetRect.bottom + window.scrollY; + const targetTop = targetRect.top + window.scrollY; // Place the tooltip above the target by default. If we find that the // tooltip content would extend past the safe area towards the window diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.tsx similarity index 71% rename from src/components/views/elements/Pill.js rename to src/components/views/elements/Pill.tsx index 7d5a9973c7f..f344f894569 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.tsx @@ -13,67 +13,82 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + import React from 'react'; import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import PropTypes from 'prop-types'; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import dis from '../../../dispatcher/dispatcher'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { getPrimaryPermalinkEntity, parsePermalink } from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; -import Tooltip from './Tooltip'; -import RoomAvatar from "../avatars/RoomAvatar"; -import MemberAvatar from "../avatars/MemberAvatar"; +import Tooltip, { Alignment } from './Tooltip'; +import RoomAvatar from '../avatars/RoomAvatar'; +import MemberAvatar from '../avatars/MemberAvatar'; + +export enum PillType { + UserMention = 'TYPE_USER_MENTION', + RoomMention = 'TYPE_ROOM_MENTION', + AtRoomMention = 'TYPE_AT_ROOM_MENTION', // '@room' mention +} + +interface IProps { + // The Type of this Pill. If url is given, this is auto-detected. + type?: PillType; + // The URL to pillify (no validation is done) + url?: string; + // Whether the pill is in a message + inMessage?: boolean; + // The room in which this pill is being rendered + room?: Room; + // Whether to include an avatar in the pill + shouldShowPillAvatar?: boolean; +} + +interface IState { + // ID/alias of the room/user + resourceId: string; + // Type of pill + pillType: string; + // The member related to the user pill + member?: RoomMember; + // The room related to the room pill + room?: Room; + // Is the user hovering the pill + hover: boolean; +} -class Pill extends React.Component { - static roomNotifPos(text) { +export default class Pill extends React.Component { + private unmounted = true; + private matrixClient: MatrixClient; + + public static roomNotifPos(text: string): number { return text.indexOf("@room"); } - static roomNotifLen() { + public static roomNotifLen(): number { return "@room".length; } - static TYPE_USER_MENTION = 'TYPE_USER_MENTION'; - static TYPE_ROOM_MENTION = 'TYPE_ROOM_MENTION'; - static TYPE_AT_ROOM_MENTION = 'TYPE_AT_ROOM_MENTION'; // '@room' mention - - static propTypes = { - // The Type of this Pill. If url is given, this is auto-detected. - type: PropTypes.string, - // The URL to pillify (no validation is done) - url: PropTypes.string, - // Whether the pill is in a message - inMessage: PropTypes.bool, - // The room in which this pill is being rendered - room: PropTypes.instanceOf(Room), - // Whether to include an avatar in the pill - shouldShowPillAvatar: PropTypes.bool, - // Whether to render this pill as if it were highlit by a selection - isSelected: PropTypes.bool, - }; - - state = { - // ID/alias of the room/user - resourceId: null, - // Type of pill - pillType: null, + constructor(props: IProps) { + super(props); - // The member related to the user pill - member: null, - // The room related to the room pill - room: null, - // Is the user hovering the pill - hover: false, - }; + this.state = { + resourceId: null, + pillType: null, + member: null, + room: null, + hover: false, + }; + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase - async UNSAFE_componentWillReceiveProps(nextProps) { + // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention + public async UNSAFE_componentWillReceiveProps(nextProps: IProps): Promise { let resourceId; let prefix; @@ -89,28 +104,28 @@ class Pill extends React.Component { } const pillType = this.props.type || { - '@': Pill.TYPE_USER_MENTION, - '#': Pill.TYPE_ROOM_MENTION, - '!': Pill.TYPE_ROOM_MENTION, + '@': PillType.UserMention, + '#': PillType.RoomMention, + '!': PillType.RoomMention, }[prefix]; let member; let room; switch (pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { room = nextProps.room; } break; - case Pill.TYPE_USER_MENTION: { + case PillType.UserMention: { const localMember = nextProps.room ? nextProps.room.getMember(resourceId) : undefined; member = localMember; if (!localMember) { member = new RoomMember(null, resourceId); this.doProfileLookup(resourceId, member); } - break; } - case Pill.TYPE_ROOM_MENTION: { + break; + case PillType.RoomMention: { const localRoom = resourceId[0] === '#' ? MatrixClientPeg.get().getRooms().find((r) => { return r.getCanonicalAlias() === resourceId || @@ -122,39 +137,39 @@ class Pill extends React.Component { // a room avatar and name. // this.doRoomProfileLookup(resourceId, member); } - break; } + break; } this.setState({ resourceId, pillType, member, room }); } - componentDidMount() { - this._unmounted = false; - this._matrixClient = MatrixClientPeg.get(); + public componentDidMount(): void { + this.unmounted = false; + this.matrixClient = MatrixClientPeg.get(); // eslint-disable-next-line new-cap this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves. } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - onMouseOver = () => { + private onMouseOver = (): void => { this.setState({ hover: true, }); }; - onMouseLeave = () => { + private onMouseLeave = (): void => { this.setState({ hover: false, }); }; - doProfileLookup(userId, member) { + private doProfileLookup(userId: string, member): void { MatrixClientPeg.get().getProfileInfo(userId).then((resp) => { - if (this._unmounted) { + if (this.unmounted) { return; } member.name = resp.displayname; @@ -173,7 +188,7 @@ class Pill extends React.Component { }); } - onUserPillClicked = (e) => { + private onUserPillClicked = (e): void => { e.preventDefault(); dis.dispatch({ action: Action.ViewUser, @@ -181,7 +196,7 @@ class Pill extends React.Component { }); }; - render() { + public render(): JSX.Element { const resource = this.state.resourceId; let avatar = null; @@ -191,7 +206,7 @@ class Pill extends React.Component { let href = this.props.url; let onClick; switch (this.state.pillType) { - case Pill.TYPE_AT_ROOM_MENTION: { + case PillType.AtRoomMention: { const room = this.props.room; if (room) { linkText = "@room"; @@ -200,9 +215,9 @@ class Pill extends React.Component { } pillClass = 'mx_AtRoomPill'; } - break; } - case Pill.TYPE_USER_MENTION: { + break; + case PillType.UserMention: { // If this user is not a member of this room, default to the empty member const member = this.state.member; if (member) { @@ -210,15 +225,15 @@ class Pill extends React.Component { member.rawDisplayName = member.rawDisplayName || ''; linkText = member.rawDisplayName; if (this.props.shouldShowPillAvatar) { - avatar =