Merge remote-tracking branch 'origin/develop' into staging

This commit is contained in:
RiotRobot 2024-07-09 11:58:22 +00:00
commit 8580b13649
229 changed files with 6559 additions and 3601 deletions

View file

@ -78,6 +78,11 @@ module.exports = {
name: "matrix-react-sdk/", name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead", message: "Please use matrix-react-sdk/src/index instead",
}, },
{
name: "emojibase-regex",
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
], ],
patterns: [ patterns: [
{ {
@ -115,13 +120,9 @@ module.exports = {
"!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError",
"!matrix-js-sdk/src/crypto", "!matrix-js-sdk/src/crypto",
"!matrix-js-sdk/src/crypto/aes", "!matrix-js-sdk/src/crypto/aes",
"!matrix-js-sdk/src/crypto/olmlib",
"!matrix-js-sdk/src/crypto/crypto",
"!matrix-js-sdk/src/crypto/keybackup", "!matrix-js-sdk/src/crypto/keybackup",
"!matrix-js-sdk/src/crypto/RoomList",
"!matrix-js-sdk/src/crypto/deviceinfo", "!matrix-js-sdk/src/crypto/deviceinfo",
"!matrix-js-sdk/src/crypto/key_passphrase", "!matrix-js-sdk/src/crypto/key_passphrase",
"!matrix-js-sdk/src/crypto/CrossSigning",
"!matrix-js-sdk/src/crypto/recoverykey", "!matrix-js-sdk/src/crypto/recoverykey",
"!matrix-js-sdk/src/crypto/dehydration", "!matrix-js-sdk/src/crypto/dehydration",
"!matrix-js-sdk/src/oidc", "!matrix-js-sdk/src/oidc",
@ -144,6 +145,11 @@ module.exports = {
], ],
message: "Please use matrix-js-sdk/src/matrix instead", message: "Please use matrix-js-sdk/src/matrix instead",
}, },
{
group: ["emojibase-regex/emoji*"],
message:
"This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
},
], ],
}, },
], ],

View file

@ -56,6 +56,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
- name: Fetch layered build - name: Fetch layered build
id: layered_build id: layered_build
@ -103,7 +104,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
# Run multiple instances in parallel to speed up the tests # Run multiple instances in parallel to speed up the tests
runner: [1, 2, 3, 4, 5, 6, 7, 8] runner: [1, 2, 3, 4, 5, 6]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@ -121,6 +122,7 @@ jobs:
with: with:
cache: "yarn" cache: "yarn"
cache-dependency-path: matrix-react-sdk/yarn.lock cache-dependency-path: matrix-react-sdk/yarn.lock
node-version: "lts/*"
- name: Install dependencies - name: Install dependencies
working-directory: matrix-react-sdk working-directory: matrix-react-sdk
@ -145,8 +147,6 @@ jobs:
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
with:
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }} run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
working-directory: matrix-react-sdk working-directory: matrix-react-sdk
@ -174,6 +174,7 @@ jobs:
if: inputs.skip != true if: inputs.skip != true
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
- name: Install dependencies - name: Install dependencies
if: inputs.skip != true if: inputs.skip != true

View file

@ -7,7 +7,7 @@ jobs:
update: update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Update matrixdotorg/synapse image - name: Update matrixdotorg/synapse image
run: | run: |
@ -20,7 +20,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: cpr id: cpr
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5 uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates branch: actions/playwright-image-updates

View file

@ -7,7 +7,7 @@ jobs:
name: Check PR base branch name: Check PR base branch
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@v3 - uses: actions/github-script@v7
with: with:
script: | script: |
const baseBranch = context.payload.pull_request.base.ref; const baseBranch = context.payload.pull_request.base.ref;

View file

@ -25,6 +25,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
- name: Install Deps - name: Install Deps
run: "./scripts/ci/install-deps.sh" run: "./scripts/ci/install-deps.sh"
@ -83,6 +84,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
# Does not need branch matching as only analyses this layer # Does not need branch matching as only analyses this layer
- name: Install Deps - name: Install Deps
@ -100,6 +102,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
# Does not need branch matching as only analyses this layer # Does not need branch matching as only analyses this layer
- name: Install Deps - name: Install Deps
@ -117,6 +120,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
# Does not need branch matching as only analyses this layer # Does not need branch matching as only analyses this layer
- name: Install Deps - name: Install Deps
@ -134,6 +138,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
- name: Install Deps - name: Install Deps
run: "scripts/ci/layered.sh" run: "scripts/ci/layered.sh"

View file

@ -44,6 +44,7 @@ jobs:
- name: Yarn cache - name: Yarn cache
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "lts/*"
cache: "yarn" cache: "yarn"
- name: Install Deps - name: Install Deps
@ -115,6 +116,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*"
- name: Run tests - name: Run tests
run: "./scripts/ci/app-tests.sh" run: "./scripts/ci/app-tests.sh"

View file

