Compare commits

..

54 Commits

Author SHA1 Message Date
Half-Shot
ba6fcbc985 Update compound 2025-07-14 09:28:13 +01:00
Half-Shot
529e6beb02 Merge remote-tracking branch 'origin/develop' into hs/update-toggles-to-use-consistent-style 2025-07-09 13:19:36 +01:00
Half-Shot
8a1eb8b632 lint 2025-07-09 13:18:59 +01:00
Half-Shot
c81d600307 Various fixes to components for tests. 2025-07-09 08:44:23 +01:00
Half-Shot
7df3b45ee3 Fixup unit tests to check correct attributes. 2025-07-08 17:03:56 +01:00
Half-Shot
f83bf60a14 Add spaces vis tab test. 2025-07-08 12:01:28 +01:00
Half-Shot
2da1cd239f Mask the IM name in the header. 2025-07-08 11:40:44 +01:00
Half-Shot
cdd41d8f5a Test creating a video room 2025-07-08 11:39:00 +01:00
Half-Shot
fc49e0f281 remove .only 2025-07-08 11:38:00 +01:00
Half-Shot
9d04af146a Add test for video settings tab. 2025-07-08 11:37:53 +01:00
Half-Shot
31e48c4fc1 Add test for room security tab. 2025-07-08 11:17:00 +01:00
Half-Shot
ddb2136d43 Add tests for user security tab. 2025-07-08 11:13:42 +01:00
Half-Shot
2c857f8fe3 Add tests for notification settings 2025-07-08 10:58:46 +01:00
Half-Shot
b9020d78fb fix copyright 2025-07-08 10:58:34 +01:00
Half-Shot
d47dd66736 Add a test for the live location sharing prompt. 2025-07-08 10:28:03 +01:00
Half-Shot
01aaddf93e Fix spec 2025-07-07 20:31:57 +01:00
Half-Shot
a4e5e77951 Add widget capabilites prompt test 2025-07-07 20:31:51 +01:00
Half-Shot
7e100f7a35 Add upgrade rooms test 2025-07-07 20:30:32 +01:00
Half-Shot
42e022e894 Add devtools test 2025-07-07 20:30:26 +01:00
Half-Shot
47a640d780 Add screenshot for create room dialog. 2025-07-07 19:22:17 +01:00
Half-Shot
d0daf2c2af Add screenshot test for DeclineAndBlockDialog 2025-07-07 19:18:18 +01:00
Half-Shot
ff8e322072 Merge remote-tracking branch 'origin/develop' into hs/update-toggles-to-use-consistent-style 2025-07-07 19:10:25 +01:00
Half-Shot
2c416f7e7b Create new snapshot tests 2025-06-23 08:57:19 +01:00
Half-Shot
782921f5ac Replace test ID tests 2025-06-23 08:57:07 +01:00
Half-Shot
452ff3b615 Prevent accidental submits 2025-06-23 08:56:57 +01:00
Half-Shot
5982a4cfed Updated screenshots 2025-06-19 15:57:22 +01:00
Half-Shot
cfb2f719fb revert changes to field 2025-06-19 14:53:59 +01:00
Half-Shot
5454c2bfbe Update RoomPublishSetting and SpaceSettingVisibilityTab to use SettingsToggleInput with a warning 2025-06-19 14:29:02 +01:00
Half-Shot
b58492fa40 Update Notifications settings to use SettingsFlag where possible 2025-06-19 14:28:17 +01:00
Half-Shot
1a2aeb14c2 Update trivial switchovers 2025-06-19 14:27:55 +01:00
Half-Shot
58f59b2d7f Update WidgetCapabilitiesPromptDialog to use SettingsToggleInput 2025-06-19 14:26:25 +01:00
Half-Shot
4a85b7c162 Update RoomUpgradeWarningDialog to use SettingsToggleInput 2025-06-19 14:26:11 +01:00
Half-Shot
6ff4ac87ac Refactor ReportRoomDialog to use SettingsToggle 2025-06-19 14:25:44 +01:00
Half-Shot
c72ebed739 Update DevtoolsDialog 2025-06-19 14:23:20 +01:00
Half-Shot
1ef5ba04e9 Refactor DeclineAndBlockInviteDialog to use SettingsToggleInput 2025-06-19 14:23:10 +01:00
Half-Shot
c59c724789 Refactor CreateRoomDialog to use SettingsToggleInput 2025-06-19 14:22:58 +01:00
Half-Shot
052cc07c13 Refactor SettingsFlag to use SettingsToggleInput 2025-06-19 14:22:46 +01:00
Half-Shot
0c40f53355 Remove LabelledToggleSwitch 2025-06-19 14:18:40 +01:00
Half-Shot
b4e09790b5 Refactor RoomPublishSetting 2025-06-19 11:35:40 +01:00
Half-Shot
e06e42116a Begin replacing settings 2025-06-17 15:44:31 +01:00
Half-Shot
7723c0f708 Merge remote-tracking branch 'origin/develop' into hs/remove-legacy-setting-toggle 2025-06-17 14:11:41 +01:00
Will Hunt
a3f1a8c649 Merge branch 'develop' into hs/remove-legacy-setting-toggle 2025-02-25 16:08:01 +00:00
Half-Shot
57e8e51821 update apperance screenshot 2025-02-25 16:07:37 +00:00
Half-Shot
b4c926f85c lint 2025-02-21 13:47:42 +00:00
Half-Shot
a413ae3f43 Update tests 2025-02-21 13:47:40 +00:00
Half-Shot
88e52601c8 Remove unused import. 2025-02-21 13:47:07 +00:00
Half-Shot
51cb4a5cfd Remove unused checkbox setting. 2025-02-21 13:47:07 +00:00
Half-Shot
ffedde2509 Update Apperance settings to use toggle switches. 2025-02-21 13:47:07 +00:00
Half-Shot
03da3b55b5 forgot a comma 2025-02-21 13:47:04 +00:00
Half-Shot
206304b5d7 Reformat other :not sections 2025-02-21 13:46:46 +00:00
Half-Shot
956d936235 fix tests 2025-02-21 13:46:32 +00:00
Half-Shot
ef4d9ea8b7 Add a test for setting an ID server. 2025-02-21 13:46:19 +00:00
Half-Shot
5aca14db74 Update test 2025-02-21 13:45:57 +00:00
Half-Shot
8a9eb35bf9 Use EditInPlace for identity server picker. 2025-02-21 13:45:13 +00:00
163 changed files with 6723 additions and 9383 deletions

View File

@@ -1,11 +1,6 @@
module.exports = { module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"], plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: [ extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: { parserOptions: {
project: ["./tsconfig.json"], project: ["./tsconfig.json"],
}, },

View File

@@ -1,51 +0,0 @@
# Triggers after the shared component tests have finished,
# It uploads the received images and diffs to netlify, printing the URLs to the console
name: Upload Shared Component Visual Test Diffs
on:
workflow_run:
workflows: ["Shared Component Visual Tests"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions: {}
jobs:
report:
if: github.event.workflow_run.conclusion == 'failure'
name: Upload Diffs
runs-on: ubuntu-24.04
environment: Netlify
permissions:
actions: read
deployments: write
steps:
- name: Install tree
run: "sudo apt-get install -y tree"
- name: Download Diffs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: received-images
path: received-images
- name: Generate Index
run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ."
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with:
path: received-images
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
desc: Shared Component Visual Diffs
deployment_env: SharedComponentDiffs
prefix: "diffs-"

View File

@@ -1,70 +0,0 @@
name: Shared Component Visual Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions: {} # No permissions required
jobs:
testStorybook:
name: "Run Visual Tests"
runs-on: ubuntu-24.04
permissions:
actions: read
issues: read
pull-requests: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
id: playwright
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "yarn playwright install --with-deps --only-shell"
- name: Build Element Web resources
# Needed to prepare language files
run: "yarn build:res"
- name: Build storybook dependencies
# When the first test is ran, it will fail because the dependencies are not yet built.
# This step is to ensure that the dependencies are built before running the tests.
run: "yarn test:storybook:ci"
continue-on-error: true
- name: Run Visual tests
run: "yarn test:storybook:ci"
- name: Upload received images & diffs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: received-images
path: playwright/shared-component-received

View File

@@ -15,14 +15,12 @@ jobs:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with: with:
operations-per-run: 100 operations-per-run: 100
# Flaky test issue closing # Flaky test issue closing
any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit" only-issue-labels: "Z-Flaky-Test"
days-before-issue-stale: 14 days-before-issue-stale: 14
days-before-issue-close: 0 days-before-issue-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved." close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled" exempt-issue-labels: "Z-Flaky-Test-Disabled"
# Stale PR closing # Stale PR closing
days-before-pr-stale: 180 days-before-pr-stale: 180
days-before-pr-close: 0 days-before-pr-close: 0

3
.gitignore vendored
View File

@@ -31,6 +31,3 @@ electron/pub
/index.html /index.html
# version file and tarball created by `npm pack` / `yarn pack` # version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt /git-revision.txt
*storybook.log
storybook-static

View File

@@ -1,28 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { create } from "storybook/theming";
export default create({
base: "light",
// Colors
textColor: "#1b1d22",
colorSecondary: "#111111",
// UI
appBg: "#ffffff",
appContentBg: "#ffffff",
// Toolbar
barBg: "#ffffff",
brandTitle: "Element Web",
brandUrl: "https://github.com/element-hq/element-web",
brandImage: "https://element.io/images/logo-ele-secondary.svg",
brandTarget: "_self",
});

View File

@@ -1,61 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Addon, types, useGlobals } from "storybook/manager-api";
import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
import React from "react";
import { GlobeIcon } from "@storybook/icons";
// We can't import `shared/i18n.tsx` directly here.
// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
import json from "../webapp/i18n/languages.json";
const languages = Object.keys(json).filter((lang) => lang !== "default");
/**
* Returns the title of a language in the user's locale.
*/
function languageTitle(language: string): string {
return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || language;
}
export const languageAddon: Addon = {
title: "Language Selector",
type: types.TOOL,
render: ({ active }) => {
const [globals, updateGlobals] = useGlobals();
const selectedLanguage = globals.language || "en";
return (
<WithTooltip
placement="top"
trigger="click"
closeOnOutsideClick
tooltip={({ onHide }) => {
return (
<TooltipLinkList
links={languages.map((language) => ({
id: language,
title: languageTitle(language),
active: selectedLanguage === language,
onClick: async () => {
// Update the global state with the selected language
updateGlobals({ language });
onHide();
},
}))}
/>
);
}}
>
<IconButton title="Language">
<GlobeIcon />
{languageTitle(selectedLanguage)}
</IconButton>
</WithTooltip>
);
},
};

View File

@@ -1,37 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { StorybookConfig } from "@storybook/react-vite";
import path from "node:path";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../webapp"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,
},
typescript: {
reactDocgen: "react-docgen-typescript",
},
async viteFinal(config) {
return mergeConfig(config, {
resolve: {
alias: {
// Alias used by i18n.tsx
$webapp: path.resolve("webapp"),
},
},
// Needed for counterpart to work
plugins: [nodePolyfills({ include: ["process", "util"] })],
});
},
};
export default config;

View File

@@ -1,18 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { addons } from "storybook/manager-api";
import ElementTheme from "./ElementTheme";
import { languageAddon } from "./languageAddon";
addons.setConfig({
theme: ElementTheme,
});
addons.register("elementhq/language", () => addons.add("language", languageAddon));

View File

@@ -1,10 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.docs-story {
background: var(--cpd-color-bg-canvas-default);
}

View File

@@ -1,90 +0,0 @@
import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
import { addons } from "storybook/preview-api";
import "../res/css/shared.pcss";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { FORCE_RE_RENDER } from "storybook/internal/core-events";
import { setLanguage } from "../src/shared-components/i18n";
export const globalTypes = {
theme: {
name: "Theme",
description: "Global theme for components",
toolbar: {
icon: "circlehollow",
title: "Theme",
items: [
{ title: "System", value: "system", icon: "browser" },
{ title: "Light", value: "light", icon: "sun" },
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
{ title: "Dark", value: "dark", icon: "moon" },
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
],
},
},
language: {
name: "Language",
description: "Global language for components",
},
initialGlobals: {
theme: "system",
language: "en",
},
} satisfies ArgTypes;
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
const ThemeSwitcher: React.FC<{
theme: string;
}> = ({ theme }) => {
useLayoutEffect(() => {
document.documentElement.classList.remove(...allThemesClasses);
if (theme !== "system") {
document.documentElement.classList.add(`cpd-theme-${theme}`);
}
return () => document.documentElement.classList.remove(...allThemesClasses);
}, [theme]);
return null;
};
const withThemeProvider: Decorator = (Story, context) => {
return (
<>
<ThemeSwitcher theme={context.globals.theme} />
<Story />
</>
);
};
const LanguageSwitcher: React.FC<{
language: string;
}> = ({ language }) => {
useLayoutEffect(() => {
const changeLanguage = async (language: string) => {
await setLanguage(language);
// Force the component to re-render to apply the new language
addons.getChannel().emit(FORCE_RE_RENDER);
};
changeLanguage(language);
}, [language]);
return null;
};
export const withLanguageProvider: Decorator = (Story, context) => {
return (
<>
<LanguageSwitcher language={context.globals.language} />
<Story />
</>
);
};
const preview: Preview = {
tags: ["autodocs"],
decorators: [withThemeProvider, withLanguageProvider],
};
export default preview;

View File

@@ -1,37 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { waitForPageReady } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
/**
* @type {import('@storybook/test-runner').TestRunnerConfig}
*/
const config = {
setup(page) {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
await waitForPageReady(page);
// If you want to take screenshot of multiple browsers, use
// page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: `${context.id}-${process.platform}`,
storeReceivedOnFailure: true,
customReceivedDir,
customDiffDir: customReceivedDir,
});
},
};
export default config;

View File

@@ -19,6 +19,3 @@ include:
* Thom Cleary (https://github.com/thomcatdotrocks) * Thom Cleary (https://github.com/thomcatdotrocks)
Small update for tarball deployment Small update for tarball deployment
* Alexander (https://github.com/ioalexander)
Save image on CTRL + S shortcut

View File

@@ -1,21 +1,3 @@
Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
====================================================================================================
## ✨ Features
* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot.
* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot.
* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave.
* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot.
* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown.
## 🐛 Bug Fixes
* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot.
* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii.
* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg.
* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy.
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01) Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
==================================================================================================== ====================================================================================================
## ✨ Features ## ✨ Features

8
declaration.d.ts vendored
View File

@@ -1,8 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
declare module "*.module.css";

View File

@@ -17,7 +17,7 @@ const config: Config = {
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg // This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
customExportConditions: ["browser", "node"], customExportConditions: ["browser", "node"],
}, },
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts", globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],

View File

