Compare commits

..

2 Commits

Author SHA1 Message Date
Robin
569d525c6e Wait for a close action before closing a call
This creates a distinction between the user hanging up and the widget being ready to close, which is useful for allowing Element Call to show error screens when disconnected from the call, for example.
2025-02-20 13:54:10 +07:00
Robin
7799cb2ec5 Avoid destroying calls until they are hidden from the UI
We often want calls to exist even when no more participants are left in the MatrixRTC session. So, we should avoid destroying calls as long as they're being presented in the UI; this means that the user has an intent to either join the call or continue looking at an error screen, and we shouldn't interrupt that interaction.

The RoomViewStore is now what takes care of creating and destroying calls, rather than the CallView. In general it seems kinda impossible to safely create and destroy model objects from React lifecycle hooks, so moving this responsibility to a store seemed appropriate and resolves existing issues with calls in React strict mode.
2025-02-20 13:54:10 +07:00
308 changed files with 4401 additions and 10912 deletions

View File

@@ -3,7 +3,6 @@ on:
workflow_dispatch: {}
push:
tags: [v*]
pull_request: {}
schedule:
# This job can take a while, and we have usage limits, so just publish develop only twice a day
- cron: "0 7/12 * * *"
@@ -13,91 +12,42 @@ jobs:
buildx:
name: Docker Buildx
runs-on: ubuntu-24.04
environment: ${{ github.event_name != 'pull_request' && 'dockerhub' || '' }}
environment: dockerhub
permissions:
id-token: write # needed for signing the images with GitHub OIDC Token
packages: write # needed for publishing packages to GHCR
env:
TEST_TAG: vectorim/element-web:test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and load
id: test-build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
with:
context: .
load: true
- name: Test the image
env:
IMAGEID: ${{ steps.test-build.outputs.imageid }}
timeout-minutes: 2
run: |
set -x
# Make a fake module to test the image
MODULE_PATH="modules/module_name/index.js"
mkdir -p $(dirname $MODULE_PATH)
echo 'alert("Testing");' > $MODULE_PATH
# Spin up a container of the image
ELEMENT_WEB_PORT=8181
CONTAINER_ID=$(
docker run \
--rm \
-e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \
-dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \
-v $(pwd)/modules:/tmp/element-web-modules \
"$IMAGEID" \
)
# Run some smoke tests
wget --retry-connrefused --tries=5 -q --wait=3 --spider "http://localhost:$ELEMENT_WEB_PORT/modules/module_name/index.js"
MODULE_0=$(curl "http://localhost:$ELEMENT_WEB_PORT/config.json" | jq -r .modules[0])
test "$MODULE_0" = "/${MODULE_PATH}"
# Check healthcheck
until test "$(docker inspect -f {{.State.Health.Status}} $CONTAINER_ID)" == "healthy"; do
sleep 1
done
# Clean up
docker stop "$CONTAINER_ID"
- name: Docker meta
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
if: github.event_name != 'pull_request'
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
with:
images: |
vectorim/element-web
@@ -110,8 +60,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
if: github.event_name != 'pull_request'
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
with:
context: .
push: true
@@ -123,7 +72,6 @@ jobs:
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
if: github.event_name != 'pull_request'
run: |
images=""
for tag in ${TAGS}; do
@@ -133,7 +81,6 @@ jobs:
- name: Update repo description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
if: github.event_name != 'pull_request'
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}

View File

@@ -23,7 +23,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates

View File

@@ -19,7 +19,6 @@ jobs:
contents: write
issues: write
pull-requests: read
id-token: write
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}

View File

@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success

View File

@@ -23,7 +23,7 @@ jobs:
run: "yarn update:jitsi"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/jitsi-update

View File