@ -22,7 +22,7 @@ const config: Config = {
testEnvironment: "jsdom", testEnvironment: "jsdom",
testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts", globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
moduleNameMapper: { moduleNameMapper: {
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js", "\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",

View file

@ -55,7 +55,7 @@
"test:playwright:open": "yarn test:playwright --ui", "test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run", "test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t matrix-react-sdk-playwright", "test:playwright:screenshots:build": "docker build playwright -t matrix-react-sdk-playwright",
"test:playwright:screenshots:run": "docker run --rm --network host -v $(pwd)/../:/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it matrix-react-sdk-playwright", "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -v $(pwd)/../:/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it matrix-react-sdk-playwright",
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'" "lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'"
}, },
@ -65,20 +65,20 @@
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"oidc-client-ts": "3.0.1", "oidc-client-ts": "3.0.1",
"jwt-decode": "4.0.0", "jwt-decode": "4.0.0",
"@floating-ui/react": "0.26.11" "@floating-ui/react": "0.26.11",
"@radix-ui/react-id": "1.1.0"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.23.0", "@matrix-org/analytics-events": "^0.23.0",
"@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/emojibase-bindings": "^1.1.2",
"@matrix-org/matrix-wysiwyg": "2.37.3", "@matrix-org/matrix-wysiwyg": "2.37.4",
"@matrix-org/olm": "3.2.15",
"@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0", "@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0", "@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.2.0", "@vector-im/compound-design-tokens": "^1.2.0",
"@vector-im/compound-web": "^4.9.0", "@vector-im/compound-web": "^5.2.3",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.2",
@ -96,7 +96,6 @@
"filesize": "10.1.2", "filesize": "10.1.2",
"github-markdown-css": "^5.5.1", "github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"graphemer": "^1.4.0",
"highlight.js": "^11.3.1", "highlight.js": "^11.3.1",
"html-entities": "^2.0.0", "html-entities": "^2.0.0",
"is-ip": "^3.1.0", "is-ip": "^3.1.0",
@ -119,8 +118,7 @@
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"posthog-js": "1.139.2", "posthog-js": "1.141.3",
"proposal-temporal": "^0.9.0",
"qrcode": "1.5.3", "qrcode": "1.5.3",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "17.0.2", "react": "17.0.2",
@ -133,6 +131,7 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "2.13.0", "sanitize-html": "2.13.0",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2", "ua-parser-js": "^1.0.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"what-input": "^5.2.10" "what-input": "^5.2.10"
@ -188,7 +187,7 @@
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/tar-js": "^0.3.2", "@types/tar-js": "^0.3.2",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.2", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.9.1", "axe-core": "4.9.1",
@ -204,7 +203,7 @@
"eslint-plugin-matrix-org": "1.2.1", "eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^53.0.0", "eslint-plugin-unicorn": "^54.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
@ -227,7 +226,8 @@
"stylelint-config-standard": "^36.0.0", "stylelint-config-standard": "^36.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.4.5" "typescript": "5.5.2",
"web-streams-polyfill": "^4.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"postcss": "^8.4.19", "postcss": "^8.4.19",

View file

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.44.1-jammy FROM mcr.microsoft.com/playwright:v1.45.0-jammy
WORKDIR /work/matrix-react-sdk WORKDIR /work/matrix-react-sdk
VOLUME ["/work/element-web/node_modules"] VOLUME ["/work/element-web/node_modules"]

View file

@ -160,7 +160,7 @@ test.describe("Audio player", () => {
// Enable high contrast manually // Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance"); const settings = await app.settings.openUserSettings("Appearance");
await settings.getByTestId("mx_ThemeChoicePanel").getByText("Use high contrast").click(); await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog(); await app.closeDialog();

View file

@ -103,7 +103,7 @@ const verify = async (page: Page, bob: Bot) => {
const bobsVerificationRequestPromise = waitForVerificationRequest(bob); const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
const roomInfo = await openRoomInfo(page); const roomInfo = await openRoomInfo(page);
await roomInfo.getByRole("menuitem", { name: "People" }).click(); await page.locator(".mx_RightPanelTabs").getByText("People").click();
await roomInfo.getByText("Bob").click(); await roomInfo.getByText("Bob").click();
await roomInfo.getByRole("button", { name: "Verify" }).click(); await roomInfo.getByRole("button", { name: "Verify" }).click();
await roomInfo.getByRole("button", { name: "Start Verification" }).click(); await roomInfo.getByRole("button", { name: "Start Verification" }).click();
@ -279,7 +279,7 @@ test.describe("Cryptography", function () {
// Assert that verified icon is rendered // Assert that verified icon is rendered
await page.getByRole("button", { name: "Room members" }).click(); await page.getByRole("button", { name: "Room members" }).click();
await page.getByRole("button", { name: "Room information" }).click(); await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted"); await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted");
// Take a snapshot of RoomSummaryCard with a verified E2EE icon // Take a snapshot of RoomSummaryCard with a verified E2EE icon

View file

@ -102,7 +102,7 @@ test.describe("Dehydration", () => {
await viewRoomSummaryByName(page, app, ROOM_NAME); await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "People" }).click(); await page.locator(".mx_RightPanelTabs").getByText("People").click();
await expect(page.locator(".mx_MemberList")).toBeVisible(); await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click(); await getMemberTileByName(page, NAME).click();

View file

@ -45,7 +45,6 @@ test.describe("Device verification", () => {
// Create a new device for alice // Create a new device for alice
aliceBotClient = new Bot(page, homeserver, { aliceBotClient = new Bot(page, homeserver, {
rustCrypto: true,
bootstrapCrossSigning: true, bootstrapCrossSigning: true,
bootstrapSecretStorage: true, bootstrapSecretStorage: true,
}); });

View file

@ -0,0 +1,77 @@
/*
Copyright 2024 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 { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
const username = "user1234";
// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
const password = "oETo7MPf0o";
const email = "user@nowhere.dummy";
test.describe("Forgot Password", () => {
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
});
test("renders properly", async ({ page, homeserver }) => {
await page.goto("/");
await page.getByRole("link", { name: "Sign in" }).click();
// need to select a homeserver at this stage, before entering the forgot password flow
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png");
});
test("renders email verification dialog properly", async ({ page, homeserver }) => {
const user = await homeserver.registerUser(username, password);
await homeserver.setThreepid(user.userId, "email", email);
await page.goto("/");
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await page.getByRole("textbox", { name: "Email address" }).fill(email);
await page.getByRole("button", { name: "Send email" }).click();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password);
await page.getByRole("button", { name: "Reset password" }).click();
await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
});
});

View file

@ -80,7 +80,7 @@ test.describe("Lazy Loading", () => {
async function openMemberlist(page: Page): Promise<void> { async function openMemberlist(page: Page): Promise<void> {
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click(); await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click();
await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members await page.locator(".mx_RightPanelTabs").getByText("People").click();
} }
function getMemberInMemberlist(page: Page, name: string): Locator { function getMemberInMemberlist(page: Page, name: string): Locator {

View file

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test"; import { expect, test } from "../../element-web-test";
import { doTokenRegistration } from "./utils"; import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
import { selectHomeserver } from "../utils";
test.describe("Login", () => { test.describe("Login", () => {
test.describe("Password login", () => { test.describe("Password login", () => {
@ -85,17 +84,6 @@ test.describe("Login", () => {
await expect(page).toHaveURL(/\/#\/room\/!room:id$/); await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible(); await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
}); });
async function selectHomeserver(page: Page, homeserverUrl: string) {
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserverUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
await expect(page.locator(".mx_Spinner")).toHaveCount(0);
await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserverUrl);
}
}); });
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server // tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server

View file

@ -399,11 +399,10 @@ class Helpers {
} }
/** /**
* Close the threads panel. (Actually, close any right panel, but for these * Close the threads panel.
* tests we only open the threads panel.)
*/ */
async closeThreadsPanel() { async closeThreadsPanel() {
await this.page.locator(".mx_RightPanel").getByLabel("Close").click(); await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click();
await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible(); await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible();
} }
@ -411,7 +410,7 @@ class Helpers {
* Return to the list of threads, given we are viewing a single thread. * Return to the list of threads, given we are viewing a single thread.
*/ */
async backToThreadsList() { async backToThreadsList() {
await this.page.locator(".mx_RightPanel").getByLabel("Threads").click(); await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click();
} }
/** /**

View file

@ -113,7 +113,7 @@ test.describe("RightPanel", () => {
test("should handle viewing room member", async ({ page, app }) => { test("should handle viewing room member", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME); await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "People" }).click(); await page.locator(".mx_RightPanelTabs").getByText("People").click();
await expect(page.locator(".mx_MemberList")).toBeVisible(); await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click(); await getMemberTileByName(page, NAME).click();
@ -123,7 +123,7 @@ test.describe("RightPanel", () => {
await page.getByRole("button", { name: "Room members" }).click(); await page.getByRole("button", { name: "Room members" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible(); await expect(page.locator(".mx_MemberList")).toBeVisible();
await page.getByRole("button", { name: "Room information" }).click(); await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await checkRoomSummaryCard(page, ROOM_NAME); await checkRoomSummaryCard(page, ROOM_NAME);
}); });
}); });

View file

@ -1,219 +0,0 @@
/*
Copyright 2023 Suguru Hirahara
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
test.describe("Appearance user settings tab", () => {
test.use({
displayName: "Hanako",
});
test("should be rendered properly", async ({ page, user, app }) => {
const tab = await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
// Assert that "Hide advanced" link button is rendered
await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
await expect(tab).toMatchScreenshot("appearance-tab.png");
});
test("should support switching layouts", async ({ page, user, app }) => {
// Create and view a room first
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
await app.settings.openUserSettings("Appearance");
const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
// Assert that the layout selected by default is "Modern"
await expect(
buttons.locator(".mx_StyledRadioButton_enabled", {
hasText: "Modern",
}),
).toBeVisible();
// Assert that the room layout is set to group (modern) layout
await expect(page.locator(".mx_RoomView_body[data-layout='group']")).toBeVisible();
// Select the first layout
await buttons.first().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
// Assert that the room layout is set to IRC layout
await expect(page.locator(".mx_RoomView_body[data-layout='irc']")).toBeVisible();
// Select the last layout
await buttons.last().click();
// Assert that the layout selected is "Message bubbles"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
// Assert that the room layout is set to bubble layout
await expect(page.locator(".mx_RoomView_body[data-layout='bubble']")).toBeVisible();
});
test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown");
await expect(fontDropdown.getByLabel("Font size")).toBeVisible();
// Default browser font size is 16px and the select value is 0
// -4 value is 12px
await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" });
await expect(page).toMatchScreenshot("window-12px.png");
});
test("should support enabling compact group (modern) layout", async ({ page, app, user }) => {
// Create and view a room first
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.locator("label", { hasText: "Use a more compact 'Modern' layout" }).click();
// Assert that the room layout is set to compact group (modern) layout
await expect(page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout")).toBeVisible();
});
test("should disable compact group (modern) layout option on IRC layout and bubble layout", async ({
page,
app,
user,
}) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
const checkDisabled = async () => {
await expect(tab.getByRole("checkbox", { name: "Use a more compact 'Modern' layout" })).toBeDisabled();
};
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
// Enable IRC layout
await buttons.first().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
await checkDisabled();
// Enable bubble layout
await buttons.last().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
await checkDisabled();
});
test("should support enabling system font", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');
});
test.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user }) => {
// Disable the default theme for consistency in case ThemeWatcher automatically chooses it
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
});
test("should be rendered with the light theme selected", async ({ page, app }) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme");
await expect(useSystemTheme.getByText("Match system theme")).toBeVisible();
// Assert that 'Match system theme' is not checked
// Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked
await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible();
const selectors = themePanel.getByTestId("theme-choice-panel-selectors");
await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible();
await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible();
// Assert that the light theme is selected
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible();
// Assert that the buttons for the light and dark theme are not enabled
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible();
await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible();
// Assert that the checkbox for the high contrast theme is rendered
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
});
test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({
page,
app,
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click();
// Assert that the labels for the light theme and dark theme are disabled
await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible();
await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible();
// Assert that there does not exist a label for an enabled theme
await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible();
// Assert that the checkbox and label to enable the high contrast theme should not exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
});
test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({
page,
app,
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
// Assert that the checkbox and the label to enable the high contrast theme should exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
// Enable the dark theme
await themePanel.locator(".mx_ThemeSelector_dark").click();
// Assert that the checkbox and the label should not exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
});
});
});

View file

@ -0,0 +1,172 @@
/*
Copyright 2023 Suguru Hirahara
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { expect, test } from ".";
test.describe("Appearance user settings tab", () => {
test.use({
displayName: "Hanako",
});
test("should be rendered properly", async ({ page, user, app }) => {
const tab = await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
// Assert that "Hide advanced" link button is rendered
await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
await expect(tab).toMatchScreenshot("appearance-tab.png");
});
test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown");
await expect(fontDropdown.getByLabel("Font size")).toBeVisible();
// Default browser font size is 16px and the select value is 0
// -4 value is 12px
await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" });
await expect(page).toMatchScreenshot("window-12px.png");
});
test("should support enabling system font", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');
});
test.describe("Message Layout Panel", () => {
test.beforeEach(async ({ app, user, util }) => {
await util.createAndDisplayRoom();
await util.assertModernLayout();
await util.openAppearanceTab();
});
test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => {
await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png");
await util.getBubbleLayout().click();
// Assert that modern are irc layout are not selected
await expect(util.getBubbleLayout()).toBeChecked();
await expect(util.getModernLayout()).not.toBeChecked();
await expect(util.getIRCLayout()).not.toBeChecked();
// Assert that the room layout is set to bubble layout
await util.assertBubbleLayout();
await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png");
});
test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => {
await expect(util.getCompactLayoutCheckbox()).not.toBeChecked();
await util.getCompactLayoutCheckbox().click();
await util.assertCompactLayout();
});
test("should disable compact layout when the modern layout is not selected", async ({
page,
app,
user,
util,
}) => {
await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled();
// Select the bubble layout, which should disable the compact layout checkbox
await util.getBubbleLayout().click();
await expect(util.getCompactLayoutCheckbox()).toBeDisabled();
});
});
test.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user, util }) => {
// Disable the default theme for consistency in case ThemeWatcher automatically chooses it
await util.disableSystemTheme();
await util.openAppearanceTab();
});
test("should be rendered with the light theme selected", async ({ page, app, util }) => {
// Assert that 'Match system theme' is not checked
await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked();
// Assert that the light theme is selected
await expect(util.getLightTheme()).toBeChecked();
// Assert that the dark and high contrast themes are not selected
await expect(util.getDarkTheme()).not.toBeChecked();
await expect(util.getHighContrastTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png");
});
test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => {
await util.getMatchSystemThemeCheckbox().click();
// Assert that the themes are disabled
await expect(util.getLightTheme()).toBeDisabled();
await expect(util.getDarkTheme()).toBeDisabled();
await expect(util.getHighContrastTheme()).toBeDisabled();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png");
});
test("should change the theme to dark", async ({ page, app, util }) => {
// Assert that the light theme is selected
await expect(util.getLightTheme()).toBeChecked();
await util.getDarkTheme().click();
// Assert that the light and high contrast themes are not selected
await expect(util.getLightTheme()).not.toBeChecked();
await expect(util.getDarkTheme()).toBeChecked();
await expect(util.getHighContrastTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png");
});
test.describe("custom theme", () => {
test.use({
labsFlags: ["feature_custom_themes"],
});
test("should render the custom theme section", async ({ page, app, util }) => {
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
test("should be able to add and remove a custom theme", async ({ page, app, util }) => {
await util.addCustomTheme();
await expect(util.getCustomTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png");
await util.removeCustomTheme();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
});
});
});

View file

@ -0,0 +1,241 @@
/*
* Copyright 2024 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 { Locator, Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Layout } from "../../../../src/settings/enums/Layout";
export { expect };
/**
* Set up for the appearance tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app }, use) => {
await use(new Helpers(page, app));
},
});
/**
* A collection of helper functions for the appearance tab test
* The goal is to make easier to get and interact with the button, input, or other elements of the appearance tab
*/
class Helpers {
private CUSTOM_THEME_URL = "http://custom.theme";
private CUSTOM_THEME = {
name: "Custom theme",
isDark: false,
colors: {},
};
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the appearance tab
*/
openAppearanceTab() {
return this.app.settings.openUserSettings("Appearance");
}
/**
* Compare screenshot and hide the matrix chat
* @param locator
* @param screenshot
*/
assertScreenshot(locator: Locator, screenshot: `${string}.png`) {
return expect(locator).toMatchScreenshot(screenshot, {
css: `
#matrixchat {
display: none;
}
`,
});
}
// Theme Panel
/**
* Disable in the settings the system theme
*/
disableSystemTheme() {
return this.app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
}
/**
* Return the theme section
*/
getThemePanel() {
return this.page.getByTestId("themePanel");
}
/**
* Return the system theme toggle
*/
getMatchSystemThemeCheckbox() {
return this.getThemePanel().getByRole("checkbox", { name: "Match system theme" });
}
/**
* Return the theme radio button
* @param theme - the theme to select
* @private
*/
private getThemeRadio(theme: string) {
return this.getThemePanel().getByRole("radio", { name: theme });
}
/**
* Return the light theme radio button
*/
getLightTheme() {
return this.getThemeRadio("Light");
}
/**
* Return the dark theme radio button
*/
getDarkTheme() {
return this.getThemeRadio("Dark");
}
/**
* Return the custom theme radio button
*/
getCustomTheme() {
return this.getThemeRadio(this.CUSTOM_THEME.name);
}
/**
* Return the high contrast theme radio button
*/
getHighContrastTheme() {
return this.getThemeRadio("High contrast");
}
/**
* Add a custom theme
* Mock the request to the custom and return a fake local custom theme
*/
async addCustomTheme() {
await this.page.route(this.CUSTOM_THEME_URL, (route) =>
route.fulfill({ body: JSON.stringify(this.CUSTOM_THEME) }),
);
await this.page.getByRole("textbox", { name: "Add custom theme" }).fill(this.CUSTOM_THEME_URL);
await this.page.getByRole("button", { name: "Add custom theme" }).click();
await this.page.unroute(this.CUSTOM_THEME_URL);
}
/**
* Remove the custom theme
*/
removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
}
// Message layout Panel
/**
* Create and display a room named Test Room
*/
async createAndDisplayRoom() {
await this.app.client.createRoom({ name: "Test Room" });
await this.app.viewRoomByName("Test Room");
}
/**
* Assert the room layout
* @param layout
* @private
*/
private assertRoomLayout(layout: Layout) {
return expect(this.page.locator(`.mx_RoomView_body[data-layout=${layout}]`)).toBeVisible();
}
/**
* Assert the room layout is modern
*/
assertModernLayout() {
return this.assertRoomLayout(Layout.Group);
}
/**
* Assert the room layout is bubble
*/
assertBubbleLayout() {
return this.assertRoomLayout(Layout.Bubble);
}
/**
* Return the layout panel
*/
getMessageLayoutPanel() {
return this.page.getByTestId("layoutPanel");
}
/**
* Return the layout radio button
* @param layoutName
* @private
*/
private getLayout(layoutName: string) {
return this.getMessageLayoutPanel().getByRole("radio", { name: layoutName });
}
/**
* Return the message bubbles layout radio button
*/
getBubbleLayout() {
return this.getLayout("Message bubbles");
}
/**
* Return the modern layout radio button
*/
getModernLayout() {
return this.getLayout("Modern");
}
/**
* Return the IRC layout radio button
*/
getIRCLayout() {
return this.getLayout("IRC (experimental)");
}
/**
* Return the compact layout checkbox
*/
getCompactLayoutCheckbox() {
return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" });
}
/**
* Assert the compact layout is enabled
*/
assertCompactLayout() {
return expect(
this.page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout"),
).toBeVisible();
}
}

View file

@ -73,29 +73,6 @@ test.describe("General user settings tab", () => {
// Assert that the add button is rendered // Assert that the add button is rendered
await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
// Check language and region setting dropdown
const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
await languageInput.scrollIntoViewIfNeeded();
// Check the default value
await expect(languageInput.getByText("English")).toBeVisible();
// Click the button to display the dropdown menu
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default option is rendered and highlighted
languageInput.getByRole("option", { name: /Albanian/ });
await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
/mx_Dropdown_option_highlight/,
);
await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
// Click again to close the dropdown
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default value is rendered again
await expect(languageInput.getByText("English")).toBeVisible();
const setIdServer = uut.locator(".mx_SetIdServer");
await setIdServer.scrollIntoViewIfNeeded();
// Assert that an input area for identity server exists
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
const setIntegrationManager = uut.locator(".mx_SetIntegrationManager"); const setIntegrationManager = uut.locator(".mx_SetIntegrationManager");
await setIntegrationManager.scrollIntoViewIfNeeded(); await setIntegrationManager.scrollIntoViewIfNeeded();
await expect( await expect(

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2023 Suguru Hirahara Copyright 2023 Suguru Hirahara
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,6 +20,10 @@ import { test, expect } from "../../element-web-test";
test.describe("Preferences user settings tab", () => { test.describe("Preferences user settings tab", () => {
test.use({ test.use({
displayName: "Bob", displayName: "Bob",
uut: async ({ app, user }, use) => {
const locator = await app.settings.openUserSettings("Preferences");
await use(locator);
},
}); });
test("should be rendered properly", async ({ app, user }) => { test("should be rendered properly", async ({ app, user }) => {
@ -28,4 +33,24 @@ test.describe("Preferences user settings tab", () => {
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
await expect(tab).toMatchScreenshot(); await expect(tab).toMatchScreenshot();
}); });
test("should be able to change the app language", async ({ uut, user }) => {
// Check language and region setting dropdown
const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
await languageInput.scrollIntoViewIfNeeded();
// Check the default value
await expect(languageInput.getByText("English")).toBeVisible();
// Click the button to display the dropdown menu
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default option is rendered and highlighted
languageInput.getByRole("option", { name: /Albanian/ });
await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
/mx_Dropdown_option_highlight/,
);
await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
// Click again to close the dropdown
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default value is rendered again
await expect(languageInput.getByText("English")).toBeVisible();
});
}); });

View file

@ -47,5 +47,14 @@ test.describe("Security user settings tab", () => {
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(); await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot();
}); });
}); });
test("should contain section to set ID server", async ({ app }) => {
const tab = await app.settings.openUserSettings("Security");
const setIdServer = tab.locator(".mx_SetIdServer");
await setIdServer.scrollIntoViewIfNeeded();
// Assert that an input area for identity server exists
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
});
}); });
}); });