@@ -1,6 +1,6 @@
{ {
"name": "element-web", "name": "element-web",
"version": "1.11.106", "version": "1.11.105",
"description": "Element: the future of secure communication", "description": "Element: the future of secure communication",
"author": "New Vector Ltd.", "author": "New Vector Ltd.",
"repository": { "repository": {
@@ -65,11 +65,7 @@
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
"postinstall": "patch-package", "postinstall": "patch-package"
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
}, },
"resolutions": { "resolutions": {
"**/pretty-format/react-is": "19.1.0", "**/pretty-format/react-is": "19.1.0",
@@ -97,7 +93,7 @@
"@types/png-chunks-extract": "^1.0.2", "@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30", "@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^5.0.0", "@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-web": "^8.1.2", "@vector-im/compound-web": "^8.2.0",
"@vector-im/matrix-wysiwyg": "2.38.4", "@vector-im/matrix-wysiwyg": "2.38.4",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
@@ -191,11 +187,6 @@
"@principalstudio/html-webpack-inject-preload": "^1.2.7", "@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18", "@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0", "@sentry/webpack-plugin": "^3.0.0",
"@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/icons": "^1.4.0",
"@storybook/react-vite": "^9.0.15",
"@storybook/test-runner": "^0.23.0",
"@stylistic/eslint-plugin": "^5.0.0", "@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0", "@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
@@ -255,7 +246,6 @@
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0", "eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0", "express": "^5.0.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
@@ -267,7 +257,6 @@
"jest": "^29.6.2", "jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2", "jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
@@ -296,7 +285,6 @@
"rimraf": "^6.0.0", "rimraf": "^6.0.0",
"semver": "^7.5.2", "semver": "^7.5.2",
"source-map-loader": "^5.0.0", "source-map-loader": "^5.0.0",
"storybook": "^9.0.12",
"stylelint": "^16.13.0", "stylelint": "^16.13.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
@@ -306,8 +294,6 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"util": "^0.12.5", "util": "^0.12.5",
"vite": "^7.0.1",
"vite-plugin-node-polyfills": "^0.24.0",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",

View File

@@ -1,46 +0,0 @@
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
index 498bb69..4e89216 100644
--- a/node_modules/@types/mdx/types.d.ts
+++ b/node_modules/@types/mdx/types.d.ts
@@ -5,7 +5,7 @@
*/
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
// defined or not.
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
/**
* This matches any function component types that ar part of `ElementType`.
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
/**
* A valid JSX string component.
*/
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
/**
* A JSX element returned by MDX content.
*/
-export type Element = JSX.Element;
+export type Element = React.JSX.Element;
/**
* A valid JSX function component.
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
*/
type ClassComponent<Props> = ElementType extends never
// If JSX.ElementType isnt defined, the valid return type is a constructor that returns JSX.ElementClass
- ? new(props: Props) => JSX.ElementClass
+ ? new(props: Props) => React.JSX.ElementClass
: ClassElementType extends never
// If JSX.ElementType is defined, but doesnt allow constructors, function components are disallowed.
? never
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
export type MDXComponents =
& NestedMDXComponents
& {
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
}
& {
/**

View File

@@ -1,34 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test("should allow us to create a public room with name, topic & address set", async ({ page, user, app }) => {
const name = "Test room 1";
const topic = "This room is dedicated to this test and this test only!";
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});

View File

@@ -168,7 +168,6 @@ test.describe("Cryptography", function () {
// Take a snapshot of RoomSummaryCard with a verified E2EE icon // Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png"); await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
}, },
); );

View File

@@ -48,38 +48,31 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
return promiseVerificationRequest; return promiseVerificationRequest;
} }
test( test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
"Verify device with SAS during login", await logIntoElement(page, credentials);
{ tag: "@screenshot" },
async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
// Launch the verification request between alice and the bot // Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page); const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification // Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog"); const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification // the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
request.startVerification("m.sas.v1"),
);
// Handle emoji request and check that emojis are matching // Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier); await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click(); await infoDialog.getByRole("button", { name: "They match" }).click();
await expect(page.locator(".mx_E2EIcon_verified")).toMatchScreenshot("device-verified-e2eIcon.png"); await infoDialog.getByRole("button", { name: "Got it" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// Check that our device is now cross-signed // Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app); await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup // Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky, // For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen. // as we need to wait for the secret gossiping to happen.
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
}, });
);
// Regression test for https://github.com/element-hq/element-web/issues/29110 // Regression test for https://github.com/element-hq/element-web/issues/29110
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {

View File

@@ -0,0 +1,27 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Devtools", () => {
test.use({
displayName: "Alice",
});
test("should render the devtools", { tag: "@screenshot" }, async ({ page, homeserver, user, app }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("/devtools");
await composer.press("Enter");
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Developer mode").check();
await expect(dialog).toMatchScreenshot("devtools-dialog.png");
});
});

View File

@@ -0,0 +1,31 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
test.describe("Room upgrade dialog", () => {
test.use({
displayName: "Alice",
});
test("should render the room upgrade dialog", { tag: "@screenshot" }, async ({ page, homeserver, user, app }) => {
// Enable developer mode
await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const composer = app.getComposer().locator("[contenteditable]");
// Pick a room version that is likely to be supported by all our target homeservers.
await composer.fill("/upgraderoom 5");
await composer.press("Enter");
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Automatically invite members from this room to the new one").check();
await expect(dialog).toMatchScreenshot("upgrade-room.png");
});
});

View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Decline and block invite dialog", function () {
test.use({
displayName: "Hanako",
});
test(
"should show decline and block dialog for a room",
{ tag: "@screenshot" },
async ({ page, app, user, bot }) => {
await bot.createRoom({ name: "Test Room", invite: [user.userId] });
await app.viewRoomByName("Test Room");
await page.getByRole("button", { name: "Decline and block" }).click();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("decline-and-block-invite-empty.png");
},
);
});

View File

@@ -49,7 +49,8 @@ test.describe("Room list", () => {
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); await page.mouse.wheel(0, 1000);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
}); });
@@ -119,8 +120,10 @@ test.describe("Room list", () => {
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
// Scroll to the end of the room list while (!(await roomItem.isVisible())) {
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); // Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
}
// The room decoration should have the muted icon // The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible(); await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -141,7 +144,7 @@ test.describe("Room list", () => {
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); await page.mouse.wheel(0, 1000);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -57,4 +57,20 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => {
await expect(page.locator(".mx_Marker")).toBeVisible(); await expect(page.locator(".mx_Marker")).toBeVisible();
}); });
test(
"is prompted for and can consent to live location sharing",
{ tag: "@screenshot" },
async ({ page, user, app }) => {
await app.viewRoomById(await app.client.createRoom({}));
const composerOptions = await app.openMessageComposerOptions();
await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click();
const menu = page.locator(".mx_LocationShareMenu");
await menu.getByRole("button", { name: "My live location" }).click();
await menu.getByLabel("Enable live location sharing").check();
await expect(menu).toMatchScreenshot("location-live-share-dialog.png");
},
);
}); });

View File

@@ -0,0 +1,64 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
const name = "Test room";
const topic = "A decently explanatory topic for a test room.";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test(
"should create a public room with name, topic & address set",
{ tag: "@screenshot" },
async ({ page, user, app }) => {
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Snapshot it
await expect(dialog).toMatchScreenshot("create-room.png");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
},
);
test("should create a video room", { tag: "@screenshot" }, async ({ page, user, app }) => {
await app.settings.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true);
const dialog = await app.openCreateRoomDialog("New video room");
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Snapshot it
await expect(dialog).toMatchScreenshot("create-video-room.png");
// Submit
await dialog.getByRole("button", { name: "Create video room" }).click();
await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`));
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});

View File

@@ -50,8 +50,8 @@ test.describe("Appearance user settings tab", () => {
// Click "Show advanced" link button // Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click(); await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.getByLabel("Use bundled emoji font").click(); await tab.getByRole("switch", { name: "Use bundled emoji font" }).click();
await tab.getByLabel("Use a system font").click(); await tab.getByRole("switch", { name: "Use a system font" }).click();
// Assert that the font-family value was removed // Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""'); await expect(page.locator("body")).toHaveCSS("font-family", '""');

View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
test.describe("Notifications 2 tab", () => {
test.use({
displayName: "Alice",
});
test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user }) => {
await app.settings.setValue("feature_notification_settings2", null, SettingLevel.DEVICE, true);
await page.setViewportSize({ width: 1024, height: 2000 });
const settings = await app.settings.openUserSettings("Notifications");
await expect(settings).toMatchScreenshot("standard-notifications-2-settings.png", {
// Mask the mxid.
mask: [settings.locator("#mx_NotificationSettings2_MentionCheckbox span")],
});
});
});

View File

@@ -0,0 +1,22 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../../element-web-test";
test.describe("Notifications tab", () => {
test.use({
displayName: "Alice",
});
test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const settings = await app.settings.openUserSettings("Notifications");
await settings.getByLabel("Enable notifications for this account").check();
await settings.getByLabel("Enable notifications for this session").check();
await expect(settings).toMatchScreenshot("standard-notification-settings.png");
});
});

View File

@@ -8,7 +8,7 @@
import { type Locator } from "@playwright/test"; import { type Locator } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../../element-web-test";
test.describe("Roles & Permissions room settings tab", () => { test.describe("Roles & Permissions room settings tab", () => {
const roomName = "Test room"; const roomName = "Test room";

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Roles & Permissions room settings tab", () => {
const roomName = "Test room";
test.use({
displayName: "Alice",
});
let settings: Locator;
test.beforeEach(async ({ user, app }) => {
await app.client.createRoom({ name: roomName });
await app.viewRoomByName(roomName);
settings = await app.settings.openRoomSettings("Security & Privacy");
});
test("should be able to toggle on encryption in a room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const encryptedToggle = settings.getByLabel("Encrypted");
await encryptedToggle.click();
// Accept the dialog.
await page.getByRole("button", { name: "Ok " }).click();
await expect(encryptedToggle).toBeChecked();
await expect(encryptedToggle).toBeDisabled();
await settings.getByLabel("Only send messages to verified users.").check();
await expect(settings).toMatchScreenshot("room-security-settings.png");
});
});

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
test.describe("Voice & Video room settings tab", () => {
const roomName = "Test room";
test.use({
displayName: "Alice",
});
let settings: Locator;
test.beforeEach(async ({ user, app, page }) => {
// Execute client actions before setting, as the setting will force a reload.
await app.client.createRoom({ name: roomName });
await app.settings.setValue("feature_group_calls", null, SettingLevel.DEVICE, true);
await app.viewRoomByName(roomName);
settings = await app.settings.openRoomSettings("Voice & Video");
});
test(
"should be able to toggle on Element Call in the room",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const callToggle = settings.getByLabel("Enable Element Call as an additional calling option in this room");
await callToggle.check();
await expect(settings).toMatchScreenshot("room-video-settings.png");
},
);
});

View File

@@ -41,6 +41,18 @@ test.describe("Security user settings tab", () => {
}); });
}); });
test("should render the security tab", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.setViewportSize({ width: 1024, height: 1400 });
const tab = await app.settings.openUserSettings("Security");
await expect(tab).toMatchScreenshot("security-settings-tab.png", {
mask: [
// Contains IM name.
tab.locator("#mx_SetIntegrationManager_BodyText"),
tab.locator("#mx_SetIntegrationManager_ManagerName"),
],
});
});
test("should be able to set an ID server", async ({ app, context, user, page }) => { test("should be able to set an ID server", async ({ app, context, user, page }) => {
const tab = await app.settings.openUserSettings("Security"); const tab = await app.settings.openUserSettings("Security");

View File

@@ -369,4 +369,16 @@ test.describe("Spaces", () => {
await app.viewSpaceByName("Root Space"); await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png"); await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
}); });
test("should render spaces visibility settings", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({
name: "My Space",
});
await app.viewSpaceByName("My space");
await page.getByLabel("Settings", { exact: true }).click();
await app.settings.switchTab("Visibility");
await expect(page.locator("#mx_tabpanel_SPACE_VISIBILITY_TAB")).toMatchScreenshot(
"space-visibility-settings.png",
);
});
}); });

View File

@@ -0,0 +1,95 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
const DEMO_WIDGET_ID = "demo-widget-id";
const DEMO_WIDGET_NAME = "Demo Widget";
const DEMO_WIDGET_TYPE = "demo";
const ROOM_NAME = "Demo";
const DEMO_WIDGET_HTML = `
<html lang="en">
<head>
<title>Demo Widget</title>
<script>
let sendEventCount = 0
window.onmessage = ev => {
if (ev.data.action === 'capabilities') {
window.parent.postMessage(Object.assign({
response: {
capabilities: [
"org.matrix.msc2762.timeline:*",
"org.matrix.msc2762.receive.state_event:m.room.topic",
"org.matrix.msc2762.send.event:net.widget_echo"
]
},
}, ev.data), '*');
}
};
</script>
</head>
</html>
`;
test.describe("Widger permissions dialog", () => {
test.use({
displayName: "Mike",
});
let demoWidgetUrl: string;
test.beforeEach(async ({ webserver }) => {
demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML);
});
test(
"should be updated if user is re-invited into the room with updated state event",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
// setup widget via state event
await app.client.sendStateEvent(
roomId,
"im.vector.modular.widgets",
{
id: DEMO_WIDGET_ID,
creatorUserId: "somebody",
type: DEMO_WIDGET_TYPE,
name: DEMO_WIDGET_NAME,
url: demoWidgetUrl,
},
DEMO_WIDGET_ID,
);
// set initial layout
await app.client.sendStateEvent(
roomId,
"io.element.widgets.layout",
{
widgets: {
[DEMO_WIDGET_ID]: {
container: "top",
index: 1,
width: 100,
height: 0,
},
},
},
"",
);
// open the room
await app.viewRoomByName(ROOM_NAME);
await expect(page.locator(".mx_WidgetCapabilitiesPromptDialog")).toMatchScreenshot(
"widget-capabilites-prompt.png",
);
},
);
});

View File

@@ -51,9 +51,9 @@ export class ElementAppPage {
/** /**
* Open room creation dialog. * Open room creation dialog.
*/ */
public async openCreateRoomDialog(): Promise<Locator> { public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
await this.page.getByRole("button", { name: "Add room", exact: true }).click(); await this.page.getByRole("button", { name: "Add room", exact: true }).click();
await this.page.getByRole("menuitem", { name: "New room", exact: true }).click(); await this.page.getByRole("menuitem", { name: roomKindname, exact: true }).click();
return this.page.locator(".mx_CreateRoomDialog"); return this.page.locator(".mx_CreateRoomDialog");
} }
@@ -213,26 +213,4 @@ export class ElementAppPage {
.getByRole("button", { name: "Dismiss" }) .getByRole("button", { name: "Dismiss" })
.click(); .click();
} }
/**
* Scroll an infinite list to the bottom.
* @param list The element to scroll
*/
public async scrollListToBottom(list: Locator): Promise<void> {
// First hover the mouse over the element that we want to scroll
await list.hover();
const needsScroll = async () => {
// From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const fullyScrolled = await list.evaluate(
(e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
);
return !fullyScrolled;
};
// Scroll the element until we detect that it is fully scrolled
do {
await this.page.mouse.wheel(0, 1000);
} while (await needsScroll());
}
} }

View File

@@ -43,7 +43,7 @@ export class Settings {
* @param {*} value The new value of the setting, may be null. * @param {*} value The new value of the setting, may be null.
* @return {Promise} Resolves when the setting has been changed. * @return {Promise} Resolves when the setting has been changed.
*/ */
public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise<void> { public async setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void> {
return this.page.evaluate< return this.page.evaluate<
Promise<void>, Promise<void>,
{ {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:b38e55f06543f83f5a13f1d843489eb7aeaf7370a5c17a51897b462eeca315f5"; const TAG = "develop@sha256:aea1d8f371268aed7a5863fa5dde960fb4f9f578cd0a5952cc4da92537f95cfa";
/** /**
* SynapseContainer which freezes the docker digest to stabilise tests, * SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -1,9 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css");

View File

@@ -28,6 +28,12 @@ Please see LICENSE files in the repository root for full details.
--collapsedWidth: 68px; --collapsedWidth: 68px;
} }
.mx_LeftPanel_newRoomList {
/* Thew new rooms list is not designed to be collapsed to just icons. */
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
--collapsedWidth: 224px;
}
.mx_LeftPanel_wrapper { .mx_LeftPanel_wrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -240,11 +246,3 @@ Please see LICENSE files in the repository root for full details.
} }
} }
} }
.mx_LeftPanel_newRoomList {
/* Thew new rooms list is not designed to be collapsed to just icons. */
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
--collapsedWidth: 224px;
/* Important to force the color on ED titlebar until we remove the old room list */
background-color: var(--cpd-color-bg-canvas-default) !important;
}