@@ -1,65 +1,3 @@
Changes in [1.11.95](https://github.com/element-hq/element-web/releases/tag/v1.11.95) (2025-03-11)
==================================================================================================
## ✨ Features
* Room List Store: Filter rooms by active space ([#29399](https://github.com/element-hq/element-web/pull/29399)). Contributed by @MidhunSureshR.
* Room List - Update the room list store on actions from the dispatcher ([#29397](https://github.com/element-hq/element-web/pull/29397)). Contributed by @MidhunSureshR.
* Room List - Implement a minimal view model ([#29357](https://github.com/element-hq/element-web/pull/29357)). Contributed by @MidhunSureshR.
* New room list: add space menu in room header ([#29352](https://github.com/element-hq/element-web/pull/29352)). Contributed by @florianduros.
* Room List - Store sorted rooms in skip list ([#29345](https://github.com/element-hq/element-web/pull/29345)). Contributed by @MidhunSureshR.
* New room list: add dial to search section ([#29359](https://github.com/element-hq/element-web/pull/29359)). Contributed by @florianduros.
* New room list: add compose menu for spaces in header ([#29347](https://github.com/element-hq/element-web/pull/29347)). Contributed by @florianduros.
* Use EditInPlace control for Identity Server picker to improve a11y ([#29280](https://github.com/element-hq/element-web/pull/29280)). Contributed by @Half-Shot.
* First step to add header to new room list ([#29320](https://github.com/element-hq/element-web/pull/29320)). Contributed by @florianduros.
* Add Windows 64-bit arm link and remove 32-bit link on compatibility page ([#29312](https://github.com/element-hq/element-web/pull/29312)). Contributed by @t3chguy.
* Honour the backup disable flag from Element X ([#29290](https://github.com/element-hq/element-web/pull/29290)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Fix edited code block width ([#29394](https://github.com/element-hq/element-web/pull/29394)). Contributed by @florianduros.
* new room list: keep space name in one line in header ([#29369](https://github.com/element-hq/element-web/pull/29369)). Contributed by @florianduros.
* Dismiss "Key storage out of sync" toast when secrets received ([#29348](https://github.com/element-hq/element-web/pull/29348)). Contributed by @richvdh.
* Minor CSS fixes for the new room list ([#29334](https://github.com/element-hq/element-web/pull/29334)). Contributed by @florianduros.
* Add padding to room header icon ([#29271](https://github.com/element-hq/element-web/pull/29271)). Contributed by @langleyd.
Changes in [1.11.94](https://github.com/element-hq/element-web/releases/tag/v1.11.94) (2025-02-27)
==================================================================================================
## 🐛 Bug Fixes
* [Backport staging] fix: /tmp/element-web-config may already exist preventing the container from booting up ([#29377](https://github.com/element-hq/element-web/pull/29377)). Contributed by @RiotRobot.
Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25)
==================================================================================================
## ✨ Features
* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy.
* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh.
* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh.
* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot.
* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros.
* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros.
* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR.
* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent.
* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros.
* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR.
* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy.
* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros.
* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros.
* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot.
## 🐛 Bug Fixes
* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros.
* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd.
* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr.
* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy.
* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown.
* Revert `GoToHome` keyboard shortcut to `Ctrl``Shift``H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate.
* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros.
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
==================================================================================================
## ✨ Features

View File

@@ -1,5 +1,3 @@
# syntax=docker.io/docker/dockerfile:1.14-labs
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
@@ -10,7 +8,7 @@ ARG JS_SDK_BRANCH="master"
WORKDIR /src
COPY --exclude=docker . /src
COPY . /src
RUN /src/scripts/docker-link-repos.sh
RUN yarn --network-timeout=200000 install
RUN /src/scripts/docker-package.sh
@@ -21,15 +19,11 @@ RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginx:alpine-slim
# Install jq and moreutils for sponge, both used by our entrypoints
RUN apk add jq moreutils
COPY --from=builder /src/webapp /app
# Override default nginx config. Templates in `/etc/nginx/templates` are passed
# through `envsubst` by the nginx docker image entry point.
COPY /docker/nginx-templates/* /etc/nginx/templates/
COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/
# Tell nginx to put its pidfile elsewhere, so it can run as non-root
RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf
@@ -46,5 +40,3 @@ USER nginx
# HTTP listen port
ENV ELEMENT_WEB_PORT=80
HEALTHCHECK --start-period=5s CMD wget -q --spider http://localhost:$ELEMENT_WEB_PORT/config.json

View File

@@ -31,7 +31,5 @@ module.exports = {
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime",
["@babel/plugin-proposal-decorators", { version: "2023-11" }], // only needed by the js-sdk
"@babel/plugin-transform-class-static-block", // only needed by the js-sdk for decorators
],
};

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# Loads modules from `/tmp/element-web-modules` into config.json's `modules` field
set -e
entrypoint_log() {
if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
echo "$@"
fi
}
# Copy these config files as a base
mkdir -p /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/tmp/element-web-modules" ]; then
cd /tmp/element-web-modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint
ENTRYPOINT="index.js"
if [ -f "/tmp/element-web-modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/tmp/element-web-modules/$MODULE/package.json")
fi
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"
# Append the module to the config
jq ".modules += [\"/modules/$MODULE/$ENTRYPOINT\"]" /tmp/element-web-config/config.json | sponge /tmp/element-web-config/config.json
done
fi

View File

@@ -18,12 +18,8 @@ server {
}
# covers config.json and config.hostname.json requests as it is prefix.
location /config {
root /tmp/element-web-config;
add_header Cache-Control "no-cache";
}
location /modules {
alias /tmp/element-web-modules;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;

View File

@@ -66,18 +66,6 @@ on other runtimes may require root privileges. To resolve this, either run the
image as root (`docker run --user 0`) or, better, change the port that nginx
listens on via the `ELEMENT_WEB_PORT` environment variable.
[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded
by being made available (e.g. via bind mount) in a directory within `/tmp/element-web-modules/`.
The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive.
These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field.
If you wish to use docker in read-only mode,
you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode)
but additionally include the following directories:
- /tmp/element-web-config/
- /etc/nginx/conf.d/
The behaviour of the docker image can be customised via the following
environment variables:

View File

@@ -19,7 +19,6 @@ export default {
ignore: [
// Keep for now
"src/hooks/useLocalStorageState.ts",
"src/hooks/useTimeout.ts",
"src/components/views/elements/InfoTooltip.tsx",
"src/components/views/elements/StyledCheckbox.tsx",
],

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.95",
"version": "1.11.91",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -74,7 +74,7 @@
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001701",
"caniuse-lite": "1.0.30001699",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -91,9 +91,9 @@
"@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.6.4",
"@vector-im/matrix-wysiwyg": "2.38.2",
"@vector-im/compound-design-tokens": "^3.0.0",
"@vector-im/compound-web": "^7.6.1",
"@vector-im/matrix-wysiwyg": "2.38.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -138,7 +138,7 @@
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"re-resizable": "6.10.3",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0",
@@ -162,11 +162,9 @@
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-proposal-export-default-from": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1",
"@babel/plugin-transform-class-static-block": "^7.26.0",
"@babel/plugin-transform-logical-assignment-operators": "^7.20.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.12.1",
"@babel/plugin-transform-numeric-separator": "^7.12.7",
@@ -220,12 +218,12 @@
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"babel-jest": "^29.0.0",
"babel-loader": "^10.0.0",
"babel-loader": "^9.0.0",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"blob-polyfill": "^9.0.0",
"chokidar": "^4.0.0",
"concurrently": "^9.0.0",
"copy-webpack-plugin": "^13.0.0",
"copy-webpack-plugin": "^12.0.0",
"core-js": "^3.38.1",
"cronstrue": "^2.41.0",
"css-loader": "^7.0.0",
@@ -276,7 +274,7 @@
"postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.5.2",
"prettier": "3.5.1",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.0",
@@ -290,7 +288,7 @@
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.16.0",
"ts-node": "^10.9.1",
"typescript": "5.8.2",
"typescript": "5.7.3",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.51.0-noble
FROM mcr.microsoft.com/playwright:v1.50.1-noble
WORKDIR /work

View File

@@ -28,7 +28,7 @@ const checkDMRoom = async (page: Page) => {
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
await expect(

View File

@@ -22,6 +22,18 @@ test.use({
msc3814_enabled: true,
},
},
config: async ({ config, context }, use) => {
const wellKnown = {
...config.default_server_config,
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use(config);
},
});
test.describe("Dehydration", () => {
@@ -104,40 +116,6 @@ test.describe("Dehydration", () => {
expect(dehydratedDeviceIds.length).toBe(1);
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
});
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
await logIntoElement(page, credentials);
// Create a dehydrated device by setting up recovery (see "'Set up
// recovery' creates dehydrated device" test above)
const settingsDialogLocator = await app.settings.openUserSettings("Encryption");
await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click();
// First it displays an informative panel about the recovery key
await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
// Next, it displays the new recovery key. We click on the copy button.
await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible();
await settingsDialogLocator.getByRole("button", { name: "Copy" }).click();
const recoveryKey = await app.getClipboard();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
await expect(
settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }),
).toBeVisible();
await settingsDialogLocator.getByRole("textbox").fill(recoveryKey);
await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click();
await expectDehydratedDeviceEnabled(app);
// After recovery is set up, we reset our cryptographic identity, which
// should drop the dehydrated device.
await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click();
await settingsDialogLocator.getByRole("button", { name: "Continue" }).click();
await expectDehydratedDeviceDisabled(app);
});
});
async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
@@ -166,16 +144,3 @@ async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise<void>
})
.toEqual(1);
}
/** Wait for our user to not have a dehydrated device */
async function expectDehydratedDeviceDisabled(app: ElementAppPage): Promise<void> {
// It might be nice to do this via the UI, but currently this info is not exposed via the UI.
//
// Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`.
await expect
.poll(async () => {
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
return dehydratedDeviceIds.length;
})
.toEqual(0);
}

View File

@@ -21,7 +21,6 @@ import {
waitForVerificationRequest,
} from "./utils";
import { type Bot } from "../../pages/bot";
import { Toasts } from "../../pages/toasts.ts";
test.describe("Device verification", { tag: "@no-webkit" }, () => {
let aliceBotClient: Bot;
@@ -73,51 +72,6 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
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 }) => {
// Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens
// when we are in an encrypted room.
await aliceBotClient.createRoom({
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
],
});
// In order to simulate a real environment more accurately, we need to slow down the arrival of the
// `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing*
// `m.secret.request` messages.
await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => {
await route.fulfill({ json: {} });
await new Promise((f) => setTimeout(f, 1000));
await route.fetch();
});
await logIntoElement(page, credentials);
// 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 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();
// There should be no toast (other than the notifications one)
const toasts = new Toasts(page);
await toasts.rejectToast("Notifications");
await toasts.assertNoToasts();
});
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
// A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
await logIntoElement(page, credentials);

View File

@@ -77,7 +77,7 @@ test.describe("Invite dialog", function () {
"should support inviting a user to Direct Messages",
{ tag: "@screenshot" },
async ({ page, app, user, bot }) => {
await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click();
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
const other = page.locator(".mx_InviteDialog_other");
// Assert that the header is rendered

View File

@@ -1,87 +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 type { Page } from "@playwright/test";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the header section of the room list
* @param page
*/
function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
await composeMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
// New message should open the direct messages dialog
await page.getByRole("menuitem", { name: "New message" }).click();
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
await app.closeDialog();
// New room should open the room creation dialog
await composeMenu.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await expect(page.getByRole("heading", { name: "Create a private room" })).toBeVisible();
await app.closeDialog();
});
test("should render the header section for a space", { tag: "@screenshot" }, async ({ page, app, user }) => {
await app.client.createSpace({ name: "MySpace" });
await page.getByRole("button", { name: "MySpace" }).click();
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png");
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible();
const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" });
await spaceMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-space-menu.png");
// It should open the space home
await page.getByRole("menuitem", { name: "Space home" }).click();
await expect(page.getByRole("main").getByRole("heading", { name: "MySpace" })).toBeVisible();
// It should open the invite dialog
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Invite" }).click();
await expect(page.getByRole("heading", { name: "Invite to MySpace" })).toBeVisible();
await app.closeDialog();
// It should open the space preferences
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Preferences" }).click();
await expect(page.getByRole("heading", { name: "Preferences" })).toBeVisible();
await app.closeDialog();
// It should open the space settings
await spaceMenu.click();
await page.getByRole("menuitem", { name: "Space Settings" }).click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await app.closeDialog();
});
});

View File

@@ -1,50 +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 Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
for (let i = 0; i < 30; i++) {
await app.client.createRoom({ name: `room${i}` });
}
});
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png");
await roomListView.hover();
// 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 expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
});
test("should open the room when it is clicked", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
});

View File

@@ -9,7 +9,7 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Room list panel", () => {
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
@@ -19,23 +19,16 @@ test.describe("Room list panel", () => {
* @param page
*/
function getRoomListView(page: Page) {
return page.getByTestId("room-list-panel");
return page.getByTestId("room-list-view");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
// Populate the room list
for (let i = 0; i < 20; i++) {
await app.client.createRoom({ name: `room${i}` });
}
});
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
test("should render the room list view", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomListView(page);
// Wait for the last room to be visible
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-panel.png");
await expect(roomListView).toMatchScreenshot("room-list-view.png");
});
});

View File

@@ -67,15 +67,6 @@ test.describe("RightPanel", () => {
},
);
test("should have padding under leave room", { tag: "@screenshot" }, async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
const leaveButton = await page.getByRole("menuitem", { name: "Leave Room" });
await leaveButton.scrollIntoViewIfNeeded();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png");
});
test("should handle clicking add widgets", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);

View File

@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { type Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { type ElementAppPage } from "../../pages/ElementAppPage";
@@ -86,15 +85,6 @@ test.describe("Room Header", () => {
await expect(header).toMatchScreenshot("room-header-long-name.png");
},
);
test("should render room header icon correctly", { tag: "@screenshot" }, async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room", visibility: "public" as Visibility });
await app.viewRoomByName("Test Room");
const header = page.locator(".mx_RoomHeader");
await expect(header).toMatchScreenshot("room-header-with-icon.png");
});
});
test.describe("with a video room", () => {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -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.getByLabel("Use bundled emoji font").click();
await tab.getByLabel("Use a system font").click();
await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');

View File

@@ -1,18 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Quick settings menu", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.getByRole("button", { name: "Quick settings" }).click();
// Assert that the top heading is renderedc
const settings = page.getByTestId("quick-settings-menu");
await expect(settings).toBeVisible();
await expect(settings).toMatchScreenshot("quick-settings.png");
});
});

View File

@@ -32,7 +32,7 @@ test.describe("Security user settings tab", () => {
});
test.describe("AnalyticsLearnMoreDialog", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
await tab.getByRole("button", { name: "Learn more" }).click();
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
@@ -41,57 +41,16 @@ test.describe("Security user settings tab", () => {
});
});
test("should be able to set an ID server", async ({ app, context, user, page }) => {
test("should contain section to set ID server", async ({ app }) => {
const tab = await app.settings.openUserSettings("Security");
await context.route("https://identity.example.org/_matrix/identity/v2", async (route) => {
await route.fulfill({
status: 200,
json: {},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/account/register", async (route) => {
await route.fulfill({
status: 200,
json: {
token: "AToken",
},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/account", async (route) => {
await route.fulfill({
status: 200,
json: {
user_id: user.userId,
},
});
});
await context.route("https://identity.example.org/_matrix/identity/v2/terms", async (route) => {
await route.fulfill({
status: 200,
json: {
policies: {},
},
});
});
const setIdServer = tab.locator(".mx_IdentityServerPicker");
const setIdServer = tab.locator(".mx_SetIdServer");
await setIdServer.scrollIntoViewIfNeeded();
const textElement = setIdServer.getByRole("textbox", { name: "Enter a new identity server" });
await textElement.click();
await textElement.fill("https://identity.example.org");
await setIdServer.getByRole("button", { name: "Change" }).click();
await expect(setIdServer.getByText("Checking server")).toBeVisible();
// Accept terms
await page.getByTestId("dialog-primary-button").click();
// Check identity has changed.
await expect(setIdServer.getByText("Your identity server has been changed")).toBeVisible();
// Ensure section title is updated.
await expect(tab.getByText(`Identity server (identity.example.org)`, { exact: true })).toBeVisible();
// Assert that an input area for identity server exists
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
});
test("should show integrations as enabled", async ({ app, page, user }) => {
test("should enable show integrations as enabled", async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
const setIntegrationManager = tab.locator(".mx_SetIntegrationManager");
@@ -102,9 +61,7 @@ test.describe("Security user settings tab", () => {
}),
).toBeVisible();
// Make sure integration manager's toggle switch is enabled
const toggleswitch = setIntegrationManager.getByLabel("Enable the integration manager");
await expect(toggleswitch).toBeVisible();
await expect(toggleswitch).toBeChecked();
await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
"Manage integrations(scalar.vector.im)",
);

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
@@ -121,10 +121,9 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list
const roomList = page.getByRole("tree", { name: "Rooms" });
await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
// Assert rooms exist in the space explorer
await expect(
@@ -156,7 +155,7 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await page.getByText("Sample Room").click({ force: true }); // force click as checkbox size is zero
// Temporal implementation as multiple elements with the role "button" and name "Add" are found
await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click();
@@ -166,50 +165,6 @@ test.describe("Spaces", () => {
).toBeVisible();
});
test(
"should allow user to add an existing room to a space after creation",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await app.client.createRoom({
name: "Sample Room",
});
await app.client.createRoom({
name: "A Room that will not be selected",
});
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");
await menu.getByRole("textbox", { name: "Name" }).press("Enter");
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "Add existing room" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await expect(page.getByRole("dialog", { name: "Avatar Add existing rooms" })).toMatchScreenshot(
"add-existing-rooms-dialog.png",
);
await page.getByRole("button", { name: "Add" }).click();
await expect(
page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }),
).toBeVisible();
},
);
test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({
visibility: "public" as any,
@@ -336,36 +291,4 @@ test.describe("Spaces", () => {
// Assert we get shown the new room intro, and thus not the soft crash screen
await expect(page.locator(".mx_NewRoomIntro")).toBeVisible();
});
test("should render spaces view", { tag: "@screenshot" }, async ({ page, app, user, axe, checkA11y }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
// XXX: We have some known contrast issues here
"color-contrast",
]);
const childSpaceId1 = await app.client.createSpace({
name: "Child Space 1",
initial_state: [],
});
const childSpaceId2 = await app.client.createSpace({
name: "Child Space 2",
initial_state: [],
});
const childSpaceId3 = await app.client.createSpace({
name: "Child Space 3",
initial_state: [],
});
await app.client.createSpace({
name: "Root Space",
initial_state: [
spaceChildInitialState(childSpaceId1),
spaceChildInitialState(childSpaceId2),
spaceChildInitialState(childSpaceId3),
],
});
await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
});
});

View File

@@ -875,40 +875,6 @@ test.describe("Timeline", () => {
);
});
});
test("should render a code block", { tag: "@screenshot" }, async ({ page, app, room }) => {
await page.goto(`/#/room/${room.roomId}`);
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary_summary")
.getByText(`${OLD_NAME} created and configured the room.`),
).toBeVisible();
// Send a code block
const composer = app.getComposerField();
await composer.fill("```\nconsole.log('Hello, world!');\n```");
await composer.press("Enter");
const tile = page.locator(".mx_EventTile");
await expect(tile).toBeVisible();
await expect(tile).toMatchScreenshot("code-block.png", { mask: [page.locator(".mx_MessageTimestamp")] });
// Edit a code block and assert the edited code block has been correctly rendered
await tile.hover();
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click();
await page
.getByRole("textbox", { name: "Edit message" })
.fill("```\nconsole.log('Edited: Hello, world!');\n```");
await page.getByRole("textbox", { name: "Edit message" }).press("Enter");
const newTile = page.locator(".mx_EventTile");
await expect(newTile).toMatchScreenshot("edited-code-block.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
});
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -25,7 +25,7 @@ import { type HomeserverContainer, type StartedHomeserverContainer } from "./Hom
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, type Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:26e0d9c5ca96218243432d48a9f8596e4c1bc10b748f0a1bddf9916b914d1216";
const TAG = "develop@sha256:fa3090607a5e07a4ff245247aa3b598c6bbcff9231fd89a558de97c37adbd744";
const DEFAULT_CONFIG = {
server_name: "localhost",
@@ -144,7 +144,6 @@ const DEFAULT_CONFIG = {
enabled: true,
include_offline_users_on_sync: true,
},
room_list_publication_rules: [{ action: "allow" }],
};
export type SynapseConfig = Partial<typeof DEFAULT_CONFIG>;

View File

@@ -589,21 +589,18 @@ legend {
* in the app look the same by being AccessibleButtons, or possibly by having explict button classes.
* We should go through and have one consistent set of styles for buttons throughout the app.
* For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons.
*
* Elements that should not be styled like a dialog button are mentioned in a :not() pseudo-class.
* For the widest browser support, we use multiple :not pseudo-classes instead of :not(.a, .b).
*/
.mx_Dialog
button:not(
.mx_EncryptionUserSettingsTab button,
.mx_UserProfileSettings button,
.mx_ShareDialog button,
.mx_UnpinAllDialog button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_Dialog_nonDialogButton,
.mx_AccessibleButton,
.mx_IdentityServerPicker button,
[class|="maplibregl"]
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] {
@mixin mx_DialogButton;
margin-left: 0px;
@@ -619,46 +616,32 @@ legend {
}
.mx_Dialog
button:not(
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,
.mx_UserProfileSettings button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):last-child {
margin-right: 0px;
}
.mx_Dialog
button:not(
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,
.mx_UserProfileSettings button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
filter: brightness($focus-brightness);
}
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]),
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].mx_Dialog_primary,
.mx_Dialog_buttons
button:not(
.mx_Dialog_nonDialogButton,
.mx_AccessibleButton,
.mx_UserProfileSettings button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
@@ -668,43 +651,32 @@ legend {
min-width: 156px;
}
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]),
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons
button.danger:not(
.mx_Dialog_nonDialogButton,
.mx_AccessibleButton,
.mx_UserProfileSettings button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button
),
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(.mx_EncryptionUserSettingsTab button),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary);
color: var(--cpd-color-text-on-solid-primary);
}
.mx_Dialog button.warning:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]),
.mx_Dialog button.warning:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].warning {
border: solid 1px var(--cpd-color-border-critical-subtle);
color: var(--cpd-color-text-critical-primary);
}
.mx_Dialog
button:not(
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,
.mx_UserProfileSettings button,
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled {
background-color: $light-fg-color;
border: solid 1px $light-fg-color;

View File

@@ -127,6 +127,7 @@
@import "./views/dialogs/_AddExistingToSpaceDialog.pcss";
@import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss";
@import "./views/dialogs/_BugReportDialog.pcss";
@import "./views/dialogs/_BulkRedactDialog.pcss";
@import "./views/dialogs/_ChangelogDialog.pcss";
@import "./views/dialogs/_CompoundDialog.pcss";
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
@@ -210,6 +211,7 @@
@import "./views/elements/_ServerPicker.pcss";
@import "./views/elements/_SettingsFlag.pcss";
@import "./views/elements/_Spinner.pcss";
@import "./views/elements/_StyledCheckbox.pcss";
@import "./views/elements/_StyledRadioButton.pcss";
@import "./views/elements/_SyntaxHighlight.pcss";
@import "./views/elements/_TagComposer.pcss";
@@ -267,11 +269,8 @@
@import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListPanel/_RoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomListCell.pcss";
@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/RoomListView/_RoomListSearch.pcss";
@import "./views/rooms/RoomListView/_RoomListView.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";
@import "./views/rooms/_AuxPanel.pcss";
@@ -289,7 +288,6 @@
@import "./views/rooms/_IRCLayout.pcss";
@import "./views/rooms/_InvitedIconView.pcss";
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LegacyRoomList.pcss";
@import "./views/rooms/_LegacyRoomListHeader.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";
@@ -314,6 +312,7 @@
@import "./views/rooms/_RoomHeader.pcss";
@import "./views/rooms/_RoomInfoLine.pcss";
@import "./views/rooms/_RoomKnocksBar.pcss";
@import "./views/rooms/_RoomList.pcss";
@import "./views/rooms/_RoomPreviewBar.pcss";
@import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
@@ -349,6 +348,7 @@
@import "./views/settings/_PowerLevelSelector.pcss";
@import "./views/settings/_RoomProfileSettings.pcss";
@import "./views/settings/_SecureBackupPanel.pcss";
@import "./views/settings/_SetIdServer.pcss";
@import "./views/settings/_SetIntegrationManager.pcss";
@import "./views/settings/_SettingsFieldset.pcss";
@import "./views/settings/_SettingsHeader.pcss";
@@ -360,7 +360,6 @@
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/encryption/_EncryptionCardEmphasisedContent.pcss";
@import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss";
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";

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
@@ -16,9 +16,9 @@ Please see LICENSE files in the repository root for full details.
.mx_SelectableDeviceTile_checkbox {
flex: 1 0;
> div {
margin-top: auto;
margin-bottom: auto;
margin-right: var(--cpd-space-1x);
.mx_Checkbox_background + div {
flex: 1 0;
/* override more specific selector */
margin-left: $spacing-16 !important;
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -70,26 +70,38 @@ Please see LICENSE files in the repository root for full details.
text-transform: uppercase;
color: var(--cpd-color-text-secondary);
margin: 20px 0 12px;
}
.mx_QuickSettingsButton_pinToSidebarHeading {
padding-left: 24px;
position: relative;
display: flex;
}
.mx_Checkbox {
margin-bottom: 8px;
}
.mx_QuickSettingsButton_favouritesCheckbox,
.mx_QuickSettingsButton_peopleCheckbox {
.mx_Checkbox_background + div {
padding-left: 22px;
position: relative;
margin-left: 6px;
font-size: $font-15px;
line-height: $font-24px;
color: var(--cpd-color-text-primary);
}
}
.mx_QuickSettingsButton_moreOptionsButton {
margin-left: var(--cpd-space-7x);
padding-left: 22px;
margin-left: 22px;
font-size: $font-15px;
line-height: $font-24px;
color: var(--cpd-color-text-primary);
position: relative;
margin-bottom: 16px;
}
.mx_QuickSettingsButton_option {
margin-bottom: var(--cpd-space-3x);
label {
/* Correctly line up icons and text. */
display: flex;
}
}
}
.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list {
@@ -99,10 +111,15 @@ Please see LICENSE files in the repository root for full details.
}
.mx_QuickSettingsButton_icon {
margin-right: var(--cpd-space-1x);
// TODO remove when all icons have fill=currentColor
* {
fill: $secondary-content;
}
color: $secondary-content;
width: 18px;
height: 18px;
margin-top: auto;
margin-bottom: auto;
width: 16px;
height: 16px;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -77,7 +77,7 @@ Please see LICENSE files in the repository root for full details.
height: 16px;
width: 16px;
left: 0;
background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
background-image: url("@vector-im/compound-design-tokens/icons/error.svg");
background-size: cover;
background-repeat: no-repeat;
}
@@ -247,6 +247,15 @@ Please see LICENSE files in the repository root for full details.
.mx_AccessibleButton_kind_primary_outline {
padding: 3px 16px; /* to account for the 1px border */
}
.mx_Checkbox {
display: inline-flex;
label {
width: 16px;
height: 16px;
}
}
}
&:hover,

View File

@@ -29,7 +29,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_MessageContextMenu_iconReport::before {
mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/error.svg");
}
.mx_MessageContextMenu_iconLink::before {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -32,11 +32,6 @@ Please see LICENSE files in the repository root for full details.
.mx_AddExistingToSpace_section {
margin-right: 12px;
ul {
list-style: none;
padding-left: 0;
}
// provides space for scrollbar so that checkbox and scrollbar do not collide
&:not(:first-child) {
@@ -219,12 +214,6 @@ Please see LICENSE files in the repository root for full details.
display: flex;
margin-top: 12px;
form {
/* Align checkboxes. */
margin-top: auto;
margin-bottom: auto;
}
.mx_DecoratedRoomAvatar, /* we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling */ {
margin-right: 12px;
}
@@ -238,4 +227,8 @@ Please see LICENSE files in the repository root for full details.
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}

View File

@@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Robin Townsend <robin@robin.town>
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.
*/
.mx_BulkRedactDialog {
.mx_Checkbox,
.mx_BulkRedactDialog_checkboxMicrocopy {
line-height: $font-20px;
}
.mx_BulkRedactDialog_checkboxMicrocopy {
margin-left: 26px;
color: $secondary-content;
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -43,6 +43,11 @@ Please see LICENSE files in the repository root for full details.
.mx_Field_valid.mx_Field:focus-within {
border-color: $input-border-color;
}
.mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background {
background: $info-plinth-fg-color;
border-color: $info-plinth-fg-color;
}
}
.mx_ExportDialog_progress {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -74,6 +74,10 @@ Please see LICENSE files in the repository root for full details.
line-height: $font-15px;
color: $tertiary-content;
}
.mx_Checkbox {
align-items: center;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -19,6 +19,13 @@ Please see LICENSE files in the repository root for full details.
margin-top: 20px;
font-size: $font-15px;
line-height: $font-15px;
.mx_WidgetCapabilitiesPromptDialog_byline {
color: $muted-fg-color;
margin-left: 26px;
font-size: $font-12px;
line-height: $font-12px;
}
}
.mx_Dialog_buttons {

View File

@@ -21,7 +21,7 @@ Please see LICENSE files in the repository root for full details.
&.mx_AccessSecretStorageDialog_resetBadge::before {
/* The image isn't capable of masking, so we use a background instead. */
background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
background-image: url("@vector-im/compound-design-tokens/icons/error.svg");
background-size: 24px;
background-color: transparent;
}
@@ -120,7 +120,7 @@ Please see LICENSE files in the repository root for full details.
width: 16px;
left: 0;
top: 2px; /* alignment */
background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
background-image: url("@vector-im/compound-design-tokens/icons/error.svg");
background-size: contain;
}

View File

@@ -29,5 +29,5 @@ Please see LICENSE files in the repository root for full details.
}
.mx_InfoTooltip_icon_warning::before {
mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/error.svg");
}

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
@@ -7,5 +7,26 @@ Please see LICENSE files in the repository root for full details.
*/
.mx_LabelledCheckbox {
margin-top: var(--cpd-space-2x);
display: flex;
gap: 8px;
flex-direction: row;
.mx_Checkbox {
margin-top: 3px; /* visually align with label text */
}
.mx_LabelledCheckbox_labels {
flex: 1;
.mx_LabelledCheckbox_label {
vertical-align: middle;
}
.mx_LabelledCheckbox_byline {
display: block;
padding-top: $spacing-4;
color: $muted-fg-color;
font-size: $font-11px;
}
}
}

View File

@@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 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.
*/
.mx_Checkbox {
$size: $font-16px;
$border-radius: 0.27rem;
display: flex;
align-items: flex-start;
input[type="checkbox"] {
appearance: none;
margin: 0;
padding: 0;
& + label {
display: flex;
align-items: center;
flex-grow: 1;
}
& + label > .mx_Checkbox_background {
display: inline-flex;
position: relative;
flex-shrink: 0;
height: $size;
width: $size;
size: 0.5rem;
border: 1px solid var(--cpd-color-border-interactive-primary);
box-sizing: border-box;
border-radius: $border-radius;
.mx_Checkbox_checkmark {
display: none;
height: 100%;
width: 100%;
mask-image: url("@vector-im/compound-design-tokens/icons/check.svg");
mask-position: center;
mask-size: 100%;
mask-repeat: no-repeat;
}
}
&:checked + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
display: block;
}
& + label > *:not(.mx_Checkbox_background) {
margin-left: 10px;
}
&:disabled + label {
cursor: not-allowed;
}
&:focus-visible {
& + label .mx_Checkbox_background {
@mixin unreal-focus;
}
}
}
}
.mx_Checkbox.mx_Checkbox_kind_solid input[type="checkbox"] {
& + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
background: var(--cpd-color-icon-on-solid-primary);
}
&:checked + label > .mx_Checkbox_background {
background: var(--cpd-color-bg-accent-rest);
border-color: var(--cpd-color-bg-accent-rest);
}
&:checked:disabled + label > .mx_Checkbox_background {
background: var(--cpd-color-bg-action-primary-disabled);
border-color: var(--cpd-color-bg-action-primary-disabled);
}
}
.mx_Checkbox.mx_Checkbox_kind_outline input[type="checkbox"] {
& + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
background: var(--cpd-color-bg-accent-rest);
}
&:checked + label > .mx_Checkbox_background {
background: transparent;
border-color: var(--cpd-color-bg-accent-rest);
}
}

View File

@@ -100,7 +100,3 @@ Please see LICENSE files in the repository root for full details.
.mx_RoomSummaryCard_roomName {
margin: $spacing-12 0 $spacing-4;
}
.mx_RoomSummaryCard_leave {
margin: 0 0 var(--cpd-space-8x);
}

View File

@@ -1,15 +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.
*/
.mx_RoomList {
height: 100%;
.mx_RoomList_List {
/* Avoid when on hover, the background color to be on top of the right border */
padding-right: 1px;
}
}

View File

@@ -1,44 +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.
*/
/**
* The RoomCell has the following structure:
* button----------------------------------------|
* | <-12px-> container--------------------------|
* | | room avatar <-12px-> content-----|
* | | | room_name |
* | | | ----------| <-- border
* |---------------------------------------------|
*/
.mx_RoomListCell {
all: unset;
&:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.mx_RoomListCell_container {
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
height: 100%;
.mx_RoomListCell_content {
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
min-width: 0;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

View File

@@ -1,39 +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.
*/
.mx_RoomListHeaderView {
flex: 0 0 60px;
padding: 0 var(--cpd-space-3x);
.mx_RoomListHeaderView_title {
min-width: 0;
h1 {
all: unset;
font: var(--cpd-font-heading-sm-semibold);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
button {
color: var(--cpd-color-icon-secondary);
}
.mx_SpaceMenu_button {
svg {
transition: transform 0.1s linear;
}
}
.mx_SpaceMenu_button[aria-expanded="true"] {
svg {
transform: rotate(180deg);
}
}
}

View File

@@ -7,9 +7,9 @@
.mx_RoomListSearch {
/* From figma, this should be aligned with the room header */
flex: 0 0 64px;
height: 64px;
box-sizing: border-box;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
border-bottom: 1px solid var(--cpd-color-bg-subtle-primary);
padding: 0 var(--cpd-space-3x);
svg {
@@ -31,7 +31,7 @@
}
}
.mx_RoomListSearch_button:hover {
.mx_RoomListSearch_explore:hover {
svg {
fill: var(--cpd-color-icon-primary);
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListPanel {
.mx_RoomListView {
background-color: var(--cpd-color-bg-canvas-default);
height: 100%;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);

View File

@@ -683,7 +683,6 @@ $left-gutter: 64px;
line-height: inherit !important;
background-color: inherit;
color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */
flex: 1;
pre,
code {

View File

@@ -59,7 +59,6 @@ Please see LICENSE files in the repository root for full details.
.mx_RoomHeader_icon {
flex-shrink: 0;
padding: var(--cpd-space-1x);
}
.mx_RoomHeader .mx_FacePile {

View File

@@ -6,31 +6,31 @@ 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.
*/
.mx_LegacyRoomList {
.mx_RoomList {
padding-right: 7px; /* width of the scrollbar, to line things up */
}
.mx_LegacyRoomList_iconPlus::before {
.mx_RoomList_iconPlus::before {
mask-image: url("$(res)/img/element-icons/roomlist/plus-circle.svg");
}
.mx_LegacyRoomList_iconNewRoom::before {
.mx_RoomList_iconNewRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg");
}
.mx_LegacyRoomList_iconNewVideoRoom::before {
.mx_RoomList_iconNewVideoRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-video.svg");
}
.mx_LegacyRoomList_iconAddExistingRoom::before {
.mx_RoomList_iconAddExistingRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash.svg");
}
.mx_LegacyRoomList_iconExplore::before {
.mx_RoomList_iconExplore::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg");
}
.mx_LegacyRoomList_iconDialpad::before {
.mx_RoomList_iconDialpad::before {
mask-image: url("$(res)/img/element-icons/roomlist/dialpad.svg");
}
.mx_LegacyRoomList_iconStartChat::before {
.mx_RoomList_iconStartChat::before {
mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg");
}
.mx_LegacyRoomList_iconInvite::before {
.mx_RoomList_iconInvite::before {
mask-image: url("$(res)/img/element-icons/room/share.svg");
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -393,7 +393,8 @@ Please see LICENSE files in the repository root for full details.
margin-bottom: 4px;
}
.mx_StyledRadioButton {
.mx_StyledRadioButton,
.mx_Checkbox {
margin-top: 8px;
}
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-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.
*/
.mx_SetIdServer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $spacing-8;
.mx_Field {
width: 100%;
margin: 0;
}
}
.mx_SetIdServer_tooltip {
max-width: var(--SettingsTab_tooltip-max-width);
}

View File

@@ -7,13 +7,19 @@ Please see LICENSE files in the repository root for full details.
*/
.mx_SetIntegrationManager {
.mx_SetIntegrationManager_heading_manager {
display: flex;
.mx_SettingsFlag {
align-items: center;
flex-wrap: wrap;
column-gap: $spacing-4;
}
form {
margin-top: var(--cpd-space-3x);
.mx_SetIntegrationManager_heading_manager {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: $spacing-4;
}
.mx_ToggleSwitch {
align-self: flex-start;
min-width: var(--ToggleSwitch-min-width); /* avoid compression */
}
}
}

View File

@@ -1,13 +0,0 @@
/*
* Copyright 2024 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.
*/
.mx_EncryptionCard_emphasisedContent {
span {
font: var(--cpd-font-body-md-medium);
text-align: center;
}
}

View File

@@ -5,7 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
// Red text for the "Do not close this window" warning
.mx_ResetIdentityPanel_warning {
color: var(--cpd-color-text-critical-primary);
.mx_ResetIdentityPanel {
.mx_ResetIdentityPanel_content {
display: flex;
flex-direction: column;
gap: var(--cpd-space-3x);
> span {
font: var(--cpd-font-body-md-medium);
text-align: center;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -14,12 +14,17 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_SidebarUserSettingsTab_icon {
margin-right: var(--cpd-space-2x);
margin-top: auto;
margin-bottom: auto;
}
.mx_SidebarUserSettingsTab_checkbox {
margin-bottom: $spacing-8;
/* override checkbox styles */
label {
align-items: flex-start !important;
}
.mx_SidebarUserSettingsTab_checkbox label {
display: flex;
svg {
height: 16px;
width: 16px;
margin-right: $spacing-8;
margin-bottom: -1px;
}
}

View File

@@ -47,7 +47,6 @@ import { type DeepReadonly } from "./common";
import type MatrixChat from "../components/structures/MatrixChat";
import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore";
import { type ModuleApiType } from "../modules/Api.ts";
import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV3.ts";
/* eslint-disable @typescript-eslint/naming-convention */
@@ -100,7 +99,6 @@ declare global {
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
mxRoomListStoreV3: RoomListStoreV3Class;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;

View File

@@ -15,10 +15,9 @@ 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 } 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";
import { PosthogAnalytics } from "./PosthogAnalytics";
import dis from "./dispatcher/dispatcher";
@@ -97,7 +96,6 @@ export default class DeviceListener {
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
// only configurable in config, so we don't need to watch the value
this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder);
@@ -120,7 +118,6 @@ export default class DeviceListener {
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
this.client.removeListener(ClientEvent.Sync, this.onSync);
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
dis.unregister(this.dispatcherRef);
@@ -228,11 +225,6 @@ export default class DeviceListener {
this.updateClientInformation();
};
private onToDeviceEvent = (event: MatrixEvent): void => {
// Receiving a 4S secret can mean we are in sync where we were not before.
if (event.getType() === EventType.SecretSend) this.recheck();
};
/**
* Fetch the key backup information from the server.
*
@@ -281,29 +273,18 @@ 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));
const cli = this.client;
// cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1
if (!(await cli.isVersionSupported("v1.1"))) {
logSpan.debug("cross-signing not supported");
return;
}
if (!(await cli.isVersionSupported("v1.1"))) return;
const crypto = cli.getCrypto();
if (!crypto) {
logSpan.debug("crypto not enabled");
return;
}
if (!crypto) return;
// don't recheck until the initial sync is complete: lots of account data events will fire
// while the initial sync is processing and we don't need to recheck on each one of them
// (we add a listener on sync to do once check after the initial sync is done)
if (!cli.isInitialSyncComplete()) {
logSpan.debug("initial sync not yet complete");
return;
}
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageReady = await crypto.isSecretStorageReady();
@@ -325,7 +306,6 @@ export default class DeviceListener {
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
logSpan.info("No toast needed");
hideSetupEncryptionToast();
this.checkKeyBackupStatus();
@@ -336,33 +316,27 @@ export default class DeviceListener {
if (!crossSigningReady) {
// This account is legacy and doesn't have cross-signing set up at all.
// Prompt the user to set it up.
logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
logger.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
} else if (!isCurrentDeviceTrusted) {
// cross signing is ready but the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
logger.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast",
crossSigningStatus.privateKeysCachedLocally,
);
logger.info("Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast");
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (defaultKeyId === null) {
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
if (!disabledEvent?.getContent().disabled) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
}
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?
logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
crossSigningReady,
secretStorageReady,
allCrossSigningSecretsCached,
@@ -371,8 +345,6 @@ export default class DeviceListener {
});
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
// This needs to be done after awaiting on getUserDeviceInfo() above, so
@@ -405,9 +377,9 @@ export default class DeviceListener {
}
}
logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
@@ -432,7 +404,7 @@ export default class DeviceListener {
// ...and hide any we don't need any more
for (const deviceId of this.displayingToastsForDeviceIds) {
if (!newUnverifiedDeviceIds.has(deviceId)) {
logSpan.debug("Hiding unverified session toast for " + deviceId);
logger.debug("Hiding unverified session toast for " + deviceId);
hideUnverifiedSessionsToast(deviceId);
}
}

View File

@@ -521,7 +521,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
[KeyBindingAction.GoToHome]: {
default: {
ctrlKey: true,
altKey: true,
altKey: !IS_MAC,
shiftKey: IS_MAC,
key: Key.H,
},
displayName: _td("keyboard|go_home_view"),
@@ -584,7 +585,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
[KeyBindingAction.ToggleHiddenEventVisibility]: {
default: {
ctrlKey: true,
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.H,
},

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
@@ -16,12 +16,13 @@ import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, onClose, ...props }) => {
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
@@ -62,6 +63,7 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, o
<StyledCheckbox
{...props}
role="menuitemcheckbox"
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}

View File

@@ -37,7 +37,7 @@ import PosthogTrackers from "../../PosthogTrackers";
import type PageType from "../../PageTypes";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
import SettingsStore from "../../settings/SettingsStore";
import { RoomListPanel } from "../views/rooms/RoomListPanel";
import { RoomListView } from "../views/rooms/RoomListView";
interface IProps {
isMinimized: boolean;
@@ -390,7 +390,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
<RoomListPanel activeSpace={this.state.activeSpace} />
<RoomListView activeSpace={this.state.activeSpace} />
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -16,7 +16,6 @@ import React, {
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
@@ -117,7 +116,6 @@ const Tile: React.FC<ITileProps> = ({
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
const [busy, setBusy] = useState(false);
const checkboxLabelId = useId();
const onPreviewClick = (ev: ButtonEvent): void => {
ev.preventDefault();
@@ -174,14 +172,7 @@ const Tile: React.FC<ITileProps> = ({
let checkbox: ReactElement | undefined;
if (onToggleClick) {
if (hasPermissions) {
checkbox = (
<StyledCheckbox
role="presentation"
aria-labelledby={checkboxLabelId}
checked={!!selected}
tabIndex={-1}
/>
);
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = (
<TextWithTooltip
@@ -190,12 +181,7 @@ const Tile: React.FC<ITileProps> = ({
ev.stopPropagation();
}}
>
<StyledCheckbox
role="presentation"
aria-labelledby={checkboxLabelId}
disabled={true}
tabIndex={-1}
/>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>
);
}
@@ -262,7 +248,7 @@ const Tile: React.FC<ITileProps> = ({
<div className="mx_SpaceHierarchy_roomTile_item">
<div className="mx_SpaceHierarchy_roomTile_avatar">{avatar}</div>
<div className="mx_SpaceHierarchy_roomTile_name">
<span id={checkboxLabelId}>{name}</span>
{name}
{joinedSection}
{suggestedSection}
</div>
@@ -344,14 +330,11 @@ const Tile: React.FC<ITileProps> = ({
};
}
const shouldToggle = hasPermissions && onToggleClick;
return (
<li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-selected={selected}
aria-labelledby={checkboxLabelId}
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
@@ -359,7 +342,7 @@ const Tile: React.FC<ITileProps> = ({
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
mx_SpaceHierarchy_joining: busy,
})}
onClick={shouldToggle ? onToggleClick : onPreviewClick}
onClick={hasPermissions && onToggleClick ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
ref={ref}
onFocus={onFocus}

View File

@@ -117,7 +117,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => {
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_LegacyRoomList_iconNewRoom"
iconClassName="mx_RoomList_iconNewRoom"
onClick={async (e): Promise<void> => {
e.preventDefault();
e.stopPropagation();
@@ -132,7 +132,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => {
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_LegacyRoomList_iconNewVideoRoom"
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={async (e): Promise<void> => {
e.preventDefault();
e.stopPropagation();
@@ -157,7 +157,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => {
)}
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_LegacyRoomList_iconAddExistingRoom"
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -168,7 +168,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => {
{canCreateSpace && (
<IconizedContextMenuOption
label={_t("room_list|add_space_label")}
iconClassName="mx_LegacyRoomList_iconPlus"
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -33,7 +33,7 @@ type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any
* The alignment of the flex children
* @default start
*/
align?: "start" | "center" | "end" | "baseline" | "stretch" | "normal";
align?: "start" | "center" | "end" | "baseline" | "stretch";
/**
* The justification of the flex children
* @default start

View File

@@ -1,215 +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 { useCallback } from "react";
import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../PosthogTrackers";
import { Action } from "../../../dispatcher/actions";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import {
getMetaSpaceName,
type MetaSpace,
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceSettings,
showCreateNewRoom,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
} from "../../../utils/space";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
/**
* Hook to get the active space and its title.
*/
function useSpace(): { activeSpace: Room | null; title: string } {
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
const allRoomsInHome = useEventEmitterState(
SpaceStore.instance,
UPDATE_HOME_BEHAVIOUR,
() => SpaceStore.instance.allRoomsInHome,
);
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
return {
activeSpace,
title,
};
}
export interface RoomListHeaderViewState {
/**
* The title of the room list
*/
title: string;
/**
* Whether to display the compose menu
* True if the user can create rooms
*/
displayComposeMenu: boolean;
/**
* Whether to display the space menu
* True if there is an active space
*/
displaySpaceMenu: boolean;
/**
* Whether the user can create rooms
*/
canCreateRoom: boolean;
/**
* Whether the user can create video rooms
*/
canCreateVideoRoom: boolean;
/**
* Whether the user can invite in the active space
*/
canInviteInSpace: boolean;
/**
* Whether the user can access space settings
*/
canAccessSpaceSettings: boolean;
/**
* Create a chat room
* @param e - The click event
*/
createChatRoom: (e: Event) => void;
/**
* Create a room
* @param e - The click event
*/
createRoom: (e: Event) => void;
/**
* Create a video room
*/
createVideoRoom: () => void;
/**
* Open the active space home
*/
openSpaceHome: () => void;
/**
* Display the space invite dialog
*/
inviteInSpace: () => void;
/**
* Open the space preferences
*/
openSpacePreferences: () => void;
/**
* Open the space settings
*/
openSpaceSettings: () => void;
}
/**
* View model for the RoomListHeader.
*/
export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const matrixClient = useMatrixClientContext();
const { activeSpace, title } = useSpace();
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
const displayComposeMenu = canCreateRoom;
const displaySpaceMenu = Boolean(activeSpace);
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
/* Actions */
const createChatRoom = useCallback((e: Event) => {
defaultDispatcher.fire(Action.CreateChat);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
}, []);
const createRoom = useCallback(
(e: Event) => {
if (activeSpace) {
showCreateNewRoom(activeSpace);
} else {
defaultDispatcher.fire(Action.CreateRoom);
}
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
},
[activeSpace],
);
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const createVideoRoom = useCallback(() => {
const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo;
if (activeSpace) {
showCreateNewRoom(activeSpace, type);
} else {
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type,
});
}
}, [activeSpace, elementCallVideoRoomsEnabled]);
const openSpaceHome = useCallback(() => {
// openSpaceHome is only available when there is an active space
if (!activeSpace) return;
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined,
});
}, [activeSpace]);
const inviteInSpace = useCallback(() => {
// inviteInSpace is only available when there is an active space
if (!activeSpace) return;
showSpaceInvite(activeSpace);
}, [activeSpace]);
const openSpacePreferences = useCallback(() => {
// openSpacePreferences is only available when there is an active space
if (!activeSpace) return;
showSpacePreferences(activeSpace);
}, [activeSpace]);
const openSpaceSettings = useCallback(() => {
// openSpaceSettings is only available when there is an active space
if (!activeSpace) return;
showSpaceSettings(activeSpace);
}, [activeSpace]);
return {
title,
displayComposeMenu,
displaySpaceMenu,
canCreateRoom,
canCreateVideoRoom,
canInviteInSpace,
canAccessSpaceSettings,
createChatRoom,
createRoom,
createVideoRoom,
openSpaceHome,
inviteInSpace,
openSpacePreferences,
openSpaceSettings,
};
}

View File

@@ -1,66 +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 { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
export interface RoomListViewState {
/**
* A list of rooms to be displayed in the left panel.
*/
rooms: Room[];
/**
* Open the room having given roomId.
*/
openRoom: (roomId: string) => void;
/**
* A list of objects that provide the view enough information
* to render primary room filters.
*/
primaryFilters: PrimaryFilter[];
/**
* A function to activate a given secondary filter.
*/
activateSecondaryFilter: (filter: SecondaryFilters) => void;
/**
* The currently active secondary filter.
*/
activeSecondaryFilter: SecondaryFilters;
}
/**
* View model for the new room list
* @see {@link RoomListViewState} for more information about what this view model returns.
*/
export function useRoomListViewModel(): RoomListViewState {
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
const openRoom = useCallback((roomId: string): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "RoomList",
});
}, []);
return {
rooms,
openRoom,
primaryFilters,
activateSecondaryFilter,
activeSecondaryFilter,
};
}

View File

@@ -1,188 +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 { useCallback, useMemo, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
/**
* Provides information about a primary filter.
* A primary filter is a commonly used filter that is given
* more precedence in the UI. For eg, primary filters may be
* rendered as pills above the room list.
*/
export interface PrimaryFilter {
// A function to toggle this filter on and off.
toggle: () => void;
// Whether this filter is currently applied
active: boolean;
// Text that can be used in the UI to represent this filter.
name: string;
}
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void;
activeSecondaryFilter: SecondaryFilters;
}
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
]);
/**
* These are the secondary filters which are not prominently shown
* in the UI.
*/
export const enum SecondaryFilters {
AllActivity,
MentionsOnly,
InvitesOnly,
LowPriority,
}
/**
* A map from {@link SecondaryFilters} which the UI understands to
* {@link FilterKey} which the store understands.
*/
const secondaryFiltersToFilterKeyMap = new Map([
[SecondaryFilters.AllActivity, undefined],
[SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter],
[SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter],
[SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter],
]);
/**
* Use this function to determine if a given primary filter is compatible with
* a given secondary filter. Practically, this determines whether it makes sense
* to expose two filters together in the UI - for eg, it does not make sense to show the
* favourite primary filter if the active secondary filter is low priority.
* @param primary Primary filter key
* @param secondary Secondary filter key
* @returns true if compatible, false otherwise
*/
function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean {
if (secondary === FilterKey.MentionsFilter) {
if (primary === FilterKey.UnreadFilter) return false;
} else if (secondary === FilterKey.InvitesFilter) {
if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false;
} else if (secondary === FilterKey.LowPriorityFilter) {
if (primary === FilterKey.FavouriteFilter) return false;
}
return true;
}
/**
* Track available filters and provide a filtered list of rooms.
*/
export function useFilteredRooms(): FilteredRooms {
/**
* Primary filter refers to the pill based filters
* rendered above the room list.
*/
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
/**
* Secondary filters are also filters but they are hidden
* away in a popup menu.
*/
const [activeSecondaryFilter, setActiveSecondaryFilter] = useState<SecondaryFilters>(SecondaryFilters.AllActivity);
const secondaryFilter = useMemo(
() => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter),
[activeSecondaryFilter],
);
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRooms(newRooms);
}, []);
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = (): FilterKey[] => {
return filterUndefined([primaryFilter, secondaryFilter]);
};
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
});
/**
* Secondary filters are activated using this function.
* This is different to how primary filters work because the secondary
* filters are static i.e they are always available and don't need to be
* hidden.
*/
const activateSecondaryFilter = useCallback(
(filter: SecondaryFilters): void => {
// If the filter is already active, just return.
if (filter === activeSecondaryFilter) return;
// SecondaryFilter is an enum for the UI, let's convert it to something
// that the store will understand.
const secondary = secondaryFiltersToFilterKeyMap.get(filter);
// Active primary filter may need to be toggled off when applying this secondary filer.
let primary = primaryFilter;
if (
primaryFilter !== undefined &&
secondary !== undefined &&
!isPrimaryFilterCompatible(primaryFilter, secondary)
) {
primary = undefined;
}
setActiveSecondaryFilter(filter);
setPrimaryFilter(primary);
updateRoomsFromStore(filterUndefined([primary, secondary]));
},
[activeSecondaryFilter, primaryFilter, updateRoomsFromStore],
);
/**
* This tells the view which primary filters are available, how to toggle them
* and whether a given primary filter is active. @see {@link PrimaryFilter}
*/
const primaryFilters = useMemo(() => {
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
return {
toggle: () => {
setPrimaryFilter((currentFilter) => {
const filter = currentFilter === key ? undefined : key;
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
return filter;
});
},
active: primaryFilter === key,
name,
};
};
const filters: PrimaryFilter[] = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) {
continue;
}
filters.push(createPrimaryFilter(key, _t(name)));
}
return filters;
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
}

View File

@@ -10,7 +10,7 @@ import React, { createRef, type ReactNode } from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left";
import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { Heading, MFAInput, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";

View File

@@ -1,12 +1,12 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 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, { type ReactElement, type ReactNode, useContext, useId, useMemo, useRef, useState } from "react";
import React, { type ReactElement, type ReactNode, useContext, useMemo, useRef, useState } from "react";
import classNames from "classnames";
import { type Room, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
@@ -53,9 +53,8 @@ export const Entry: React.FC<{
checked: boolean;
onChange?(value: boolean): void;
}> = ({ room, checked, onChange }) => {
const id = useId();
return (
<li id={id} className="mx_AddExistingToSpace_entry" aria-label={room.name}>
<label className="mx_AddExistingToSpace_entry">
{room?.isSpaceRoom() ? (
<RoomAvatar room={room} size="32px" />
) : (
@@ -63,12 +62,11 @@ export const Entry: React.FC<{
)}
<span className="mx_AddExistingToSpace_entry_name">{room.name}</span>
<StyledCheckbox
aria-labelledby={id}
onChange={onChange ? (e) => onChange(e.currentTarget.checked) : undefined}
checked={checked}
disabled={!onChange}
/>
</li>
</label>
);
};
@@ -359,7 +357,6 @@ const defaultRendererFactory =
<div className="mx_AddExistingToSpace_section">
<h3>{_t(title)}</h3>
<LazyRenderList
element="ul"
itemHeight={ROW_HEIGHT}
items={rooms}
scrollTop={scrollTop}

View File

@@ -9,13 +9,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 ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import React from "react";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import sendBugReport, { downloadBugReport, RageshakeError } from "../../../rageshake/submit-rageshake";
import sendBugReport, { downloadBugReport } from "../../../rageshake/submit-rageshake";
import AccessibleButton from "../elements/AccessibleButton";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
@@ -27,7 +26,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { getBrowserSupport } from "../../../SupportedBrowser";
export interface BugReportDialogProps {
interface IProps {
onFinished: (success: boolean) => void;
initialText?: string;
label?: string;
@@ -37,7 +36,7 @@ export interface BugReportDialogProps {
interface IState {
sendLogs: boolean;
busy: boolean;
err: ReactNode | null;
err: string | null;
issueUrl: string;
text: string;
progress: string | null;
@@ -45,11 +44,11 @@ interface IState {
downloadProgress: string | null;
}
export default class BugReportDialog extends React.Component<BugReportDialogProps, IState> {
export default class BugReportDialog extends React.Component<IProps, IState> {
private unmounted: boolean;
private issueRef: React.RefObject<Field>;
public constructor(props: BugReportDialogProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@@ -90,42 +89,6 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.props.onFinished(false);
};
private getErrorText(error: Error | RageshakeError): ReactNode {
if (error instanceof RageshakeError) {
let errorText;
switch (error.errorcode) {
case "DISALLOWED_APP":
errorText = _t("bug_reporting|failed_send_logs_causes|disallowed_app");
break;
case "REJECTED_BAD_VERSION":
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_version");
break;
case "REJECTED_UNEXPECTED_RECOVERY_KEY":
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_recovery_key");
break;
default:
if (error.errorcode?.startsWith("REJECTED")) {
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_generic");
} else {
errorText = _t("bug_reporting|failed_send_logs_causes|server_unknown_error");
}
break;
}
return (
<>
<p>{errorText}</p>
{error.policyURL && (
<Link size="medium" target="_blank" href={error.policyURL}>
{_t("action|learn_more")}
</Link>
)}
</>
);
} else {
return <p>{_t("bug_reporting|failed_send_logs_causes|unknown_error")}</p>;
}
}
private onSubmit = (): void => {
if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) {
this.setState({
@@ -163,7 +126,7 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.setState({
busy: false,
progress: null,
err: this.getErrorText(err),
err: _t("bug_reporting|failed_send_logs") + `${err.message}`,
});
}
},
@@ -192,7 +155,7 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.setState({
downloadBusy: false,
downloadProgress:
_t("bug_reporting|failed_download_logs") + `${err instanceof Error ? err.message : ""}`,
_t("bug_reporting|failed_send_logs") + `${err instanceof Error ? err.message : ""}`,
});
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -113,13 +113,12 @@ const BulkRedactDialog: React.FC<Props> = (props) => {
<div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{_t("user_info|redact|confirm_description_1", { count, user })}</p>
<p>{_t("user_info|redact|confirm_description_2")}</p>
<StyledCheckbox
description={_t("user_info|redact|confirm_keep_state_explainer")}
checked={keepStateEvents}
onChange={(e) => setKeepStateEvents(e.target.checked)}
>
<StyledCheckbox checked={keepStateEvents} onChange={(e) => setKeepStateEvents(e.target.checked)}>
{_t("user_info|redact|confirm_keep_state_label")}
</StyledCheckbox>
<div className="mx_BulkRedactDialog_checkboxMicrocopy">
{_t("user_info|redact|confirm_keep_state_explainer")}
</div>
</div>
<DialogButtons
primaryButton={_t("user_info|redact|confirm_button", { count })}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -78,6 +78,7 @@ const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={(e) => setCanContact((e.target as HTMLInputElement).checked)}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -44,23 +44,22 @@ const Entry: React.FC<{
}
return (
<div className="mx_ManageRestrictedJoinRuleDialog_entry">
<label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{localRoom ? <RoomAvatar room={room} size="20px" /> : <RoomAvatar oobData={room} size="20px" />}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{room.name}</span>
</div>
{description && (
<div className="mx_ManageRestrictedJoinRuleDialog_entry_description">{description}</div>
)}
</div>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : undefined}
checked={checked}
disabled={!onChange}
description={description}
>
<div>
{localRoom ? (
<RoomAvatar role="none" room={room} size="20px" />
) : (
<RoomAvatar oobData={room} size="20px" />
)}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{room.name}</span>
</div>
</StyledCheckbox>
</div>
/>
</label>
);
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -45,13 +45,14 @@ const SpacePreferencesAppearanceTab: React.FC<Pick<IProps, "space">> = ({ space
!showPeople,
);
}}
description={_t("space|preferences|show_people_in_space", {
spaceName: space.name,
})}
>
{_t("common|people")}
</StyledCheckbox>
<SettingsSubsectionText />
<SettingsSubsectionText>
{_t("space|preferences|show_people_in_space", {
spaceName: space.name,
})}
</SettingsSubsectionText>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>

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