View file

@ -337,12 +337,10 @@ export class Helpers {
} }
/** /**
* Assert that the thread panel is focused (actually the 'close' button, specifically) * Assert that the thread tab is focused
*/ */
assertThreadPanelFocused() { assertThreadTabFocused() {
return expect( return expect(this.page.locator("#thread-panel-tab")).toBeFocused();
this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByLabel("Close"),
).toBeFocused();
} }
/** /**

View file

@ -161,17 +161,12 @@ test.describe("Threads Activity Centre", () => {
await util.assertNoTacIndicator(); await util.assertNoTacIndicator();
}); });
test("should focus the thread panel close button when clicking an item in the TAC", async ({ test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg }) => {
room1,
room2,
util,
msg,
}) => {
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
await util.openTac(); await util.openTac();
await util.clickRoomInTac(room1.name); await util.clickRoomInTac(room1.name);
await util.assertThreadPanelFocused(); await util.assertThreadTabFocused();
}); });
}); });

View file

@ -781,10 +781,10 @@ test.describe("Timeline", () => {
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png"); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message");
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message"); await expect(page.locator(".mx_RoomSearchAuxPanel")).toMatchScreenshot("search-aux-panel.png");
await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter");
for (const locator of await page for (const locator of await page
.locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight") .locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight")
@ -822,8 +822,8 @@ test.describe("Timeline", () => {
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
// Search the string to display both the message and TextualEvent on search results panel // Search the string to display both the message and TextualEvent on search results panel
await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch);
await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter"); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
// On search results panel // On search results panel
const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel"); const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");

View file

@ -17,8 +17,8 @@ limitations under the License.
*/ */
import { uniqueId } from "lodash"; import { uniqueId } from "lodash";
import { expect, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { Client } from "../pages/client"; import { Client } from "../pages/client";
@ -63,4 +63,15 @@ export async function waitForRoom(
); );
} }
export async function selectHomeserver(page: Page, homeserverUrl: string) {
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserverUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
await expect(page.locator(".mx_Spinner")).toHaveCount(0);
await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserverUrl);
}
export const CommandOrControl = process.platform === "darwin" ? "Meta" : "Control"; export const CommandOrControl = process.platform === "darwin" ? "Meta" : "Control";

View file