View File

@@ -52,10 +52,17 @@ Please see LICENSE files in the repository root for full details.
.mx_E2EIcon_normal::after { .mx_E2EIcon_normal::after {
mask-image: url("$(res)/img/e2e/normal.svg"); mask-image: url("$(res)/img/e2e/normal.svg");
background-color: white; background-color: var(--cpd-color-icon-tertiary);
} }
.mx_E2EIcon_verified::after { .mx_E2EIcon_verified::after {
mask-image: url("$(res)/img/e2e/verified.svg"); mask-image: url("$(res)/img/e2e/verified.svg");
background-color: $e2e-verified-color; background-color: $e2e-verified-color;
} }
// When using the "normal" icon as a background for verified or warning icon,
// it should be slightly smaller than the foreground icon
.mx_E2EIcon_verified, .mx_E2EIcon_warning .mx_E2EIcon_normal::after {
mask-size: 90%;
background-color: white;
}

View File

@@ -77,10 +77,6 @@ Please see LICENSE files in the repository root for full details.
} }
} }
.mx_SettingsTab_toggleWithDescription {
margin-top: $spacing-24;
}
.mx_SettingsTab_sections { .mx_SettingsTab_sections {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -8,6 +8,6 @@ Please see LICENSE files in the repository root for full details.
.mx_Field.mx_AppearanceUserSettingsTab_checkboxControlledField { .mx_Field.mx_AppearanceUserSettingsTab_checkboxControlledField {
width: 256px; width: 256px;
/* matches checkbox box + padding to align with checkbox label */ /* Line up with Settings field toggle button */
margin-inline-start: calc($font-16px + 10px); margin-inline-start: 0;
} }

View File

@@ -5,8 +5,7 @@ sonar.organization=element-hq
#sonar.sourceEncoding=UTF-8 #sonar.sourceEncoding=UTF-8
sonar.sources=src,res sonar.sources=src,res
sonar.tests=test,playwright,src sonar.tests=test,playwright
sonar.test.inclusions=test/*,playwright/*,src/**/*.test.tsx
sonar.exclusions=__mocks__,docs,element.io,nginx sonar.exclusions=__mocks__,docs,element.io,nginx
sonar.cpd.exclusions=src/i18n/strings/*.json sonar.cpd.exclusions=src/i18n/strings/*.json

View File

@@ -135,7 +135,6 @@ declare global {
initialise(): Promise<{ initialise(): Promise<{
protocol: string; protocol: string;
sessionId: string; sessionId: string;
supportsBadgeOverlay: boolean;
config: IConfigOptions; config: IConfigOptions;
supportedSettings: Record<string, boolean>; supportedSettings: Record<string, boolean>;
}>; }>;

View File

@@ -494,12 +494,15 @@ export default abstract class BasePlatform {
} }
private updateFavicon(): void { private updateFavicon(): void {
const notif: string | number = this.notificationCount; let bgColor = "#d00";
let notif: string | number = this.notificationCount;
if (this.errorDidOccur) { if (this.errorDidOccur) {
this.favicon.badge(notif || "×", { bgColor: "#f00" }); notif = notif || "×";
bgColor = "#f00";
} }
this.favicon.badge(notif);
this.favicon.badge(notif, { bgColor });
} }
/** /**

View File

@@ -15,7 +15,7 @@ import {
type SyncState, type SyncState,
ClientStoppedError, ClientStoppedError,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { secureRandomString } from "matrix-js-sdk/src/randomstring";
@@ -213,7 +213,6 @@ export default class DeviceListener {
}; };
private onKeyBackupStatusChanged = (): void => { private onKeyBackupStatusChanged = (): void => {
logger.info("Backup status changed");
this.cachedKeyBackupUploadActive = undefined; this.cachedKeyBackupUploadActive = undefined;
this.recheck(); this.recheck();
}; };
@@ -314,7 +313,6 @@ export default class DeviceListener {
private async doRecheck(): Promise<void> { private async doRecheck(): Promise<void> {
if (!this.running || !this.client) return; // we have been stopped if (!this.running || !this.client) return; // we have been stopped
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4)); const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
logSpan.debug("starting recheck...");
const cli = this.client; const cli = this.client;
@@ -357,7 +355,7 @@ export default class DeviceListener {
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
); );
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan); const keyBackupUploadActive = await this.isKeyBackupUploadActive();
const backupDisabled = await this.recheckBackupDisabled(cli); const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly // We warn if key backup upload is turned off and we have not explicitly
@@ -581,7 +579,7 @@ export default class DeviceListener {
* trigger an auto-rageshake). * trigger an auto-rageshake).
*/ */
private checkKeyBackupStatus = async (): Promise<void> => { private checkKeyBackupStatus = async (): Promise<void> => {
if (!(await this.isKeyBackupUploadActive(logger))) { if (!(await this.isKeyBackupUploadActive())) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
} }
}; };
@@ -589,7 +587,7 @@ export default class DeviceListener {
/** /**
* Is key backup enabled? Use a cached answer if we have one. * Is key backup enabled? Use a cached answer if we have one.
*/ */
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => { private isKeyBackupUploadActive = async (): Promise<boolean> => {
if (!this.client) { if (!this.client) {
// To preserve existing behaviour, if there is no client, we // To preserve existing behaviour, if there is no client, we
// pretend key backup upload is on. // pretend key backup upload is on.
@@ -613,7 +611,6 @@ export default class DeviceListener {
// Fetch the answer and cache it // Fetch the answer and cache it
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion(); const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion; this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
return this.cachedKeyBackupUploadActive; return this.cachedKeyBackupUploadActive;
}; };

View File

@@ -8,8 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
// Import i18n.tsx instead of languageHandler to avoid circular deps import { _td, type TranslationKey } from "../languageHandler";
import { _td, type TranslationKey } from "../shared-components/i18n";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard"; import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { type IBaseSetting } from "../settings/Settings"; import { type IBaseSetting } from "../settings/Settings";
import { type KeyCombo } from "../KeyBindingsManager"; import { type KeyCombo } from "../KeyBindingsManager";
@@ -146,7 +145,6 @@ export enum KeyBindingAction {
ArrowDown = "KeyBinding.arrowDown", ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab", Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma", Comma = "KeyBinding.comma",
Save = "KeyBinding.save",
/** Toggle visibility of hidden events */ /** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility", ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
@@ -270,7 +268,6 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.ArrowRight, KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown, KeyBindingAction.ArrowDown,
KeyBindingAction.Comma, KeyBindingAction.Comma,
KeyBindingAction.Save,
], ],
}, },
[CategoryName.NAVIGATION]: { [CategoryName.NAVIGATION]: {
@@ -623,13 +620,6 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
}, },
displayName: _td("keyboard|composer_redo"), displayName: _td("keyboard|composer_redo"),
}, },
[KeyBindingAction.Save]: {
default: {
key: Key.S,
ctrlOrCmdKey: true,
},
displayName: _td("keyboard|save"),
},
[KeyBindingAction.PreviousVisitedRoomOrSpace]: { [KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: { default: {
metaKey: IS_MAC, metaKey: IS_MAC,

View File

@@ -183,30 +183,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
/**
* Returns true if the current selection is entirely within a single "mx_MTextBody" element.
*/
private isSelectionWithinSingleTextBody(): boolean {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
function getParentByClass(node: Node | null, className: string): HTMLElement | null {
while (node) {
if (node instanceof HTMLElement && node.classList.contains(className)) {
return node;
}
node = node.parentNode;
}
return null;
}
const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody");
const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody");
return !!startTextBody && startTextBody === endTextBody;
}
private onResendReactionsClick = (): void => { private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) { for (const reaction of this.getUnsentReactions()) {
Resend.resend(MatrixClientPeg.safeGet(), reaction); Resend.resend(MatrixClientPeg.safeGet(), reaction);
@@ -303,24 +279,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu(); this.closeMenu();
}; };
private onQuoteClick = (): void => {
const selectedText = getSelectedText();
if (selectedText) {
// Format as markdown quote
const quotedText = selectedText
.trim()
.split(/\r?\n/)
.map((line) => `> ${line}`)
.join("\n");
dis.dispatch({
action: Action.ComposerInsert,
text: "\n" + quotedText + "\n\n ",
timelineRenderingType: this.context.timelineRenderingType,
});
}
this.closeMenu();
};
private onEditClick = (): void => { private onEditClick = (): void => {
editEvent( editEvent(
MatrixClientPeg.safeGet(), MatrixClientPeg.safeGet(),
@@ -591,10 +549,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
const selectedText = getSelectedText();
let copyButton: JSX.Element | undefined; let copyButton: JSX.Element | undefined;
if (rightClick && selectedText) { if (rightClick && getSelectedText()) {
copyButton = ( copyButton = (
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCopy" iconClassName="mx_MessageContextMenu_iconCopy"
@@ -605,18 +561,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
); );
} }
let quoteButton: JSX.Element | undefined;
if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
quoteButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconQuote"
label={_t("action|quote")}
triggerOnMouseDown={true}
onClick={this.onQuoteClick}
/>
);
}
let editButton: JSX.Element | undefined; let editButton: JSX.Element | undefined;
if (rightClick && canEditContent(cli, mxEvent)) { if (rightClick && canEditContent(cli, mxEvent)) {
editButton = ( editButton = (
@@ -686,11 +630,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
} }
let nativeItemsList: JSX.Element | undefined; let nativeItemsList: JSX.Element | undefined;
if (copyButton || quoteButton || copyLinkButton) { if (copyButton || copyLinkButton) {
nativeItemsList = ( nativeItemsList = (
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
{copyButton} {copyButton}
{quoteButton}
{copyLinkButton} {copyLinkButton}
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
); );

View File

@@ -7,8 +7,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ChangeEvent, createRef, type KeyboardEvent, type SyntheticEvent } from "react"; import React, {
type JSX,
type ChangeEvent,
createRef,
type KeyboardEvent,
type SyntheticEvent,
type ChangeEventHandler,
} from "react";
import { type Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix"; import { type Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation";
@@ -17,7 +25,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { checkUserIsAllowedToChangeEncryption, type IOpts } from "../../../createRoom"; import { checkUserIsAllowedToChangeEncryption, type IOpts } from "../../../createRoom";
import Field from "../elements/Field"; import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField"; import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown"; import JoinRuleDropdown from "../elements/JoinRuleDropdown";
@@ -25,7 +32,6 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { privateShouldBeEncrypted } from "../../../utils/rooms"; import { privateShouldBeEncrypted } from "../../../utils/rooms";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import LabelledCheckbox from "../elements/LabelledCheckbox";
interface IProps { interface IProps {
type?: RoomType; type?: RoomType;
@@ -219,8 +225,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.setState({ joinRule }); this.setState({ joinRule });
}; };
private onEncryptedChange = (isEncrypted: boolean): void => { private onEncryptedChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ isEncrypted }); this.setState({ isEncrypted: evt.target.checked });
}; };
private onAliasChange = (alias: string): void => { private onAliasChange = (alias: string): void => {
@@ -231,8 +237,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open }); this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
}; };
private onNoFederateChange = (noFederate: boolean): void => { private onNoFederateChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ noFederate }); this.setState({ noFederate: evt.target.checked });
}; };
private onNameValidate = async (fieldState: IFieldState): Promise<IValidationResult> => { private onNameValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
@@ -241,8 +247,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
return result; return result;
}; };
private onIsPublicKnockRoomChange = (isPublicKnockRoom: boolean): void => { private onIsPublicKnockRoomChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ isPublicKnockRoom }); this.setState({ isPublicKnockRoom: evt.target.checked });
}; };
private static validateRoomName = withValidation({ private static validateRoomName = withValidation({
@@ -329,11 +335,12 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
let visibilitySection: JSX.Element | undefined; let visibilitySection: JSX.Element | undefined;
if (this.state.joinRule === JoinRule.Knock) { if (this.state.joinRule === JoinRule.Knock) {
visibilitySection = ( visibilitySection = (
<LabelledCheckbox <SettingsToggleInput
name="publish-room"
className="mx_CreateRoomDialog_labelledCheckbox" className="mx_CreateRoomDialog_labelledCheckbox"
label={_t("room_settings|security|publish_room")} label={_t("room_settings|security|publish_room")}
onChange={this.onIsPublicKnockRoomChange} onChange={this.onIsPublicKnockRoomChange}
value={this.state.isPublicKnockRoom} checked={this.state.isPublicKnockRoom}
/> />
); );
} }
@@ -354,11 +361,11 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
} }
e2eeSection = ( e2eeSection = (
<React.Fragment> <React.Fragment>
<LabelledToggleSwitch <SettingsToggleInput
name="encryption-toggle"
label={_t("create_room|encryption_label")} label={_t("create_room|encryption_label")}
onChange={this.onEncryptedChange} onChange={this.onEncryptedChange}
value={this.state.isEncrypted} checked={this.state.isEncrypted}
className="mx_CreateRoomDialog_e2eSwitch" // for end-to-end tests
disabled={!this.state.canChangeEncryption} disabled={!this.state.canChangeEncryption}
/> />
<p>{microcopy}</p> <p>{microcopy}</p>
@@ -392,7 +399,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
title={title} title={title}
screenName="CreateRoom" screenName="CreateRoom"
> >
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}> <Form.Root onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<Field <Field
ref={this.nameField} ref={this.nameField}
@@ -431,17 +438,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
<summary className="mx_CreateRoomDialog_details_summary"> <summary className="mx_CreateRoomDialog_details_summary">
{this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")} {this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")}
</summary> </summary>
<LabelledToggleSwitch <SettingsToggleInput
name="unfederated"
label={_t("create_room|unfederated", { label={_t("create_room|unfederated", {
serverName: MatrixClientPeg.safeGet().getDomain(), serverName: MatrixClientPeg.safeGet().getDomain(),
})} })}
onChange={this.onNoFederateChange} onChange={this.onNoFederateChange}
value={this.state.noFederate} checked={this.state.noFederate}
helpMessage={federateLabel}
/> />
<p>{federateLabel}</p>
</details> </details>
</div> </div>
</form> </Form.Root>
<DialogButtons <DialogButtons
primaryButton={ primaryButton={
isVideoRoom ? _t("create_room|action_create_video_room") : _t("create_room|action_create_room") isVideoRoom ? _t("create_room|action_create_video_room") : _t("create_room|action_create_room")

View File

@@ -6,12 +6,11 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ChangeEventHandler, useCallback, useState } from "react"; import React, { type ChangeEventHandler, useCallback, useState } from "react";
import { Field, Label, Root } from "@vector-im/compound-web"; import { Field, Label, Root, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
interface IProps { interface IProps {
onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void; onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void;
@@ -22,6 +21,14 @@ export const DeclineAndBlockInviteDialog: React.FunctionComponent<IProps> = ({ o
const [shouldReport, setShouldReport] = useState<boolean>(false); const [shouldReport, setShouldReport] = useState<boolean>(false);
const [ignoreUser, setIgnoreUser] = useState<boolean>(false); const [ignoreUser, setIgnoreUser] = useState<boolean>(false);
const onShouldReportChanged = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => setShouldReport(e.target.checked),
[setShouldReport],
);
const onIgnoreUserChanged = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => setIgnoreUser(e.target.checked),
[setIgnoreUser],
);
const [reportReason, setReportReason] = useState<string>(""); const [reportReason, setReportReason] = useState<string>("");
const reportReasonChanged = useCallback<ChangeEventHandler<HTMLTextAreaElement>>( const reportReasonChanged = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(e) => setReportReason(e.target.value), (e) => setReportReason(e.target.value),
@@ -43,17 +50,19 @@ export const DeclineAndBlockInviteDialog: React.FunctionComponent<IProps> = ({ o
> >
<Root> <Root>
<p>{_t("decline_invitation_dialog|confirm", { roomName })}</p> <p>{_t("decline_invitation_dialog|confirm", { roomName })}</p>
<LabelledToggleSwitch <SettingsToggleInput
name="ignore-user"
label={_t("report_content|ignore_user")} label={_t("report_content|ignore_user")}
onChange={setIgnoreUser} onChange={onIgnoreUserChanged}
caption={_t("decline_invitation_dialog|ignore_user_help")} helpMessage={_t("decline_invitation_dialog|ignore_user_help")}
value={ignoreUser} checked={ignoreUser}
/> />
<LabelledToggleSwitch <SettingsToggleInput
name="report-room"
label={_t("action|report_room")} label={_t("action|report_room")}
onChange={setShouldReport} onChange={onShouldReportChanged}
caption={_t("decline_invitation_dialog|report_room_description")} helpMessage={_t("decline_invitation_dialog|report_room_description")}
value={shouldReport} checked={shouldReport}
/> />
<Field name="report-reason" aria-disabled={!shouldReport}> <Field name="report-reason" aria-disabled={!shouldReport}>
<Label htmlFor="mx_DeclineAndBlockInviteDialog_reason"> <Label htmlFor="mx_DeclineAndBlockInviteDialog_reason">

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, useState } from "react"; import React, { type JSX, useState } from "react";
import { Form } from "@vector-im/compound-web";
import { _t, _td, type TranslationKey } from "../../../languageHandler"; import { _t, _td, type TranslationKey } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
@@ -97,13 +98,18 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished })
})} })}
</div> </div>
))} ))}
<div> <Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<h3>{_t("common|options")}</h3> <h3>{_t("common|options")}</h3>
<SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} /> <SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} /> <SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} /> <SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsField settingKey="Developer.elementCallUrl" level={SettingLevel.DEVICE} /> <SettingsField settingKey="Developer.elementCallUrl" level={SettingLevel.DEVICE} />
</div> </Form.Root>
</BaseTool> </BaseTool>
); );
} }

