Merge remote-tracking branch 'origin/develop' into staging
14
.eslintrc.js
|
@ -78,6 +78,11 @@ module.exports = {
|
|||
name: "matrix-react-sdk/",
|
||||
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: [
|
||||
{
|
||||
|
@ -115,13 +120,9 @@ module.exports = {
|
|||
"!matrix-js-sdk/src/extensible_events_v1/InvalidEventError",
|
||||
"!matrix-js-sdk/src/crypto",
|
||||
"!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/RoomList",
|
||||
"!matrix-js-sdk/src/crypto/deviceinfo",
|
||||
"!matrix-js-sdk/src/crypto/key_passphrase",
|
||||
"!matrix-js-sdk/src/crypto/CrossSigning",
|
||||
"!matrix-js-sdk/src/crypto/recoverykey",
|
||||
"!matrix-js-sdk/src/crypto/dehydration",
|
||||
"!matrix-js-sdk/src/oidc",
|
||||
|
@ -144,6 +145,11 @@ module.exports = {
|
|||
],
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
11
.github/workflows/end-to-end-tests.yaml
vendored
|
@ -56,6 +56,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Fetch layered build
|
||||
id: layered_build
|
||||
|
@ -103,7 +104,7 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
# 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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
@ -121,6 +122,7 @@ jobs:
|
|||
with:
|
||||
cache: "yarn"
|
||||
cache-dependency-path: matrix-react-sdk/yarn.lock
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: matrix-react-sdk
|
||||
|
@ -145,10 +147,8 @@ jobs:
|
|||
run: yarn playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
|
||||
with:
|
||||
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
|
||||
working-directory: matrix-react-sdk
|
||||
run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
|
||||
working-directory: matrix-react-sdk
|
||||
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: always()
|
||||
|
@ -174,6 +174,7 @@ jobs:
|
|||
if: inputs.skip != true
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.skip != true
|
||||
|
|
|
@ -7,7 +7,7 @@ jobs:
|
|||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Update matrixdotorg/synapse image
|
||||
run: |
|
||||
|
@ -20,7 +20,7 @@ jobs:
|
|||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/playwright-image-updates
|
||||
|
|
|
@ -7,7 +7,7 @@ jobs:
|
|||
name: Check PR base branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const baseBranch = context.payload.pull_request.base.ref;
|
||||
|
|
5
.github/workflows/static_analysis.yaml
vendored
|
@ -25,6 +25,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Deps
|
||||
run: "./scripts/ci/install-deps.sh"
|
||||
|
@ -83,6 +84,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
# Does not need branch matching as only analyses this layer
|
||||
- name: Install Deps
|
||||
|
@ -100,6 +102,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
# Does not need branch matching as only analyses this layer
|
||||
- name: Install Deps
|
||||
|
@ -117,6 +120,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
# Does not need branch matching as only analyses this layer
|
||||
- name: Install Deps
|
||||
|
@ -134,6 +138,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Deps
|
||||
run: "scripts/ci/layered.sh"
|
||||
|
|
2
.github/workflows/tests.yml
vendored
|
@ -44,6 +44,7 @@ jobs:
|
|||
- name: Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
|
@ -115,6 +116,7 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Run tests
|
||||
run: "./scripts/ci/app-tests.sh"
|
||||
|
|
|
@ -22,7 +22,7 @@ const config: Config = {
|
|||
testEnvironment: "jsdom",
|
||||
testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"],
|
||||
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||
setupFiles: ["jest-canvas-mock"],
|
||||
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||
moduleNameMapper: {
|
||||
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
||||
|
|
22
package.json
|
@ -55,7 +55,7 @@
|
|||
"test:playwright:open": "yarn test:playwright --ui",
|
||||
"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: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",
|
||||
"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",
|
||||
"oidc-client-ts": "3.0.1",
|
||||
"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": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/analytics-events": "^0.23.0",
|
||||
"@matrix-org/emojibase-bindings": "^1.1.2",
|
||||
"@matrix-org/matrix-wysiwyg": "2.37.3",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@matrix-org/matrix-wysiwyg": "2.37.4",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^8.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@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/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
|
@ -96,7 +96,6 @@
|
|||
"filesize": "10.1.2",
|
||||
"github-markdown-css": "^5.5.1",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"highlight.js": "^11.3.1",
|
||||
"html-entities": "^2.0.0",
|
||||
"is-ip": "^3.1.0",
|
||||
|
@ -119,8 +118,7 @@
|
|||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.139.2",
|
||||
"proposal-temporal": "^0.9.0",
|
||||
"posthog-js": "1.141.3",
|
||||
"qrcode": "1.5.3",
|
||||
"re-resizable": "^6.9.0",
|
||||
"react": "17.0.2",
|
||||
|
@ -133,6 +131,7 @@
|
|||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.13.0",
|
||||
"tar-js": "^0.3.0",
|
||||
"temporal-polyfill": "^0.2.5",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"uuid": "^10.0.0",
|
||||
"what-input": "^5.2.10"
|
||||
|
@ -188,7 +187,7 @@
|
|||
"@types/seedrandom": "3.0.8",
|
||||
"@types/tar-js": "^0.3.2",
|
||||
"@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/parser": "^7.0.0",
|
||||
"axe-core": "4.9.1",
|
||||
|
@ -204,7 +203,7 @@
|
|||
"eslint-plugin-matrix-org": "1.2.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-unicorn": "^53.0.0",
|
||||
"eslint-plugin-unicorn": "^54.0.0",
|
||||
"express": "^4.18.2",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
|
@ -227,7 +226,8 @@
|
|||
"stylelint-config-standard": "^36.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.5.2",
|
||||
"web-streams-polyfill": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.4.19",
|
||||
|
|
|
@ -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
|
||||
VOLUME ["/work/element-web/node_modules"]
|
||||
|
|
|
@ -160,7 +160,7 @@ test.describe("Audio player", () => {
|
|||
|
||||
// Enable high contrast manually
|
||||
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();
|
||||
|
||||
|
|
|
@ -103,7 +103,7 @@ const verify = async (page: Page, bob: Bot) => {
|
|||
const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
|
||||
|
||||
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.getByRole("button", { name: "Verify" }).click();
|
||||
await roomInfo.getByRole("button", { name: "Start Verification" }).click();
|
||||
|
@ -279,7 +279,7 @@ test.describe("Cryptography", function () {
|
|||
|
||||
// Assert that verified icon is rendered
|
||||
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");
|
||||
|
||||
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
|
||||
|
|
|
@ -102,7 +102,7 @@ test.describe("Dehydration", () => {
|
|||
|
||||
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 getMemberTileByName(page, NAME).click();
|
||||
|
|
|
@ -45,7 +45,6 @@ test.describe("Device verification", () => {
|
|||
|
||||
// Create a new device for alice
|
||||
aliceBotClient = new Bot(page, homeserver, {
|
||||
rustCrypto: true,
|
||||
bootstrapCrossSigning: true,
|
||||
bootstrapSecretStorage: true,
|
||||
});
|
||||
|
|
77
playwright/e2e/forgot-password/forgot-password.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
|
@ -80,7 +80,7 @@ test.describe("Lazy Loading", () => {
|
|||
|
||||
async function openMemberlist(page: Page): Promise<void> {
|
||||
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 {
|
||||
|
|
|
@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "../../element-web-test";
|
||||
import { doTokenRegistration } from "./utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { selectHomeserver } from "../utils";
|
||||
|
||||
test.describe("Login", () => {
|
||||
test.describe("Password login", () => {
|
||||
|
@ -85,17 +84,6 @@ test.describe("Login", () => {
|
|||
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
|
||||
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
|
||||
|
|
|
@ -399,11 +399,10 @@ class Helpers {
|
|||
}
|
||||
|
||||
/**
|
||||
* Close the threads panel. (Actually, close any right panel, but for these
|
||||
* tests we only open the threads panel.)
|
||||
* Close the threads panel.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -411,7 +410,7 @@ class Helpers {
|
|||
* Return to the list of threads, given we are viewing a single thread.
|
||||
*/
|
||||
async backToThreadsList() {
|
||||
await this.page.locator(".mx_RightPanel").getByLabel("Threads").click();
|
||||
await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -113,7 +113,7 @@ test.describe("RightPanel", () => {
|
|||
test("should handle viewing room member", async ({ page, app }) => {
|
||||
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 getMemberTileByName(page, NAME).click();
|
||||
|
@ -123,7 +123,7 @@ test.describe("RightPanel", () => {
|
|||
await page.getByRole("button", { name: "Room members" }).click();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
241
playwright/e2e/settings/appearance-user-settings-tab/index.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -73,29 +73,6 @@ test.describe("General user settings tab", () => {
|
|||
// Assert that the add button is rendered
|
||||
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");
|
||||
await setIntegrationManager.scrollIntoViewIfNeeded();
|
||||
await expect(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2023 Suguru Hirahara
|
||||
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.
|
||||
|
@ -19,6 +20,10 @@ import { test, expect } from "../../element-web-test";
|
|||
test.describe("Preferences user settings tab", () => {
|
||||
test.use({
|
||||
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 }) => {
|
||||
|
@ -28,4 +33,24 @@ test.describe("Preferences user settings tab", () => {
|
|||
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -47,5 +47,14 @@ test.describe("Security user settings tab", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
return expect(
|
||||
this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByLabel("Close"),
|
||||
).toBeFocused();
|
||||
assertThreadTabFocused() {
|
||||
return expect(this.page.locator("#thread-panel-tab")).toBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -161,17 +161,12 @@ test.describe("Threads Activity Centre", () => {
|
|||
await util.assertNoTacIndicator();
|
||||
});
|
||||
|
||||
test("should focus the thread panel close button when clicking an item in the TAC", async ({
|
||||
room1,
|
||||
room2,
|
||||
util,
|
||||
msg,
|
||||
}) => {
|
||||
test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg }) => {
|
||||
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
|
||||
|
||||
await util.openTac();
|
||||
await util.clickRoomInTac(room1.name);
|
||||
|
||||
await util.assertThreadPanelFocused();
|
||||
await util.assertThreadTabFocused();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -781,10 +781,10 @@ test.describe("Timeline", () => {
|
|||
|
||||
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 page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter");
|
||||
await expect(page.locator(".mx_RoomSearchAuxPanel")).toMatchScreenshot("search-aux-panel.png");
|
||||
|
||||
for (const locator of await page
|
||||
.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();
|
||||
|
||||
// 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_SearchBar").getByRole("textbox").press("Enter");
|
||||
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch);
|
||||
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
|
||||
|
||||
// On search results panel
|
||||
const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");
|
||||
|
|
|
@ -17,8 +17,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
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 { 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";
|
||||
|
|
|
@ -53,7 +53,10 @@ class FlakyReporter implements Reporter {
|
|||
|
||||
const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` };
|
||||
// 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();
|
||||
for (const flake of this.flakes) {
|
||||
const title = ISSUE_TITLE_PREFIX + "`" + flake + "`";
|
||||
|
@ -61,6 +64,12 @@ class FlakyReporter implements Reporter {
|
|||
|
||||
if (existingIssue) {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
|
|
@ -45,10 +45,6 @@ export interface CreateBotOpts {
|
|||
* Whether to generate cross-signing keys
|
||||
*/
|
||||
bootstrapCrossSigning?: boolean;
|
||||
/**
|
||||
* Whether to use the rust crypto impl. Defaults to false (for now!)
|
||||
*/
|
||||
rustCrypto?: boolean;
|
||||
/**
|
||||
* Whether to bootstrap the secret storage
|
||||
*/
|
||||
|
@ -188,11 +184,7 @@ export class Bot extends Client {
|
|||
return cli;
|
||||
}
|
||||
|
||||
if (opts.rustCrypto) {
|
||||
await cli.initRustCrypto({ useIndexedDB: false });
|
||||
} else {
|
||||
await cli.initCrypto();
|
||||
}
|
||||
await cli.initRustCrypto({ useIndexedDB: false });
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
await cli.startClient();
|
||||
|
||||
|
|
|
@ -39,6 +39,15 @@ export interface HomeserverInstance {
|
|||
* @param password login password
|
||||
*/
|
||||
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 {
|
||||
|
|
|
@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
|
|||
// 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.
|
||||
// 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">> {
|
||||
const templateDir = path.join(__dirname, "templates", opts.template);
|
||||
|
@ -94,6 +94,8 @@ export class Synapse implements Homeserver, HomeserverInstance {
|
|||
protected docker: Docker = new Docker();
|
||||
public config: HomeserverConfig & { serverId: string };
|
||||
|
||||
private adminToken?: string;
|
||||
|
||||
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")];
|
||||
}
|
||||
|
||||
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 { nonce } = await this.request.get(url).then((r) => r.json());
|
||||
const mac = crypto
|
||||
.createHmac("sha1", this.config.registrationSecret)
|
||||
.update(`${nonce}\0${username}\0${password}\0notadmin`)
|
||||
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
|
||||
.digest("hex");
|
||||
const res = await this.request.post(url, {
|
||||
data: {
|
||||
|
@ -165,7 +172,7 @@ export class Synapse implements Homeserver, HomeserverInstance {
|
|||
username,
|
||||
password,
|
||||
mac,
|
||||
admin: false,
|
||||
admin,
|
||||
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> {
|
||||
const url = `${this.config.baseUrl}/_matrix/client/v3/login`;
|
||||
const res = await this.request.post(url, {
|
||||
|
@ -207,4 +218,30 @@ export class Synapse implements Homeserver, HomeserverInstance {
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 9 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
|
@ -177,9 +177,9 @@ a:visited {
|
|||
color: $accent-alt;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="password"] {
|
||||
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="text"],
|
||||
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="search"],
|
||||
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="password"] {
|
||||
padding: 9px;
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
|
@ -522,6 +522,8 @@ legend {
|
|||
content: "";
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
|
||||
mask-repeat: no-repeat;
|
||||
|
@ -604,7 +606,7 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
||||
.mx_Dialog input[type="submit"],
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
|
||||
.mx_Dialog_buttons input[type="submit"] {
|
||||
|
@ -624,14 +626,14 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):last-child {
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):focus,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
|
||||
.mx_Dialog input[type="submit"]:focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
|
||||
.mx_Dialog_buttons input[type="submit"]:focus {
|
||||
|
@ -643,7 +645,7 @@ legend {
|
|||
.mx_Dialog_buttons
|
||||
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
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 input[type="submit"].danger,
|
||||
.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 {
|
||||
background-color: var(--cpd-color-bg-critical-primary);
|
||||
border: solid 1px var(--cpd-color-bg-critical-primary);
|
||||
|
@ -670,7 +674,7 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):disabled,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
|
||||
.mx_Dialog input[type="submit"]:disabled,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
|
||||
.mx_Dialog_buttons input[type="submit"]:disabled {
|
||||
|
|
|
@ -261,6 +261,7 @@
|
|||
@import "./views/right_panel/_BaseCard.pcss";
|
||||
@import "./views/right_panel/_EncryptionInfo.pcss";
|
||||
@import "./views/right_panel/_PinnedMessagesCard.pcss";
|
||||
@import "./views/right_panel/_RightPanelTabs.pcss";
|
||||
@import "./views/right_panel/_RoomSummaryCard.pcss";
|
||||
@import "./views/right_panel/_ThreadPanel.pcss";
|
||||
@import "./views/right_panel/_TimelineCard.pcss";
|
||||
|
@ -306,10 +307,10 @@
|
|||
@import "./views/rooms/_RoomListHeader.pcss";
|
||||
@import "./views/rooms/_RoomPreviewBar.pcss";
|
||||
@import "./views/rooms/_RoomPreviewCard.pcss";
|
||||
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
|
||||
@import "./views/rooms/_RoomSublist.pcss";
|
||||
@import "./views/rooms/_RoomTile.pcss";
|
||||
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
|
||||
@import "./views/rooms/_SearchBar.pcss";
|
||||
@import "./views/rooms/_SendMessageComposer.pcss";
|
||||
@import "./views/rooms/_SpaceScopeHeader.pcss";
|
||||
@import "./views/rooms/_Stickers.pcss";
|
||||
|
|
|
@ -17,6 +17,12 @@ limitations under the License.
|
|||
.mx_SettingsSubsection {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.mx_SettingsSubsection_newUi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SettingsSubsection_description {
|
||||
|
@ -54,4 +60,8 @@ limitations under the License.
|
|||
&.mx_SettingsSubsection_noHeading {
|
||||
margin-top: 0;
|
||||
}
|
||||
&.mx_SettingsSubsection_content_newUi {
|
||||
gap: var(--cpd-space-6x);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
|
25
res/css/views/right_panel/_RightPanelTabs.pcss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -235,28 +235,15 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_RoomSummaryCard_header {
|
||||
padding: 15px 12px;
|
||||
padding: 24px 12px 15px;
|
||||
}
|
||||
|
||||
.mx_RoomSummaryCard_search input {
|
||||
/* Overriding very broad CSS rules */
|
||||
border: 0 !important;
|
||||
margin: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mx_RoomSummaryCard_search {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
.mx_RoomSummaryCard_searchBtn {
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
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);
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: unset; /* override _common.pcss which inhibits this */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 24px;
|
||||
|
||||
.mx_Spinner {
|
||||
flex: 1 0 auto;
|
||||
|
|
|
@ -20,7 +20,7 @@ limitations under the License.
|
|||
padding: 0 var(--cpd-space-3x);
|
||||
border-bottom: 1px solid $separator;
|
||||
background-color: $background;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mx_RoomHeader:hover {
|
||||
|
@ -74,14 +74,17 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_RoomHeader:hover .mx_RoomHeader_topic {
|
||||
/* height needed to compute the transition, it equals to the `line-height`
|
||||
.mx_RoomHeader:hover,
|
||||
.mx_RoomHeader:focus-within {
|
||||
.mx_RoomHeader_topic {
|
||||
/* height needed to compute the transition, it equals to the `line-height`
|
||||
value in pixels */
|
||||
height: calc($font-13px * 1.5);
|
||||
opacity: 1;
|
||||
height: calc($font-13px * 1.5);
|
||||
opacity: 1;
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
72
res/css/views/rooms/_RoomSearchAuxPanel.pcss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -15,79 +15,80 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_LayoutSwitcher_RadioButtons {
|
||||
.mx_LayoutSwitcher_LayoutSelector {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
color: $primary-content;
|
||||
.mxLayoutSwitcher_LayoutSelector_LayoutRadio {
|
||||
border: 1px solid var(--cpd-color-border-interactive-primary);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
|
||||
> .mx_LayoutSwitcher_RadioButton {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
flex-basis: 33%;
|
||||
min-width: 0;
|
||||
|
||||
border: 1px solid $quinary-content;
|
||||
border-radius: 10px;
|
||||
|
||||
.mx_EventTile_msgOption,
|
||||
.mx_MessageActionBar {
|
||||
display: none;
|
||||
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline {
|
||||
display: flex;
|
||||
/*
|
||||
* 10px
|
||||
*/
|
||||
gap: calc(var(--cpd-space-2x) + var(--cpd-space-0-5x));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_LayoutSwitcher_RadioButton_preview {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
.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[data-layout="bubble"] .mx_EventTile_line {
|
||||
padding-right: 11px;
|
||||
.mx_EventTile {
|
||||
margin: 0;
|
||||
|
||||
/**
|
||||
* Hide the message options and message action bar in the preview
|
||||
*/
|
||||
.mx_EventTile_msgOption,
|
||||
.mx_MessageActionBar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_EventTile_content {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&[data-layout="group"] {
|
||||
margin-top: calc(var(--cpd-space-3x) * -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add margin to center the bubble
|
||||
*/
|
||||
&[data-layout="bubble"] {
|
||||
/**
|
||||
* Add the layout margin and the margin to vertically center the bubble
|
||||
*/
|
||||
margin-top: var(--cpd-space-6x);
|
||||
margin-right: 34px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.mx_EventTile_line {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_StyledRadioButton {
|
||||
flex-grow: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mx_EventTile_content {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.mx_LayoutSwitcher_RadioButton_selected {
|
||||
border-color: var(--cpd-color-bg-accent-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_StyledRadioButton {
|
||||
border-top: 1px solid $quinary-content;
|
||||
}
|
||||
|
||||
.mx_StyledRadioButton_checked {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
.mx_EventTile {
|
||||
margin: 0;
|
||||
&[data-layout="bubble"] {
|
||||
margin-right: 40px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
&[data-layout="irc"] {
|
||||
> a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.mx_EventTile_line {
|
||||
max-width: 90%;
|
||||
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator {
|
||||
border-top: 0;
|
||||
border-bottom: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,48 +14,72 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_ThemeChoicePanel_themeSelectors {
|
||||
color: $primary-content;
|
||||
.mx_ThemeChoicePanel_ThemeSelectors {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
/* Override form default style */
|
||||
flex-direction: row !important;
|
||||
gap: var(--cpd-space-4x) !important;
|
||||
|
||||
> .mx_StyledRadioButton {
|
||||
align-items: center;
|
||||
padding: $font-16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
width: 180px;
|
||||
.mx_ThemeChoicePanel_themeSelector {
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-space-1-5x);
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-5x) var(--cpd-space-3x) var(--cpd-space-3x);
|
||||
gap: var(--cpd-space-2x);
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
|
||||
background: $accent-200;
|
||||
opacity: 0.4;
|
||||
|
||||
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 {
|
||||
opacity: 1;
|
||||
|
||||
/* These colors need to be hardcoded because they don't change with the theme */
|
||||
&.mx_ThemeSelector_light {
|
||||
background-color: #f3f8fd;
|
||||
color: #2e2f32;
|
||||
&.mx_ThemeChoicePanel_themeSelector_enabled {
|
||||
border-color: var(--cpd-color-border-interactive-primary);
|
||||
}
|
||||
|
||||
&.mx_ThemeSelector_dark {
|
||||
/* 5% lightened version of 181b21 */
|
||||
background-color: #25282e;
|
||||
color: #f3f8fd;
|
||||
&.mx_ThemeChoicePanel_themeSelector_disabled {
|
||||
border-color: var(--cpd-color-border-disabled);
|
||||
}
|
||||
|
||||
.mx_ThemeChoicePanel_themeSelector_Label {
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ limitations under the License.
|
|||
|
||||
.mx_UserProfileSettings_profile_controls_userId {
|
||||
width: 100%;
|
||||
margin-top: var(--cpd-space-4x);
|
||||
.mx_CopyableText {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
width: 100%;
|
||||
|
@ -46,6 +47,15 @@ limitations under the License.
|
|||
font-size: 15px;
|
||||
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) {
|
||||
|
|
|
@ -34,3 +34,8 @@ limitations under the License.
|
|||
margin-right: $spacing-8;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mx_GeneralUserSettingsTab_section_hint {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
.mx_InlineTermsAgreement_cbContainer {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
margin-bottom: 10px;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
|
||||
|
|
|
@ -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 { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
|
||||
|
||||
/** The key that we use to store the `reportedEvents` bloom filter in localstorage */
|
||||
const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids";
|
||||
|
@ -207,7 +208,7 @@ export class DecryptionFailureTracker {
|
|||
*/
|
||||
private eventDecrypted(e: MatrixEvent, nowTs: number): void {
|
||||
// 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;
|
||||
}
|
||||
const errCode = e.decryptionFailureReason;
|
||||
|
|
|
@ -20,13 +20,11 @@ limitations under the License.
|
|||
import React, { LegacyRef, ReactNode } from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import classNames from "classnames";
|
||||
import EMOJIBASE_REGEX from "emojibase-regex";
|
||||
import katex from "katex";
|
||||
import { decode } from "html-entities";
|
||||
import { IContent } from "matrix-js-sdk/src/matrix";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
import escapeHtml from "escape-html";
|
||||
import GraphemeSplitter from "graphemer";
|
||||
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
|
||||
|
||||
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
|
||||
|
@ -34,6 +32,7 @@ import SettingsStore from "./settings/SettingsStore";
|
|||
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
import { sanitizeHtmlParams, transformTags } from "./Linkify";
|
||||
import { graphemeSegmenter } from "./utils/strings";
|
||||
|
||||
export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify";
|
||||
|
||||
|
@ -46,10 +45,35 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
|||
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
|
||||
|
||||
// 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)
|
||||
const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]|\uFE0F/g;
|
||||
// (Zero-Width Space, other whitespace)
|
||||
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
|
||||
|
@ -265,17 +289,16 @@ export function formatEmojis(message: string | undefined, isHtmlMessage?: boolea
|
|||
let text = "";
|
||||
let key = 0;
|
||||
|
||||
const splitter = new GraphemeSplitter();
|
||||
for (const char of splitter.iterateGraphemes(message)) {
|
||||
if (EMOJIBASE_REGEX.test(char)) {
|
||||
for (const data of graphemeSegmenter.segment(message)) {
|
||||
if (EMOJI_REGEX.test(data.segment)) {
|
||||
if (text) {
|
||||
result.push(text);
|
||||
text = "";
|
||||
}
|
||||
result.push(emojiToSpan(char, key));
|
||||
result.push(emojiToSpan(data.segment, key));
|
||||
key++;
|
||||
} else {
|
||||
text += char;
|
||||
text += data.segment;
|
||||
}
|
||||
}
|
||||
if (text) {
|
||||
|
|
|
@ -66,6 +66,7 @@ import { localNotificationsAreSilenced } from "./utils/notifications";
|
|||
import { SdkContextClass } from "./contexts/SDKContext";
|
||||
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
|
||||
import { isNotNull } from "./Typeguards";
|
||||
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
||||
|
||||
export const PROTOCOL_PSTN = "m.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
|
||||
// call with a different party to this one.
|
||||
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 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
|
||||
|
@ -170,6 +169,9 @@ export default class LegacyCallHandler extends EventEmitter {
|
|||
|
||||
private silencedCalls = new Set<string>(); // callIds
|
||||
|
||||
private backgroundAudio = new BackgroundAudio();
|
||||
private playingSources: Record<string, AudioBufferSourceNode> = {}; // Record them for stopping
|
||||
|
||||
public static get instance(): LegacyCallHandler {
|
||||
if (!window.mxLegacyCallHandler) {
|
||||
window.mxLegacyCallHandler = new LegacyCallHandler();
|
||||
|
@ -199,33 +201,11 @@ export default class LegacyCallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
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)) {
|
||||
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -233,27 +213,6 @@ export default class LegacyCallHandler extends EventEmitter {
|
|||
if (cli) {
|
||||
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) */
|
||||
|
@ -465,74 +424,46 @@ export default class LegacyCallHandler extends EventEmitter {
|
|||
return this.transferees.get(callId);
|
||||
}
|
||||
|
||||
public play(audioId: AudioID): void {
|
||||
public async play(audioId: AudioID): Promise<void> {
|
||||
const logPrefix = `LegacyCallHandler.play(${audioId}):`;
|
||||
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
|
||||
// the promise is rejected, even though we're catching the exception.
|
||||
logger.debug(`${logPrefix} attempting to play audio at volume=${audio.volume}`);
|
||||
await audio.play();
|
||||
logger.debug(`${logPrefix} playing audio successfully`);
|
||||
} 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(
|
||||
audioId,
|
||||
this.audioPromises.get(audioId)!.then(() => {
|
||||
audio.load();
|
||||
return playAudio();
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.audioPromises.set(audioId, playAudio());
|
||||
}
|
||||
} else {
|
||||
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
|
||||
}
|
||||
const audioInfo: Record<AudioID, [prefix: string, loop: boolean]> = {
|
||||
[AudioID.Ring]: [`./media/ring`, true],
|
||||
[AudioID.Ringback]: [`./media/ringback`, true],
|
||||
[AudioID.CallEnd]: [`./media/callend`, false],
|
||||
[AudioID.Busy]: [`./media/busy`, false],
|
||||
};
|
||||
|
||||
const [urlPrefix, loop] = audioInfo[audioId];
|
||||
const source = await this.backgroundAudio.pickFormatAndPlay(urlPrefix, ["mp3", "ogg"], loop);
|
||||
this.playingSources[audioId] = source;
|
||||
logger.debug(`${logPrefix} playing audio successfully`);
|
||||
}
|
||||
|
||||
public pause(audioId: AudioID): void {
|
||||
const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
|
||||
logger.debug(`${logPrefix} beginning of function`);
|
||||
// TODO: Attach an invisible element for this instead
|
||||
// which listens?
|
||||
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
||||
const pauseAudio = (): void => {
|
||||
logger.debug(`${logPrefix} pausing audio`);
|
||||
// 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}`);
|
||||
|
||||
const source = this.playingSources[audioId];
|
||||
if (!source) {
|
||||
logger.debug(`${logPrefix} audio not playing`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
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 { Glass } from "@vector-im/compound-web";
|
||||
|
||||
|
@ -47,11 +47,12 @@ export interface IModal<C extends ComponentType> {
|
|||
elem: React.ReactNode;
|
||||
className?: string;
|
||||
beforeClosePromise?: Promise<boolean>;
|
||||
closeReason?: string;
|
||||
onBeforeClose?(reason?: string): Promise<boolean>;
|
||||
closeReason?: ModalCloseReason;
|
||||
onBeforeClose?(reason?: ModalCloseReason): Promise<boolean>;
|
||||
onFinished: ComponentProps<C>["onFinished"];
|
||||
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
|
||||
hidden?: boolean;
|
||||
deferred?: IDeferred<Parameters<ComponentProps<C>["onFinished"]>>;
|
||||
}
|
||||
|
||||
export interface IHandle<C extends ComponentType> {
|
||||
|
@ -73,6 +74,8 @@ type HandlerMap = {
|
|||
[ModalManagerEvent.Closed]: () => void;
|
||||
};
|
||||
|
||||
type ModalCloseReason = "backgroundClick";
|
||||
|
||||
export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMap> {
|
||||
private counter = 0;
|
||||
// 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
|
||||
* @return whether a modal was closed
|
||||
*/
|
||||
public closeCurrentModal(reason?: string): boolean {
|
||||
public closeCurrentModal(reason?: ModalCloseReason): boolean {
|
||||
const modal = this.getCurrentModal();
|
||||
if (!modal) {
|
||||
return false;
|
||||
|
@ -161,6 +168,22 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
|||
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>(
|
||||
prom: Promise<C>,
|
||||
props?: ComponentProps<C>,
|
||||
|
@ -199,7 +222,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
|||
modal: IModal<C>,
|
||||
props?: ComponentProps<C>,
|
||||
): [IHandle<C>["close"], IHandle<C>["finished"]] {
|
||||
const deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
|
||||
modal.deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
|
||||
return [
|
||||
async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => {
|
||||
if (modal.beforeClosePromise) {
|
||||
|
@ -212,7 +235,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
|||
return;
|
||||
}
|
||||
}
|
||||
deferred.resolve(args);
|
||||
modal.deferred?.resolve(args);
|
||||
if (props?.onFinished) props.onFinished.apply(null, args);
|
||||
const i = this.modals.indexOf(modal);
|
||||
if (i >= 0) {
|
||||
|
@ -236,7 +259,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
|||
this.reRender();
|
||||
this.emitClosed();
|
||||
},
|
||||
deferred.promise,
|
||||
modal.deferred.promise,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -58,6 +58,7 @@ import ToastStore from "./stores/ToastStore";
|
|||
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
|
||||
import { getSenderName } from "./utils/event/getSenderName";
|
||||
import { stripPlainReply } from "./utils/Reply";
|
||||
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
||||
|
||||
/*
|
||||
* Dispatches:
|
||||
|
@ -112,6 +113,8 @@ class NotifierClass {
|
|||
private toolbarHidden?: boolean;
|
||||
private isSyncing?: boolean;
|
||||
|
||||
private backgroundAudio = new BackgroundAudio();
|
||||
|
||||
public notificationMessageForEvent(ev: MatrixEvent): string | null {
|
||||
const msgType = ev.getContent().msgtype;
|
||||
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
|
||||
|
@ -226,28 +229,14 @@ class NotifierClass {
|
|||
return;
|
||||
}
|
||||
|
||||
// Play notification sound here
|
||||
const sound = this.getSoundForRoom(room.roomId);
|
||||
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
|
||||
|
||||
try {
|
||||
const selector = document.querySelector<HTMLAudioElement>(
|
||||
sound ? `audio[src='${sound.url}']` : "#messageAudio",
|
||||
);
|
||||
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);
|
||||
if (sound) {
|
||||
await this.backgroundAudio.play(sound.url);
|
||||
} else {
|
||||
await this.backgroundAudio.pickFormatAndPlay("media/message", ["mp3", "ogg"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -681,3 +681,49 @@ export default function eventSearch(
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
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 dis from "../../../../dispatcher/dispatcher";
|
||||
|
@ -28,7 +28,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
|||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
|
||||
interface IProps {
|
||||
newVersionInfo: IKeyBackupInfo;
|
||||
newVersionInfo: KeyBackupInfo;
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
|
|
74
src/audio/BackgroundAudio.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -99,6 +99,8 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
|
|||
|
||||
onSelectionChange(newSelection);
|
||||
focusEditor();
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const removeSelection = (completion: ICompletion): void => {
|
||||
|
|
|
@ -458,9 +458,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.SearchInRoom:
|
||||
dis.dispatch({
|
||||
action: "focus_search",
|
||||
});
|
||||
dis.fire(Action.FocusMessageSearch);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
@ -490,11 +488,15 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
handled = true;
|
||||
break;
|
||||
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({
|
||||
action: Action.ViewHomePage,
|
||||
});
|
||||
Modal.closeCurrentModal("homeKeyboardShortcut");
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.ToggleSpacePanel:
|
||||
dis.fire(Action.ToggleSpacePanel);
|
||||
|
|
|
@ -31,7 +31,7 @@ import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
|
|||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { throttle } from "lodash";
|
||||
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
|
||||
import "what-input";
|
||||
|
@ -1544,7 +1544,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (Lifecycle.isLoggingOut()) return;
|
||||
|
||||
// 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"]) {
|
||||
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> => {
|
||||
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 (cli.getKeyBackupEnabled()) {
|
||||
haveNewVersion = true;
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
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 { throttle } from "lodash";
|
||||
|
||||
|
@ -42,6 +42,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
|||
import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { XOR } from "../../@types/common";
|
||||
import { RightPanelTabs } from "../views/right_panel/RightPanelTabs";
|
||||
|
||||
interface BaseProps {
|
||||
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 {
|
||||
room: Room;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onSearchClick?: () => void;
|
||||
onSearchChange?: (e: ChangeEvent) => void;
|
||||
onSearchCancel?: () => void;
|
||||
}
|
||||
|
||||
type Props = XOR<RoomlessProps, RoomProps>;
|
||||
|
@ -170,6 +172,7 @@ export default class RightPanel extends React.Component<Props, IState> {
|
|||
<MemberList
|
||||
roomId={roomId}
|
||||
key={roomId}
|
||||
hideHeaderButtons
|
||||
onClose={this.onClose}
|
||||
searchQuery={this.state.searchQuery}
|
||||
onSearchQueryChanged={this.onSearchQueryChanged}
|
||||
|
@ -293,10 +296,11 @@ export default class RightPanel extends React.Component<Props, IState> {
|
|||
card = (
|
||||
<RoomSummaryCard
|
||||
room={this.props.room}
|
||||
onClose={this.onClose}
|
||||
// whenever RightPanel is passed a room it is passed a 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 (
|
||||
<aside className="mx_RightPanel" id="mx_RightPanel">
|
||||
{phase && <RightPanelTabs phase={phase} />}
|
||||
{card}
|
||||
</aside>
|
||||
);
|
||||
|
|
|
@ -24,12 +24,11 @@ import {
|
|||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import { SearchScope } from "../views/rooms/SearchBar";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { haveRendererForEvent } from "../../events/EventTileFactory";
|
||||
import SearchResultTile from "../views/rooms/SearchResultTile";
|
||||
import { searchPagination } from "../../Searching";
|
||||
import { searchPagination, SearchScope } from "../../Searching";
|
||||
import Modal from "../../Modal";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
|
@ -49,6 +48,7 @@ if (DEBUG) {
|
|||
interface Props {
|
||||
term: string;
|
||||
scope: SearchScope;
|
||||
inProgress: boolean;
|
||||
promise: Promise<ISearchResults>;
|
||||
abortController?: AbortController;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
|
@ -59,10 +59,9 @@ interface Props {
|
|||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
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 roomContext = useContext(RoomContext);
|
||||
const [inProgress, setInProgress] = useState(true);
|
||||
const [highlights, setHighlights] = useState<string[] | null>(null);
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const aborted = useRef(false);
|
||||
|
@ -79,73 +78,71 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
|||
|
||||
const handleSearchResult = useCallback(
|
||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||
setInProgress(true);
|
||||
onUpdate(true, null);
|
||||
|
||||
return searchPromise
|
||||
.then(
|
||||
async (results): Promise<boolean> => {
|
||||
debuglog("search complete");
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
return searchPromise.then(
|
||||
async (results): Promise<boolean> => {
|
||||
debuglog("search complete");
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
// postgres on synapse returns us precise details of the strings
|
||||
// which actually got matched for highlighting.
|
||||
//
|
||||
// In either case, we want to highlight the literal search term
|
||||
// whether it was used by the search engine or not.
|
||||
// postgres on synapse returns us precise details of the strings
|
||||
// which actually got matched for highlighting.
|
||||
//
|
||||
// In either case, we want to highlight the literal search term
|
||||
// whether it was used by the search engine or not.
|
||||
|
||||
let highlights = results.highlights;
|
||||
if (!highlights.includes(term)) {
|
||||
highlights = highlights.concat(term);
|
||||
}
|
||||
let highlights = results.highlights;
|
||||
if (!highlights.includes(term)) {
|
||||
highlights = highlights.concat(term);
|
||||
}
|
||||
|
||||
// For overlapping highlights,
|
||||
// favour longer (more specific) terms first
|
||||
highlights = highlights.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
// For overlapping highlights,
|
||||
// favour longer (more specific) terms first
|
||||
highlights = highlights.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
|
||||
for (const result of results.results) {
|
||||
for (const event of result.context.getTimeline()) {
|
||||
const bundledRelationship =
|
||||
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||
THREAD_RELATION_TYPE.name,
|
||||
);
|
||||
if (!bundledRelationship || event.getThread()) continue;
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
const thread = room?.findThreadForEvent(event);
|
||||
if (thread) {
|
||||
event.setThread(thread);
|
||||
} else {
|
||||
room?.createThread(event.getId()!, event, [], true);
|
||||
}
|
||||
for (const result of results.results) {
|
||||
for (const event of result.context.getTimeline()) {
|
||||
const bundledRelationship =
|
||||
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||
THREAD_RELATION_TYPE.name,
|
||||
);
|
||||
if (!bundledRelationship || event.getThread()) continue;
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
const thread = room?.findThreadForEvent(event);
|
||||
if (thread) {
|
||||
event.setThread(thread);
|
||||
} else {
|
||||
room?.createThread(event.getId()!, event, [], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
onUpdate(false, results);
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("error_dialog|search_failed|title"),
|
||||
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
||||
});
|
||||
return false;
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
setInProgress(false);
|
||||
});
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("error_dialog|search_failed|title"),
|
||||
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
||||
});
|
||||
onUpdate(false, null);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
},
|
||||
[client, term],
|
||||
[client, term, onUpdate],
|
||||
);
|
||||
|
||||
// Mount & unmount effect
|
||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
|||
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 {
|
||||
IRecommendedVersion,
|
||||
|
@ -41,7 +41,7 @@ import {
|
|||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
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 { 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 RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
|
||||
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
||||
import AuxPanel from "../views/rooms/AuxPanel";
|
||||
import LegacyRoomHeader, { ISearchInfo } from "../views/rooms/LegacyRoomHeader";
|
||||
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
||||
|
@ -121,7 +120,7 @@ import { SDKContext } from "../../contexts/SDKContext";
|
|||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||
import { Call } from "../../models/Call";
|
||||
import { RoomSearchView } from "./RoomSearchView";
|
||||
import eventSearch from "../../Searching";
|
||||
import eventSearch, { SearchInfo, SearchScope } from "../../Searching";
|
||||
import VoipUserMapper from "../../VoipUserMapper";
|
||||
import { isCallEvent } from "./LegacyCallEventGrouper";
|
||||
import { WidgetType } from "../../widgets/WidgetType";
|
||||
|
@ -133,6 +132,7 @@ import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoi
|
|||
import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload";
|
||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
||||
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
||||
|
||||
const DEBUG = false;
|
||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||
|
@ -190,7 +190,7 @@ export interface IRoomState {
|
|||
/**
|
||||
* The state of an ongoing search if there is one.
|
||||
*/
|
||||
search?: ISearchInfo;
|
||||
search?: SearchInfo;
|
||||
callState?: CallState;
|
||||
activeCall: Call | null;
|
||||
canPeek: boolean;
|
||||
|
@ -1196,9 +1196,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
);
|
||||
}
|
||||
break;
|
||||
case "focus_search":
|
||||
this.onSearchClick();
|
||||
break;
|
||||
|
||||
case "local_room_event":
|
||||
this.onLocalRoomEvent(payload.roomId);
|
||||
|
@ -1290,7 +1287,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
]);
|
||||
}
|
||||
} else {
|
||||
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.RoomMemberList);
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList);
|
||||
}
|
||||
break;
|
||||
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;
|
||||
debuglog("sending search request");
|
||||
const abortController = new AbortController();
|
||||
const promise = eventSearch(this.context.client!, term, roomId, abortController.signal);
|
||||
|
||||
this.setState({
|
||||
timelineRenderingType: TimelineRenderingType.Search,
|
||||
search: {
|
||||
// make sure that we don't end up showing results from
|
||||
// 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 => {
|
||||
this.setState({
|
||||
search: {
|
||||
|
@ -1839,15 +1841,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
};
|
||||
|
||||
private onSearchClick = (): void => {
|
||||
if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
|
||||
this.onCancelSearchClick();
|
||||
} else {
|
||||
this.setState({
|
||||
timelineRenderingType: TimelineRenderingType.Search,
|
||||
});
|
||||
}
|
||||
dis.fire(Action.FocusMessageSearch);
|
||||
};
|
||||
|
||||
private onSearchChange = debounce((e: ChangeEvent): void => {
|
||||
const term = (e.target as HTMLInputElement).value;
|
||||
this.onSearch(term);
|
||||
}, 300);
|
||||
|
||||
private onCancelSearchClick = (): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.setState(
|
||||
|
@ -2328,10 +2329,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
let previewBar;
|
||||
if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
|
||||
aux = (
|
||||
<SearchBar
|
||||
searchInProgress={this.state.search?.inProgress}
|
||||
<RoomSearchAuxPanel
|
||||
searchInfo={this.state.search}
|
||||
onCancelClick={this.onCancelSearchClick}
|
||||
onSearch={this.onSearch}
|
||||
onSearchScopeChange={this.onSearchScopeChange}
|
||||
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}
|
||||
promise={this.state.search.promise}
|
||||
abortController={this.state.search.abortController}
|
||||
inProgress={!!this.state.search.inProgress}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
className={this.messagePanelClassNames}
|
||||
onUpdate={this.onSearchUpdate}
|
||||
|
@ -2507,7 +2509,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
resizeNotifier={this.props.resizeNotifier}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
onSearchClick={this.onSearchClick}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onSearchCancel={this.onCancelSearchClick}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
|
|
|
@ -37,9 +37,6 @@ import { ButtonEvent } from "../views/elements/AccessibleButton";
|
|||
import Spinner from "../views/elements/Spinner";
|
||||
import Heading from "../views/typography/Heading";
|
||||
import { clearRoomNotification } from "../../utils/notifications";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
|
@ -259,14 +256,6 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
|||
}
|
||||
}, [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 (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
|
@ -277,6 +266,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
|||
}}
|
||||
>
|
||||
<BaseCard
|
||||
hideHeaderButtons
|
||||
header={
|
||||
<ThreadPanelHeader
|
||||
filterOption={filterOption}
|
||||
|
@ -284,7 +274,10 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
|||
empty={!hasThreads}
|
||||
/>
|
||||
}
|
||||
id="thread-panel"
|
||||
className="mx_ThreadPanel"
|
||||
ariaLabelledBy="thread-panel-tab"
|
||||
role="tabpanel"
|
||||
onClose={onClose}
|
||||
withoutScrollContainer={true}
|
||||
ref={card}
|
||||
|
|
|
@ -55,7 +55,6 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
|
|||
<LegacyRoomHeader
|
||||
room={context.room}
|
||||
inRoom={true}
|
||||
onSearchClick={null}
|
||||
onInviteClick={null}
|
||||
onForgetClick={null}
|
||||
e2eStatus={E2EStatus.Normal}
|
||||
|
|
|
@ -15,8 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
||||
import { KeyBackupInfo, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
|
||||
|
||||
|
@ -40,7 +39,7 @@ interface IProps {
|
|||
interface IState {
|
||||
phase?: Phase;
|
||||
verificationRequest: VerificationRequest | null;
|
||||
backupInfo: IKeyBackupInfo | null;
|
||||
backupInfo: KeyBackupInfo | null;
|
||||
lostKeys: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { HTMLProps } from "react";
|
||||
import { Temporal } from "proposal-temporal";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
|
||||
import { formatSeconds } from "../../../DateUtils";
|
||||
|
||||
|
@ -45,8 +45,9 @@ export default class Clock extends React.Component<Props> {
|
|||
return currentFloor !== nextFloor;
|
||||
}
|
||||
|
||||
private calculateDuration(seconds: number): string {
|
||||
return new Temporal.Duration(0, 0, 0, 0, 0, 0, seconds)
|
||||
private calculateDuration(seconds: number): string | undefined {
|
||||
if (isNaN(seconds)) return undefined;
|
||||
return new Temporal.Duration(0, 0, 0, 0, 0, 0, Math.round(seconds))
|
||||
.round({ smallestUnit: "seconds", largestUnit: "hours" })
|
||||
.toString();
|
||||
}
|
||||
|
|
|
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
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: Playback;
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import React from "react";
|
|||
|
||||
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
|
||||
import { ChevronFace, ContextMenuButton, MenuProps, useContextMenu } from "../../structures/ContextMenu";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { ButtonProps } from "../elements/AccessibleButton";
|
||||
import IconizedContextMenu, { IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||
|
||||
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
|
||||
|
@ -29,10 +29,10 @@ const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
|
|||
return { left, top, chevronFace };
|
||||
};
|
||||
|
||||
interface KebabContextMenuProps extends Partial<React.ComponentProps<typeof AccessibleButton>> {
|
||||
type KebabContextMenuProps = Partial<ButtonProps<any>> & {
|
||||
options: React.ReactNode[];
|
||||
title: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const KebabContextMenu: React.FC<KebabContextMenuProps> = ({ options, title, ...props }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
|
|
@ -128,7 +128,8 @@ export default class BaseDialog extends React.Component<IProps> {
|
|||
onClick={this.onCancelClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
aria-label={_t("dialog_close_label")}
|
||||
title={_t("dialog_close_label")}
|
||||
title={_t("action|close")}
|
||||
placement="bottom"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,8 +17,9 @@ limitations under the License.
|
|||
|
||||
import React, { ChangeEvent } from "react";
|
||||
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 { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
@ -51,7 +52,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
backupInfo: IKeyBackupInfo | null;
|
||||
backupInfo: KeyBackupInfo | null;
|
||||
backupKeyStored: Record<string, SecretStorage.SecretStorageKeyDescription> | null;
|
||||
loading: boolean;
|
||||
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;
|
||||
try {
|
||||
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache(
|
||||
|
|
|
@ -15,18 +15,23 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import AccessibleButton, { ButtonProps } from "../../elements/AccessibleButton";
|
||||
import { Ref } from "../../../../accessibility/roving/types";
|
||||
|
||||
interface TooltipOptionProps extends ComponentProps<typeof AccessibleButton> {
|
||||
type TooltipOptionProps<T extends keyof JSX.IntrinsicElements> = ButtonProps<T> & {
|
||||
endAdornment?: ReactNode;
|
||||
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);
|
||||
return (
|
||||
<AccessibleButton
|
||||
|
@ -37,6 +42,7 @@ export const TooltipOption: React.FC<TooltipOptionProps> = ({ inputRef, classNam
|
|||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
element={element as keyof JSX.IntrinsicElements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -113,6 +113,8 @@ type Props<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T> &
|
|||
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.
|
||||
*/
|
||||
|
|
|
@ -37,7 +37,7 @@ interface IProps {
|
|||
/**
|
||||
* classnames to apply to the wrapper of the preview
|
||||
*/
|
||||
className: string;
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The ID of the displayed user
|
||||
|
|
|
@ -21,7 +21,7 @@ import { AvatarStack, Tooltip } from "@vector-im/compound-web";
|
|||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
|
||||
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
members: RoomMember[];
|
||||
size: string;
|
||||
overflow: boolean;
|
||||
|
@ -32,6 +32,11 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
|||
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> = ({
|
||||
members,
|
||||
size,
|
||||
|
@ -40,6 +45,7 @@ const FacePile: FC<IProps> = ({
|
|||
tooltipShortcut,
|
||||
children,
|
||||
viewUserOnClick = true,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const faces = members.map(
|
||||
|
@ -47,12 +53,7 @@ const FacePile: FC<IProps> = ({
|
|||
? (m) => <MemberAvatar key={m.userId} member={m} size={size} hideTitle />
|
||||
: (m) => (
|
||||
<Tooltip key={m.userId} label={m.name} caption={tooltipShortcut}>
|
||||
<MemberAvatar
|
||||
member={m}
|
||||
size={size}
|
||||
viewUserOnClick={!props.onClick && viewUserOnClick}
|
||||
hideTitle
|
||||
/>
|
||||
<MemberAvatar member={m} size={size} viewUserOnClick={!onClick && viewUserOnClick} hideTitle />
|
||||
</Tooltip>
|
||||
),
|
||||
);
|
||||
|
@ -65,7 +66,7 @@ const FacePile: FC<IProps> = ({
|
|||
);
|
||||
|
||||
const content = (
|
||||
<AccessibleButton className="mx_FacePile" onClick={props.onClick ?? null}>
|
||||
<AccessibleButton {...props} className="mx_FacePile" onClick={onClick ?? null}>
|
||||
<AvatarStack>{pileContents}</AvatarStack>
|
||||
{children}
|
||||
</AccessibleButton>
|
||||
|
|
|
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
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;
|
||||
description: string | React.ReactNode;
|
||||
};
|
||||
|
|
|
@ -33,9 +33,10 @@ export enum WarningKind {
|
|||
interface IProps {
|
||||
isRoomEncrypted?: boolean;
|
||||
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 (EventIndexPeg.get()) return <></>;
|
||||
|
||||
|
@ -121,7 +122,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
|
|||
|
||||
return (
|
||||
<div className="mx_SearchWarning">
|
||||
{logo}
|
||||
{showLogo ? logo : null}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,8 +16,8 @@ limitations under the License.
|
|||
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
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 { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
|
@ -25,30 +25,29 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { objectHasDiff } from "../../../utils/objects";
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
timestamp?: JSX.Element;
|
||||
}
|
||||
|
||||
const ALGORITHM = "m.megolm.v1.aes-sha2";
|
||||
|
||||
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp }, ref) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomId = mxEvent.getRoomId()!;
|
||||
const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
|
||||
|
||||
const prevContent = mxEvent.getPrevContent() as IRoomEncryption;
|
||||
const content = mxEvent.getContent<IRoomEncryption>();
|
||||
const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent;
|
||||
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 (!objectHasDiff(prevContent, content)) return null; // nop
|
||||
|
||||
if (content.algorithm === ALGORITHM && isRoomEncrypted) {
|
||||
if (content.algorithm === MEGOLM_ENCRYPTION_ALGORITHM && isRoomEncrypted) {
|
||||
let subtitle: string;
|
||||
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
const room = cli?.getRoom(roomId);
|
||||
if (prevContent.algorithm === ALGORITHM) {
|
||||
if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) {
|
||||
subtitle = _t("timeline|m.room.encryption|parameters_changed");
|
||||
} else if (dmPartner) {
|
||||
const displayName = room?.getMember(dmPartner)?.rawDisplayName || dmPartner;
|
||||
|
|
|
@ -26,8 +26,12 @@ import { CardContext } from "./context";
|
|||
|
||||
interface IProps {
|
||||
header?: ReactNode | null;
|
||||
hideHeaderButtons?: boolean;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
role?: "tabpanel";
|
||||
ariaLabelledBy?: string;
|
||||
withoutScrollContainer?: boolean;
|
||||
closeLabel?: string;
|
||||
onClose?(ev: ButtonEvent): void;
|
||||
|
@ -62,6 +66,10 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
|
|||
onClose,
|
||||
onBack,
|
||||
className,
|
||||
id,
|
||||
ariaLabelledBy,
|
||||
role,
|
||||
hideHeaderButtons,
|
||||
header,
|
||||
footer,
|
||||
withoutScrollContainer,
|
||||
|
@ -100,13 +108,31 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
|
|||
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
|
||||
}
|
||||
|
||||
let headerButtons: React.ReactElement | undefined;
|
||||
if (!hideHeaderButtons) {
|
||||
headerButtons = (
|
||||
<>
|
||||
{backButton}
|
||||
{closeButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldRenderHeader = header || !hideHeaderButtons;
|
||||
|
||||
return (
|
||||
<CardContext.Provider value={{ isCard: true }}>
|
||||
<div className={classNames("mx_BaseCard", className)} ref={ref} onKeyDown={onKeyDown}>
|
||||
{header !== null && (
|
||||
<div
|
||||
id={id}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
role={role}
|
||||
className={classNames("mx_BaseCard", className)}
|
||||
ref={ref}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{shouldRenderHeader && (
|
||||
<div className="mx_BaseCard_header">
|
||||
{backButton}
|
||||
{closeButton}
|
||||
{headerButtons}
|
||||
<div className="mx_BaseCard_headerProp">{header}</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -214,27 +214,27 @@ export default class LegacyRoomHeaderButtons extends HeaderButtons<IProps> {
|
|||
const currentPhase = RightPanelStore.instance.currentCard.phase;
|
||||
if (currentPhase && ROOM_INFO_PHASES.includes(currentPhase)) {
|
||||
if (this.state.phase === currentPhase) {
|
||||
RightPanelStore.instance.showOrHidePanel(currentPhase);
|
||||
RightPanelStore.instance.showOrHidePhase(currentPhase);
|
||||
} else {
|
||||
RightPanelStore.instance.showOrHidePanel(currentPhase, RightPanelStore.instance.currentCard.state);
|
||||
RightPanelStore.instance.showOrHidePhase(currentPhase, RightPanelStore.instance.currentCard.state);
|
||||
}
|
||||
} else {
|
||||
// This toggles for us, if needed
|
||||
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.RoomSummary);
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
|
||||
}
|
||||
};
|
||||
|
||||
private onNotificationsClicked = (): void => {
|
||||
// This toggles for us, if needed
|
||||
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel);
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
|
||||
};
|
||||
|
||||
private onPinnedMessagesClicked = (): void => {
|
||||
// This toggles for us, if needed
|
||||
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.PinnedMessages);
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
|
||||
};
|
||||
private onTimelineCardClicked = (): void => {
|
||||
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.Timeline);
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.Timeline);
|
||||
};
|
||||
|
||||
private onThreadsPanelClicked = (ev: ButtonEvent): void => {
|
||||
|
|