@ -53,7 +53,10 @@ class FlakyReporter implements Reporter {
const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` }; const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` };
// Fetch all existing issues with the flaky-test label. // Fetch all existing issues with the flaky-test label.
const issuesRequest = await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}`, { headers }); const issuesRequest = await fetch(
`${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=100&sort=created`,
{ headers },
);
const issues = await issuesRequest.json(); const issues = await issuesRequest.json();
for (const flake of this.flakes) { for (const flake of this.flakes) {
const title = ISSUE_TITLE_PREFIX + "`" + flake + "`"; const title = ISSUE_TITLE_PREFIX + "`" + flake + "`";
@ -61,6 +64,12 @@ class FlakyReporter implements Reporter {
if (existingIssue) { if (existingIssue) {
console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`); console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`);
// Ensure that the test is open
await fetch(existingIssue.url, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "open" }),
});
await fetch(`${existingIssue.url}/comments`, { await fetch(`${existingIssue.url}/comments`, {
method: "POST", method: "POST",
headers, headers,

View file

@ -45,10 +45,6 @@ export interface CreateBotOpts {
* Whether to generate cross-signing keys * Whether to generate cross-signing keys
*/ */
bootstrapCrossSigning?: boolean; bootstrapCrossSigning?: boolean;
/**
* Whether to use the rust crypto impl. Defaults to false (for now!)
*/
rustCrypto?: boolean;
/** /**
* Whether to bootstrap the secret storage * Whether to bootstrap the secret storage
*/ */
@ -188,11 +184,7 @@ export class Bot extends Client {
return cli; return cli;
} }
if (opts.rustCrypto) {
await cli.initRustCrypto({ useIndexedDB: false }); await cli.initRustCrypto({ useIndexedDB: false });
} else {
await cli.initCrypto();
}
cli.setGlobalErrorOnUnknownDevices(false); cli.setGlobalErrorOnUnknownDevices(false);
await cli.startClient(); await cli.startClient();

View file

@ -39,6 +39,15 @@ export interface HomeserverInstance {
* @param password login password * @param password login password
*/ */
loginUser(userId: string, password: string): Promise<Credentials>; loginUser(userId: string, password: string): Promise<Credentials>;
/**
* Sets a third party identifier for the given user. This only supports setting a single 3pid and will
* replace any others.
* @param userId The full ID of the user to edit (as returned from registerUser)
* @param medium The medium of the 3pid to set
* @param address The address of the 3pid to set
*/
setThreepid(userId: string, medium: string, address: string): Promise<void>;
} }
export interface StartHomeserverOpts { export interface StartHomeserverOpts {

View file

@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for `matrixdotorg/synapse` image. // Docker tag to use for `matrixdotorg/synapse` image.
// We target a specific digest as every now and then a Synapse update will break our CI. // We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically. // This digest is updated by the playwright-image-updates.yaml workflow periodically.
const DOCKER_TAG = "develop@sha256:38bdd185e32dbfb40d11a69a26c5b04c0ccf1cb7d4078a14d6fdb16620bd4b3c"; const DOCKER_TAG = "develop@sha256:db5f8e8ca4a903379ea18b010ac3360bd843c9ac7eb2e73ad89f5059d01f8104";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> { async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template); const templateDir = path.join(__dirname, "templates", opts.template);
@ -94,6 +94,8 @@ export class Synapse implements Homeserver, HomeserverInstance {
protected docker: Docker = new Docker(); protected docker: Docker = new Docker();
public config: HomeserverConfig & { serverId: string }; public config: HomeserverConfig & { serverId: string };
private adminToken?: string;
public constructor(private readonly request: APIRequestContext) {} public constructor(private readonly request: APIRequestContext) {}
/** /**
@ -152,12 +154,17 @@ export class Synapse implements Homeserver, HomeserverInstance {
return [path.join(synapseLogsPath, "stdout.log"), path.join(synapseLogsPath, "stderr.log")]; return [path.join(synapseLogsPath, "stdout.log"), path.join(synapseLogsPath, "stderr.log")];
} }
public async registerUser(username: string, password: string, displayName?: string): Promise<Credentials> { private async registerUserInternal(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<Credentials> {
const url = `${this.config.baseUrl}/_synapse/admin/v1/register`; const url = `${this.config.baseUrl}/_synapse/admin/v1/register`;
const { nonce } = await this.request.get(url).then((r) => r.json()); const { nonce } = await this.request.get(url).then((r) => r.json());
const mac = crypto const mac = crypto
.createHmac("sha1", this.config.registrationSecret) .createHmac("sha1", this.config.registrationSecret)
.update(`${nonce}\0${username}\0${password}\0notadmin`) .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
.digest("hex"); .digest("hex");
const res = await this.request.post(url, { const res = await this.request.post(url, {
data: { data: {
@ -165,7 +172,7 @@ export class Synapse implements Homeserver, HomeserverInstance {
username, username,
password, password,
mac, mac,
admin: false, admin,
displayname: displayName, displayname: displayName,
}, },
}); });
@ -185,6 +192,10 @@ export class Synapse implements Homeserver, HomeserverInstance {
}; };
} }
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.registerUserInternal(username, password, displayName, false);
}
public async loginUser(userId: string, password: string): Promise<Credentials> { public async loginUser(userId: string, password: string): Promise<Credentials> {
const url = `${this.config.baseUrl}/_matrix/client/v3/login`; const url = `${this.config.baseUrl}/_matrix/client/v3/login`;
const res = await this.request.post(url, { const res = await this.request.post(url, {
@ -207,4 +218,30 @@ export class Synapse implements Homeserver, HomeserverInstance {
homeServer: json.home_server, homeServer: json.home_server,
}; };
} }
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
if (this.adminToken === undefined) {
const result = await this.registerUserInternal("admin", "totalyinsecureadminpassword", undefined, true);
this.adminToken = result.accessToken;
}
const url = `${this.config.baseUrl}/_synapse/admin/v2/users/${userId}`;
const res = await this.request.put(url, {
data: {
threepids: [
{
medium,
address,
},
],
},
headers: {
Authorization: `Bearer ${this.adminToken}`,
},
});
if (!res.ok()) {
throw await res.json();
}
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -177,9 +177,9 @@ a:visited {
color: $accent-alt; color: $accent-alt;
} }
input[type="text"], :not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="text"],
input[type="search"], :not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="search"],
input[type="password"] { :not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="password"] {
padding: 9px; padding: 9px;
font: var(--cpd-font-body-md-semibold); font: var(--cpd-font-body-md-semibold);
font-weight: var(--cpd-font-weight-semibold); font-weight: var(--cpd-font-weight-semibold);
@ -522,6 +522,8 @@ legend {
content: ""; content: "";
width: 28px; width: 28px;
height: 28px; height: 28px;
left: 0;
top: 0;
position: absolute; position: absolute;
mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
mask-repeat: no-repeat; mask-repeat: no-repeat;
@ -604,7 +606,7 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
), ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog input[type="submit"], .mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] { .mx_Dialog_buttons input[type="submit"] {
@ -624,14 +626,14 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):last-child { ):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
margin-right: 0px; margin-right: 0px;
} }
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):focus, ):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
.mx_Dialog input[type="submit"]:focus, .mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus { .mx_Dialog_buttons input[type="submit"]:focus {
@ -643,7 +645,7 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
), ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary); color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest); background-color: var(--cpd-color-bg-action-primary-rest);
@ -654,7 +656,9 @@ legend {
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].danger, .mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons .mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button), button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
),
.mx_Dialog_buttons input[type="submit"].danger { .mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary); background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary);
@ -670,7 +674,7 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):disabled, ):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
.mx_Dialog input[type="submit"]:disabled, .mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled { .mx_Dialog_buttons input[type="submit"]:disabled {

View file

@ -261,6 +261,7 @@
@import "./views/right_panel/_BaseCard.pcss"; @import "./views/right_panel/_BaseCard.pcss";
@import "./views/right_panel/_EncryptionInfo.pcss"; @import "./views/right_panel/_EncryptionInfo.pcss";
@import "./views/right_panel/_PinnedMessagesCard.pcss"; @import "./views/right_panel/_PinnedMessagesCard.pcss";
@import "./views/right_panel/_RightPanelTabs.pcss";
@import "./views/right_panel/_RoomSummaryCard.pcss"; @import "./views/right_panel/_RoomSummaryCard.pcss";
@import "./views/right_panel/_ThreadPanel.pcss"; @import "./views/right_panel/_ThreadPanel.pcss";
@import "./views/right_panel/_TimelineCard.pcss"; @import "./views/right_panel/_TimelineCard.pcss";
@ -306,10 +307,10 @@
@import "./views/rooms/_RoomListHeader.pcss"; @import "./views/rooms/_RoomListHeader.pcss";
@import "./views/rooms/_RoomPreviewBar.pcss"; @import "./views/rooms/_RoomPreviewBar.pcss";
@import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
@import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss"; @import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss";
@import "./views/rooms/_SearchBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss"; @import "./views/rooms/_SendMessageComposer.pcss";
@import "./views/rooms/_SpaceScopeHeader.pcss"; @import "./views/rooms/_SpaceScopeHeader.pcss";
@import "./views/rooms/_Stickers.pcss"; @import "./views/rooms/_Stickers.pcss";

View file

@ -17,6 +17,12 @@ limitations under the License.
.mx_SettingsSubsection { .mx_SettingsSubsection {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
&.mx_SettingsSubsection_newUi {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
}
} }
.mx_SettingsSubsection_description { .mx_SettingsSubsection_description {
@ -54,4 +60,8 @@ limitations under the License.
&.mx_SettingsSubsection_noHeading { &.mx_SettingsSubsection_noHeading {
margin-top: 0; margin-top: 0;
} }
&.mx_SettingsSubsection_content_newUi {
gap: var(--cpd-space-6x);
margin-top: 0;
}
} }

View file

@ -0,0 +1,25 @@
/*
Copyright 2024 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_RightPanelTabs {
margin: 0;
height: 64px;
box-sizing: border-box;
ul {
margin-left: 16px;
}
}

View file

@ -235,28 +235,15 @@ limitations under the License.
} }
.mx_RoomSummaryCard_header { .mx_RoomSummaryCard_header {
padding: 15px 12px; padding: 24px 12px 15px;
} }
.mx_RoomSummaryCard_search input { .mx_RoomSummaryCard_search {
/* Overriding very broad CSS rules */ flex-grow: 1;
border: 0 !important; min-width: 0;
margin: 0 !important;
cursor: pointer;
}
.mx_RoomSummaryCard_searchBtn { input[type="search"]::-webkit-search-cancel-button {
background: var(--cpd-color-bg-canvas-default); display: unset; /* override _common.pcss which inhibits this */
color: var(--cpd-color-icon-primary);
border: 1px solid var(--cpd-color-gray-400);
border-radius: 50%;
width: 36px;
height: 36px;
padding: var(--cpd-space-2x);
cursor: pointer;
&:hover {
background: var(--cpd-color-bg-subtle-primary);
} }
} }

View file

@ -19,6 +19,7 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
margin-top: 24px;
.mx_Spinner { .mx_Spinner {
flex: 1 0 auto; flex: 1 0 auto;

View file

@ -20,7 +20,7 @@ limitations under the License.
padding: 0 var(--cpd-space-3x); padding: 0 var(--cpd-space-3x);
border-bottom: 1px solid $separator; border-bottom: 1px solid $separator;
background-color: $background; background-color: $background;
transition: all 0.3s ease; transition: all 0.2s ease;
} }
.mx_RoomHeader:hover { .mx_RoomHeader:hover {
@ -74,7 +74,9 @@ limitations under the License.
} }
} }
.mx_RoomHeader:hover .mx_RoomHeader_topic { .mx_RoomHeader:hover,
.mx_RoomHeader:focus-within {
.mx_RoomHeader_topic {
/* height needed to compute the transition, it equals to the `line-height` /* height needed to compute the transition, it equals to the `line-height`
value in pixels */ value in pixels */
height: calc($font-13px * 1.5); height: calc($font-13px * 1.5);
@ -83,6 +85,7 @@ limitations under the License.
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
}
} }
.mx_RoomHeader_icon { .mx_RoomHeader_icon {

View file

@ -0,0 +1,72 @@
/*
Copyright 2024 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_RoomSearchAuxPanel {
/* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
min-height: 84px;
display: flex;
align-items: center;
border-color: var(--cpd-color-bg-canvas-default);
border-style: solid;
border-width: 1px 0;
padding: var(--cpd-space-3x);
box-sizing: border-box;
gap: var(--cpd-space-2x);
.mx_RoomSearchAuxPanel_summary {
flex-grow: 1;
display: inherit; /* flex */
gap: var(--cpd-space-2x);
align-items: center;
overflow: hidden;
> svg {
padding: var(--cpd-space-2x);
border-radius: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
}
.mx_RoomSearchAuxPanel_summary_text {
display: flex;
flex-direction: column;
font-size: $font-15px;
line-height: $font-22px;
overflow: hidden;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mx_SearchWarning {
display: contents;
font-size: $font-13px;
line-height: $font-20px;
color: var(--cpd-color-text-secondary);
}
}
.mx_RoomSearchAuxPanel_buttons {
display: inherit; /* flex */
gap: var(--cpd-space-6x);
align-items: center;
flex-shrink: 0;
}
}

View file

@ -1,83 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_SearchBar {
/* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
min-height: 56px;
display: flex;
align-items: center;
border-bottom: 1px solid $primary-hairline-color;
.mx_SearchBar_input {
--size-button-search: 37px; /* size of the search button inside `input` element */
/* border: 1px solid $input-border-color; */
/* font-size: $font-15px; */
flex: 1 1 0;
margin-left: 22px;
/* do not allow the input element to shrink below the width needed for the placeholder 'Search…'
and the search button */
min-width: calc(7em + var(--size-button-search));
input {
box-sizing: border-box; /* include padding value into width calculation */
}
}
.mx_SearchBar_searchButton {
cursor: pointer;
width: var(--size-button-search);
height: var(--size-button-search);
background-color: $accent;
mask: url("$(res)/img/feather-customised/search-input.svg");
mask-repeat: no-repeat;
mask-position: center;
}
.mx_SearchBar_buttons {
display: inherit; /* flex */
min-width: 0; /* have the close button displayed even on a very narrow timeline */
}
.mx_SearchBar_button {
border: 0;
margin: 0 0 0 22px;
padding: 5px;
font-size: $font-15px;
cursor: pointer;
color: $primary-content;
border-bottom: 2px solid $accent;
font-weight: var(--cpd-font-weight-semibold);
word-break: break-all; /* prevent the input area and cancel button from being overlapped by BaseCard */
}
.mx_SearchBar_unselected {
color: $input-darker-fg-color;
border-color: transparent;
}
.mx_SearchBar_cancel {
background-color: $alert;
mask: url("$(res)/img/cancel.svg");
mask-repeat: no-repeat;
mask-position: center;
mask-size: 14px;
padding: 9px;
margin: 0 12px 0 3px;
cursor: pointer;
}
}

View file

@ -15,79 +15,80 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_LayoutSwitcher_RadioButtons { .mx_LayoutSwitcher_LayoutSelector {
display: flex;
flex-direction: row;
gap: 24px;
width: 100%;
color: $primary-content;
> .mx_LayoutSwitcher_RadioButton {
flex-grow: 0;
flex-shrink: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; /**
* The settings form has a default gap of 10px
* We want to have a bigger gap between the layout options
*/
gap: var(--cpd-space-4x) !important;
flex-basis: 33%; .mxLayoutSwitcher_LayoutSelector_LayoutRadio {
min-width: 0; border: 1px solid var(--cpd-color-border-interactive-primary);
border-radius: var(--cpd-space-2x);
border: 1px solid $quinary-content; .mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline {
border-radius: 10px; display: flex;
/*
* 10px
*/
gap: calc(var(--cpd-space-2x) + var(--cpd-space-0-5x));
align-items: center;
}
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline,
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
margin: var(--cpd-space-3x);
}
/**
* Override the event tile style to make it fit in the selector
* Tweak also hover style and remove action bar
*/
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
pointer-events: none;
.mx_EventTile {
margin: 0;
/**
* Hide the message options and message action bar in the preview
*/
.mx_EventTile_msgOption, .mx_EventTile_msgOption,
.mx_MessageActionBar { .mx_MessageActionBar {
display: none; display: none;
} }
.mx_LayoutSwitcher_RadioButton_preview {
flex-grow: 1;
display: flex;
align-items: center;
padding: 10px;
pointer-events: none;
.mx_EventTile[data-layout="bubble"] .mx_EventTile_line {
padding-right: 11px;
}
}
.mx_StyledRadioButton {
flex-grow: 0;
padding: 10px;
}
.mx_EventTile_content { .mx_EventTile_content {
margin-right: 0; margin-right: 0;
} }
&.mx_LayoutSwitcher_RadioButton_selected { &[data-layout="group"] {
border-color: var(--cpd-color-bg-accent-rest); margin-top: calc(var(--cpd-space-3x) * -1);
}
} }
.mx_StyledRadioButton { /**
border-top: 1px solid $quinary-content; * Add margin to center the bubble
} */
.mx_StyledRadioButton_checked {
background-color: var(--cpd-color-bg-subtle-secondary);
}
.mx_EventTile {
margin: 0;
&[data-layout="bubble"] { &[data-layout="bubble"] {
margin-right: 40px; /**
* Add the layout margin and the margin to vertically center the bubble
*/
margin-top: var(--cpd-space-6x);
margin-right: 34px;
flex-shrink: 1; flex-shrink: 1;
} }
&[data-layout="irc"] {
> a {
display: none;
}
}
.mx_EventTile_line { .mx_EventTile_line {
max-width: 90%; max-width: 100%;
}
}
}
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator {
border-top: 0;
border-bottom: 1px solid var(--cpd-color-border-interactive-secondary);
} }
} }
} }

View file

@ -14,48 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_ThemeChoicePanel_themeSelectors { .mx_ThemeChoicePanel_ThemeSelectors {
color: $primary-content;
display: flex; display: flex;
flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
/* Override form default style */
flex-direction: row !important;
gap: var(--cpd-space-4x) !important;
> .mx_StyledRadioButton { .mx_ThemeChoicePanel_themeSelector {
align-items: center; border: 1px solid var(--cpd-color-border-interactive-secondary);
padding: $font-16px; border-radius: var(--cpd-space-1-5x);
box-sizing: border-box; padding: var(--cpd-space-3x) var(--cpd-space-5x) var(--cpd-space-3x) var(--cpd-space-3x);
border-radius: 10px; gap: var(--cpd-space-2x);
width: 180px; background-color: var(--cpd-color-bg-canvas-default);
background: $accent-200; &.mx_ThemeChoicePanel_themeSelector_enabled {
opacity: 0.4; border-color: var(--cpd-color-border-interactive-primary);
flex-shrink: 1;
flex-grow: 0;
margin-right: 15px;
margin-top: 10px;
font-weight: var(--cpd-font-weight-semibold);
> span {
justify-content: center;
}
} }
> .mx_StyledRadioButton_enabled { &.mx_ThemeChoicePanel_themeSelector_disabled {
opacity: 1; border-color: var(--cpd-color-border-disabled);
/* These colors need to be hardcoded because they don't change with the theme */
&.mx_ThemeSelector_light {
background-color: #f3f8fd;
color: #2e2f32;
} }
&.mx_ThemeSelector_dark { .mx_ThemeChoicePanel_themeSelector_Label {
/* 5% lightened version of 181b21 */ color: var(--cpd-color-text-primary);
background-color: #25282e; font: var(--cpd-font-body-md-semibold);
color: #f3f8fd; }
}
}
.mx_ThemeChoicePanel_CustomTheme {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
.mx_ThemeChoicePanel_CustomTheme_EditInPlace input:focus {
/*
* When the input is focused, the border is growing
* We need to move it a bit to avoid the left border to be under the left panel
*/
margin-left: var(--cpd-space-0-5x);
}
.mx_ThemeChoicePanel_CustomThemeList {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
/*
* Override the default padding/margin of the list
*/
padding: 0;
margin: 0;
.mx_ThemeChoicePanel_CustomThemeList_theme {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-4x);
.mx_ThemeChoicePanel_CustomThemeList_name {
font: var(--cpd-font-body-sm-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
} }
} }

View file

@ -35,6 +35,7 @@ limitations under the License.
.mx_UserProfileSettings_profile_controls_userId { .mx_UserProfileSettings_profile_controls_userId {
width: 100%; width: 100%;
margin-top: var(--cpd-space-4x);
.mx_CopyableText { .mx_CopyableText {
margin-top: var(--cpd-space-1x); margin-top: var(--cpd-space-1x);
width: 100%; width: 100%;
@ -46,6 +47,15 @@ limitations under the License.
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
} }
.mx_UserProfileSettings_profile_buttons {
margin-top: var(--cpd-space-8x);
margin-bottom: var(--cpd-space-8x);
}
.mx_UserProfileSettings_accountmanageIcon {
margin-right: var(--cpd-space-2x);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View file

@ -34,3 +34,8 @@ limitations under the License.
margin-right: $spacing-8; margin-right: $spacing-8;
margin-bottom: 2px; margin-bottom: 2px;
} }
.mx_GeneralUserSettingsTab_section_hint {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
.mx_InlineTermsAgreement_cbContainer { .mx_InlineTermsAgreement_cbContainer {
margin-top: var(--cpd-space-4x);
margin-bottom: 10px; margin-bottom: 10px;
font: var(--cpd-font-body-md-regular); font: var(--cpd-font-body-md-regular);

View file

@ -20,6 +20,7 @@ import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescri
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { PosthogAnalytics } from "./PosthogAnalytics"; import { PosthogAnalytics } from "./PosthogAnalytics";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
/** The key that we use to store the `reportedEvents` bloom filter in localstorage */ /** The key that we use to store the `reportedEvents` bloom filter in localstorage */
const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids"; const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids";
@ -207,7 +208,7 @@ export class DecryptionFailureTracker {
*/ */
private eventDecrypted(e: MatrixEvent, nowTs: number): void { private eventDecrypted(e: MatrixEvent, nowTs: number): void {
// for now we only track megolm decryption failures // for now we only track megolm decryption failures
if (e.getWireContent().algorithm != "m.megolm.v1.aes-sha2") { if (e.getWireContent().algorithm != MEGOLM_ENCRYPTION_ALGORITHM) {
return; return;
} }
const errCode = e.decryptionFailureReason; const errCode = e.decryptionFailureReason;

View file

@ -20,13 +20,11 @@ limitations under the License.
import React, { LegacyRef, ReactNode } from "react"; import React, { LegacyRef, ReactNode } from "react";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import classNames from "classnames"; import classNames from "classnames";
import EMOJIBASE_REGEX from "emojibase-regex";
import katex from "katex"; import katex from "katex";
import { decode } from "html-entities"; import { decode } from "html-entities";
import { IContent } from "matrix-js-sdk/src/matrix"; import { IContent } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import GraphemeSplitter from "graphemer";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { IExtendedSanitizeOptions } from "./@types/sanitize-html"; import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
@ -34,6 +32,7 @@ import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply"; import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
import { sanitizeHtmlParams, transformTags } from "./Linkify"; import { sanitizeHtmlParams, transformTags } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify"; export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify";
@ -46,10 +45,35 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
const SYMBOL_PATTERN = /([\u2100-\u2bff])/; const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// Regex pattern for non-emoji characters that can appear in an "all-emoji" message // Regex pattern for non-emoji characters that can appear in an "all-emoji" message
// (Zero-Width Joiner, Zero-Width Space, Emoji presentation character, other whitespace) // (Zero-Width Space, other whitespace)
const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]|\uFE0F/g; const EMOJI_SEPARATOR_REGEX = /[\u200B\s]/g;
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, "i"); // Regex for emoji. This includes any RGI_Emoji sequence followed by an optional
// emoji presentation VS (U+FE0F), but not those sequences that are followed by
// a text presentation VS (U+FE0E). We also count lone regional indicators
// (U+1F1E6-U+1F1FF). Technically this regex produces false negatives for emoji
// followed by U+FE0E when the emoji doesn't have a text variant, but in
// practice this doesn't matter.
export const EMOJI_REGEX = (() => {
try {
// Per our support policy, v mode is available to us, but we still don't
// want the app to completely crash on older platforms. We use the
// constructor here to avoid a syntax error on such platforms.
return new RegExp("\\p{RGI_Emoji}(?!\\uFE0E)(?:(?<!\\uFE0F)\\uFE0F)?|[\\u{1f1e6}-\\u{1f1ff}]", "v");
} catch (_e) {
// v mode not supported; fall back to matching nothing
return /(?!)/;
}
})();
const BIGEMOJI_REGEX = (() => {
try {
return new RegExp(`^(${EMOJI_REGEX.source})+$`, "iv");
} catch (_e) {
// Fall back, just like for EMOJI_REGEX
return /(?!)/;
}
})();
/* /*
* Return true if the given string contains emoji * Return true if the given string contains emoji
@ -265,17 +289,16 @@ export function formatEmojis(message: string | undefined, isHtmlMessage?: boolea
let text = ""; let text = "";
let key = 0; let key = 0;
const splitter = new GraphemeSplitter(); for (const data of graphemeSegmenter.segment(message)) {
for (const char of splitter.iterateGraphemes(message)) { if (EMOJI_REGEX.test(data.segment)) {
if (EMOJIBASE_REGEX.test(char)) {
if (text) { if (text) {
result.push(text); result.push(text);
text = ""; text = "";
} }
result.push(emojiToSpan(char, key)); result.push(emojiToSpan(data.segment, key));
key++; key++;
} else { } else {
text += char; text += data.segment;
} }
} }
if (text) { if (text) {

View file

@ -66,6 +66,7 @@ import { localNotificationsAreSilenced } from "./utils/notifications";
import { SdkContextClass } from "./contexts/SDKContext"; import { SdkContextClass } from "./contexts/SDKContext";
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog"; import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
import { isNotNull } from "./Typeguards"; import { isNotNull } from "./Typeguards";
import { BackgroundAudio } from "./audio/BackgroundAudio";
export const PROTOCOL_PSTN = "m.protocol.pstn"; export const PROTOCOL_PSTN = "m.protocol.pstn";
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn"; export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
@ -157,8 +158,6 @@ export default class LegacyCallHandler extends EventEmitter {
// Calls started as an attended transfer, ie. with the intention of transferring another // Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one. // call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee) private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private audioElementsWithListeners = new Map<HTMLMediaElement, boolean>();
private supportsPstnProtocol: boolean | null = null; private supportsPstnProtocol: boolean | null = null;
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
@ -170,6 +169,9 @@ export default class LegacyCallHandler extends EventEmitter {
private silencedCalls = new Set<string>(); // callIds private silencedCalls = new Set<string>(); // callIds
private backgroundAudio = new BackgroundAudio();
private playingSources: Record<string, AudioBufferSourceNode> = {}; // Record them for stopping
public static get instance(): LegacyCallHandler { public static get instance(): LegacyCallHandler {
if (!window.mxLegacyCallHandler) { if (!window.mxLegacyCallHandler) {
window.mxLegacyCallHandler = new LegacyCallHandler(); window.mxLegacyCallHandler = new LegacyCallHandler();
@ -199,33 +201,11 @@ export default class LegacyCallHandler extends EventEmitter {
} }
public start(): void { public start(): void {
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
if (navigator.mediaSession) {
navigator.mediaSession.setActionHandler("play", function () {});
navigator.mediaSession.setActionHandler("pause", function () {});
navigator.mediaSession.setActionHandler("seekbackward", function () {});
navigator.mediaSession.setActionHandler("seekforward", function () {});
navigator.mediaSession.setActionHandler("previoustrack", function () {});
navigator.mediaSession.setActionHandler("nexttrack", function () {});
}
if (SettingsStore.getValue(UIFeature.Voip)) { if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming); MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
} }
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS); this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
// Add event listeners for the <audio> elements
Object.values(AudioID).forEach((audioId) => {
const audioElement = document.getElementById(audioId) as HTMLMediaElement;
if (audioElement) {
this.addEventListenersForAudioElement(audioElement);
} else {
logger.warn(`LegacyCallHandler: missing <audio id="${audioId}"> from page`);
}
});
} }
public stop(): void { public stop(): void {
@ -233,27 +213,6 @@ export default class LegacyCallHandler extends EventEmitter {
if (cli) { if (cli) {
cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming); cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming);
} }
// Remove event listeners for the <audio> elements
Array.from(this.audioElementsWithListeners.keys()).forEach((audioElement) => {
this.removeEventListenersForAudioElement(audioElement);
});
}
private addEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
// Only need to setup the listeners once
if (!this.audioElementsWithListeners.get(audioElement)) {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.addEventListener(errorEventType, this);
this.audioElementsWithListeners.set(audioElement, true);
});
}
}
private removeEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.removeEventListener(errorEventType, this);
});
} }
/* istanbul ignore next (remove if we start using this function for things other than debug logging) */ /* istanbul ignore next (remove if we start using this function for things other than debug logging) */
@ -465,74 +424,46 @@ export default class LegacyCallHandler extends EventEmitter {
return this.transferees.get(callId); return this.transferees.get(callId);
} }
public play(audioId: AudioID): void { public async play(audioId: AudioID): Promise<void> {
const logPrefix = `LegacyCallHandler.play(${audioId}):`; const logPrefix = `LegacyCallHandler.play(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`); logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
if (audio) {
this.addEventListenersForAudioElement(audio);
const playAudio = async (): Promise<void> => {
try {
if (audio.muted) {
logger.error(
`${logPrefix} <audio> element was unexpectedly muted but we recovered ` +
`gracefully by unmuting it`,
);
// Recover gracefully
audio.muted = false;
}
// This still causes the chrome debugger to break on promise rejection if const audioInfo: Record<AudioID, [prefix: string, loop: boolean]> = {
// the promise is rejected, even though we're catching the exception. [AudioID.Ring]: [`./media/ring`, true],
logger.debug(`${logPrefix} attempting to play audio at volume=${audio.volume}`); [AudioID.Ringback]: [`./media/ringback`, true],
await audio.play(); [AudioID.CallEnd]: [`./media/callend`, false],
logger.debug(`${logPrefix} playing audio successfully`); [AudioID.Busy]: [`./media/busy`, false],
} catch (e) {
// This is usually because the user hasn't interacted with the document,
// or chrome doesn't think so and is denying the request. Not sure what
// we can really do here...
// https://github.com/vector-im/element-web/issues/7657
logger.warn(`${logPrefix} unable to play audio clip`, e);
}
}; };
if (this.audioPromises.has(audioId)) {
this.audioPromises.set( const [urlPrefix, loop] = audioInfo[audioId];
audioId, const source = await this.backgroundAudio.pickFormatAndPlay(urlPrefix, ["mp3", "ogg"], loop);
this.audioPromises.get(audioId)!.then(() => { this.playingSources[audioId] = source;
audio.load(); logger.debug(`${logPrefix} playing audio successfully`);
return playAudio();
}),
);
} else {
this.audioPromises.set(audioId, playAudio());
}
} else {
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
}
} }
public pause(audioId: AudioID): void { public pause(audioId: AudioID): void {
const logPrefix = `LegacyCallHandler.pause(${audioId}):`; const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`); logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens? const source = this.playingSources[audioId];
const audio = document.getElementById(audioId) as HTMLMediaElement; if (!source) {
const pauseAudio = (): void => { logger.debug(`${logPrefix} audio not playing`);
logger.debug(`${logPrefix} pausing audio`); return;
// pause doesn't return a promise, so just do it
audio.pause();
};
if (audio) {
if (this.audioPromises.has(audioId)) {
this.audioPromises.set(audioId, this.audioPromises.get(audioId)!.then(pauseAudio));
} else {
pauseAudio();
} }
} else {
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`); source.stop();
delete this.playingSources[audioId];
logger.debug(`${logPrefix} paused audio`);
} }
/**
* Returns whether the given audio is currently playing
* Only supported for looping audio tracks
* @param audioId the ID of the audio to query for playing state
*/
public isPlaying(audioId: AudioID.Ring | AudioID.Ringback): boolean {
return !!this.playingSources[audioId];
} }
private matchesCallForThisRoom(call: MatrixCall): boolean { private matchesCallForThisRoom(call: MatrixCall): boolean {

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import classNames from "classnames"; import classNames from "classnames";
import { defer, sleep } from "matrix-js-sdk/src/utils"; import { IDeferred, defer, sleep } from "matrix-js-sdk/src/utils";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { Glass } from "@vector-im/compound-web"; import { Glass } from "@vector-im/compound-web";
@ -47,11 +47,12 @@ export interface IModal<C extends ComponentType> {
elem: React.ReactNode; elem: React.ReactNode;
className?: string; className?: string;
beforeClosePromise?: Promise<boolean>; beforeClosePromise?: Promise<boolean>;
closeReason?: string; closeReason?: ModalCloseReason;
onBeforeClose?(reason?: string): Promise<boolean>; onBeforeClose?(reason?: ModalCloseReason): Promise<boolean>;
onFinished: ComponentProps<C>["onFinished"]; onFinished: ComponentProps<C>["onFinished"];
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void; close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
hidden?: boolean; hidden?: boolean;
deferred?: IDeferred<Parameters<ComponentProps<C>["onFinished"]>>;
} }
export interface IHandle<C extends ComponentType> { export interface IHandle<C extends ComponentType> {
@ -73,6 +74,8 @@ type HandlerMap = {
[ModalManagerEvent.Closed]: () => void; [ModalManagerEvent.Closed]: () => void;
}; };
type ModalCloseReason = "backgroundClick";
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> { export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
private counter = 0; private counter = 0;
// The modal to prioritise over all others. If this is set, only show // The modal to prioritise over all others. If this is set, only show
@ -148,10 +151,14 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
} }
/** /**
* DEPRECATED.
* This is used only for tests. They should be using forceCloseAllModals but that
* caused a chunk of tests to fail, so for now they continue to use this.
*
* @param reason either "backgroundClick" or undefined * @param reason either "backgroundClick" or undefined
* @return whether a modal was closed * @return whether a modal was closed
*/ */
public closeCurrentModal(reason?: string): boolean { public closeCurrentModal(reason?: ModalCloseReason): boolean {
const modal = this.getCurrentModal(); const modal = this.getCurrentModal();
if (!modal) { if (!modal) {
return false; return false;
@ -161,6 +168,22 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
return true; return true;
} }
/**
* Forces closes all open modals. The modals onBeforeClose function will not be
* run and the modal will not have a chance to prevent closing. Intended for
* situations like the user logging out of the app.
*/
public forceCloseAllModals(): void {
for (const modal of this.modals) {
modal.deferred?.resolve([]);
if (modal.onFinished) modal.onFinished.apply(null);
this.emitClosed();
}
this.modals = [];
this.reRender();
}
private buildModal<C extends ComponentType>( private buildModal<C extends ComponentType>(
prom: Promise<C>, prom: Promise<C>,
props?: ComponentProps<C>, props?: ComponentProps<C>,
@ -199,7 +222,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
modal: IModal<C>, modal: IModal<C>,
props?: ComponentProps<C>, props?: ComponentProps<C>,
): [IHandle<C>["close"], IHandle<C>["finished"]] { ): [IHandle<C>["close"], IHandle<C>["finished"]] {
const deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>(); modal.deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
return [ return [
async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => { async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => {
if (modal.beforeClosePromise) { if (modal.beforeClosePromise) {
@ -212,7 +235,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
return; return;
} }
} }
deferred.resolve(args); modal.deferred?.resolve(args);
if (props?.onFinished) props.onFinished.apply(null, args); if (props?.onFinished) props.onFinished.apply(null, args);
const i = this.modals.indexOf(modal); const i = this.modals.indexOf(modal);
if (i >= 0) { if (i >= 0) {
@ -236,7 +259,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
this.reRender(); this.reRender();
this.emitClosed(); this.emitClosed();
}, },
deferred.promise, modal.deferred.promise,
]; ];
} }

View file

@ -58,6 +58,7 @@ import ToastStore from "./stores/ToastStore";
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast"; import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
import { getSenderName } from "./utils/event/getSenderName"; import { getSenderName } from "./utils/event/getSenderName";
import { stripPlainReply } from "./utils/Reply"; import { stripPlainReply } from "./utils/Reply";
import { BackgroundAudio } from "./audio/BackgroundAudio";
/* /*
* Dispatches: * Dispatches:
@ -112,6 +113,8 @@ class NotifierClass {
private toolbarHidden?: boolean; private toolbarHidden?: boolean;
private isSyncing?: boolean; private isSyncing?: boolean;
private backgroundAudio = new BackgroundAudio();
public notificationMessageForEvent(ev: MatrixEvent): string | null { public notificationMessageForEvent(ev: MatrixEvent): string | null {
const msgType = ev.getContent().msgtype; const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) { if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
@ -226,28 +229,14 @@ class NotifierClass {
return; return;
} }
// Play notification sound here
const sound = this.getSoundForRoom(room.roomId); const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`); logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
try { if (sound) {
const selector = document.querySelector<HTMLAudioElement>( await this.backgroundAudio.play(sound.url);
sound ? `audio[src='${sound.url}']` : "#messageAudio", } else {
); await this.backgroundAudio.pickFormatAndPlay("media/message", ["mp3", "ogg"]);
let audioElement = selector;
if (!audioElement) {
if (!sound) {
logger.error("No audio element or sound to play for notification");
return;
}
audioElement = new Audio(sound.url);
if (sound.type) {
audioElement.type = sound.type;
}
document.body.appendChild(audioElement);
}
await audioElement.play();
} catch (ex) {
logger.warn("Caught error when trying to fetch room notification sound:", ex);
} }
} }