View File

@@ -6,7 +6,15 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ChangeEventHandler, useCallback, useState } from "react"; import React, { type JSX, type ChangeEventHandler, useCallback, useState } from "react";
import { Root, Field, Label, InlineSpinner, ErrorMessage, HelpMessage } from "@vector-im/compound-web"; import {
Root,
Field,
Label,
InlineSpinner,
ErrorMessage,
HelpMessage,
SettingsToggleInput,
} from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
@@ -14,7 +22,6 @@ import Markdown from "../../../Markdown";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
interface IProps { interface IProps {
roomId: string; roomId: string;
@@ -33,6 +40,10 @@ export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished
const client = MatrixClientPeg.safeGet(); const client = MatrixClientPeg.safeGet();
const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []); const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []);
const onLeaveRoomChanged = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => setLeaveRoom(e.target.checked),
[setLeaveRoom],
);
const onCancel = useCallback(() => onFinished(false), [onFinished]); const onCancel = useCallback(() => onFinished(false), [onFinished]);
const onSubmit = useCallback(async () => { const onSubmit = useCallback(async () => {
setBusy(true); setBusy(true);
@@ -78,10 +89,11 @@ export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished
</Field> </Field>
{adminMessage} {adminMessage}
{busy ? <InlineSpinner /> : null} {busy ? <InlineSpinner /> : null}
<LabelledToggleSwitch <SettingsToggleInput
name="leave-room"
label={_t("room_list|more_options|leave_room")} label={_t("room_list|more_options|leave_room")}
value={leaveRoom} checked={leaveRoom}
onChange={setLeaveRoom} onChange={onLeaveRoomChanged}
/> />
<DialogButtons <DialogButtons
primaryButton={_t("action|send_report")} primaryButton={_t("action|send_report")}

View File

@@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ReactNode, type SyntheticEvent } from "react"; import React, { type ChangeEventHandler, type JSX, type ReactNode, type SyntheticEvent } from "react";
import { EventType, JoinRule } from "matrix-js-sdk/src/matrix"; import { EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import BugReportDialog from "./BugReportDialog"; import BugReportDialog from "./BugReportDialog";
@@ -87,8 +87,8 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
this.props.onFinished({ continue: false, invite: false }); this.props.onFinished({ continue: false, invite: false });
}; };
private onInviteUsersToggle = (inviteUsersToNewRoom: boolean): void => { private onInviteUsersToggle: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ inviteUsersToNewRoom }); this.setState({ inviteUsersToNewRoom: evt.target.checked });
}; };
private openBugReportDialog = (e: SyntheticEvent): void => { private openBugReportDialog = (e: SyntheticEvent): void => {
@@ -104,11 +104,19 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
let inviteToggle: JSX.Element | undefined; let inviteToggle: JSX.Element | undefined;
if (this.isInviteOrKnockRoom) { if (this.isInviteOrKnockRoom) {
inviteToggle = ( inviteToggle = (
<LabelledToggleSwitch <Form.Root
value={this.state.inviteUsersToNewRoom} onSubmit={(evt) => {
onChange={this.onInviteUsersToggle} evt.preventDefault();
label={_t("room_settings|advanced|upgrade_warning_dialog_invite_label")} evt.stopPropagation();
/> }}
>
<SettingsToggleInput
name="room-upgrade-warning"
checked={this.state.inviteUsersToNewRoom}
onChange={this.onInviteUsersToggle}
label={_t("room_settings|advanced|upgrade_warning_dialog_invite_label")}
/>
</Form.Root>
); );
} }

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ChangeEventHandler } from "react";
import { import {
type Capability, type Capability,
isTimelineCapability, isTimelineCapability,
@@ -15,13 +15,13 @@ import {
type WidgetKind, type WidgetKind,
} from "matrix-widget-api"; } from "matrix-widget-api";
import { lexicographicCompare } from "matrix-js-sdk/src/utils"; import { lexicographicCompare } from "matrix-js-sdk/src/utils";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { objectShallowClone } from "../../../utils/objects"; import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox"; import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText"; import { CapabilityText } from "../../../widgets/CapabilityText";
interface IProps { interface IProps {
@@ -64,8 +64,8 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
this.setState({ booleanStates: newStates }); this.setState({ booleanStates: newStates });
}; };
private onRememberSelectionChange = (newVal: boolean): void => { private onRememberSelectionChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ rememberSelection: newVal }); this.setState({ rememberSelection: evt.target.checked });
}; };
private onSubmit = async (): Promise<void> => { private onSubmit = async (): Promise<void> => {
@@ -116,7 +116,7 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t("widget|capabilities_dialog|title")} title={_t("widget|capabilities_dialog|title")}
> >
<form onSubmit={this.onSubmit}> <Form.Root onSubmit={this.onSubmit}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="text-muted">{_t("widget|capabilities_dialog|content_starting_text")}</div> <div className="text-muted">{_t("widget|capabilities_dialog|content_starting_text")}</div>
{checkboxRows} {checkboxRows}
@@ -126,16 +126,16 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
onPrimaryButtonClick={this.onSubmit} onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject} onCancel={this.onReject}
additive={ additive={
<LabelledToggleSwitch <SettingsToggleInput
value={this.state.rememberSelection} name="remember-selection"
toggleInFront={true} checked={this.state.rememberSelection}
onChange={this.onRememberSelectionChange} onChange={this.onRememberSelectionChange}
label={_t("widget|capabilities_dialog|remember_Selection")} label={_t("widget|capabilities_dialog|remember_Selection")}
/> />
} }
/> />
</div> </div>
</form> </Form.Root>
</BaseDialog> </BaseDialog>
); );
} }

View File

@@ -7,12 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ChangeEventHandler } from "react";
import { type Widget, type WidgetKind } from "matrix-widget-api"; import { type Widget, type WidgetKind } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore"; import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
@@ -61,8 +61,8 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<I
this.props.onFinished(allowed); this.props.onFinished(allowed);
} }
private onRememberSelectionChange = (newVal: boolean): void => { private onRememberSelectionChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ rememberSelection: newVal }); this.setState({ rememberSelection: evt.target.checked });
}; };
public render(): React.ReactNode { public render(): React.ReactNode {
@@ -85,12 +85,19 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<I
onPrimaryButtonClick={this.onAllow} onPrimaryButtonClick={this.onAllow}
onCancel={this.onDeny} onCancel={this.onDeny}
additive={ additive={
<LabelledToggleSwitch <Form.Root
value={this.state.rememberSelection} onSubmit={(evt) => {
toggleInFront={true} evt.preventDefault();
onChange={this.onRememberSelectionChange} evt.stopPropagation();
label={_t("widget|open_id_permissions_dialog|remember_selection")} }}
/> >
<SettingsToggleInput
name="remember-selection"
checked={this.state.rememberSelection}
onChange={this.onRememberSelectionChange}
label={_t("widget|open_id_permissions_dialog|remember_selection")}
/>
</Form.Root>
} }
/> />
</BaseDialog> </BaseDialog>

View File

@@ -19,7 +19,6 @@ import FilteredList from "./FilteredList";
import Spinner from "../../elements/Spinner"; import Spinner from "../../elements/Spinner";
import SyntaxHighlight from "../../elements/SyntaxHighlight"; import SyntaxHighlight from "../../elements/SyntaxHighlight";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => { export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const context = useContext(DevtoolsContext); const context = useContext(DevtoolsContext);
@@ -115,7 +114,6 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [event, setEvent] = useState<MatrixEvent | null>(null); const [event, setEvent] = useState<MatrixEvent | null>(null);
const [history, setHistory] = useState(false); const [history, setHistory] = useState(false);
const [hideEmptyState, setHideEmptyState] = useState(false);
const events = context.room.currentState.events.get(eventType)!; const events = context.room.currentState.events.get(eventType)!;
@@ -151,13 +149,10 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
return ( return (
<BaseTool onBack={onBack}> <BaseTool onBack={onBack}>
<FilteredList query={query} onChange={setQuery}> <FilteredList query={query} onChange={setQuery}>
{Array.from(events.entries()) {Array.from(events.entries()).map(([stateKey, ev]) => (
.filter(([_, ev]) => !hideEmptyState || Object.keys(ev.getContent()).length > 0) <StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
.map(([stateKey, ev]) => ( ))}
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
))}
</FilteredList> </FilteredList>
<LabelledToggleSwitch label={_t("devtools|hide_empty_content_events")} onChange={setHideEmptyState} value={hideEmptyState} />
</BaseTool> </BaseTool>
); );
}; };

View File

@@ -8,9 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, createRef, type CSSProperties, useEffect } from "react"; import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
import FocusLock from "react-focus-lock"; import FocusLock from "react-focus-lock";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
@@ -30,7 +31,11 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import { FileDownloader } from "../../../utils/FileDownloader";
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
import ModuleApi from "../../../modules/Api";
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@@ -118,8 +123,6 @@ export default class ImageView extends React.Component<IProps, IState> {
private imageWrapper = createRef<HTMLDivElement>(); private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>(); private image = createRef<HTMLImageElement>();
private downloadFunction?: () => Promise<void>;
private initX = 0; private initX = 0;
private initY = 0; private initY = 0;
private previousX = 0; private previousX = 0;
@@ -299,13 +302,6 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(); this.props.onFinished();
break; break;
case KeyBindingAction.Save:
ev.preventDefault();
ev.stopPropagation();
if (this.downloadFunction) {
this.downloadFunction();
}
break;
} }
}; };
@@ -331,10 +327,6 @@ export default class ImageView extends React.Component<IProps, IState> {
}); });
}; };
private onDownloadFunctionReady = (download: () => Promise<void>): void => {
this.downloadFunction = download;
};
private onPermalinkClicked = (ev: React.MouseEvent): void => { private onPermalinkClicked = (ev: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as // This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Element when clicked. // matrix.to, but also for it to enable routing within Element when clicked.
@@ -560,12 +552,7 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("lightbox|rotate_right")} title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick} onClick={this.onRotateClockwiseClick}
/> />
<DownloadButton <DownloadButton url={this.props.src} fileName={this.props.name} mxEvent={this.props.mxEvent} />
url={this.props.src}
fileName={this.props.name}
mxEvent={this.props.mxEvent}
onDownloadReady={this.onDownloadFunctionReady}
/>
{contextMenuButton} {contextMenuButton}
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close" className="mx_ImageView_button mx_ImageView_button_close"
@@ -598,28 +585,97 @@ export default class ImageView extends React.Component<IProps, IState> {
} }
} }
interface DownloadButtonProps { function DownloadButton({
url,
fileName,
mxEvent,
}: {
url: string; url: string;
fileName?: string; fileName?: string;
mxEvent?: MatrixEvent; mxEvent?: MatrixEvent;
onDownloadReady?: (download: () => Promise<void>) => void; }): JSX.Element | null {
} const downloader = useRef(new FileDownloader()).current;
const [loading, setLoading] = useState(false);
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => { const [canDownload, setCanDownload] = useState<boolean>(false);
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent); const blobRef = useRef<Blob>(undefined);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
useEffect(() => { useEffect(() => {
if (onDownloadReady) onDownloadReady(download); if (!mxEvent) {
}, [download, onDownloadReady]); // If we have no event, we assume this is safe to download.
setCanDownload(true);
return;
}
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
if (hints?.allowDownloadingMedia) {
// Disable downloading as soon as we know there is a hint.
setCanDownload(false);
hints
.allowDownloadingMedia()
.then((downloadable) => {
setCanDownload(downloadable);
})
.catch((ex) => {
logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex);
// Err on the side of safety.
setCanDownload(false);
});
}
}, [mxEvent]);
if (!canDownload) return null; function showError(e: unknown): void {
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: (
<>
<div>{_t("timeline|download_failed_description")}</div>
<div>{e instanceof Error ? e.toString() : ""}</div>
</>
),
});
setLoading(false);
}
const onDownloadClick = async (): Promise<void> => {
try {
if (loading) return;
setLoading(true);
if (blobRef.current) {
// Cheat and trigger a download, again.
return downloadBlob(blobRef.current);
}
const res = await fetch(url);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
const blob = await res.blob();
blobRef.current = blob;
await downloadBlob(blob);
} catch (e) {
showError(e);
}
};
async function downloadBlob(blob: Blob): Promise<void> {
await downloader.download({
blob,
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
});
setLoading(false);
}
if (!canDownload) {
return null;
}
return ( return (
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")} title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={download} onClick={onDownloadClick}
disabled={loading} disabled={loading}
/> />
); );
}; }

View File

