Compare commits

..

38 Commits

Author SHA1 Message Date
Timo
a00a8d85ac use translated string 2025-07-21 13:01:19 +02:00
Timo
2e182580bd Add toggle to hide empty state in devtools 2025-07-21 12:58:01 +02:00
Florian Duros
5b659fe2e5 fix: force ED titlebar color for new room list (#30332) 2025-07-18 13:24:19 +00:00
R Midhun Suresh
42c718666c Skip flaky test (#30338) 2025-07-18 11:35:51 +00:00
David Baker
f3a181a792 Fix shared component diff index generation
Because OF COURSE ubuntu has a version of tree from 2023 that doesn't
support the '-' to remove the first path element.
2025-07-18 10:54:07 +01:00
David Baker
148d7fc0a9 Add deployments write priv to visual test uploader 2025-07-18 09:54:46 +01:00
David Baker
e42fcb797f Add deployment env 2025-07-18 09:48:34 +01:00
ElementRobot
31fb23a170 [create-pull-request] automated change (#30335)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-18 06:19:14 +00:00
David Baker
69c2afe8e4 Upload visual diffs from storybook tests (#30298)
* Very first pass at shared component views

Turn the trivial TextualEvent into a shared component with a separate view
model for element web. Args to view model will probably change to be more
specific and VM typer needs abstracting out into an interface, but should
give the general idea.

* Remove old TextualEvent

* Pass showHiddenEvents

Because we used it anyway, we just cheated by getting it from the context

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

There's no reason to use the react-webpack plugin just because our app
is stuck on webpack, it just means we have vite as a dependency too.

* Change here too

* Workaround for incomatible types in rollup

https://github.com/rollup/rollup/issues/5199

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

Co-authored-by: Florian Duros <florianduros@element.io>

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix listener interface

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix types

* Fix more types

* Revert useMemo

as this isn't a hook

* Unused import

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

* Change to 'add' rather than 'subs.subscribe'

* Add cache specifier for only shell playwright browsers

* Add copyright headers

* Upload visual diffs from storybook testing

* Replace tab

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
2025-07-17 16:18:08 +00:00
Will Hunt
bc1effd2a2 Support rendering notification badges on platforms that do their own icon overlays (#30315)
* Support rendering a seperate overlay icon on supported platforms.

* Add required globals.

* i18n-ize

* Add tests

* lint

* lint

* lint

* update copyrights

* Fix test

* lint

* Fixup

* lint

* remove unused string

* fix test
2025-07-17 12:59:17 +00:00
David Baker
3b0c04c2e9 Add SubscriptionViewModel base class (#30297)
* Very first pass at shared component views

Turn the trivial TextualEvent into a shared component with a separate view
model for element web. Args to view model will probably change to be more
specific and VM typer needs abstracting out into an interface, but should
give the general idea.

* Remove old TextualEvent

* Pass showHiddenEvents

Because we used it anyway, we just cheated by getting it from the context

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

There's no reason to use the react-webpack plugin just because our app
is stuck on webpack, it just means we have vite as a dependency too.

* Change here too

* Workaround for incomatible types in rollup

https://github.com/rollup/rollup/issues/5199

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

Co-authored-by: Florian Duros <florianduros@element.io>

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix listener interface

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix types

* Fix more types

* Add a superclass that simple view models can extend

to reduce boilerplate

* Revert useMemo

as this isn't a hook

* Unused import

* Actually commit the file the branch is named after

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

* Change to 'add' rather than 'subs.subscribe'

* Add cache specifier for only shell playwright browsers

* Add copyright headers

* Better comment wording

* Make amit an arrow function

so it can be passed directly as a callback

* Add a test

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
2025-07-17 12:32:31 +00:00
ioalexander
77cb4b3157 Enhancement: Save image on CTRL+S (#30330)
* Save image on CTRL+S

* fixed cosmetic comments

* fixed test

* refactored out downloading functionality from buttons to useDownloadMedia hook

* ImageView CTRL+S use button component

* added CTRL+S test & lint

* removed forwardRef

* fix lint

* i18n
2025-07-17 09:53:11 +00:00
AlirezaMrtz
3e11a62a3f Add quote functionality to MessageContextMenu (#29893) (#30323)
* Add quote functionality to MessageContextMenu (#29893)

* Remove unused import of getSelectedText from strings utility in EventTile component

* Add space after quoted text in ComposerInsert action

* Add space after quoted text in MessageContextMenu test

* add new line before and after the formated text
2025-07-17 09:45:08 +00:00
ElementRobot
084f447c6e [create-pull-request] automated change (#30331)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-17 06:19:13 +00:00
Florian Duros
55c8256900 fix:put a background color to the left panel when the new room list is enabled (#30328) 2025-07-16 19:13:49 +00:00
Florian Duros
b64e9ed675 Add i18n to storybook (#30268)
* refactor: extract i18n from languageHandler to not import matrix-js-sdk, settings...

* fix: circular deps

* feat: add language selector to storybook

* fix: make visual test works in CI
2025-07-16 18:21:09 +00:00
R Midhun Suresh
dc2060fc7b Fix flaky scrolling (#30329)
There are two potential problems here:
1. mouse.scroll returns before the scroll is completed
2. visibility check does not check if the element is actually in the
   viewport.

I've added a helper function to make it easier to scroll to the end of
an infinite list.
2025-07-16 15:10:05 +00:00
ElementRobot
0e37fea9f5 [create-pull-request] automated change (#30325)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-16 06:18:29 +00:00
RiotRobot
7bb526b83a Reset matrix-js-sdk back to develop branch 2025-07-15 15:06:00 +00:00
RiotRobot
2885fc2443 Merge branch 'master' into develop 2025-07-15 15:05:36 +00:00
RiotRobot
d05806b9e9 v1.11.106 2025-07-15 15:01:54 +00:00
RiotRobot
3f2f463bc3 Upgrade dependency to matrix-js-sdk@37.11.0 2025-07-15 14:47:04 +00:00
ElementRobot
557293af31 Fix missing image download button (#30320) (#30322)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Fixes https://github.com/element-hq/element-web/issues/30319
2025-07-15 15:39:46 +01:00
David Baker
114ad1df0d Fix missing image download button (#30320)
Fixes https://github.com/element-hq/element-web/issues/30319
2025-07-15 15:14:14 +01:00
ElementRobot
0fe275fbd2 [create-pull-request] automated change (#30316)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-15 06:20:27 +00:00
AlirezaMrtz
93f04f7aaa Prevent default form submission in MemberListView (#30312) 2025-07-14 13:44:03 +00:00
David Baker
4bbcb8bb5d Initial structure for shared component views (#30216)
* Very first pass at shared component views

Turn the trivial TextualEvent into a shared component with a separate view
model for element web. Args to view model will probably change to be more
specific and VM typer needs abstracting out into an interface, but should
give the general idea.

* Remove old TextualEvent

* Pass showHiddenEvents

Because we used it anyway, we just cheated by getting it from the context

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

There's no reason to use the react-webpack plugin just because our app
is stuck on webpack, it just means we have vite as a dependency too.

* Change here too

* Workaround for incomatible types in rollup

https://github.com/rollup/rollup/issues/5199

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

Co-authored-by: Florian Duros <florianduros@element.io>

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix listener interface

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix types

* Fix more types

* Revert useMemo

as this isn't a hook

* Unused import

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

* Change to 'add' rather than 'subs.subscribe'

* Add cache specifier for only shell playwright browsers

* Add copyright headers

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
2025-07-14 13:13:02 +00:00
ElementRobot
361d36272e [create-pull-request] automated change (#30314)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-14 08:02:29 +00:00
ElementRobot
8bb1b22d46 [create-pull-request] automated change (#30311)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-12 06:18:38 +00:00
Richard van der Hoff
1090c52410 Flaky test issue auto-closer: only close playwright test issues (#30302)
Only the playwright tests are automatically updated, and are therefore safe to
auto-close.
2025-07-11 13:03:55 +00:00
ElementRobot
e528f95b2e [create-pull-request] automated change (#30307)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-11 06:19:01 +00:00
ElementRobot
f3058c9597 Fix e2e icon colour (#30299) (#30304)
* fix: remove white background on e2e verification icon and put white on the checkmark

* test(e2e): add non regression tests

* chore: remove unused CSS mask

(cherry picked from commit a05ca97409)

Co-authored-by: Florian Duros <florianduros@element.io>
2025-07-10 19:47:50 +00:00
Florian Duros
a05ca97409 Fix e2e icon colour (#30299)
* fix: remove white background on e2e verification icon and put white on the checkmark

* test(e2e): add non regression tests

* chore: remove unused CSS mask
2025-07-10 13:50:18 +00:00
Valere Fedronic
2d92b73e5f Widgets: Use the new ClientEvent.ReceivedToDeviceMessage instead of ToDeviceEvent (#30239) 2025-07-10 08:04:29 +00:00
ElementRobot
366eeb7d61 [create-pull-request] automated change (#30301)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-10 06:18:43 +00:00
Richard van der Hoff
26d71530f5 DeviceListener: add logging around key backup upload check (#30291)
* DeviceListener: add logging around key backup upload check

... in an attempt to diagnose what is going on with
https://github.com/element-hq/element-web/issues/30270

* fix typescript

* fix lint
2025-07-09 17:31:36 +00:00
RiotRobot
3a01a00d51 v1.11.106-rc.0 2025-07-08 13:27:21 +00:00
RiotRobot
33f3ee15fe Upgrade dependency to matrix-js-sdk@37.11.0-rc.0 2025-07-08 13:23:57 +00:00
163 changed files with 9213 additions and 6553 deletions

View File

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

View File

@@ -0,0 +1,51 @@
# 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

@@ -0,0 +1,70 @@
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,12 +15,14 @@ jobs:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
operations-per-run: 100
# Flaky test issue closing
only-issue-labels: "Z-Flaky-Test"
any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit"
days-before-issue-stale: 14
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."
exempt-issue-labels: "Z-Flaky-Test-Disabled"
# Stale PR closing
days-before-pr-stale: 180
days-before-pr-close: 0

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,28 @@
/*
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

@@ -0,0 +1,61 @@
/*
* 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>
);
},
};

37
.storybook/main.ts Normal file
View File

@@ -0,0 +1,37 @@
/*
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;

18
.storybook/manager.js Normal file
View File

@@ -0,0 +1,18 @@
/*
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));

10
.storybook/preview.css Normal file
View File

@@ -0,0 +1,10 @@
/*
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);
}

90
.storybook/preview.tsx Normal file
View File

@@ -0,0 +1,90 @@
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;

37
.storybook/test-runner.js Normal file
View File

@@ -0,0 +1,37 @@
/*
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,3 +19,6 @@ include:
* Thom Cleary (https://github.com/thomcatdotrocks)
Small update for tarball deployment
* Alexander (https://github.com/ioalexander)
Save image on CTRL + S shortcut

View File

@@ -1,3 +1,21 @@
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)
====================================================================================================
## ✨ Features

8
declaration.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/*
* 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
customExportConditions: ["browser", "node"],
},
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.105",
"version": "1.11.106",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -65,7 +65,11 @@
"coverage": "yarn test --coverage",
"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",
"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": {
"**/pretty-format/react-is": "19.1.0",
@@ -93,7 +97,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-web": "^8.2.0",
"@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.38.4",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -187,6 +191,11 @@
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@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",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0",
@@ -246,6 +255,7 @@
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0",
"fake-indexeddb": "^6.0.0",
@@ -257,6 +267,7 @@
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0",
@@ -285,6 +296,7 @@
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"storybook": "^9.0.12",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0",
@@ -294,6 +306,8 @@
"ts-node": "^10.9.1",
"typescript": "5.8.3",
"util": "^0.12.5",
"vite": "^7.0.1",
"vite-plugin-node-polyfills": "^0.24.0",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0",

View File

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,34 @@
/*
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,6 +168,7 @@ test.describe("Cryptography", function () {
// 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_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
},
);

View File

@@ -48,31 +48,38 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
return promiseVerificationRequest;
}
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
test(
"Verify device with SAS during login",
{ tag: "@screenshot" },
async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
// Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) =>
request.startVerification("m.sas.v1"),
);
// Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier);
// Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).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();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// 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,
// as we need to wait for the secret gossiping to happen.
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
});
// 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,
// as we need to wait for the secret gossiping to happen.
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
},
);
// 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 }) => {

View File

@@ -1,27 +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 { 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

@@ -1,31 +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 { 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

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

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
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
@@ -57,20 +57,4 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => {
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

@@ -1,64 +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 { 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
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.getByRole("switch", { name: "Use bundled emoji font" }).click();
await tab.getByRole("switch", { name: "Use a system font" }).click();
await tab.getByLabel("Use bundled emoji font").click();
await tab.getByLabel("Use a system font").click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');

View File

@@ -1,25 +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 { 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

@@ -1,22 +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 { 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 { test, expect } from "../../../element-web-test";
import { test, expect } from "../../element-web-test";
test.describe("Roles & Permissions room settings tab", () => {
const roomName = "Test room";

View File

@@ -1,41 +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 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

@@ -1,40 +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 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,18 +41,6 @@ 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 }) => {
const tab = await app.settings.openUserSettings("Security");

View File

@@ -369,16 +369,4 @@ test.describe("Spaces", () => {
await app.viewSpaceByName("Root Space");
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

@@ -1,95 +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 { 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.
*/
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
public async openCreateRoomDialog(): Promise<Locator> {
await this.page.getByRole("button", { name: "Add room", exact: true }).click();
await this.page.getByRole("menuitem", { name: roomKindname, exact: true }).click();
await this.page.getByRole("menuitem", { name: "New room", exact: true }).click();
return this.page.locator(".mx_CreateRoomDialog");
}
@@ -213,4 +213,26 @@ export class ElementAppPage {
.getByRole("button", { name: "Dismiss" })
.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.
* @return {Promise} Resolves when the setting has been changed.
*/
public async setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void> {
public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise<void> {
return this.page.evaluate<
Promise<void>,
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

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: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

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";
const TAG = "develop@sha256:aea1d8f371268aed7a5863fa5dde960fb4f9f578cd0a5952cc4da92537f95cfa";
const TAG = "develop@sha256:b38e55f06543f83f5a13f1d843489eb7aeaf7370a5c17a51897b462eeca315f5";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

9
res/css/shared.pcss Normal file
View File

@@ -0,0 +1,9 @@
/*
* 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,12 +28,6 @@ Please see LICENSE files in the repository root for full details.
--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 {
display: flex;
flex-direction: row;
@@ -246,3 +240,11 @@ 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,17 +52,10 @@ Please see LICENSE files in the repository root for full details.
.mx_E2EIcon_normal::after {
mask-image: url("$(res)/img/e2e/normal.svg");
background-color: var(--cpd-color-icon-tertiary);
background-color: white;
}
.mx_E2EIcon_verified::after {
mask-image: url("$(res)/img/e2e/verified.svg");
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,6 +77,10 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_SettingsTab_toggleWithDescription {
margin-top: $spacing-24;
}
.mx_SettingsTab_sections {
display: grid;
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 {
width: 256px;
/* Line up with Settings field toggle button */
margin-inline-start: 0;
/* matches checkbox box + padding to align with checkbox label */
margin-inline-start: calc($font-16px + 10px);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,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.
*/
import { _td, type TranslationKey } from "../languageHandler";
// Import i18n.tsx instead of languageHandler to avoid circular deps
import { _td, type TranslationKey } from "../shared-components/i18n";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { type IBaseSetting } from "../settings/Settings";
import { type KeyCombo } from "../KeyBindingsManager";
@@ -145,6 +146,7 @@ export enum KeyBindingAction {
ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma",
Save = "KeyBinding.save",
/** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
@@ -268,6 +270,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown,
KeyBindingAction.Comma,
KeyBindingAction.Save,
],
},
[CategoryName.NAVIGATION]: {
@@ -620,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
displayName: _td("keyboard|composer_redo"),
},
[KeyBindingAction.Save]: {
default: {
key: Key.S,
ctrlOrCmdKey: true,
},
displayName: _td("keyboard|save"),
},
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: {
metaKey: IS_MAC,

View File

@@ -183,6 +183,30 @@ 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 => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(MatrixClientPeg.safeGet(), reaction);
@@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
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 => {
editEvent(
MatrixClientPeg.safeGet(),
@@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
const selectedText = getSelectedText();
let copyButton: JSX.Element | undefined;
if (rightClick && getSelectedText()) {
if (rightClick && selectedText) {
copyButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCopy"
@@ -561,6 +605,18 @@ 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;
if (rightClick && canEditContent(cli, mxEvent)) {
editButton = (
@@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}
let nativeItemsList: JSX.Element | undefined;
if (copyButton || copyLinkButton) {
if (copyButton || quoteButton || copyLinkButton) {
nativeItemsList = (
<IconizedContextMenuOptionList>
{copyButton}
{quoteButton}
{copyLinkButton}
</IconizedContextMenuOptionList>
);

View File

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

View File

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

View File

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

View File

@@ -6,15 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ChangeEventHandler, useCallback, useState } from "react";
import {
Root,
Field,
Label,
InlineSpinner,
ErrorMessage,
HelpMessage,
SettingsToggleInput,
} from "@vector-im/compound-web";
import { Root, Field, Label, InlineSpinner, ErrorMessage, HelpMessage } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
@@ -22,6 +14,7 @@ import Markdown from "../../../Markdown";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
interface IProps {
roomId: string;
@@ -40,10 +33,6 @@ export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished
const client = MatrixClientPeg.safeGet();
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 onSubmit = useCallback(async () => {
setBusy(true);
@@ -89,11 +78,10 @@ export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished
</Field>
{adminMessage}
{busy ? <InlineSpinner /> : null}
<SettingsToggleInput
name="leave-room"
<LabelledToggleSwitch
label={_t("room_list|more_options|leave_room")}
checked={leaveRoom}
onChange={onLeaveRoomChanged}
value={leaveRoom}
onChange={setLeaveRoom}
/>
<DialogButtons
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.
*/
import React, { type ChangeEventHandler, type JSX, type ReactNode, type SyntheticEvent } from "react";
import React, { type JSX, type ReactNode, type SyntheticEvent } from "react";
import { EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import BugReportDialog from "./BugReportDialog";
@@ -87,8 +87,8 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
this.props.onFinished({ continue: false, invite: false });
};
private onInviteUsersToggle: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ inviteUsersToNewRoom: evt.target.checked });
private onInviteUsersToggle = (inviteUsersToNewRoom: boolean): void => {
this.setState({ inviteUsersToNewRoom });
};
private openBugReportDialog = (e: SyntheticEvent): void => {
@@ -104,19 +104,11 @@ export default class RoomUpgradeWarningDialog extends React.Component<IProps, IS
let inviteToggle: JSX.Element | undefined;
if (this.isInviteOrKnockRoom) {
inviteToggle = (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
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>
<LabelledToggleSwitch
value={this.state.inviteUsersToNewRoom}
onChange={this.onInviteUsersToggle}
label={_t("room_settings|advanced|upgrade_warning_dialog_invite_label")}
/>
);
}

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.
*/
import React, { type ChangeEventHandler } from "react";
import React from "react";
import {
type Capability,
isTimelineCapability,
@@ -15,13 +15,13 @@ import {
type WidgetKind,
} from "matrix-widget-api";
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
interface IProps {
@@ -64,8 +64,8 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
this.setState({ booleanStates: newStates });
};
private onRememberSelectionChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ rememberSelection: evt.target.checked });
private onRememberSelectionChange = (newVal: boolean): void => {
this.setState({ rememberSelection: newVal });
};
private onSubmit = async (): Promise<void> => {
@@ -116,7 +116,7 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
onFinished={this.props.onFinished}
title={_t("widget|capabilities_dialog|title")}
>
<Form.Root onSubmit={this.onSubmit}>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="text-muted">{_t("widget|capabilities_dialog|content_starting_text")}</div>
{checkboxRows}
@@ -126,16 +126,16 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
additive={
<SettingsToggleInput
name="remember-selection"
checked={this.state.rememberSelection}
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("widget|capabilities_dialog|remember_Selection")}
/>
}
/>
</div>
</Form.Root>
</form>
</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.
*/
import React, { type ChangeEventHandler } from "react";
import React from "react";
import { type Widget, type WidgetKind } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
@@ -61,8 +61,8 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<I
this.props.onFinished(allowed);
}
private onRememberSelectionChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
this.setState({ rememberSelection: evt.target.checked });
private onRememberSelectionChange = (newVal: boolean): void => {
this.setState({ rememberSelection: newVal });
};
public render(): React.ReactNode {
@@ -85,19 +85,12 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<I
onPrimaryButtonClick={this.onAllow}
onCancel={this.onDeny}
additive={
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsToggleInput
name="remember-selection"
checked={this.state.rememberSelection}
onChange={this.onRememberSelectionChange}
label={_t("widget|open_id_permissions_dialog|remember_selection")}
/>
</Form.Root>
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("widget|open_id_permissions_dialog|remember_selection")}
/>
}
/>
</BaseDialog>

View File

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

View File

@@ -8,10 +8,9 @@ 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.
*/
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
import React, { type JSX, createRef, type CSSProperties, useEffect } from "react";
import FocusLock from "react-focus-lock";
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
@@ -31,11 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton";
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";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();
private downloadFunction?: () => Promise<void>;
private initX = 0;
private initY = 0;
private previousX = 0;
@@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.preventDefault();
this.props.onFinished();
break;
case KeyBindingAction.Save:
ev.preventDefault();
ev.stopPropagation();
if (this.downloadFunction) {
this.downloadFunction();
}
break;
}
};
@@ -327,6 +331,10 @@ export default class ImageView extends React.Component<IProps, IState> {
});
};
private onDownloadFunctionReady = (download: () => Promise<void>): void => {
this.downloadFunction = download;
};
private onPermalinkClicked = (ev: React.MouseEvent): void => {
// 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.
@@ -552,7 +560,12 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick}
/>
<DownloadButton url={this.props.src} fileName={this.props.name} mxEvent={this.props.mxEvent} />
<DownloadButton
url={this.props.src}
fileName={this.props.name}
mxEvent={this.props.mxEvent}
onDownloadReady={this.onDownloadFunctionReady}
/>
{contextMenuButton}
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close"
@@ -585,97 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
}
}
function DownloadButton({
url,
fileName,
mxEvent,
}: {
interface DownloadButtonProps {
url: string;
fileName?: string;
mxEvent?: MatrixEvent;
}): JSX.Element | null {
const downloader = useRef(new FileDownloader()).current;
const [loading, setLoading] = useState(false);
const [canDownload, setCanDownload] = useState<boolean>(false);
const blobRef = useRef<Blob>(undefined);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
onDownloadReady?: (download: () => Promise<void>) => void;
}
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
useEffect(() => {
if (!mxEvent) {
// 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 (onDownloadReady) onDownloadReady(download);
}, [download, onDownloadReady]);
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;
}
if (!canDownload) return null;
return (
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={onDownloadClick}
onClick={download}
disabled={loading}
/>
);
}
};

View File

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

View File

@@ -0,0 +1,83 @@
/*
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.
*/
import React, { type ChangeEvent } from "react";
import React from "react";
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 { _t } from "../../../languageHandler";
import ToggleSwitch from "./ToggleSwitch";
import StyledCheckbox from "./StyledCheckbox";
import { type SettingLevel } from "../../../settings/SettingLevel";
import { type BooleanSettingKey, defaultWatchManager } from "../../../settings/Settings";
@@ -24,6 +24,8 @@ interface IProps {
roomId?: string; // for per-room settings
label?: string;
isExplicit?: boolean;
// XXX: once design replaces all toggles make this the default
useCheckbox?: boolean;
hideIfCannotSet?: boolean;
onChange?(checked: boolean): void;
}
@@ -72,16 +74,14 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
});
};
private onChange = async (evt: ChangeEvent<HTMLInputElement>): Promise<void> => {
const newValue = evt.target.checked;
try {
await this.save(newValue);
} catch (ex) {
logger.info(`Failed to save setting ${this.props.name}`, ex);
return;
}
this.setState({ value: newValue });
this.props.onChange?.(newValue);
private onChange = async (checked: boolean): Promise<void> => {
await this.save(checked);
this.setState({ value: checked });
this.props.onChange?.(checked);
};
private checkBoxOnChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.onChange(e.target.checked);
};
private save = async (val?: boolean): Promise<void> => {
@@ -101,37 +101,45 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
const label = this.props.label ?? SettingsStore.getDisplayName(this.props.name, this.props.level);
const description = SettingsStore.getDescription(this.props.name);
const shouldWarn = SettingsStore.shouldHaveWarning(this.props.name);
const helpMessage = shouldWarn
? _t(
"settings|warning",
{},
{
w: (sub) => <span className="mx_SettingsTab_microcopy_warning">{sub}</span>,
description,
},
)
: description;
if (this.props.name === "sendReadReceipts") {
console.log(
"Fully disabled",
this.props.name,
SettingsStore.disabledMessage(this.props.name),
description,
helpMessage,
if (this.props.useCheckbox) {
return (
<StyledCheckbox checked={this.state.value} onChange={this.checkBoxOnChange} disabled={disabled}>
{label}
</StyledCheckbox>
);
} else {
return (
<div className="mx_SettingsFlag">
<label className="mx_SettingsFlag_label" htmlFor={this.id}>
<span className="mx_SettingsFlag_labelText">{label}</span>
{description && (
<div className="mx_SettingsFlag_microcopy">
{shouldWarn
? _t(
"settings|warning",
{},
{
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,11 +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.
*/
import React, { type ChangeEventHandler, type FormEventHandler, useCallback, useState } from "react";
import { SettingsToggleInput, Form, Button } from "@vector-im/compound-web";
import React, { useState } from "react";
import { _t } from "../../../languageHandler";
import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon";
import AccessibleButton from "../elements/AccessibleButton";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import Heading from "../typography/Heading";
interface Props {
@@ -19,25 +20,6 @@ interface Props {
export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
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 (
<div data-testid="location-picker-enable-live-share" className="mx_EnableLiveShare">
<StyledLiveBeaconIcon className="mx_EnableLiveShare_icon" />
@@ -45,17 +27,22 @@ export const EnableLiveShare: React.FC<Props> = ({ onSubmit }) => {
{_t("location_sharing|live_enable_heading")}
</Heading>
<p className="mx_EnableLiveShare_description">{_t("location_sharing|live_enable_description")}</p>
<Form.Root onSubmit={onSubmitForm}>
<SettingsToggleInput
name="enable-live-share-toggle"
checked={isEnabled}
onChange={onEnabledChanged}
label={_t("location_sharing|live_toggle_label")}
/>
<Button className="mx_EnableLiveShare_button" kind="primary" disabled={!isEnabled}>
{_t("action|ok")}
</Button>
</Form.Root>
<LabelledToggleSwitch
data-testid="enable-live-share-toggle"
value={isEnabled}
onChange={setEnabled}
label={_t("location_sharing|live_toggle_label")}
/>
<AccessibleButton
data-testid="enable-live-share-submit"
className="mx_EnableLiveShare_button"
element="button"
kind="primary"
onClick={onSubmit}
disabled={!isEnabled}
>
{_t("action|ok")}
</AccessibleButton>
</div>
);
};

View File

@@ -7,19 +7,15 @@ Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React, { type JSX } from "react";
import React, { type ReactElement, useMemo } from "react";
import classNames from "classnames";
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 { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { FileDownloader } from "../../../utils/FileDownloader";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import ModuleApi from "../../../modules/Api";
import { _t } from "../../../languageHandler";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
interface IProps {
mxEvent: MatrixEvent;
@@ -30,121 +26,32 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined;
}
interface IState {
canDownload: null | boolean;
loading: boolean;
blob?: Blob;
tooltip: TranslationKey;
}
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private downloader = new FileDownloader();
public constructor(props: IProps) {
super(props);
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
if (moduleHints?.allowDownloadingMedia) {
downloadState.canDownload = null;
moduleHints
.allowDownloadingMedia()
.then((canDownload) => {
this.setState({
canDownload: canDownload,
});
})
.catch((ex) => {
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
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>
);
}
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
const fileName = mediaEventHelper?.fileName;
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
if (!canDownload) return null;
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});
return (
<RovingAccessibleButton
className={classes}
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={download}
disabled={loading}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}

View File

@@ -1,47 +0,0 @@
/*
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,11 +6,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.
*/
import React, { type ChangeEventHandler } from "react";
import React from "react";
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 { MatrixClientPeg } from "../../../MatrixClientPeg";
import DirectoryCustomisations from "../../../customisations/Directory";
@@ -25,7 +24,6 @@ interface IProps {
interface IState {
isRoomPublished: boolean;
busy: boolean;
}
export default class RoomPublishSetting extends React.PureComponent<IProps, IState> {
@@ -34,7 +32,6 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
this.state = {
isRoomPublished: false,
busy: false,
};
}
@@ -45,23 +42,19 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
});
}
private onRoomPublishChange: ChangeEventHandler<HTMLInputElement> = async (evt): Promise<void> => {
const newValue = evt.target.checked;
this.setState({ busy: true });
private onRoomPublishChange = (): void => {
const valueBefore = this.state.isRoomPublished;
const newValue = !valueBefore;
this.setState({ isRoomPublished: newValue });
const client = MatrixClientPeg.safeGet();
try {
await client.setRoomDirectoryVisibility(
this.props.roomId,
newValue ? Visibility.Public : Visibility.Private,
);
this.setState({ isRoomPublished: newValue });
} catch (ex) {
logger.error("Error while setting room directory visibility", ex);
this.showError();
} finally {
this.setState({ busy: false });
}
client
.setRoomDirectoryVisibility(this.props.roomId, newValue ? Visibility.Public : Visibility.Private)
.catch(() => {
this.showError();
// Roll back the local echo on the change
this.setState({ isRoomPublished: valueBefore });
});
};
public componentDidMount(): void {
@@ -76,26 +69,17 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
const room = client.getRoom(this.props.roomId);
const isRoomPublishable = room && room.getJoinRule() !== JoinRule.Invite;
const canSetCanonicalAlias =
DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
this.props.canSetCanonicalAlias;
let disabledMessage;
if (!isRoomPublishable) {
disabledMessage = _t("room_settings|general|publish_warn_invite_only");
} else if (!canSetCanonicalAlias) {
disabledMessage = _t("room_settings|general|publish_warn_no_canonical_permission");
}
const enabled = canSetCanonicalAlias && (isRoomPublishable || this.state.isRoomPublished);
const enabled =
(DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
this.props.canSetCanonicalAlias) &&
(isRoomPublishable || this.state.isRoomPublished);
return (
<SettingsToggleInput
name="room-publish"
checked={this.state.isRoomPublished}
<LabelledToggleSwitch
value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!enabled || this.state.busy}
disabledMessage={disabledMessage}
disabled={!enabled}
label={_t("room_settings|general|publish_toggle", {
domain: client.getDomain(),
})}

View File

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

View File

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

View File

@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator,
showHiddenEvents: false,
},
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.
*/
import React, { type ChangeEvent, type ChangeEventHandler, type JSX, type ReactNode } from "react";
import React, { type JSX, type ReactNode } from "react";
import {
type IAnnotatedPushRule,
type IPusher,
@@ -19,7 +19,6 @@ import {
type EmptyObject,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -32,6 +31,7 @@ import {
type VectorPushRuleDefinition,
} from "../../../notifications";
import { _t, type TranslatedString } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import { SettingLevel } from "../../../settings/SettingLevel";
@@ -122,6 +122,9 @@ interface IState {
threepids?: IThreepid[];
deviceNotificationsEnabled: boolean;
desktopNotifications: boolean;
desktopShowBody: boolean;
audioNotifications: boolean;
clearingNotifications: boolean;
@@ -191,15 +194,10 @@ const maximumVectorState = (
const NotificationActivitySettings = (): JSX.Element => {
return (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<div>
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</Form.Root>
</div>
);
};
@@ -215,6 +213,9 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
this.state = {
phase: Phase.Loading,
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true,
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
clearingNotifications: false,
ruleIdsWithError: {},
};
@@ -230,9 +231,18 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
public componentDidMount(): void {
this.settingWatchers = [
SettingsStore.watchSetting("notificationsEnabled", null, (...[, , , , value]) =>
this.setState({ desktopNotifications: value as boolean }),
),
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => {
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
@@ -276,7 +286,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
if (settingsEvent) {
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, notificationsEnabled);
await this.updateDeviceNotifications(notificationsEnabled);
}
}
@@ -400,8 +410,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
});
}
private onMasterRuleChanged: ChangeEventHandler<HTMLInputElement> = async (evt): Promise<void> => {
const { checked } = evt.target;
private onMasterRuleChanged = async (checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
const masterRule = this.state.masterPushRule!;
@@ -422,8 +431,11 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
}));
};
private onEmailNotificationsChanged = async (email: string, evt: ChangeEvent<HTMLInputElement>): Promise<void> => {
const { checked } = evt.target;
private updateDeviceNotifications = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onEmailNotificationsChanged = async (email: string, checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
try {
@@ -458,6 +470,18 @@ 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> => {
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.Persisting,
@@ -639,11 +663,11 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
private renderTopSection(): JSX.Element {
const masterSwitch = (
<SettingsToggleInput
checked={!this.isInhibited}
name="notif-master-switch"
<LabelledToggleSwitch
data-testid="notif-master-switch"
value={!this.isInhibited}
label={_t("settings|notifications|enable_notifications_account")}
helpMessage={_t("settings|notifications|enable_notifications_account_detail")}
caption={_t("settings|notifications|enable_notifications_account_detail")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>
@@ -657,10 +681,10 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
const emailSwitches = (this.state.threepids || [])
.filter((t) => t.medium === ThreepidMedium.Email)
.map((e) => (
<SettingsToggleInput
name="notif-email-switch"
<LabelledToggleSwitch
data-testid="notif-email-switch"
key={e.address}
checked={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)}
value={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)}
label={_t("settings|notifications|enable_email_notifications", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting}
@@ -671,13 +695,37 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
<SettingsSubsection>
{masterSwitch}
<SettingsFlag name="deviceNotificationsEnabled" level={SettingLevel.DEVICE} />
<LabelledToggleSwitch
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 && (
<>
<SettingsFlag name="notificationsEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="notificationBodyEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="audioNotificationsEnabled" level={SettingLevel.DEVICE} />
<LabelledToggleSwitch
data-testid="notif-setting-notificationsEnabled"
value={this.state.desktopNotifications}
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}
/>
</>
)}
@@ -820,12 +868,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
}
return (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<>
{this.renderTopSection()}
{this.renderCategory(RuleClass.VectorGlobal)}
{this.renderCategory(RuleClass.VectorMentions)}
@@ -833,7 +876,7 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
{this.renderTargets()}
<NotificationActivitySettings />
{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 { logger } from "matrix-js-sdk/src/logger";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { Root, InlineField, Label, ToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
@@ -66,31 +66,33 @@ export default class SetIntegrationManager extends React.Component<EmptyObject,
if (!SettingsStore.getValue(UIFeature.Widgets)) return null;
return (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
className="mx_SetIntegrationManager"
data-testid="mx_SetIntegrationManager"
>
<div className="mx_SetIntegrationManager" data-testid="mx_SetIntegrationManager">
<div className="mx_SettingsFlag">
<div className="mx_SetIntegrationManager_heading_manager">
<Heading size="3">{_t("integration_manager|manage_title")}</Heading>
<Heading id="mx_SetIntegrationManager_ManagerName" size="4">
{managerName}
</Heading>
<Heading size="4">{managerName}</Heading>
</div>
</div>
<SettingsSubsectionText id="mx_SetIntegrationManager_BodyText">{bodyText}</SettingsSubsectionText>
<SettingsSubsectionText>{bodyText}</SettingsSubsectionText>
<SettingsSubsectionText>{_t("integration_manager|explainer")}</SettingsSubsectionText>
<SettingsToggleInput
name="enable_im"
label={_t("integration_manager|toggle_label")}
checked={this.state.provisioningEnabled}
onChange={this.onProvisioningToggled}
/>
</Form.Root>
<Root>
<InlineField
name="enable_im"
control={
<ToggleInput
role="switch"
id="mx_SetIntegrationManager_Toggle"
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 { Form, SettingsToggleInput } from "@vector-im/compound-web";
import NewAndImprovedIcon from "../../../../../res/img/element-icons/new-and-improved.svg";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useNotificationSettings } from "../../../../hooks/useNotificationSettings";
import { useSettingValue } from "../../../../hooks/useSettings";
import { _t } from "../../../../languageHandler";
import {
DefaultNotificationSettings,
@@ -19,11 +19,13 @@ import {
} from "../../../../models/notificationsettings/NotificationSettings";
import { RoomNotifState } from "../../../../RoomNotifs";
import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { clearAllNotifications } from "../../../../utils/notifications";
import AccessibleButton from "../../elements/AccessibleButton";
import ExternalLink from "../../elements/ExternalLink";
import LabelledCheckbox from "../../elements/LabelledCheckbox";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import StyledRadioGroup from "../../elements/StyledRadioGroup";
import TagComposer from "../../elements/TagComposer";
import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge";
@@ -68,6 +70,10 @@ function useHasUnreadNotifications(): boolean {
export default function NotificationSettings2(): JSX.Element {
const cli = useMatrixClientContext();
const desktopNotifications = useSettingValue("notificationsEnabled");
const desktopShowBody = useSettingValue("notificationBodyEnabled");
const audioNotifications = useSettingValue("audioNotificationsEnabled");
const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli);
const disabled = model === null || hasPendingChanges;
@@ -110,258 +116,267 @@ export default function NotificationSettings2(): JSX.Element {
</SettingsBanner>
)}
<SettingsSection>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
<div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags">
<LabelledToggleSwitch
label={_t("settings|notifications|enable_notifications_account")}
value={!settings.globalMute}
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")}
>
<div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags">
<SettingsToggleInput
name="enable_notifications_account"
label={_t("settings|notifications|enable_notifications_account")}
checked={!settings.globalMute}
disabled={disabled}
onChange={(evt) => {
reconcile({
...model!,
globalMute: !evt.target.checked,
});
}}
/>
<SettingsFlag name="notificationsEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="notificationBodyEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="audioNotificationsEnabled" level={SettingLevel.DEVICE} />
</div>
<SettingsSubsection
heading={_t("settings|notifications|default_setting_section")}
description={_t("settings|notifications|default_setting_description")}
>
<StyledRadioGroup
name="defaultNotificationLevel"
value={toDefaultLevels(settings.defaultLevels)}
disabled={disabled}
definitions={NotificationOptions}
onChange={(value) => {
reconcile({
...model!,
defaultLevels: {
...model!.defaultLevels,
dm:
value !== NotificationDefaultLevels.MentionsKeywords
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
room:
value === NotificationDefaultLevels.AllMessages
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|play_sound_for_section")}
description={_t("settings|notifications|play_sound_for_description")}
>
<LabelledCheckbox
label={_t("common|people")}
value={settings.sound.people !== undefined}
disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
people: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|mentions_keywords")}
value={settings.sound.mentions !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
mentions: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|voip")}
value={settings.sound.calls !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
calls: value ? "ring" : undefined,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|notifications|other_section")}>
<LabelledCheckbox
label={_t("settings|notifications|invites")}
value={settings.activity.invite}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
invite: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|room_activity")}
value={settings.activity.status_event}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
status_event: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notices")}
value={settings.activity.bot_notices}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
bot_notices: value,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|mentions_keywords")}
description={_t(
"settings|notifications|keywords",
{},
{
badge: (
<StatelessNotificationBadge
symbol="1"
count={1}
level={NotificationLevel.Notification}
/>
),
},
)}
>
<LabelledCheckbox
label={_t("settings|notifications|notify_at_room")}
value={settings.mentions.room}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
room: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_mention", {
mxid: cli.getUserId()!,
})}
id="mx_NotificationSettings2_MentionCheckbox"
value={settings.mentions.user}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
user: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_keyword")}
byline={_t("settings|notifications|keywords_prompt")}
value={settings.mentions.keywords}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
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")}
/>
<StyledRadioGroup
name="defaultNotificationLevel"
value={toDefaultLevels(settings.defaultLevels)}
disabled={disabled}
definitions={NotificationOptions}
onChange={(value) => {
reconcile({
...model!,
defaultLevels: {
...model!.defaultLevels,
dm:
value !== NotificationDefaultLevels.MentionsKeywords
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
room:
value === NotificationDefaultLevels.AllMessages
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|play_sound_for_section")}
description={_t("settings|notifications|play_sound_for_description")}
>
<LabelledCheckbox
label={_t("common|people")}
value={settings.sound.people !== undefined}
disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
people: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|mentions_keywords")}
value={settings.sound.mentions !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
mentions: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|voip")}
value={settings.sound.calls !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
calls: value ? "ring" : undefined,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|notifications|other_section")}>
<LabelledCheckbox
label={_t("settings|notifications|invites")}
value={settings.activity.invite}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
invite: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|room_activity")}
value={settings.activity.status_event}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
status_event: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notices")}
value={settings.activity.bot_notices}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
bot_notices: value,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|mentions_keywords")}
description={_t(
"settings|notifications|keywords",
{},
{
badge: (
<StatelessNotificationBadge
symbol="1"
count={1}
level={NotificationLevel.Notification}
/>
),
},
)}
>
<LabelledCheckbox
label={_t("settings|notifications|notify_at_room")}
value={settings.mentions.room}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
room: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_mention", {
mxid: cli.getUserId()!,
})}
value={settings.mentions.user}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
user: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_keyword")}
byline={_t("settings|notifications|keywords_prompt")}
value={settings.mentions.keywords}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
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.tac_only_notifications" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<NotificationPusherSettings />
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
{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>
)}
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<NotificationPusherSettings />
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
{hasUnreadNotifications && (
<AccessibleButton
kind="danger_outline"
disabled={model === null}
onClick={() => {
reconcile(DefaultNotificationSettings);
kind="primary_outline"
disabled={updatingUnread}
onClick={async () => {
setUpdatingUnread(true);
await clearAllNotifications(cli);
setUpdatingUnread(false);
}}
>
{_t("settings|notifications|quick_actions_reset")}
{_t("settings|notifications|quick_actions_mark_all_read")}
</AccessibleButton>
</SettingsSubsection>
</Form.Root>
)}
<AccessibleButton
kind="danger_outline"
disabled={model === null}
onClick={() => {
reconcile(DefaultNotificationSettings);
}}
>
{_t("settings|notifications|quick_actions_reset")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection>
</div>
);

View File

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

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import React, { type ContextType } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler";
import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
@@ -79,33 +78,26 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
return (
<SettingsTab data-testid="General">
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsSection heading={_t("common|general")}>
<RoomProfileSettings roomId={room.roomId} />
</SettingsSection>
<SettingsSection heading={_t("common|general")}>
<RoomProfileSettings roomId={room.roomId} />
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|aliases_section")}>
<AliasSettings
roomId={room.roomId}
canSetCanonicalAlias={canSetCanonical}
canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv}
/>
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|aliases_section")}>
<AliasSettings
roomId={room.roomId}
canSetCanonicalAlias={canSetCanonical}
canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv}
/>
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection>
{leaveSection}
</SettingsSection>
</Form.Root>
<SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection>
{leaveSection}
</SettingsSection>
</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.
*/
import React, { type ChangeEventHandler, type JSX, type ReactNode } from "react";
import React, { type JSX, type ReactNode } from "react";
import {
GuestAccess,
HistoryVisibility,
@@ -17,10 +17,11 @@ import {
EventType,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Form, InlineSpinner, SettingsToggleInput } from "@vector-im/compound-web";
import { InlineSpinner } from "@vector-im/compound-web";
import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg";
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
@@ -183,8 +184,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
});
};
private onGuestAccessChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
const allowed = evt.target.checked;
private onGuestAccessChange = (allowed: boolean): void => {
const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
const beforeGuestAccess = this.state.guestAccess;
if (beforeGuestAccess === guestAccess) return;
@@ -405,14 +405,13 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
return (
<div className="mx_SecurityRoomSettingsTab_advancedSection">
<SettingsToggleInput
name="guest-access"
checked={guestAccess === GuestAccess.CanJoin}
<LabelledToggleSwitch
value={guestAccess === GuestAccess.CanJoin}
onChange={this.onGuestAccessChange}
disabled={!canSetGuestAccess}
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>
);
}
@@ -446,41 +445,33 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
return (
<SettingsTab>
<SettingsSection heading={_t("room_settings|security|title")}>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
<SettingsFieldset
legend={_t("settings|security|encryption_section")}
description={
isEncryptionForceDisabled && !isEncrypted
? undefined
: _t("room_settings|security|encryption_permanent")
}
>
<SettingsFieldset
legend={_t("settings|security|encryption_section")}
description={
isEncryptionForceDisabled && !isEncrypted
? undefined
: _t("room_settings|security|encryption_permanent")
}
>
{isEncryptionLoading ? (
<InlineSpinner />
) : (
<>
<SettingsToggleInput
name="enable-encryption"
checked={isEncrypted}
onChange={this.onEncryptionChange}
label={_t("common|encrypted")}
disabled={!canEnableEncryption}
/>
{isEncryptionForceDisabled && !isEncrypted && (
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
)}
{encryptionSettings}
</>
)}
</SettingsFieldset>
{this.renderJoinRule()}
{historySection}
</Form.Root>
{isEncryptionLoading ? (
<InlineSpinner />
) : (
<>
<LabelledToggleSwitch
value={isEncrypted}
onChange={this.onEncryptionChange}
label={_t("common|encrypted")}
disabled={!canEnableEncryption}
/>
{isEncryptionForceDisabled && !isEncrypted && (
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
)}
{encryptionSettings}
</>
)}
</SettingsFieldset>
{this.renderJoinRule()}
{historySection}
</SettingsSection>
</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.
*/
import React, { type ChangeEventHandler, useCallback, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { JoinRule, EventType, type RoomState, type Room } from "matrix-js-sdk/src/matrix";
import { type RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
import { Form, SettingsToggleInput } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { SettingsSubsection } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { ElementCall } from "../../../../../models/Call";
@@ -45,9 +45,8 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
});
const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(evt): void => {
const enabled = evt.target.checked;
const onChange = useCallback(
(enabled: boolean): void => {
setElementCallEnabled(enabled);
// Take a copy to avoid mutating the original
@@ -74,17 +73,16 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
return (
<SettingsToggleInput
name="element-call-switch"
data-test-id="element-call-switch"
<LabelledToggleSwitch
data-testid="element-call-switch"
label={_t("room_settings|voip|enable_element_call_label", { brand })}
helpMessage={_t("room_settings|voip|enable_element_call_caption", {
caption={_t("room_settings|voip|enable_element_call_caption", {
brand,
})}
checked={elementCallEnabled}
value={elementCallEnabled}
onChange={onChange}
disabled={!maySend}
disabledMessage={_t("room_settings|voip|enable_element_call_no_permissions_tooltip")}
tooltip={_t("room_settings|voip|enable_element_call_no_permissions_tooltip")}
/>
);
};
@@ -97,16 +95,9 @@ export const VoipRoomSettingsTab: React.FC<Props> = ({ room }) => {
return (
<SettingsTab>
<SettingsSection heading={_t("settings|voip|title")}>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsSubsection heading={_t("room_settings|voip|call_type_section")}>
<ElementCallSwitch room={room} />
</SettingsSubsection>
</Form.Root>
<SettingsSubsection heading={_t("room_settings|voip|call_type_section")}>
<ElementCallSwitch room={room} />
</SettingsSubsection>
</SettingsSection>
</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 { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import SettingsStore from "../../../../../settings/SettingsStore";
import SettingsFlag from "../../../elements/SettingsFlag";
import Field from "../../../elements/Field";
@@ -48,6 +48,7 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
const brand = SdkConfig.get().brand;
const toggle = (
<AccessibleButton
kind="link"
@@ -61,18 +62,21 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
const tooltipContent = _t("settings|appearance|custom_font_description", { brand });
advanced = (
<>
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} />
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} useCheckbox={true} />
<SettingsFlag
name="useBundledEmojiFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useBundledEmojiFont: checked })}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useSystemFont: checked })}
/>
<Field
@@ -85,6 +89,8 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value);
}}
tooltipContent={tooltipContent}
forceTooltipVisible={true}
disabled={!this.state.useSystemFont}
value={this.state.systemFont}
/>
@@ -103,18 +109,11 @@ export default class AppearanceUserSettingsTab extends React.Component<EmptyObje
return (
<SettingsTab data-testid="mx_AppearanceUserSettingsTab">
<SettingsSection>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<ThemeChoicePanel />
<LayoutSwitcher />
<FontScalingPanel />
{this.renderAdvancedSection()}
<ImageSizePanel />
</Form.Root>
<ThemeChoicePanel />
<LayoutSwitcher />
<FontScalingPanel />
{this.renderAdvancedSection()}
<ImageSizePanel />
</SettingsSection>
</SettingsTab>
);

View File

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

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import { sortBy } from "lodash";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Form } from "@vector-im/compound-web";
import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
@@ -107,44 +106,37 @@ export default class LabsUserSettingsTab extends React.Component<EmptyObject> {
return (
<SettingsTab>
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<SettingsSection heading={_t("labs|beta_section")}>
<SettingsSubsectionText>
{_t("labs|beta_description", { brand: SdkConfig.get("brand") })}
</SettingsSubsectionText>
{betaSection}
</SettingsSection>
<SettingsSection heading={_t("labs|beta_section")}>
<SettingsSubsectionText>
{_t("labs|beta_description", { brand: SdkConfig.get("brand") })}
</SettingsSubsectionText>
{betaSection}
</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>
);
},
{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>
},
)}
</SettingsSubsectionText>
{labsSections}
</SettingsSection>
)}
</SettingsTab>
);
}

View File

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

View File

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

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