View file

@ -681,3 +681,49 @@ export default function eventSearch(
return eventIndexSearch(client, term, roomId, abortSignal); return eventIndexSearch(client, term, roomId, abortSignal);
} }
} }
/**
* The scope for a message search, either in the current room or across all rooms.
*/
export enum SearchScope {
Room = "Room",
All = "All",
}
/**
* Information about a message search in progress.
*/
export interface SearchInfo {
/**
* Opaque ID for this search.
*/
searchId: number;
/**
* The room ID being searched, or undefined if searching all rooms.
*/
roomId?: string;
/**
* The search term.
*/
term: string;
/**
* The scope of the search.
*/
scope: SearchScope;
/**
* The promise for the search results.
*/
promise: Promise<ISearchResults>;
/**
* Controller for aborting the search.
*/
abortController?: AbortController;
/**
* Whether the search is currently awaiting data from the backend.
*/
inProgress?: boolean;
/**
* The total count of matching results as returned by the backend.
*/
count?: number;
}

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import dis from "../../../../dispatcher/dispatcher"; import dis from "../../../../dispatcher/dispatcher";
@ -28,7 +28,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
interface IProps { interface IProps {
newVersionInfo: IKeyBackupInfo; newVersionInfo: KeyBackupInfo;
onFinished(): void; onFinished(): void;
} }