@@ -24,11 +24,13 @@ interface IProps {
onChange(checked: boolean): void; onChange(checked: boolean): void;
// Optional additional CSS class to apply to the label // Optional additional CSS class to apply to the label
className?: string; className?: string;
// The id for the checkbox
id?: string;
} }
const LabelledCheckbox: React.FC<IProps> = ({ value, label, byline, disabled, onChange, className }) => { const LabelledCheckbox: React.FC<IProps> = ({ value, label, byline, disabled, onChange, className, id }) => {
return ( return (
<div className={classnames("mx_LabelledCheckbox", className)}> <div id={id} className={classnames("mx_LabelledCheckbox", className)}>
<StyledCheckbox <StyledCheckbox
description={byline} description={byline}
disabled={disabled} disabled={disabled}

View File

@@ -1,83 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type FC, useId } from "react";
import classNames from "classnames";
import ToggleSwitch from "./ToggleSwitch";
import { Caption } from "../typography/Caption";
interface IProps {
// The value for the toggle switch
"value": boolean;
// The translated label for the switch
"label": string;
// The translated caption for the switch
"caption"?: string;
// Tooltip to display
"tooltip"?: string;
// Whether or not to disable the toggle switch
"disabled"?: boolean;
// True to put the toggle in front of the label
// Default false.
"toggleInFront"?: boolean;
// Additional class names to append to the switch. Optional.
"className"?: string;
// The function to call when the value changes
onChange(checked: boolean): void;
"data-testid"?: string;
}
const LabelledToggleSwitch: FC<IProps> = ({
label,
caption,
value,
disabled,
onChange,
tooltip,
toggleInFront,
className,
"data-testid": testId,
}) => {
// This is a minimal version of a SettingsFlag
const generatedId = useId();
const id = `mx_LabelledToggleSwitch_${generatedId}`;
let firstPart = (
<span className="mx_SettingsFlag_label">
<div id={id}>{label}</div>
{caption && <Caption id={`${id}_caption`}>{caption}</Caption>}
</span>
);
let secondPart = (
<ToggleSwitch
checked={value}
disabled={disabled}
onChange={onChange}
tooltip={tooltip}
aria-labelledby={id}
aria-describedby={caption ? `${id}_caption` : undefined}
/>
);
if (toggleInFront) {
[firstPart, secondPart] = [secondPart, firstPart];
}
const classes = classNames("mx_SettingsFlag", className, {
mx_SettingsFlag_toggleInFront: toggleInFront,
});
return (
<div data-testid={testId} className={classes}>
{firstPart}
{secondPart}
</div>
);
};
export default LabelledToggleSwitch;

View File

@@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ChangeEvent } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { SettingsToggleInput } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import ToggleSwitch from "./ToggleSwitch";
import StyledCheckbox from "./StyledCheckbox";
import { type SettingLevel } from "../../../settings/SettingLevel"; import { type SettingLevel } from "../../../settings/SettingLevel";
import { type BooleanSettingKey, defaultWatchManager } from "../../../settings/Settings"; import { type BooleanSettingKey, defaultWatchManager } from "../../../settings/Settings";
@@ -24,8 +24,6 @@ interface IProps {
roomId?: string; // for per-room settings roomId?: string; // for per-room settings
label?: string; label?: string;
isExplicit?: boolean; isExplicit?: boolean;
// XXX: once design replaces all toggles make this the default
useCheckbox?: boolean;
hideIfCannotSet?: boolean; hideIfCannotSet?: boolean;
onChange?(checked: boolean): void; onChange?(checked: boolean): void;
} }
@@ -74,14 +72,16 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
}); });
}; };
private onChange = async (checked: boolean): Promise<void> => { private onChange = async (evt: ChangeEvent<HTMLInputElement>): Promise<void> => {
await this.save(checked); const newValue = evt.target.checked;
this.setState({ value: checked }); try {
this.props.onChange?.(checked); await this.save(newValue);
}; } catch (ex) {
logger.info(`Failed to save setting ${this.props.name}`, ex);
private checkBoxOnChange = (e: React.ChangeEvent<HTMLInputElement>): void => { return;
this.onChange(e.target.checked); }
this.setState({ value: newValue });
this.props.onChange?.(newValue);
}; };
private save = async (val?: boolean): Promise<void> => { private save = async (val?: boolean): Promise<void> => {
@@ -101,45 +101,37 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
const label = this.props.label ?? SettingsStore.getDisplayName(this.props.name, this.props.level); const label = this.props.label ?? SettingsStore.getDisplayName(this.props.name, this.props.level);
const description = SettingsStore.getDescription(this.props.name); const description = SettingsStore.getDescription(this.props.name);
const shouldWarn = SettingsStore.shouldHaveWarning(this.props.name); const shouldWarn = SettingsStore.shouldHaveWarning(this.props.name);
const helpMessage = shouldWarn
if (this.props.useCheckbox) { ? _t(
return ( "settings|warning",
<StyledCheckbox checked={this.state.value} onChange={this.checkBoxOnChange} disabled={disabled}> {},
{label} {
</StyledCheckbox> w: (sub) => <span className="mx_SettingsTab_microcopy_warning">{sub}</span>,
); description,
} else { },
return ( )
<div className="mx_SettingsFlag"> : description;
<label className="mx_SettingsFlag_label" htmlFor={this.id}> if (this.props.name === "sendReadReceipts") {
<span className="mx_SettingsFlag_labelText">{label}</span> console.log(
{description && ( "Fully disabled",
<div className="mx_SettingsFlag_microcopy"> this.props.name,
{shouldWarn SettingsStore.disabledMessage(this.props.name),
? _t( description,
"settings|warning", helpMessage,
{},
{
w: (sub) => (
<span className="mx_SettingsTab_microcopy_warning">{sub}</span>
),
description,
},
)
: description}
</div>
)}
</label>
<ToggleSwitch
id={this.id}
checked={this.state.value}
onChange={this.onChange}
disabled={disabled}
tooltip={disabled ? SettingsStore.disabledMessage(this.props.name) : undefined}
title={label ?? undefined}
/>
</div>
); );
} }
const disabledMessage = SettingsStore.disabledMessage(this.props.name);
return (
<SettingsToggleInput
id={this.id}
checked={this.state.value}
onChange={disabledMessage ? undefined : this.onChange}
name={this.props.name}
disabled={disabled}
label={label ?? this.props.name}
helpMessage={helpMessage as string}
disabledMessage={disabledMessage}
/>
);
} }
} }

View File

@@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { useState } from "react"; import React, { type ChangeEventHandler, type FormEventHandler, useCallback, useState } from "react";
import { SettingsToggleInput, Form, Button } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon"; import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon";
import AccessibleButton from "../elements/AccessibleButton";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import Heading from "../typography/Heading"; import Heading from "../typography/Heading";
interface Props { interface Props {
@@ -20,6 +19,25 @@ interface Props {
export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => { export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
const [isEnabled, setEnabled] = useState(false); const [isEnabled, setEnabled] = useState(false);
const onEnabledChanged = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => setEnabled(e.target.checked),
[setEnabled],
);
const onSubmitForm = useCallback<FormEventHandler>(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
if (isEnabled) {
onSubmit();
}
},
[isEnabled, onSubmit],
);
console.log("EnableLiveShare", isEnabled);
return ( return (
<div data-testid="location-picker-enable-live-share" className="mx_EnableLiveShare"> <div data-testid="location-picker-enable-live-share" className="mx_EnableLiveShare">
<StyledLiveBeaconIcon className="mx_EnableLiveShare_icon" /> <StyledLiveBeaconIcon className="mx_EnableLiveShare_icon" />
@@ -27,22 +45,17 @@ export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
{_t("location_sharing|live_enable_heading")} {_t("location_sharing|live_enable_heading")}
</Heading> </Heading>
<p className="mx_EnableLiveShare_description">{_t("location_sharing|live_enable_description")}</p> <p className="mx_EnableLiveShare_description">{_t("location_sharing|live_enable_description")}</p>
<LabelledToggleSwitch <Form.Root onSubmit={onSubmitForm}>
data-testid="enable-live-share-toggle" <SettingsToggleInput
value={isEnabled} name="enable-live-share-toggle"
onChange={setEnabled} checked={isEnabled}
label={_t("location_sharing|live_toggle_label")} onChange={onEnabledChanged}
/> label={_t("location_sharing|live_toggle_label")}
<AccessibleButton />
data-testid="enable-live-share-submit" <Button className="mx_EnableLiveShare_button" kind="primary" disabled={!isEnabled}>
className="mx_EnableLiveShare_button" {_t("action|ok")}
element="button" </Button>
kind="primary" </Form.Root>
onClick={onSubmit}
disabled={!isEnabled}
>
{_t("action|ok")}
</AccessibleButton>
</div> </div>
); );
}; };

View File

@@ -7,15 +7,19 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React, { type ReactElement, useMemo } from "react"; import React, { type JSX } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/src/logger";
import { type MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { _t } from "../../../languageHandler"; import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia"; import { FileDownloader } from "../../../utils/FileDownloader";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import ModuleApi from "../../../modules/Api";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@@ -26,32 +30,121 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined; mediaEventHelperGet: () => MediaEventHelper | undefined;
} }
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null { interface IState {
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]); canDownload: null | boolean;
const downloadUrl = mediaEventHelper?.media.srcHttp ?? ""; loading: boolean;
const fileName = mediaEventHelper?.fileName; blob?: Blob;
tooltip: TranslationKey;
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent); }
if (!canDownload) return null; export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private downloader = new FileDownloader();
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
const classes = classNames({ public constructor(props: IProps) {
mx_MessageActionBar_iconButton: true, super(props);
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner, const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
}); const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
if (moduleHints?.allowDownloadingMedia) {
return ( downloadState.canDownload = null;
<RovingAccessibleButton moduleHints
className={classes} .allowDownloadingMedia()
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")} .then((canDownload) => {
onClick={download} this.setState({
disabled={loading} canDownload: canDownload,
placement="left" });
> })
<DownloadIcon /> .catch((ex) => {
{spinner} logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
</RovingAccessibleButton> this.setState({
); canDownload: false,
});
});
}
this.state = {
loading: false,
tooltip: _td("timeline|download_action_downloading"),
...downloadState,
};
}
private onDownloadClick = async (): Promise<void> => {
try {
await this.doDownload();
} catch (e) {
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: (
<>
<div>{_t("timeline|download_failed_description")}</div>
<div>{e instanceof Error ? e.toString() : ""}</div>
</>
),
});
this.setState({ loading: false });
}
};
private async doDownload(): Promise<void> {
const mediaEventHelper = this.props.mediaEventHelperGet();
if (this.state.loading || !mediaEventHelper) return;
if (mediaEventHelper.media.isEncrypted) {
this.setState({ tooltip: _td("timeline|download_action_decrypting") });
}
this.setState({ loading: true });
if (this.state.blob) {
// Cheat and trigger a download, again.
return this.downloadBlob(this.state.blob);
}
const blob = await mediaEventHelper.sourceBlob.value;
this.setState({ blob });
await this.downloadBlob(blob);
}
private async downloadBlob(blob: Blob): Promise<void> {
await this.downloader.download({
blob,
name: this.props.mediaEventHelperGet()!.fileName,
});
this.setState({ loading: false });
}
public render(): React.ReactNode {
let spinner: JSX.Element | undefined;
if (this.state.loading) {
spinner = <Spinner w={18} h={18} />;
}
if (this.state.canDownload === null) {
spinner = <Spinner w={18} h={18} />;
}
if (this.state.canDownload === false) {
return null;
}
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});
return (
<RovingAccessibleButton
className={classes}
title={spinner ? _t(this.state.tooltip) : _t("action|download")}
onClick={this.onDownloadClick}
disabled={!!spinner}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}
} }

View File

@@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import RoomContext from "../../../contexts/RoomContext";
import * as TextForEvent from "../../../TextForEvent";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
mxEvent: MatrixEvent;
}
export default class TextualEvent extends React.Component<IProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public componentDidMount(): void {
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
}
public componentWillUnmount(): void {
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
}
private onEventSentinelUpdated = (): void => {
// XXX: this is crap, but we don't have a better way to force a re-render
// Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated
this.forceUpdate();
};
public render(): React.ReactNode {
const text = TextForEvent.textForEvent(
this.props.mxEvent,
MatrixClientPeg.safeGet(),
true,
this.context?.showHiddenEvents,
);
if (!text) return null;
return <div className="mx_TextualEvent">{text}</div>;
}
}

View File

@@ -6,10 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ChangeEventHandler } from "react";
import { JoinRule, Visibility } from "matrix-js-sdk/src/matrix"; import { JoinRule, Visibility } from "matrix-js-sdk/src/matrix";
import { SettingsToggleInput } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import DirectoryCustomisations from "../../../customisations/Directory"; import DirectoryCustomisations from "../../../customisations/Directory";
@@ -24,6 +25,7 @@ interface IProps {
interface IState { interface IState {
isRoomPublished: boolean; isRoomPublished: boolean;
busy: boolean;
} }
export default class RoomPublishSetting extends React.PureComponent<IProps, IState> { export default class RoomPublishSetting extends React.PureComponent<IProps, IState> {
@@ -32,6 +34,7 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
this.state = { this.state = {
isRoomPublished: false, isRoomPublished: false,
busy: false,
}; };
} }
@@ -42,19 +45,23 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
}); });
} }
private onRoomPublishChange = (): void => { private onRoomPublishChange: ChangeEventHandler<HTMLInputElement> = async (evt): Promise<void> => {
const valueBefore = this.state.isRoomPublished; const newValue = evt.target.checked;
const newValue = !valueBefore; this.setState({ busy: true });
this.setState({ isRoomPublished: newValue });
const client = MatrixClientPeg.safeGet(); const client = MatrixClientPeg.safeGet();
client try {
.setRoomDirectoryVisibility(this.props.roomId, newValue ? Visibility.Public : Visibility.Private) await client.setRoomDirectoryVisibility(
.catch(() => { this.props.roomId,
this.showError(); newValue ? Visibility.Public : Visibility.Private,
// Roll back the local echo on the change );
this.setState({ isRoomPublished: valueBefore }); this.setState({ isRoomPublished: newValue });
}); } catch (ex) {
logger.error("Error while setting room directory visibility", ex);
this.showError();
} finally {
this.setState({ busy: false });
}
}; };
public componentDidMount(): void { public componentDidMount(): void {
@@ -69,17 +76,26 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const isRoomPublishable = room && room.getJoinRule() !== JoinRule.Invite; const isRoomPublishable = room && room.getJoinRule() !== JoinRule.Invite;
const canSetCanonicalAlias =
DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
this.props.canSetCanonicalAlias;
const enabled = let disabledMessage;
(DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false || if (!isRoomPublishable) {
this.props.canSetCanonicalAlias) && disabledMessage = _t("room_settings|general|publish_warn_invite_only");
(isRoomPublishable || this.state.isRoomPublished); } else if (!canSetCanonicalAlias) {
disabledMessage = _t("room_settings|general|publish_warn_no_canonical_permission");
}
const enabled = canSetCanonicalAlias && (isRoomPublishable || this.state.isRoomPublished);
return ( return (
<LabelledToggleSwitch <SettingsToggleInput
value={this.state.isRoomPublished} name="room-publish"
checked={this.state.isRoomPublished}
onChange={this.onRoomPublishChange} onChange={this.onRoomPublishChange}
disabled={!enabled} disabled={!enabled || this.state.busy}
disabledMessage={disabledMessage}
label={_t("room_settings|general|publish_toggle", { label={_t("room_settings|general|publish_toggle", {
domain: client.getDomain(), domain: client.getDomain(),
})} })}

View File

@@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton"; import { type ButtonEvent } from "../elements/AccessibleButton";
import { copyPlaintext } from "../../../utils/strings"; import { copyPlaintext, getSelectedText } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import RedactedBody from "../messages/RedactedBody"; import RedactedBody from "../messages/RedactedBody";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -840,8 +840,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Electron layer (webcontents-handler.ts) // Electron layer (webcontents-handler.ts)
if (clickTarget instanceof HTMLImageElement) return; if (clickTarget instanceof HTMLImageElement) return;
// Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu // Return if we're in a browser and click either an a tag or we have
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return; // selected text, as in those cases we want to use the native browser
// menu
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
// We don't want to show the menu when editing a message // We don't want to show the menu when editing a message
if (this.props.editState) return; if (this.props.editState) return;
@@ -1235,19 +1237,22 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}> <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()} {this.renderContextMenu()}
{replyChain} {replyChain}
{renderTile(TimelineRenderingType.Thread, { {renderTile(
...this.props, TimelineRenderingType.Thread,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator!, permalinkCreator: this.props.permalinkCreator!,
showHiddenEvents: this.context.showHiddenEvents, },
})} this.context.showHiddenEvents,
)}
{actionBar} {actionBar}
<a href={permalink} onClick={this.onPermalinkClicked}> <a href={permalink} onClick={this.onPermalinkClicked}>
{timestamp} {timestamp}
@@ -1378,19 +1383,22 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
</a>, </a>,
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}> <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()} {this.renderContextMenu()}
{renderTile(TimelineRenderingType.File, { {renderTile(
...this.props, TimelineRenderingType.File,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
showHiddenEvents: this.context.showHiddenEvents, },
})} this.context.showHiddenEvents,
)}
</div>, </div>,
], ],
); );
@@ -1425,20 +1433,23 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{groupTimestamp} {groupTimestamp}
{groupPadlock} {groupPadlock}
{replyChain} {replyChain}
{renderTile(this.context.timelineRenderingType, { {renderTile(
...this.props, this.context.timelineRenderingType,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
timestamp: bubbleTimestamp, timestamp: bubbleTimestamp,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
showHiddenEvents: this.context.showHiddenEvents, },
})} this.context.showHiddenEvents,
)}
{actionBar} {actionBar}
{this.props.layout === Layout.IRC && ( {this.props.layout === Layout.IRC && (
<> <>

View File

@@ -95,7 +95,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
className="mx_MemberListView_container" className="mx_MemberListView_container"
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
> >
<Form.Root onSubmit={(e) => e.preventDefault()}> <Form.Root>
<MemberListHeaderView vm={vm} /> <MemberListHeaderView vm={vm} />
</Form.Root> </Form.Root>
<AutoSizer> <AutoSizer>

View File

@@ -163,7 +163,6 @@ export default class ReplyTile extends React.PureComponent<IProps> {
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
showHiddenEvents: false,
}, },
false /* showHiddenEvents shouldn't be relevant */, false /* showHiddenEvents shouldn't be relevant */,
)} )}

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ReactNode } from "react"; import React, { type ChangeEvent, type ChangeEventHandler, type JSX, type ReactNode } from "react";
import { import {
type IAnnotatedPushRule, type IAnnotatedPushRule,
type IPusher, type IPusher,
@@ -19,6 +19,7 @@ import {
type EmptyObject, type EmptyObject,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -31,7 +32,6 @@ import {
type VectorPushRuleDefinition, type VectorPushRuleDefinition,
} from "../../../notifications"; } from "../../../notifications";
import { _t, type TranslatedString } from "../../../languageHandler"; import { _t, type TranslatedString } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton"; import StyledRadioButton from "../elements/StyledRadioButton";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
@@ -122,9 +122,6 @@ interface IState {
threepids?: IThreepid[]; threepids?: IThreepid[];
deviceNotificationsEnabled: boolean; deviceNotificationsEnabled: boolean;
desktopNotifications: boolean;
desktopShowBody: boolean;
audioNotifications: boolean;
clearingNotifications: boolean; clearingNotifications: boolean;
@@ -194,10 +191,15 @@ const maximumVectorState = (
const NotificationActivitySettings = (): JSX.Element => { const NotificationActivitySettings = (): JSX.Element => {
return ( return (
<div> <Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} /> <SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} /> <SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</div> </Form.Root>
); );
}; };
@@ -213,9 +215,6 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
this.state = { this.state = {
phase: Phase.Loading, phase: Phase.Loading,
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true, deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true,
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
clearingNotifications: false, clearingNotifications: false,
ruleIdsWithError: {}, ruleIdsWithError: {},
}; };
@@ -231,18 +230,9 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
public componentDidMount(): void { public componentDidMount(): void {
this.settingWatchers = [ this.settingWatchers = [
SettingsStore.watchSetting("notificationsEnabled", null, (...[, , , , value]) =>
this.setState({ desktopNotifications: value as boolean }),
),
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => { SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => {
this.setState({ deviceNotificationsEnabled: value as boolean }); this.setState({ deviceNotificationsEnabled: value as boolean });
}), }),
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[, , , , value]) =>
this.setState({ desktopShowBody: value as boolean }),
),
SettingsStore.watchSetting("audioNotificationsEnabled", null, (...[, , , , value]) =>
this.setState({ audioNotifications: value as boolean }),
),
]; ];
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
@@ -286,7 +276,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId)); const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
if (settingsEvent) { if (settingsEvent) {
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced; const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
await this.updateDeviceNotifications(notificationsEnabled); await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, notificationsEnabled);
} }
} }
@@ -410,7 +400,8 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
}); });
} }
private onMasterRuleChanged = async (checked: boolean): Promise<void> => { private onMasterRuleChanged: ChangeEventHandler<HTMLInputElement> = async (evt): Promise<void> => {
const { checked } = evt.target;
this.setState({ phase: Phase.Persisting }); this.setState({ phase: Phase.Persisting });
const masterRule = this.state.masterPushRule!; const masterRule = this.state.masterPushRule!;
@@ -431,11 +422,8 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
})); }));
}; };
private updateDeviceNotifications = async (checked: boolean): Promise<void> => { private onEmailNotificationsChanged = async (email: string, evt: ChangeEvent<HTMLInputElement>): Promise<void> => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked); const { checked } = evt.target;
};
private onEmailNotificationsChanged = async (email: string, checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting }); this.setState({ phase: Phase.Persisting });
try { try {
@@ -470,18 +458,6 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
} }
}; };
private onDesktopNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onDesktopShowBodyChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
};
private onAudioNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise<void> => { private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise<void> => {
this.setState(({ ruleIdsWithError }) => ({ this.setState(({ ruleIdsWithError }) => ({
phase: Phase.Persisting, phase: Phase.Persisting,
@@ -663,11 +639,11 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
private renderTopSection(): JSX.Element { private renderTopSection(): JSX.Element {
const masterSwitch = ( const masterSwitch = (
<LabelledToggleSwitch <SettingsToggleInput
data-testid="notif-master-switch" checked={!this.isInhibited}
value={!this.isInhibited} name="notif-master-switch"
label={_t("settings|notifications|enable_notifications_account")} label={_t("settings|notifications|enable_notifications_account")}
caption={_t("settings|notifications|enable_notifications_account_detail")} helpMessage={_t("settings|notifications|enable_notifications_account_detail")}
onChange={this.onMasterRuleChanged} onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting} disabled={this.state.phase === Phase.Persisting}
/> />
@@ -681,10 +657,10 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
const emailSwitches = (this.state.threepids || []) const emailSwitches = (this.state.threepids || [])
.filter((t) => t.medium === ThreepidMedium.Email) .filter((t) => t.medium === ThreepidMedium.Email)
.map((e) => ( .map((e) => (
<LabelledToggleSwitch <SettingsToggleInput
data-testid="notif-email-switch" name="notif-email-switch"
key={e.address} key={e.address}
value={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)} checked={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)}
label={_t("settings|notifications|enable_email_notifications", { email: e.address })} label={_t("settings|notifications|enable_email_notifications", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)} onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting} disabled={this.state.phase === Phase.Persisting}
@@ -695,37 +671,13 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
<SettingsSubsection> <SettingsSubsection>
{masterSwitch} {masterSwitch}
<LabelledToggleSwitch <SettingsFlag name="deviceNotificationsEnabled" level={SettingLevel.DEVICE} />
data-testid="notif-device-switch"
value={this.state.deviceNotificationsEnabled}
label={_t("settings|notifications|enable_notifications_device")}
onChange={(checked) => this.updateDeviceNotifications(checked)}
disabled={this.state.phase === Phase.Persisting}
/>
{this.state.deviceNotificationsEnabled && ( {this.state.deviceNotificationsEnabled && (
<> <>
<LabelledToggleSwitch <SettingsFlag name="notificationsEnabled" level={SettingLevel.DEVICE} />
data-testid="notif-setting-notificationsEnabled" <SettingsFlag name="notificationBodyEnabled" level={SettingLevel.DEVICE} />
value={this.state.desktopNotifications} <SettingsFlag name="audioNotificationsEnabled" level={SettingLevel.DEVICE} />
onChange={this.onDesktopNotificationsChanged}
label={_t("settings|notifications|enable_desktop_notifications_session")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-notificationBodyEnabled"
value={this.state.desktopShowBody}
onChange={this.onDesktopShowBodyChanged}
label={_t("settings|notifications|show_message_desktop_notification")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-audioNotificationsEnabled"
value={this.state.audioNotifications}
onChange={this.onAudioNotificationsChanged}
label={_t("settings|notifications|enable_audible_notifications_session")}
disabled={this.state.phase === Phase.Persisting}
/>
</> </>
)} )}
@@ -868,7 +820,12 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
} }
return ( return (
<> <Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
{this.renderTopSection()} {this.renderTopSection()}
{this.renderCategory(RuleClass.VectorGlobal)} {this.renderCategory(RuleClass.VectorGlobal)}
{this.renderCategory(RuleClass.VectorMentions)} {this.renderCategory(RuleClass.VectorMentions)}
@@ -876,7 +833,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
{this.renderTargets()} {this.renderTargets()}
<NotificationActivitySettings /> <NotificationActivitySettings />
{clearNotifsButton} {clearNotifsButton}
</> </Form.Root>
); );
} }
} }

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react"; import React from "react";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Root, InlineField, Label, ToggleInput } from "@vector-im/compound-web"; import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
@@ -66,33 +66,31 @@ export default class SetIntegrationManager extends React.Component<EmptyObject,
if (!SettingsStore.getValue(UIFeature.Widgets)) return null; if (!SettingsStore.getValue(UIFeature.Widgets)) return null;
return ( return (
<div className="mx_SetIntegrationManager" data-testid="mx_SetIntegrationManager"> <Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
className="mx_SetIntegrationManager"
data-testid="mx_SetIntegrationManager"
>
<div className="mx_SettingsFlag"> <div className="mx_SettingsFlag">
<div className="mx_SetIntegrationManager_heading_manager"> <div className="mx_SetIntegrationManager_heading_manager">
<Heading size="3">{_t("integration_manager|manage_title")}</Heading> <Heading size="3">{_t("integration_manager|manage_title")}</Heading>
<Heading size="4">{managerName}</Heading> <Heading id="mx_SetIntegrationManager_ManagerName" size="4">
{managerName}
</Heading>
</div> </div>
</div> </div>
<SettingsSubsectionText>{bodyText}</SettingsSubsectionText> <SettingsSubsectionText id="mx_SetIntegrationManager_BodyText">{bodyText}</SettingsSubsectionText>
<SettingsSubsectionText>{_t("integration_manager|explainer")}</SettingsSubsectionText> <SettingsSubsectionText>{_t("integration_manager|explainer")}</SettingsSubsectionText>
<Root> <SettingsToggleInput
<InlineField name="enable_im"
name="enable_im" label={_t("integration_manager|toggle_label")}
control={ checked={this.state.provisioningEnabled}
<ToggleInput onChange={this.onProvisioningToggled}
role="switch" />
id="mx_SetIntegrationManager_Toggle" </Form.Root>
checked={this.state.provisioningEnabled}
onChange={this.onProvisioningToggled}
/>
}
>
<Label htmlFor="mx_SetIntegrationManager_Toggle">
{_t("integration_manager|toggle_label")}
</Label>
</InlineField>
</Root>
</div>
); );
} }
} }

View File

@@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, useState } from "react"; import React, { type JSX, useState } from "react";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import NewAndImprovedIcon from "../../../../../res/img/element-icons/new-and-improved.svg"; import NewAndImprovedIcon from "../../../../../res/img/element-icons/new-and-improved.svg";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useNotificationSettings } from "../../../../hooks/useNotificationSettings"; import { useNotificationSettings } from "../../../../hooks/useNotificationSettings";
import { useSettingValue } from "../../../../hooks/useSettings";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { import {
DefaultNotificationSettings, DefaultNotificationSettings,
@@ -19,13 +19,11 @@ import {
} from "../../../../models/notificationsettings/NotificationSettings"; } from "../../../../models/notificationsettings/NotificationSettings";
import { RoomNotifState } from "../../../../RoomNotifs"; import { RoomNotifState } from "../../../../RoomNotifs";
import { SettingLevel } from "../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { clearAllNotifications } from "../../../../utils/notifications"; import { clearAllNotifications } from "../../../../utils/notifications";
import AccessibleButton from "../../elements/AccessibleButton"; import AccessibleButton from "../../elements/AccessibleButton";
import ExternalLink from "../../elements/ExternalLink"; import ExternalLink from "../../elements/ExternalLink";
import LabelledCheckbox from "../../elements/LabelledCheckbox"; import LabelledCheckbox from "../../elements/LabelledCheckbox";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import StyledRadioGroup from "../../elements/StyledRadioGroup"; import StyledRadioGroup from "../../elements/StyledRadioGroup";
import TagComposer from "../../elements/TagComposer"; import TagComposer from "../../elements/TagComposer";
import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge"; import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge";
@@ -70,10 +68,6 @@ function useHasUnreadNotifications(): boolean {
export default function NotificationSettings2(): JSX.Element { export default function NotificationSettings2(): JSX.Element {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const desktopNotifications = useSettingValue("notificationsEnabled");
const desktopShowBody = useSettingValue("notificationBodyEnabled");
const audioNotifications = useSettingValue("audioNotificationsEnabled");
const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli); const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli);
const disabled = model === null || hasPendingChanges; const disabled = model === null || hasPendingChanges;
@@ -116,267 +110,258 @@ export default function NotificationSettings2(): JSX.Element {
</SettingsBanner> </SettingsBanner>
)} )}
<SettingsSection> <SettingsSection>
<div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags"> <Form.Root
<LabelledToggleSwitch onSubmit={(evt) => {
label={_t("settings|notifications|enable_notifications_account")} evt.preventDefault();
value={!settings.globalMute} evt.stopPropagation();
disabled={disabled} }}
onChange={(value) => {
reconcile({
...model!,
globalMute: !value,
});
}}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|enable_desktop_notifications_session")}
value={desktopNotifications}
onChange={(value) =>
SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|desktop_notification_message_preview")}
value={desktopShowBody}
onChange={(value) =>
SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|enable_audible_notifications_session")}
value={audioNotifications}
onChange={(value) =>
SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
</div>
<SettingsSubsection
heading={_t("settings|notifications|default_setting_section")}
description={_t("settings|notifications|default_setting_description")}
> >
<StyledRadioGroup <div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags">
name="defaultNotificationLevel" <SettingsToggleInput
value={toDefaultLevels(settings.defaultLevels)} name="enable_notifications_account"
disabled={disabled} label={_t("settings|notifications|enable_notifications_account")}
definitions={NotificationOptions} checked={!settings.globalMute}
onChange={(value) => { disabled={disabled}
reconcile({ onChange={(evt) => {
...model!, reconcile({
defaultLevels: { ...model!,
...model!.defaultLevels, globalMute: !evt.target.checked,
dm: });
value !== NotificationDefaultLevels.MentionsKeywords }}
? RoomNotifState.AllMessages />
: RoomNotifState.MentionsOnly, <SettingsFlag name="notificationsEnabled" level={SettingLevel.DEVICE} />
room: <SettingsFlag name="notificationBodyEnabled" level={SettingLevel.DEVICE} />
value === NotificationDefaultLevels.AllMessages <SettingsFlag name="audioNotificationsEnabled" level={SettingLevel.DEVICE} />
? RoomNotifState.AllMessages </div>
: RoomNotifState.MentionsOnly, <SettingsSubsection
}, heading={_t("settings|notifications|default_setting_section")}
}); description={_t("settings|notifications|default_setting_description")}
}} >
/> <StyledRadioGroup
</SettingsSubsection> name="defaultNotificationLevel"
<SettingsSubsection value={toDefaultLevels(settings.defaultLevels)}
heading={_t("settings|notifications|play_sound_for_section")} disabled={disabled}
description={_t("settings|notifications|play_sound_for_description")} definitions={NotificationOptions}
> onChange={(value) => {
<LabelledCheckbox reconcile({
label={_t("common|people")} ...model!,
value={settings.sound.people !== undefined} defaultLevels: {
disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly} ...model!.defaultLevels,
onChange={(value) => { dm:
reconcile({ value !== NotificationDefaultLevels.MentionsKeywords
...model!, ? RoomNotifState.AllMessages
sound: { : RoomNotifState.MentionsOnly,
...model!.sound, room:
people: value ? "default" : undefined, value === NotificationDefaultLevels.AllMessages
}, ? RoomNotifState.AllMessages
}); : RoomNotifState.MentionsOnly,
}} },
/> });
<LabelledCheckbox }}
label={_t("settings|notifications|mentions_keywords")} />
value={settings.sound.mentions !== undefined} </SettingsSubsection>
disabled={disabled} <SettingsSubsection
onChange={(value) => { heading={_t("settings|notifications|play_sound_for_section")}
reconcile({ description={_t("settings|notifications|play_sound_for_description")}
...model!, >
sound: { <LabelledCheckbox
...model!.sound, label={_t("common|people")}
mentions: value ? "default" : undefined, value={settings.sound.people !== undefined}
}, disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly}
}); onChange={(value) => {
}} reconcile({
/> ...model!,
<LabelledCheckbox sound: {
label={_t("settings|notifications|voip")} ...model!.sound,
value={settings.sound.calls !== undefined} people: value ? "default" : undefined,
disabled={disabled} },
onChange={(value) => { });
reconcile({ }}
...model!, />
sound: { <LabelledCheckbox
...model!.sound, label={_t("settings|notifications|mentions_keywords")}
calls: value ? "ring" : undefined, value={settings.sound.mentions !== undefined}
}, disabled={disabled}
}); onChange={(value) => {
}} reconcile({
/> ...model!,
</SettingsSubsection> sound: {
<SettingsSubsection heading={_t("settings|notifications|other_section")}> ...model!.sound,
<LabelledCheckbox mentions: value ? "default" : undefined,
label={_t("settings|notifications|invites")} },
value={settings.activity.invite} });
disabled={disabled} }}
onChange={(value) => { />
reconcile({ <LabelledCheckbox
...model!, label={_t("settings|notifications|voip")}
activity: { value={settings.sound.calls !== undefined}
...model!.activity, disabled={disabled}
invite: value, onChange={(value) => {
}, reconcile({
}); ...model!,
}} sound: {
/> ...model!.sound,
<LabelledCheckbox calls: value ? "ring" : undefined,
label={_t("settings|notifications|room_activity")} },
value={settings.activity.status_event} });
disabled={disabled} }}
onChange={(value) => { />
reconcile({ </SettingsSubsection>
...model!, <SettingsSubsection heading={_t("settings|notifications|other_section")}>
activity: { <LabelledCheckbox
...model!.activity, label={_t("settings|notifications|invites")}
status_event: value, value={settings.activity.invite}
}, disabled={disabled}
}); onChange={(value) => {
}} reconcile({
/> ...model!,
<LabelledCheckbox activity: {
label={_t("settings|notifications|notices")} ...model!.activity,
value={settings.activity.bot_notices} invite: value,
disabled={disabled} },
onChange={(value) => { });
reconcile({ }}
...model!, />
activity: { <LabelledCheckbox
...model!.activity, label={_t("settings|notifications|room_activity")}
bot_notices: value, value={settings.activity.status_event}
}, disabled={disabled}
}); onChange={(value) => {
}} reconcile({
/> ...model!,
</SettingsSubsection> activity: {
<SettingsSubsection ...model!.activity,
heading={_t("settings|notifications|mentions_keywords")} status_event: value,
description={_t( },
"settings|notifications|keywords", });
{}, }}
{ />
badge: ( <LabelledCheckbox
<StatelessNotificationBadge label={_t("settings|notifications|notices")}
symbol="1" value={settings.activity.bot_notices}
count={1} disabled={disabled}
level={NotificationLevel.Notification} onChange={(value) => {
/> reconcile({
), ...model!,
}, activity: {
)} ...model!.activity,
> bot_notices: value,
<LabelledCheckbox },
label={_t("settings|notifications|notify_at_room")} });
value={settings.mentions.room} }}
disabled={disabled} />
onChange={(value) => { </SettingsSubsection>
reconcile({ <SettingsSubsection
...model!, heading={_t("settings|notifications|mentions_keywords")}
mentions: { description={_t(
...model!.mentions, "settings|notifications|keywords",
room: value, {},
}, {
}); badge: (
}} <StatelessNotificationBadge
/> symbol="1"
<LabelledCheckbox count={1}
label={_t("settings|notifications|notify_mention", { level={NotificationLevel.Notification}
mxid: cli.getUserId()!, />
})} ),
value={settings.mentions.user} },
disabled={disabled} )}
onChange={(value) => { >
reconcile({ <LabelledCheckbox
...model!, label={_t("settings|notifications|notify_at_room")}
mentions: { value={settings.mentions.room}
...model!.mentions, disabled={disabled}
user: value, onChange={(value) => {
}, reconcile({
}); ...model!,
}} mentions: {
/> ...model!.mentions,
<LabelledCheckbox room: value,
label={_t("settings|notifications|notify_keyword")} },
byline={_t("settings|notifications|keywords_prompt")} });
value={settings.mentions.keywords} }}
disabled={disabled} />
onChange={(value) => { <LabelledCheckbox
reconcile({ label={_t("settings|notifications|notify_mention", {
...model!, mxid: cli.getUserId()!,
mentions: { })}
...model!.mentions, id="mx_NotificationSettings2_MentionCheckbox"
keywords: value, value={settings.mentions.user}
}, disabled={disabled}
}); onChange={(value) => {
}} reconcile({
/> ...model!,
<TagComposer mentions: {
id="mx_NotificationSettings2_Keywords" ...model!.mentions,
tags={model?.keywords ?? []} user: value,
disabled={disabled} },
onAdd={(keyword) => { });
reconcile({ }}
...model!, />
keywords: [keyword, ...model!.keywords], <LabelledCheckbox
}); label={_t("settings|notifications|notify_keyword")}
}} byline={_t("settings|notifications|keywords_prompt")}
onRemove={(keyword) => { value={settings.mentions.keywords}
reconcile({ disabled={disabled}
...model!, onChange={(value) => {
keywords: model!.keywords.filter((it) => it !== keyword), reconcile({
}); ...model!,
}} mentions: {
label={_t("notifications|keyword")} ...model!.mentions,
placeholder={_t("notifications|keyword_new")} keywords: value,
/> },
});
}}
/>
<TagComposer
id="mx_NotificationSettings2_Keywords"
tags={model?.keywords ?? []}
disabled={disabled}
onAdd={(keyword) => {
reconcile({
...model!,
keywords: [keyword, ...model!.keywords],
});
}}
onRemove={(keyword) => {
reconcile({
...model!,
keywords: model!.keywords.filter((it) => it !== keyword),
});
}}
label={_t("notifications|keyword")}
placeholder={_t("notifications|keyword_new")}
/>
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} /> <SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} /> <SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</SettingsSubsection> </SettingsSubsection>
<NotificationPusherSettings /> <NotificationPusherSettings />
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}> <SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
{hasUnreadNotifications && ( {hasUnreadNotifications && (
<AccessibleButton
kind="primary_outline"
disabled={updatingUnread}
onClick={async () => {
setUpdatingUnread(true);
await clearAllNotifications(cli);
setUpdatingUnread(false);
}}
>
{_t("settings|notifications|quick_actions_mark_all_read")}
</AccessibleButton>
)}
<AccessibleButton <AccessibleButton
kind="primary_outline" kind="danger_outline"
disabled={updatingUnread} disabled={model === null}
onClick={async () => { onClick={() => {
setUpdatingUnread(true); reconcile(DefaultNotificationSettings);
await clearAllNotifications(cli);
setUpdatingUnread(false);
}} }}
> >
{_t("settings|notifications|quick_actions_mark_all_read")} {_t("settings|notifications|quick_actions_reset")}
</AccessibleButton> </AccessibleButton>
)} </SettingsSubsection>
<AccessibleButton </Form.Root>
kind="danger_outline"
disabled={model === null}
onClick={() => {
reconcile(DefaultNotificationSettings);
}}
>
{_t("settings|notifications|quick_actions_reset")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection> </SettingsSection>
</div> </div>
); );

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import classNames from "classnames"; import classNames from "classnames";
import React, { type HTMLAttributes } from "react"; import React, { type HTMLAttributes } from "react";
import { Separator } from "@vector-im/compound-web"; import { Form, Separator } from "@vector-im/compound-web";
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading"; import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
@@ -23,6 +23,11 @@ export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement>
* @default true * @default true
*/ */
legacy?: boolean; legacy?: boolean;
/**
* Wrap in a Form Root component, for compatibility with compound components.
*/
formWrap?: boolean;
} }
export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => ( export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => (
@@ -37,31 +42,48 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
children, children,
stretchContent, stretchContent,
legacy = true, legacy = true,
formWrap,
...rest ...rest
}) => ( }) => {
<div const content = (
{...rest} <div
className={classNames("mx_SettingsSubsection", { {...rest}
mx_SettingsSubsection_newUi: !legacy, className={classNames("mx_SettingsSubsection", {
})} mx_SettingsSubsection_newUi: !legacy,
> })}
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>} >
{!!description && ( {typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>}
<div className="mx_SettingsSubsection_description"> {!!description && (
<SettingsSubsectionText>{description}</SettingsSubsectionText> <div className="mx_SettingsSubsection_description">
</div> <SettingsSubsectionText>{description}</SettingsSubsectionText>
)} </div>
{!!children && ( )}
<div {!!children && (
className={classNames("mx_SettingsSubsection_content", { <div
mx_SettingsSubsection_contentStretch: !!stretchContent, className={classNames("mx_SettingsSubsection_content", {
mx_SettingsSubsection_noHeading: !heading && !description, mx_SettingsSubsection_contentStretch: !!stretchContent,
mx_SettingsSubsection_content_newUi: !legacy, mx_SettingsSubsection_noHeading: !heading && !description,
})} mx_SettingsSubsection_content_newUi: !legacy,
})}
>
{children}
</div>
)}
{!legacy && <Separator />}
</div>
);
if (formWrap) {
return (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
> >
{children} {content}
</div> </Form.Root>
)} );
{!legacy && <Separator />} }
</div> return content;
); };

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ContextType } from "react"; import React, { type ContextType } from "react";
import { type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import RoomProfileSettings from "../../../room_settings/RoomProfileSettings"; import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
@@ -78,26 +79,33 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
return ( return (
<SettingsTab data-testid="General"> <SettingsTab data-testid="General">
<SettingsSection heading={_t("common|general")}> <Form.Root
<RoomProfileSettings roomId={room.roomId} /> onSubmit={(evt) => {
</SettingsSection> evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsSection heading={_t("common|general")}>
<RoomProfileSettings roomId={room.roomId} />
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|aliases_section")}> <SettingsSection heading={_t("room_settings|general|aliases_section")}>
<AliasSettings <AliasSettings
roomId={room.roomId} roomId={room.roomId}
canSetCanonicalAlias={canSetCanonical} canSetCanonicalAlias={canSetCanonical}
canSetAliases={canSetAliases} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} canonicalAliasEvent={canonicalAliasEv}
/> />
</SettingsSection> </SettingsSection>
<SettingsSection heading={_t("room_settings|general|other_section")}> <SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings} {urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}> <SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} /> <MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection> </SettingsSubsection>
{leaveSection} {leaveSection}
</SettingsSection> </SettingsSection>
</Form.Root>
</SettingsTab> </SettingsTab>
); );
} }

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ReactNode } from "react"; import React, { type ChangeEventHandler, type JSX, type ReactNode } from "react";
import { import {
GuestAccess, GuestAccess,
HistoryVisibility, HistoryVisibility,
@@ -17,11 +17,10 @@ import {
EventType, EventType,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { InlineSpinner } from "@vector-im/compound-web"; import { Form, InlineSpinner, SettingsToggleInput } from "@vector-im/compound-web";
import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg"; import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog"; import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup from "../../../elements/StyledRadioGroup"; import StyledRadioGroup from "../../../elements/StyledRadioGroup";
@@ -184,7 +183,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
}); });
}; };
private onGuestAccessChange = (allowed: boolean): void => { private onGuestAccessChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
const allowed = evt.target.checked;
const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden; const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
const beforeGuestAccess = this.state.guestAccess; const beforeGuestAccess = this.state.guestAccess;
if (beforeGuestAccess === guestAccess) return; if (beforeGuestAccess === guestAccess) return;
@@ -405,13 +405,14 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
return ( return (
<div className="mx_SecurityRoomSettingsTab_advancedSection"> <div className="mx_SecurityRoomSettingsTab_advancedSection">
<LabelledToggleSwitch <SettingsToggleInput
value={guestAccess === GuestAccess.CanJoin} name="guest-access"
checked={guestAccess === GuestAccess.CanJoin}
onChange={this.onGuestAccessChange} onChange={this.onGuestAccessChange}
disabled={!canSetGuestAccess} disabled={!canSetGuestAccess}
label={_t("room_settings|visibility|guest_access_label")} label={_t("room_settings|visibility|guest_access_label")}
helpMessage={_t("room_settings|security|guest_access_warning")}
/> />
<p>{_t("room_settings|security|guest_access_warning")}</p>
</div> </div>
); );
} }
@@ -445,33 +446,41 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
return ( return (
<SettingsTab> <SettingsTab>
<SettingsSection heading={_t("room_settings|security|title")}> <SettingsSection heading={_t("room_settings|security|title")}>
<SettingsFieldset <Form.Root
legend={_t("settings|security|encryption_section")} onSubmit={(evt) => {
description={ evt.preventDefault();
isEncryptionForceDisabled && !isEncrypted evt.stopPropagation();
? undefined }}
: _t("room_settings|security|encryption_permanent")
}
> >
{isEncryptionLoading ? ( <SettingsFieldset
<InlineSpinner /> legend={_t("settings|security|encryption_section")}
) : ( description={
<> isEncryptionForceDisabled && !isEncrypted
<LabelledToggleSwitch ? undefined
value={isEncrypted} : _t("room_settings|security|encryption_permanent")
onChange={this.onEncryptionChange} }
label={_t("common|encrypted")} >
disabled={!canEnableEncryption} {isEncryptionLoading ? (
/> <InlineSpinner />
{isEncryptionForceDisabled && !isEncrypted && ( ) : (
<Caption>{_t("room_settings|security|encryption_forced")}</Caption> <>
)} <SettingsToggleInput
{encryptionSettings} name="enable-encryption"
</> checked={isEncrypted}
)} onChange={this.onEncryptionChange}
</SettingsFieldset> label={_t("common|encrypted")}
{this.renderJoinRule()} disabled={!canEnableEncryption}
{historySection} />
{isEncryptionForceDisabled && !isEncrypted && (
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
)}
{encryptionSettings}
</>
)}
</SettingsFieldset>
{this.renderJoinRule()}
{historySection}
</Form.Root>
</SettingsSection> </SettingsSection>
</SettingsTab> </SettingsTab>
); );