View file

@ -0,0 +1,74 @@
/*
Copyright 2024 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 { logger } from "matrix-js-sdk/src/logger";
import { createAudioContext } from "./compat";
const formatMap = {
mp3: "audio/mpeg",
ogg: "audio/ogg",
};
export class BackgroundAudio {
private audioContext = createAudioContext();
private sounds: Record<string, AudioBuffer> = {};
public async pickFormatAndPlay<F extends Array<keyof typeof formatMap>>(
urlPrefix: string,
formats: F,
loop = false,
): Promise<AudioBufferSourceNode> {
const format = this.pickFormat(...formats);
if (!format) {
console.log("Browser doesn't support any of the formats", formats);
// Will probably never happen. If happened, format="" and will fail to load audio. Who cares...
}
return this.play(`${urlPrefix}.${format}`, loop);
}
public async play(url: string, loop = false): Promise<AudioBufferSourceNode> {
if (!this.sounds.hasOwnProperty(url)) {
// No cache, fetch it
const response = await fetch(url);
if (response.status != 200) {
logger.warn("Failed to fetch error audio");
}
const buffer = await response.arrayBuffer();
const sound = await this.audioContext.decodeAudioData(buffer);
this.sounds[url] = sound;
}
const source = this.audioContext.createBufferSource();
source.buffer = this.sounds[url];
source.loop = loop;
source.connect(this.audioContext.destination);
source.start();
return source;
}
private pickFormat<F extends Array<keyof typeof formatMap>>(...formats: F): F[number] | null {
// Detect supported formats
const audioElement = document.createElement("audio");
for (const format of formats) {
if (audioElement.canPlayType(formatMap[format])) {
return format;
}
}
return null;
}
}

View file

@ -99,6 +99,8 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
onSelectionChange(newSelection); onSelectionChange(newSelection);
focusEditor(); focusEditor();
setQuery("");
setSuggestions([]);
}; };
const removeSelection = (completion: ICompletion): void => { const removeSelection = (completion: ICompletion): void => {

View file

@ -458,9 +458,7 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true; handled = true;
break; break;
case KeyBindingAction.SearchInRoom: case KeyBindingAction.SearchInRoom:
dis.dispatch({ dis.fire(Action.FocusMessageSearch);
action: "focus_search",
});
handled = true; handled = true;
break; break;
} }
@ -490,11 +488,15 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true; handled = true;
break; break;
case KeyBindingAction.GoToHome: case KeyBindingAction.GoToHome:
// even if we cancel because there are modals open, we still
// handled it: nothing else should happen.
handled = true;
if (Modal.hasDialogs()) {
return;
}
dis.dispatch({ dis.dispatch({
action: Action.ViewHomePage, action: Action.ViewHomePage,
}); });
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
break; break;
case KeyBindingAction.ToggleSpacePanel: case KeyBindingAction.ToggleSpacePanel:
dis.fire(Action.ToggleSpacePanel); dis.fire(Action.ToggleSpacePanel);

View file

@ -31,7 +31,7 @@ import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { throttle } from "lodash"; import { throttle } from "lodash";
import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
// what-input helps improve keyboard accessibility // what-input helps improve keyboard accessibility
import "what-input"; import "what-input";
@ -1544,7 +1544,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (Lifecycle.isLoggingOut()) return; if (Lifecycle.isLoggingOut()) return;
// A modal might have been open when we were logged out by the server // A modal might have been open when we were logged out by the server
Modal.closeCurrentModal("Session.logged_out"); Modal.forceCloseAllModals();
if (errObj.httpStatus === 401 && errObj.data && errObj.data["soft_logout"]) { if (errObj.httpStatus === 401 && errObj.data && errObj.data["soft_logout"]) {
logger.warn("Soft logout issued by server - avoiding data deletion"); logger.warn("Soft logout issued by server - avoiding data deletion");
@ -1614,7 +1614,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
cli.on(CryptoEvent.KeyBackupFailed, async (errcode): Promise<void> => { cli.on(CryptoEvent.KeyBackupFailed, async (errcode): Promise<void> => {
let haveNewVersion: boolean | undefined; let haveNewVersion: boolean | undefined;
let newVersionInfo: IKeyBackupInfo | null = null; let newVersionInfo: KeyBackupInfo | null = null;
// if key backup is still enabled, there must be a new backup in place // if key backup is still enabled, there must be a new backup in place
if (cli.getKeyBackupEnabled()) { if (cli.getKeyBackupEnabled()) {
haveNewVersion = true; haveNewVersion = true;

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ChangeEvent } from "react";
import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash"; import { throttle } from "lodash";
@ -42,6 +42,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState"; import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { XOR } from "../../@types/common"; import { XOR } from "../../@types/common";
import { RightPanelTabs } from "../views/right_panel/RightPanelTabs";
interface BaseProps { interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
@ -57,7 +58,8 @@ interface RoomlessProps extends BaseProps {
interface RoomProps extends BaseProps { interface RoomProps extends BaseProps {
room: Room; room: Room;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
onSearchClick?: () => void; onSearchChange?: (e: ChangeEvent) => void;
onSearchCancel?: () => void;
} }
type Props = XOR<RoomlessProps, RoomProps>; type Props = XOR<RoomlessProps, RoomProps>;
@ -170,6 +172,7 @@ export default class RightPanel extends React.Component<Props, IState> {
<MemberList <MemberList
roomId={roomId} roomId={roomId}
key={roomId} key={roomId}
hideHeaderButtons
onClose={this.onClose} onClose={this.onClose}
searchQuery={this.state.searchQuery} searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged} onSearchQueryChanged={this.onSearchQueryChanged}
@ -293,10 +296,11 @@ export default class RightPanel extends React.Component<Props, IState> {
card = ( card = (
<RoomSummaryCard <RoomSummaryCard
room={this.props.room} room={this.props.room}
onClose={this.onClose}
// whenever RightPanel is passed a room it is passed a permalinkcreator // whenever RightPanel is passed a room it is passed a permalinkcreator
permalinkCreator={this.props.permalinkCreator!} permalinkCreator={this.props.permalinkCreator!}
onSearchClick={this.props.onSearchClick} onSearchChange={this.props.onSearchChange}
onSearchCancel={this.props.onSearchCancel}
focusRoomSearch={cardState?.focusRoomSearch}
/> />
); );
} }
@ -311,6 +315,7 @@ export default class RightPanel extends React.Component<Props, IState> {
return ( return (
<aside className="mx_RightPanel" id="mx_RightPanel"> <aside className="mx_RightPanel" id="mx_RightPanel">
{phase && <RightPanelTabs phase={phase} />}
{card} {card}
</aside> </aside>
); );

View file

@ -24,12 +24,11 @@ import {
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import ScrollPanel from "./ScrollPanel"; import ScrollPanel from "./ScrollPanel";
import { SearchScope } from "../views/rooms/SearchBar";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import { haveRendererForEvent } from "../../events/EventTileFactory"; import { haveRendererForEvent } from "../../events/EventTileFactory";
import SearchResultTile from "../views/rooms/SearchResultTile"; import SearchResultTile from "../views/rooms/SearchResultTile";
import { searchPagination } from "../../Searching"; import { searchPagination, SearchScope } from "../../Searching";
import Modal from "../../Modal"; import Modal from "../../Modal";
import ErrorDialog from "../views/dialogs/ErrorDialog"; import ErrorDialog from "../views/dialogs/ErrorDialog";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
@ -49,6 +48,7 @@ if (DEBUG) {
interface Props { interface Props {
term: string; term: string;
scope: SearchScope; scope: SearchScope;
inProgress: boolean;
promise: Promise<ISearchResults>; promise: Promise<ISearchResults>;
abortController?: AbortController; abortController?: AbortController;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
@ -59,10 +59,9 @@ interface Props {
// XXX: todo: merge overlapping results somehow? // XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work? // XXX: why doesn't searching on name work?
export const RoomSearchView = forwardRef<ScrollPanel, Props>( export const RoomSearchView = forwardRef<ScrollPanel, Props>(
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate }: Props, ref) => { ({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => {
const client = useContext(MatrixClientContext); const client = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const [inProgress, setInProgress] = useState(true);
const [highlights, setHighlights] = useState<string[] | null>(null); const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null); const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false); const aborted = useRef(false);
@ -79,10 +78,9 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
const handleSearchResult = useCallback( const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => { (searchPromise: Promise<ISearchResults>): Promise<boolean> => {
setInProgress(true); onUpdate(true, null);
return searchPromise return searchPromise.then(
.then(
async (results): Promise<boolean> => { async (results): Promise<boolean> => {
debuglog("search complete"); debuglog("search complete");
if (aborted.current) { if (aborted.current) {
@ -126,6 +124,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
setHighlights(highlights); setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh setResults({ ...results }); // copy to force a refresh
onUpdate(false, results);
return false; return false;
}, },
(error) => { (error) => {
@ -138,14 +137,12 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
title: _t("error_dialog|search_failed|title"), title: _t("error_dialog|search_failed|title"),
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"), description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
}); });
onUpdate(false, null);
return false; return false;
}, },
) );
.finally(() => {
setInProgress(false);
});
}, },
[client, term], [client, term, onUpdate],
); );
// Mount & unmount effect // Mount & unmount effect

View file

@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; import React, { ChangeEvent, createRef, ReactElement, ReactNode, RefObject, useContext } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { import {
IRecommendedVersion, IRecommendedVersion,
@ -41,7 +41,7 @@ import {
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { throttle } from "lodash"; import { debounce, throttle } from "lodash";
import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
@ -70,10 +70,9 @@ import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary"; import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import LegacyRoomHeader, { ISearchInfo } from "../views/rooms/LegacyRoomHeader"; import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay"; import EffectsOverlay from "../views/elements/EffectsOverlay";
@ -121,7 +120,7 @@ import { SDKContext } from "../../contexts/SDKContext";
import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { Call } from "../../models/Call"; import { Call } from "../../models/Call";
import { RoomSearchView } from "./RoomSearchView"; import { RoomSearchView } from "./RoomSearchView";
import eventSearch from "../../Searching"; import eventSearch, { SearchInfo, SearchScope } from "../../Searching";
import VoipUserMapper from "../../VoipUserMapper"; import VoipUserMapper from "../../VoipUserMapper";
import { isCallEvent } from "./LegacyCallEventGrouper"; import { isCallEvent } from "./LegacyCallEventGrouper";
import { WidgetType } from "../../widgets/WidgetType"; import { WidgetType } from "../../widgets/WidgetType";
@ -133,6 +132,7 @@ import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoi
import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload"; import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload";
import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
const DEBUG = false; const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@ -190,7 +190,7 @@ export interface IRoomState {
/** /**
* The state of an ongoing search if there is one. * The state of an ongoing search if there is one.
*/ */
search?: ISearchInfo; search?: SearchInfo;
callState?: CallState; callState?: CallState;
activeCall: Call | null; activeCall: Call | null;
canPeek: boolean; canPeek: boolean;
@ -1196,9 +1196,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
); );
} }
break; break;
case "focus_search":
this.onSearchClick();
break;
case "local_room_event": case "local_room_event":
this.onLocalRoomEvent(payload.roomId); this.onLocalRoomEvent(payload.roomId);
@ -1290,7 +1287,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
]); ]);
} }
} else { } else {
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.RoomMemberList); RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList);
} }
break; break;
case Action.View3pidInvite: case Action.View3pidInvite:
@ -1725,13 +1722,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}); });
} }
private onSearch = (term: string, scope: SearchScope): void => { private onSearch = (term: string, scope = SearchScope.Room): void => {
const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined; const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined;
debuglog("sending search request"); debuglog("sending search request");
const abortController = new AbortController(); const abortController = new AbortController();
const promise = eventSearch(this.context.client!, term, roomId, abortController.signal); const promise = eventSearch(this.context.client!, term, roomId, abortController.signal);
this.setState({ this.setState({
timelineRenderingType: TimelineRenderingType.Search,
search: { search: {
// make sure that we don't end up showing results from // make sure that we don't end up showing results from
// an aborted search by keeping a unique id. // an aborted search by keeping a unique id.
@ -1745,6 +1743,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}); });
}; };
private onSearchScopeChange = (scope: SearchScope): void => {
this.onSearch(this.state.search?.term ?? "", scope);
};
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => { private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
this.setState({ this.setState({
search: { search: {
@ -1839,15 +1841,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}; };
private onSearchClick = (): void => { private onSearchClick = (): void => {
if (this.state.timelineRenderingType === TimelineRenderingType.Search) { dis.fire(Action.FocusMessageSearch);
this.onCancelSearchClick();
} else {
this.setState({
timelineRenderingType: TimelineRenderingType.Search,
});
}
}; };
private onSearchChange = debounce((e: ChangeEvent): void => {
const term = (e.target as HTMLInputElement).value;
this.onSearch(term);
}, 300);
private onCancelSearchClick = (): Promise<void> => { private onCancelSearchClick = (): Promise<void> => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
this.setState( this.setState(
@ -2328,10 +2329,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let previewBar; let previewBar;
if (this.state.timelineRenderingType === TimelineRenderingType.Search) { if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
aux = ( aux = (
<SearchBar <RoomSearchAuxPanel
searchInProgress={this.state.search?.inProgress} searchInfo={this.state.search}
onCancelClick={this.onCancelSearchClick} onCancelClick={this.onCancelSearchClick}
onSearch={this.onSearch} onSearchScopeChange={this.onSearchScopeChange}
isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)} isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)}
/> />
); );
@ -2438,6 +2439,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
scope={this.state.search.scope} scope={this.state.search.scope}
promise={this.state.search.promise} promise={this.state.search.promise}
abortController={this.state.search.abortController} abortController={this.state.search.abortController}
inProgress={!!this.state.search.inProgress}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
className={this.messagePanelClassNames} className={this.messagePanelClassNames}
onUpdate={this.onSearchUpdate} onUpdate={this.onSearchUpdate}
@ -2507,7 +2509,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.permalinkCreator} permalinkCreator={this.permalinkCreator}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
onSearchClick={this.onSearchClick} onSearchChange={this.onSearchChange}
onSearchCancel={this.onCancelSearchClick}
/> />
) : undefined; ) : undefined;

View file

@ -37,9 +37,6 @@ import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import Heading from "../views/typography/Heading"; import Heading from "../views/typography/Heading";
import { clearRoomNotification } from "../../utils/notifications"; import { clearRoomNotification } from "../../utils/notifications";
import { useDispatcher } from "../../hooks/useDispatcher";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -259,14 +256,6 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
} }
}, [timelineSet, timelinePanel]); }, [timelineSet, timelinePanel]);
useDispatcher(dis, (payload) => {
// This actually foucses the close button on the threads panel, as its the only interactive element,
// but at least it puts the user in the right area of the app.
if (payload.action === Action.FocusThreadsPanel) {
closeButonRef.current?.focus();
}
});
return ( return (
<RoomContext.Provider <RoomContext.Provider
value={{ value={{
@ -277,6 +266,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
}} }}
> >
<BaseCard <BaseCard
hideHeaderButtons
header={ header={
<ThreadPanelHeader <ThreadPanelHeader
filterOption={filterOption} filterOption={filterOption}
@ -284,7 +274,10 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
empty={!hasThreads} empty={!hasThreads}
/> />
} }
id="thread-panel"
className="mx_ThreadPanel" className="mx_ThreadPanel"
ariaLabelledBy="thread-panel-tab"
role="tabpanel"
onClose={onClose} onClose={onClose}
withoutScrollContainer={true} withoutScrollContainer={true}
ref={card} ref={card}

View file

@ -55,7 +55,6 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
<LegacyRoomHeader <LegacyRoomHeader
room={context.room} room={context.room}
inRoom={true} inRoom={true}
onSearchClick={null}
onInviteClick={null} onInviteClick={null}
onForgetClick={null} onForgetClick={null}
e2eStatus={E2EStatus.Normal} e2eStatus={E2EStatus.Normal}

View file

@ -15,8 +15,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { KeyBackupInfo, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; import { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
@ -40,7 +39,7 @@ interface IProps {
interface IState { interface IState {
phase?: Phase; phase?: Phase;
verificationRequest: VerificationRequest | null; verificationRequest: VerificationRequest | null;
backupInfo: IKeyBackupInfo | null; backupInfo: KeyBackupInfo | null;
lostKeys: boolean; lostKeys: boolean;
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React, { HTMLProps } from "react"; import React, { HTMLProps } from "react";
import { Temporal } from "proposal-temporal"; import { Temporal } from "temporal-polyfill";
import { formatSeconds } from "../../../DateUtils"; import { formatSeconds } from "../../../DateUtils";
@ -45,8 +45,9 @@ export default class Clock extends React.Component<Props> {
return currentFloor !== nextFloor; return currentFloor !== nextFloor;
} }
private calculateDuration(seconds: number): string { private calculateDuration(seconds: number): string | undefined {
return new Temporal.Duration(0, 0, 0, 0, 0, 0, seconds) if (isNaN(seconds)) return undefined;
return new Temporal.Duration(0, 0, 0, 0, 0, 0, Math.round(seconds))
.round({ smallestUnit: "seconds", largestUnit: "hours" }) .round({ smallestUnit: "seconds", largestUnit: "hours" })
.toString(); .toString();
} }

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ComponentProps, ReactNode } from "react"; import React, { ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../audio/Playback"; import { Playback, PlaybackState } from "../../../audio/Playback";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton, { ButtonProps } from "../elements/AccessibleButton";
type Props = Omit<ComponentProps<typeof AccessibleButton>, "title" | "onClick" | "disabled" | "element" | "ref"> & { type Props = Omit<ButtonProps<"div">, "title" | "onClick" | "disabled" | "element" | "ref"> & {
// Playback instance to manipulate. Cannot change during the component lifecycle. // Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback; playback: Playback;

View file

@ -18,7 +18,7 @@ import React from "react";
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg"; import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
import { ChevronFace, ContextMenuButton, MenuProps, useContextMenu } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenuButton, MenuProps, useContextMenu } from "../../structures/ContextMenu";
import AccessibleButton from "../elements/AccessibleButton"; import { ButtonProps } from "../elements/AccessibleButton";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "./IconizedContextMenu"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "./IconizedContextMenu";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => { const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
@ -29,10 +29,10 @@ const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
return { left, top, chevronFace }; return { left, top, chevronFace };
}; };
interface KebabContextMenuProps extends Partial<React.ComponentProps<typeof AccessibleButton>> { type KebabContextMenuProps = Partial<ButtonProps<any>> & {
options: React.ReactNode[]; options: React.ReactNode[];
title: string; title: string;
} };
export const KebabContextMenu: React.FC<KebabContextMenuProps> = ({ options, title, ...props }) => { export const KebabContextMenu: React.FC<KebabContextMenuProps> = ({ options, title, ...props }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();

View file

@ -128,7 +128,8 @@ export default class BaseDialog extends React.Component<IProps> {
onClick={this.onCancelClick} onClick={this.onCancelClick}
className="mx_Dialog_cancelButton" className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")} aria-label={_t("dialog_close_label")}
title={_t("dialog_close_label")} title={_t("action|close")}
placement="bottom"
/> />
); );
} }

View file

@ -17,8 +17,9 @@ limitations under the License.
import React, { ChangeEvent } from "react"; import React, { ChangeEvent } from "react";
import { MatrixClient, MatrixError, SecretStorage } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixError, SecretStorage } from "matrix-js-sdk/src/matrix";
import { IKeyBackupInfo, IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup"; import { IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
@ -51,7 +52,7 @@ interface IProps {
} }
interface IState { interface IState {
backupInfo: IKeyBackupInfo | null; backupInfo: KeyBackupInfo | null;
backupKeyStored: Record<string, SecretStorage.SecretStorageKeyDescription> | null; backupKeyStored: Record<string, SecretStorage.SecretStorageKeyDescription> | null;
loading: boolean; loading: boolean;
loadError: boolean | null; loadError: boolean | null;
@ -246,7 +247,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
} }
} }
private async restoreWithCachedKey(backupInfo: IKeyBackupInfo | null): Promise<boolean> { private async restoreWithCachedKey(backupInfo: KeyBackupInfo | null): Promise<boolean> {
if (!backupInfo) return false; if (!backupInfo) return false;
try { try {
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache( const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache(

View file

@ -15,18 +15,23 @@ limitations under the License.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { ComponentProps, ReactNode } from "react"; import React, { ReactNode } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex"; import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../elements/AccessibleButton"; import AccessibleButton, { ButtonProps } from "../../elements/AccessibleButton";
import { Ref } from "../../../../accessibility/roving/types"; import { Ref } from "../../../../accessibility/roving/types";
interface TooltipOptionProps extends ComponentProps<typeof AccessibleButton> { type TooltipOptionProps<T extends keyof JSX.IntrinsicElements> = ButtonProps<T> & {
endAdornment?: ReactNode; endAdornment?: ReactNode;
inputRef?: Ref; inputRef?: Ref;
} };
export const TooltipOption: React.FC<TooltipOptionProps> = ({ inputRef, className, ...props }) => { export const TooltipOption = <T extends keyof JSX.IntrinsicElements>({
inputRef,
className,
element,
...props
}: TooltipOptionProps<T>): JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return ( return (
<AccessibleButton <AccessibleButton
@ -37,6 +42,7 @@ export const TooltipOption: React.FC<TooltipOptionProps> = ({ inputRef, classNam
tabIndex={-1} tabIndex={-1}
aria-selected={isActive} aria-selected={isActive}
role="option" role="option"
element={element as keyof JSX.IntrinsicElements}
/> />
); );
}; };

View file

@ -113,6 +113,8 @@ type Props<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T> &
disableTooltip?: TooltipProps["disabled"]; disableTooltip?: TooltipProps["disabled"];
}; };
export type ButtonProps<T extends keyof JSX.IntrinsicElements> = Props<T>;
/** /**
* Type of the props passed to the element that is rendered by AccessibleButton. * Type of the props passed to the element that is rendered by AccessibleButton.
*/ */

View file

@ -37,7 +37,7 @@ interface IProps {
/** /**
* classnames to apply to the wrapper of the preview * classnames to apply to the wrapper of the preview
*/ */
className: string; className?: string;
/** /**
* The ID of the displayed user * The ID of the displayed user

View file

@ -21,7 +21,7 @@ import { AvatarStack, Tooltip } from "@vector-im/compound-web";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import AccessibleButton, { ButtonEvent } from "./AccessibleButton"; import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
interface IProps extends HTMLAttributes<HTMLSpanElement> { interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
members: RoomMember[]; members: RoomMember[];
size: string; size: string;
overflow: boolean; overflow: boolean;
@ -32,6 +32,11 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
onClick?: (e: ButtonEvent) => void | Promise<void>; onClick?: (e: ButtonEvent) => void | Promise<void>;
} }
/**
* A component which displays a list of avatars in a row, with a tooltip showing the names of the users.
*
* Any additional props, not named explicitly here, are passed to the underlying {@link AccessibleButton}.
*/
const FacePile: FC<IProps> = ({ const FacePile: FC<IProps> = ({
members, members,
size, size,
@ -40,6 +45,7 @@ const FacePile: FC<IProps> = ({
tooltipShortcut, tooltipShortcut,
children, children,
viewUserOnClick = true, viewUserOnClick = true,
onClick,
...props ...props
}) => { }) => {
const faces = members.map( const faces = members.map(
@ -47,12 +53,7 @@ const FacePile: FC<IProps> = ({
? (m) => <MemberAvatar key={m.userId} member={m} size={size} hideTitle /> ? (m) => <MemberAvatar key={m.userId} member={m} size={size} hideTitle />
: (m) => ( : (m) => (
<Tooltip key={m.userId} label={m.name} caption={tooltipShortcut}> <Tooltip key={m.userId} label={m.name} caption={tooltipShortcut}>
<MemberAvatar <MemberAvatar member={m} size={size} viewUserOnClick={!onClick && viewUserOnClick} hideTitle />
member={m}
size={size}
viewUserOnClick={!props.onClick && viewUserOnClick}
hideTitle
/>
</Tooltip> </Tooltip>
), ),
); );
@ -65,7 +66,7 @@ const FacePile: FC<IProps> = ({
); );
const content = ( const content = (
<AccessibleButton className="mx_FacePile" onClick={props.onClick ?? null}> <AccessibleButton {...props} className="mx_FacePile" onClick={onClick ?? null}>
<AvatarStack>{pileContents}</AvatarStack> <AvatarStack>{pileContents}</AvatarStack>
{children} {children}
</AccessibleButton> </AccessibleButton>

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ComponentProps } from "react"; import React from "react";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import InfoDialog from "../dialogs/InfoDialog"; import InfoDialog from "../dialogs/InfoDialog";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton, { ButtonProps } from "./AccessibleButton";
type Props = Omit<ComponentProps<typeof AccessibleButton>, "kind" | "onClick" | "className"> & { type Props = Omit<ButtonProps<"div">, "element" | "kind" | "onClick" | "className"> & {
title: string; title: string;
description: string | React.ReactNode; description: string | React.ReactNode;
}; };

View file

@ -33,9 +33,10 @@ export enum WarningKind {
interface IProps { interface IProps {
isRoomEncrypted?: boolean; isRoomEncrypted?: boolean;
kind: WarningKind; kind: WarningKind;
showLogo?: boolean;
} }
export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.Element { export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true }: IProps): JSX.Element {
if (!isRoomEncrypted) return <></>; if (!isRoomEncrypted) return <></>;
if (EventIndexPeg.get()) return <></>; if (EventIndexPeg.get()) return <></>;
@ -121,7 +122,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
return ( return (
<div className="mx_SearchWarning"> <div className="mx_SearchWarning">
{logo} {showLogo ? logo : null}
<span>{text}</span> <span>{text}</span>
</div> </div>
); );

View file

@ -16,8 +16,8 @@ limitations under the License.
import React, { forwardRef, useContext } from "react"; import React, { forwardRef, useContext } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IRoomEncryption } from "matrix-js-sdk/src/crypto/RoomList";
import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import EventTileBubble from "./EventTileBubble"; import EventTileBubble from "./EventTileBubble";
@ -25,30 +25,29 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { objectHasDiff } from "../../../utils/objects"; import { objectHasDiff } from "../../../utils/objects";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
timestamp?: JSX.Element; timestamp?: JSX.Element;
} }
const ALGORITHM = "m.megolm.v1.aes-sha2";
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp }, ref) => { const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp }, ref) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId()!; const roomId = mxEvent.getRoomId()!;
const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
const prevContent = mxEvent.getPrevContent() as IRoomEncryption; const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent;
const content = mxEvent.getContent<IRoomEncryption>(); const content = mxEvent.getContent<RoomEncryptionEventContent>();
// if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level. // if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level.
if (!objectHasDiff(prevContent, content)) return null; // nop if (!objectHasDiff(prevContent, content)) return null; // nop
if (content.algorithm === ALGORITHM && isRoomEncrypted) { if (content.algorithm === MEGOLM_ENCRYPTION_ALGORITHM && isRoomEncrypted) {
let subtitle: string; let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
const room = cli?.getRoom(roomId); const room = cli?.getRoom(roomId);
if (prevContent.algorithm === ALGORITHM) { if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) {
subtitle = _t("timeline|m.room.encryption|parameters_changed"); subtitle = _t("timeline|m.room.encryption|parameters_changed");
} else if (dmPartner) { } else if (dmPartner) {
const displayName = room?.getMember(dmPartner)?.rawDisplayName || dmPartner; const displayName = room?.getMember(dmPartner)?.rawDisplayName || dmPartner;

View file

@ -26,8 +26,12 @@ import { CardContext } from "./context";
interface IProps { interface IProps {
header?: ReactNode | null; header?: ReactNode | null;
hideHeaderButtons?: boolean;
footer?: ReactNode; footer?: ReactNode;
className?: string; className?: string;
id?: string;
role?: "tabpanel";
ariaLabelledBy?: string;
withoutScrollContainer?: boolean; withoutScrollContainer?: boolean;
closeLabel?: string; closeLabel?: string;
onClose?(ev: ButtonEvent): void; onClose?(ev: ButtonEvent): void;
@ -62,6 +66,10 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
onClose, onClose,
onBack, onBack,
className, className,
id,
ariaLabelledBy,
role,
hideHeaderButtons,
header, header,
footer, footer,
withoutScrollContainer, withoutScrollContainer,
@ -100,13 +108,31 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>; children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
} }
return ( let headerButtons: React.ReactElement | undefined;
<CardContext.Provider value={{ isCard: true }}> if (!hideHeaderButtons) {
<div className={classNames("mx_BaseCard", className)} ref={ref} onKeyDown={onKeyDown}> headerButtons = (
{header !== null && ( <>
<div className="mx_BaseCard_header">
{backButton} {backButton}
{closeButton} {closeButton}
</>
);
}
const shouldRenderHeader = header || !hideHeaderButtons;
return (
<CardContext.Provider value={{ isCard: true }}>
<div
id={id}
aria-labelledby={ariaLabelledBy}
role={role}
className={classNames("mx_BaseCard", className)}
ref={ref}
onKeyDown={onKeyDown}
>
{shouldRenderHeader && (
<div className="mx_BaseCard_header">
{headerButtons}
<div className="mx_BaseCard_headerProp">{header}</div> <div className="mx_BaseCard_headerProp">{header}</div>
</div> </div>
)} )}

View file

@ -214,27 +214,27 @@ export default class LegacyRoomHeaderButtons extends HeaderButtons<IProps> {
const currentPhase = RightPanelStore.instance.currentCard.phase; const currentPhase = RightPanelStore.instance.currentCard.phase;
if (currentPhase && ROOM_INFO_PHASES.includes(currentPhase)) { if (currentPhase && ROOM_INFO_PHASES.includes(currentPhase)) {
if (this.state.phase === currentPhase) { if (this.state.phase === currentPhase) {
RightPanelStore.instance.showOrHidePanel(currentPhase); RightPanelStore.instance.showOrHidePhase(currentPhase);
} else { } else {
RightPanelStore.instance.showOrHidePanel(currentPhase, RightPanelStore.instance.currentCard.state); RightPanelStore.instance.showOrHidePhase(currentPhase, RightPanelStore.instance.currentCard.state);
} }
} else { } else {
// This toggles for us, if needed // This toggles for us, if needed
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.RoomSummary); RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
} }
}; };
private onNotificationsClicked = (): void => { private onNotificationsClicked = (): void => {
// This toggles for us, if needed // This toggles for us, if needed
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel); RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
}; };
private onPinnedMessagesClicked = (): void => { private onPinnedMessagesClicked = (): void => {
// This toggles for us, if needed // This toggles for us, if needed
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.PinnedMessages); RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
}; };
private onTimelineCardClicked = (): void => { private onTimelineCardClicked = (): void => {
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.Timeline); RightPanelStore.instance.showOrHidePhase(RightPanelPhases.Timeline);
}; };
private onThreadsPanelClicked = (ev: ButtonEvent): void => { private onThreadsPanelClicked = (ev: ButtonEvent): void => {

Some files were not shown because too many files have changed in this diff Show more