View File

@@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { useCallback, useMemo, useState } from "react"; import React, { type ChangeEventHandler, useCallback, useMemo, useState } from "react";
import { JoinRule, EventType, type RoomState, type Room } from "matrix-js-sdk/src/matrix"; import { JoinRule, EventType, type RoomState, type Room } from "matrix-js-sdk/src/matrix";
import { type RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types"; import { type RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { SettingsSubsection } from "../../shared/SettingsSubsection"; import { SettingsSubsection } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab"; import SettingsTab from "../SettingsTab";
import { ElementCall } from "../../../../../models/Call"; import { ElementCall } from "../../../../../models/Call";
@@ -45,8 +45,9 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0; return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
}); });
const onChange = useCallback( const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(enabled: boolean): void => { (evt): void => {
const enabled = evt.target.checked;
setElementCallEnabled(enabled); setElementCallEnabled(enabled);
// Take a copy to avoid mutating the original // Take a copy to avoid mutating the original
@@ -73,16 +74,17 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
return ( return (
<LabelledToggleSwitch <SettingsToggleInput
data-testid="element-call-switch" name="element-call-switch"
data-test-id="element-call-switch"
label={_t("room_settings|voip|enable_element_call_label", { brand })} label={_t("room_settings|voip|enable_element_call_label", { brand })}
caption={_t("room_settings|voip|enable_element_call_caption", { helpMessage={_t("room_settings|voip|enable_element_call_caption", {
brand, brand,
})} })}
value={elementCallEnabled} checked={elementCallEnabled}
onChange={onChange} onChange={onChange}
disabled={!maySend} disabled={!maySend}
tooltip={_t("room_settings|voip|enable_element_call_no_permissions_tooltip")} disabledMessage={_t("room_settings|voip|enable_element_call_no_permissions_tooltip")}
/> />
); );
}; };
@@ -95,9 +97,16 @@ export const VoipRoomSettingsTab: React.FC<Props> = ({ room }) => {
return ( return (
<SettingsTab> <SettingsTab>
<SettingsSection heading={_t("settings|voip|title")}> <SettingsSection heading={_t("settings|voip|title")}>
<SettingsSubsection heading={_t("room_settings|voip|call_type_section")}> <Form.Root
<ElementCallSwitch room={room} /> onSubmit={(evt) => {
</SettingsSubsection> evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsSubsection heading={_t("room_settings|voip|call_type_section")}>
<ElementCallSwitch room={room} />
</SettingsSubsection>
</Form.Root>
</SettingsSection> </SettingsSection>
</SettingsTab> </SettingsTab>
); );

View File

@@ -9,9 +9,9 @@ Please see LICENSE files in the repository root for full details.
import React, { type ChangeEvent, type ReactNode } from "react"; import React, { type ChangeEvent, type ReactNode } from "react";
import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import SettingsFlag from "../../../elements/SettingsFlag"; import SettingsFlag from "../../../elements/SettingsFlag";
import Field from "../../../elements/Field"; import Field from "../../../elements/Field";
@@ -48,7 +48,6 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
private renderAdvancedSection(): ReactNode { private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null; if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
const brand = SdkConfig.get().brand;
const toggle = ( const toggle = (
<AccessibleButton <AccessibleButton
kind="link" kind="link"
@@ -62,21 +61,18 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
let advanced: React.ReactNode; let advanced: React.ReactNode;
if (this.state.showAdvanced) { if (this.state.showAdvanced) {
const tooltipContent = _t("settings|appearance|custom_font_description", { brand });
advanced = ( advanced = (
<> <>
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} useCheckbox={true} /> <SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} />
<SettingsFlag <SettingsFlag
name="useBundledEmojiFont" name="useBundledEmojiFont"
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useBundledEmojiFont: checked })} onChange={(checked) => this.setState({ useBundledEmojiFont: checked })}
/> />
<SettingsFlag <SettingsFlag
name="useSystemFont" name="useSystemFont"
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useSystemFont: checked })} onChange={(checked) => this.setState({ useSystemFont: checked })}
/> />
<Field <Field
@@ -89,8 +85,6 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value); SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value);
}} }}
tooltipContent={tooltipContent}
forceTooltipVisible={true}
disabled={!this.state.useSystemFont} disabled={!this.state.useSystemFont}
value={this.state.systemFont} value={this.state.systemFont}
/> />
@@ -109,11 +103,18 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
return ( return (
<SettingsTab data-testid="mx_AppearanceUserSettingsTab"> <SettingsTab data-testid="mx_AppearanceUserSettingsTab">
<SettingsSection> <SettingsSection>
<ThemeChoicePanel /> <Form.Root
<LayoutSwitcher /> onSubmit={(evt) => {
<FontScalingPanel /> evt.preventDefault();
{this.renderAdvancedSection()} evt.stopPropagation();
<ImageSizePanel /> }}
>
<ThemeChoicePanel />
<LayoutSwitcher />
<FontScalingPanel />
{this.renderAdvancedSection()}
<ImageSizePanel />
</Form.Root>
</SettingsSection> </SettingsSection>
</SettingsTab> </SettingsTab>
); );

View File

@@ -5,26 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type FC, useCallback, useState } from "react"; import React, { type ChangeEventHandler, type FC, useCallback, useState } from "react";
import { Root } from "@vector-im/compound-web"; import { Root, SettingsToggleInput } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import { useSettingValue } from "../../../../../hooks/useSettings"; import { useSettingValue } from "../../../../../hooks/useSettings";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
export const InviteRulesAccountSetting: FC = () => { export const InviteRulesAccountSetting: FC = () => {
const rules = useSettingValue("inviteRules"); const rules = useSettingValue("inviteRules");
const settingsDisabled = SettingsStore.disabledMessage("inviteRules"); const settingsDisabled = SettingsStore.disabledMessage("inviteRules");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const onChange = useCallback(async (checked: boolean) => { const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(async (evt) => {
try { try {
setBusy(true); setBusy(true);
await SettingsStore.setValue("inviteRules", null, SettingLevel.ACCOUNT, { await SettingsStore.setValue("inviteRules", null, SettingLevel.ACCOUNT, {
allBlocked: !checked, allBlocked: !evt.target.checked,
}); });
} catch (ex) { } catch (ex) {
logger.error(`Unable to set invite rules`, ex); logger.error(`Unable to set invite rules`, ex);
@@ -33,13 +32,20 @@ export const InviteRulesAccountSetting: FC = () => {
} }
}, []); }, []);
return ( return (
<Root className="mx_MediaPreviewAccountSetting_Form"> <Root
<LabelledToggleSwitch className="mx_MediaPreviewAccountSetting_Form"
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsToggleInput
className="mx_MediaPreviewAccountSetting_ToggleSwitch" className="mx_MediaPreviewAccountSetting_ToggleSwitch"
name="invite_control_blocked"
label={_t("settings|invite_controls|default_label")} label={_t("settings|invite_controls|default_label")}
value={!rules.allBlocked} checked={!rules.allBlocked}
onChange={onChange} onChange={onChange}
tooltip={settingsDisabled} disabledMessage={settingsDisabled}
disabled={!!settingsDisabled || busy} disabled={!!settingsDisabled || busy}
/> />
</Root> </Root>

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { sortBy } from "lodash"; import { sortBy } from "lodash";
import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
@@ -106,37 +107,44 @@ export default class LabsUserSettingsTab extends React.Component<EmptyObject> {
return ( return (
<SettingsTab> <SettingsTab>
<SettingsSection heading={_t("labs|beta_section")}> <Form.Root
<SettingsSubsectionText> onSubmit={(evt) => {
{_t("labs|beta_description", { brand: SdkConfig.get("brand") })} evt.preventDefault();
</SettingsSubsectionText> evt.stopPropagation();
{betaSection} }}
</SettingsSection> >
<SettingsSection heading={_t("labs|beta_section")}>
{labsSections && (
<SettingsSection heading={_t("labs|experimental_section")}>
<SettingsSubsectionText> <SettingsSubsectionText>
{_t( {_t("labs|beta_description", { brand: SdkConfig.get("brand") })}
"labs|experimental_description",
{},
{
a: (sub) => {
return (
<a
href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</a>
);
},
},
)}
</SettingsSubsectionText> </SettingsSubsectionText>
{labsSections} {betaSection}
</SettingsSection> </SettingsSection>
)}
{labsSections && (
<SettingsSection heading={_t("labs|experimental_section")}>
<SettingsSubsectionText>
{_t(
"labs|experimental_description",
{},
{
a: (sub) => {
return (
<a
href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</a>
);
},
},
)}
</SettingsSubsectionText>
{labsSections}
</SettingsSection>
)}
</Form.Root>
</SettingsTab> </SettingsTab>
); );
} }

View File

@@ -6,9 +6,8 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ChangeEventHandler, useCallback } from "react"; import React, { type ChangeEventHandler, useCallback } from "react";
import { Field, HelpMessage, InlineField, Label, RadioInput, Root } from "@vector-im/compound-web"; import { Field, HelpMessage, InlineField, Label, RadioInput, Root, SettingsToggleInput } from "@vector-im/compound-web";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { type MediaPreviewConfig, MediaPreviewValue } from "../../../../../@types/media_preview"; import { type MediaPreviewConfig, MediaPreviewValue } from "../../../../../@types/media_preview";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import { useSettingValue } from "../../../../../hooks/useSettings"; import { useSettingValue } from "../../../../../hooks/useSettings";
@@ -30,12 +29,12 @@ export const MediaPreviewAccountSettings: React.FC<{ roomId?: string }> = ({ roo
[roomId], [roomId],
); );
const avatarOnChange = useCallback( const avatarOnChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(c: boolean) => { (evt) => {
changeSetting({ changeSetting({
...currentMediaPreview, ...currentMediaPreview,
// Switch is inverted. "Hide avatars..." // Switch is inverted. "Hide avatars..."
invite_avatars: c ? MediaPreviewValue.Off : MediaPreviewValue.On, invite_avatars: evt.target.checked ? MediaPreviewValue.Off : MediaPreviewValue.On,
}); });
}, },
[changeSetting, currentMediaPreview], [changeSetting, currentMediaPreview],
@@ -83,10 +82,10 @@ export const MediaPreviewAccountSettings: React.FC<{ roomId?: string }> = ({ roo
return ( return (
<Root className="mx_MediaPreviewAccountSetting_Form"> <Root className="mx_MediaPreviewAccountSetting_Form">
{!roomId && ( {!roomId && (
<LabelledToggleSwitch <SettingsToggleInput
className="mx_MediaPreviewAccountSetting_ToggleSwitch" name="hide_avatars"
label={_t("settings|media_preview|hide_avatars")} label={_t("settings|media_preview|hide_avatars")}
value={currentMediaPreview.invite_avatars === MediaPreviewValue.Off} checked={currentMediaPreview.invite_avatars === MediaPreviewValue.Off}
onChange={avatarOnChange} onChange={avatarOnChange}
/> />
)} )}

View File

@@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ReactElement, useCallback, useEffect, useState } from "react"; import React, { type ChangeEventHandler, type JSX, type ReactElement, useCallback, useEffect, useState } from "react";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { type NonEmptyArray } from "../../../../../@types/common"; import { type NonEmptyArray } from "../../../../../@types/common";
import { _t, getCurrentLanguage } from "../../../../../languageHandler"; import { _t, getCurrentLanguage } from "../../../../../languageHandler";
@@ -29,7 +30,6 @@ import LanguageDropdown from "../../../elements/LanguageDropdown";
import PlatformPeg from "../../../../../PlatformPeg"; import PlatformPeg from "../../../../../PlatformPeg";
import { IS_MAC } from "../../../../../Keyboard"; import { IS_MAC } from "../../../../../Keyboard";
import SpellCheckSettings from "../../SpellCheckSettings"; import SpellCheckSettings from "../../SpellCheckSettings";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as TimezoneHandler from "../../../../../TimezoneHandler"; import * as TimezoneHandler from "../../../../../TimezoneHandler";
import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx"; import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx";
import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx"; import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx";
@@ -91,9 +91,9 @@ const SpellCheckSection: React.FC = () => {
})(); })();
}, []); }, []);
const onSpellCheckEnabledChange = useCallback((enabled: boolean) => { const onSpellCheckEnabledChange = useCallback<ChangeEventHandler<HTMLInputElement>>((evt) => {
setSpellCheckEnabled(enabled); setSpellCheckEnabled(evt.target.checked);
PlatformPeg.get()?.setSpellCheckEnabled(enabled); PlatformPeg.get()?.setSpellCheckEnabled(evt.target.checked);
}, []); }, []);
const onSpellCheckLanguagesChange = useCallback((languages: string[]): void => { const onSpellCheckLanguagesChange = useCallback((languages: string[]): void => {
@@ -105,11 +105,14 @@ const SpellCheckSection: React.FC = () => {
return ( return (
<> <>
<LabelledToggleSwitch <Form.Root>
label={_t("settings|general|allow_spellcheck")} <SettingsToggleInput
value={Boolean(spellCheckEnabled)} label={_t("settings|general|allow_spellcheck")}
onChange={onSpellCheckEnabledChange} name="spell_check"
/> checked={Boolean(spellCheckEnabled)}
onChange={onSpellCheckEnabledChange}
/>
</Form.Root>
{spellCheckEnabled && spellCheckLanguages !== undefined && !IS_MAC && ( {spellCheckEnabled && spellCheckLanguages !== undefined && !IS_MAC && (
<SpellCheckSettings languages={spellCheckLanguages} onLanguagesChange={onSpellCheckLanguagesChange} /> <SpellCheckSettings languages={spellCheckLanguages} onLanguagesChange={onSpellCheckLanguagesChange} />
)} )}
@@ -263,8 +266,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<LanguageSection /> <LanguageSection />
<SpellCheckSection /> <SpellCheckSection />
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection formWrap heading={_t("settings|preferences|room_list_heading")}>
<SettingsSubsection heading={_t("settings|preferences|room_list_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
{/* The settings is on device level where the other room list settings are on account level */} {/* The settings is on device level where the other room list settings are on account level */}
{newRoomListEnabled && ( {newRoomListEnabled && (
@@ -272,12 +274,13 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
)} )}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("common|spaces")}> <SettingsSubsection formWrap heading={_t("common|spaces")}>
{this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT)} {this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection <SettingsSubsection
heading={_t("settings|preferences|keyboard_heading")} heading={_t("settings|preferences|keyboard_heading")}
formWrap
description={_t( description={_t(
"settings|preferences|keyboard_view_shortcuts_button", "settings|preferences|keyboard_view_shortcuts_button",
{}, {},
@@ -293,7 +296,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{this.renderGroup(PreferencesUserSettingsTab.KEYBINDINGS_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.KEYBINDINGS_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|time_heading")}> <SettingsSubsection heading={_t("settings|preferences|time_heading")} formWrap>
<div className="mx_SettingsSubsection_dropdown"> <div className="mx_SettingsSubsection_dropdown">
{_t("settings|preferences|user_timezone")} {_t("settings|preferences|user_timezone")}
<Dropdown <Dropdown
@@ -316,38 +319,39 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection <SettingsSubsection
formWrap
heading={_t("common|presence")} heading={_t("common|presence")}
description={_t("settings|preferences|presence_description")} description={_t("settings|preferences|presence_description")}
> >
{this.renderGroup(PreferencesUserSettingsTab.PRESENCE_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.PRESENCE_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|composer_heading")}> <SettingsSubsection formWrap heading={_t("settings|preferences|composer_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|code_blocks_heading")}> <SettingsSubsection formWrap heading={_t("settings|preferences|code_blocks_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|media_heading")}> <SettingsSubsection formWrap heading={_t("settings|preferences|media_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("common|timeline")}> <SettingsSubsection formWrap heading={_t("common|timeline")}>
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}> <SettingsSubsection formWrap heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings /> <MediaPreviewAccountSettings />
<InviteRulesAccountSetting /> <InviteRulesAccountSetting />
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}> <SettingsSubsection formWrap heading={_t("settings|preferences|room_directory_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("common|general")} stretchContent> <SettingsSubsection formWrap heading={_t("common|general")} stretchContent>
{this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
<SettingsFlag name="Electron.showTrayIcon" level={SettingLevel.PLATFORM} hideIfCannotSet /> <SettingsFlag name="Electron.showTrayIcon" level={SettingLevel.PLATFORM} hideIfCannotSet />

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