Merge branch 'element-hq:master' into master

This commit is contained in:
2025-08-07 19:37:39 +02:00
committed by GitHub
339 changed files with 13023 additions and 6353 deletions
+6 -1
View File
@@ -1,6 +1,11 @@
module.exports = { module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"], plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: { parserOptions: {
project: ["./tsconfig.json"], project: ["./tsconfig.json"],
}, },
+4 -4
View File
@@ -25,14 +25,14 @@ jobs:
fetch-depth: 0 # needed for docker-package to be able to calculate the version fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3 uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with: with:
install: true install: true
@@ -53,7 +53,7 @@ jobs:
- name: Build and load - name: Build and load
id: test-build id: test-build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
context: . context: .
load: true load: true
@@ -110,7 +110,7 @@ jobs:
- name: Build and push - name: Build and push
id: build-and-push id: build-and-push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
context: . context: .
+1 -1
View File
@@ -227,7 +227,7 @@ jobs:
- name: Merge into HTML Report - name: Merge into HTML Report
if: inputs.skip != true if: inputs.skip != true
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports
env: env:
# Only pass creds to the flaky-reporter on main branch runs # Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
@@ -0,0 +1,51 @@
# Triggers after the shared component tests have finished,
# It uploads the received images and diffs to netlify, printing the URLs to the console
name: Upload Shared Component Visual Test Diffs
on:
workflow_run:
workflows: ["Shared Component Visual Tests"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions: {}
jobs:
report:
if: github.event.workflow_run.conclusion == 'failure'
name: Upload Diffs
runs-on: ubuntu-24.04
environment: Netlify
permissions:
actions: read
deployments: write
steps:
- name: Install tree
run: "sudo apt-get install -y tree"
- name: Download Diffs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: received-images
path: received-images
- name: Generate Index
run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ."
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with:
path: received-images
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
desc: Shared Component Visual Diffs
deployment_env: SharedComponentDiffs
prefix: "diffs-"
@@ -0,0 +1,70 @@
name: Shared Component Visual Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions: {} # No permissions required
jobs:
testStorybook:
name: "Run Visual Tests"
runs-on: ubuntu-24.04
permissions:
actions: read
issues: read
pull-requests: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
id: playwright
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "yarn playwright install --with-deps --only-shell"
- name: Build Element Web resources
# Needed to prepare language files
run: "yarn build:res"
- name: Build storybook dependencies
# When the first test is ran, it will fail because the dependencies are not yet built.
# This step is to ensure that the dependencies are built before running the tests.
run: "yarn test:storybook:ci"
continue-on-error: true
- name: Run Visual tests
run: "yarn test:storybook:ci"
- name: Upload received images & diffs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: received-images
path: playwright/shared-component-received
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue - name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63 uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with: with:
authToken: ${{ secrets.GITHUB_TOKEN }} authToken: ${{ secrets.GITHUB_TOKEN }}
state: success state: success
+3 -1
View File
@@ -15,12 +15,14 @@ jobs:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with: with:
operations-per-run: 100 operations-per-run: 100
# Flaky test issue closing # Flaky test issue closing
only-issue-labels: "Z-Flaky-Test" any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit"
days-before-issue-stale: 14 days-before-issue-stale: 14
days-before-issue-close: 0 days-before-issue-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved." close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled" exempt-issue-labels: "Z-Flaky-Test-Disabled"
# Stale PR closing # Stale PR closing
days-before-pr-stale: 180 days-before-pr-stale: 180
days-before-pr-close: 0 days-before-pr-close: 0
+6 -1
View File
@@ -26,7 +26,7 @@ jobs:
env: env:
HS_URL: ${{ secrets.BETABOT_HS_URL }} HS_URL: ${{ secrets.BETABOT_HS_URL }}
LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }} LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }}
PUBLIC_ROOM_ID: "!YTvKGNlinIzlkMTVRl:matrix.org" PUBLIC_ROOM_ID: "!IemiTbwVankHTFiEoh:matrix.org"
ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org" ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org"
TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }} TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }}
RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}" RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}"
@@ -81,6 +81,11 @@ jobs:
d.body = d.body.replace(regex, releaseTopic); d.body = d.body.replace(regex, releaseTopic);
}); });
} }
if (data["m.topic"]) {
data["m.topic"].forEach(d => {
d.body = d.body.replace(regex, releaseTopic);
});
}
res = await fetch(apiUrl, { res = await fetch(apiUrl, {
method: "PUT", method: "PUT",
+3
View File
@@ -31,3 +31,6 @@ electron/pub
/index.html /index.html
# version file and tarball created by `npm pack` / `yarn pack` # version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt /git-revision.txt
*storybook.log
storybook-static
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { create } from "storybook/theming";
export default create({
base: "light",
// Colors
textColor: "#1b1d22",
colorSecondary: "#111111",
// UI
appBg: "#ffffff",
appContentBg: "#ffffff",
// Toolbar
barBg: "#ffffff",
brandTitle: "Element Web",
brandUrl: "https://github.com/element-hq/element-web",
brandImage: "https://element.io/images/logo-ele-secondary.svg",
brandTarget: "_self",
});
+61
View File
@@ -0,0 +1,61 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Addon, types, useGlobals } from "storybook/manager-api";
import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
import React from "react";
import { GlobeIcon } from "@storybook/icons";
// We can't import `shared/i18n.tsx` directly here.
// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
import json from "../webapp/i18n/languages.json";
const languages = Object.keys(json).filter((lang) => lang !== "default");
/**
* Returns the title of a language in the user's locale.
*/
function languageTitle(language: string): string {
return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || language;
}
export const languageAddon: Addon = {
title: "Language Selector",
type: types.TOOL,
render: ({ active }) => {
const [globals, updateGlobals] = useGlobals();
const selectedLanguage = globals.language || "en";
return (
<WithTooltip
placement="top"
trigger="click"
closeOnOutsideClick
tooltip={({ onHide }) => {
return (
<TooltipLinkList
links={languages.map((language) => ({
id: language,
title: languageTitle(language),
active: selectedLanguage === language,
onClick: async () => {
// Update the global state with the selected language
updateGlobals({ language });
onHide();
},
}))}
/>
);
}}
>
<IconButton title="Language">
<GlobeIcon />
{languageTitle(selectedLanguage)}
</IconButton>
</WithTooltip>
);
},
};
+37
View File
@@ -0,0 +1,37 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { StorybookConfig } from "@storybook/react-vite";
import path from "node:path";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../webapp"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,
},
typescript: {
reactDocgen: "react-docgen-typescript",
},
async viteFinal(config) {
return mergeConfig(config, {
resolve: {
alias: {
// Alias used by i18n.tsx
$webapp: path.resolve("webapp"),
},
},
// Needed for counterpart to work
plugins: [nodePolyfills({ include: ["process", "util"] })],
});
},
};
export default config;
+18
View File
@@ -0,0 +1,18 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { addons } from "storybook/manager-api";
import ElementTheme from "./ElementTheme";
import { languageAddon } from "./languageAddon";
addons.setConfig({
theme: ElementTheme,
});
addons.register("elementhq/language", () => addons.add("language", languageAddon));
+10
View File
@@ -0,0 +1,10 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.docs-story {
background: var(--cpd-color-bg-canvas-default);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
import { addons } from "storybook/preview-api";
import "../res/css/shared.pcss";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { FORCE_RE_RENDER } from "storybook/internal/core-events";
import { setLanguage } from "../src/shared-components/i18n";
export const globalTypes = {
theme: {
name: "Theme",
description: "Global theme for components",
toolbar: {
icon: "circlehollow",
title: "Theme",
items: [
{ title: "System", value: "system", icon: "browser" },
{ title: "Light", value: "light", icon: "sun" },
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
{ title: "Dark", value: "dark", icon: "moon" },
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
],
},
},
language: {
name: "Language",
description: "Global language for components",
},
initialGlobals: {
theme: "system",
language: "en",
},
} satisfies ArgTypes;
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
const ThemeSwitcher: React.FC<{
theme: string;
}> = ({ theme }) => {
useLayoutEffect(() => {
document.documentElement.classList.remove(...allThemesClasses);
if (theme !== "system") {
document.documentElement.classList.add(`cpd-theme-${theme}`);
}
return () => document.documentElement.classList.remove(...allThemesClasses);
}, [theme]);
return null;
};
const withThemeProvider: Decorator = (Story, context) => {
return (
<>
<ThemeSwitcher theme={context.globals.theme} />
<Story />
</>
);
};
const LanguageSwitcher: React.FC<{
language: string;
}> = ({ language }) => {
useLayoutEffect(() => {
const changeLanguage = async (language: string) => {
await setLanguage(language);
// Force the component to re-render to apply the new language
addons.getChannel().emit(FORCE_RE_RENDER);
};
changeLanguage(language);
}, [language]);
return null;
};
export const withLanguageProvider: Decorator = (Story, context) => {
return (
<>
<LanguageSwitcher language={context.globals.language} />
<Story />
</>
);
};
const preview: Preview = {
tags: ["autodocs"],
decorators: [withThemeProvider, withLanguageProvider],
};
export default preview;
+37
View File
@@ -0,0 +1,37 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { waitForPageReady } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
/**
* @type {import('@storybook/test-runner').TestRunnerConfig}
*/
const config = {
setup(page) {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
await waitForPageReady(page);
// If you want to take screenshot of multiple browsers, use
// page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: `${context.id}-${process.platform}`,
storeReceivedOnFailure: true,
customReceivedDir,
customDiffDir: customReceivedDir,
});
},
};
export default config;
+3
View File
@@ -19,3 +19,6 @@ include:
* Thom Cleary (https://github.com/thomcatdotrocks) * Thom Cleary (https://github.com/thomcatdotrocks)
Small update for tarball deployment Small update for tarball deployment
* Alexander (https://github.com/ioalexander)
Save image on CTRL + S shortcut
+65
View File
@@ -1,3 +1,68 @@
Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30)
====================================================================================================
## 🐛 Bug Fixes
* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot.
Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29)
====================================================================================================
## ✨ Features
* Message preview should show tooltip with the full message on hover ([#30265](https://github.com/element-hq/element-web/pull/30265)). Contributed by @MidhunSureshR.
* Support rendering notification badges on platforms that do their own icon overlays ([#30315](https://github.com/element-hq/element-web/pull/30315)). Contributed by @Half-Shot.
* Add SubscriptionViewModel base class ([#30297](https://github.com/element-hq/element-web/pull/30297)). Contributed by @dbkr.
* Enhancement: Save image on CTRL+S ([#30330](https://github.com/element-hq/element-web/pull/30330)). Contributed by @ioalexander.
* Add quote functionality to MessageContextMenu (#29893) ([#30323](https://github.com/element-hq/element-web/pull/30323)). Contributed by @AlirezaMrtz.
* Initial structure for shared component views ([#30216](https://github.com/element-hq/element-web/pull/30216)). Contributed by @dbkr.
## 🐛 Bug Fixes
* [Backport staging] Fix e2e shield being invisible in white mode for encrypted room ([#30411](https://github.com/element-hq/element-web/pull/30411)). Contributed by @RiotRobot.
* Force ED titlebar color for new room list ([#30332](https://github.com/element-hq/element-web/pull/30332)). Contributed by @florianduros.
* Add a background color to left panel for macos titlebar in element desktop ([#30328](https://github.com/element-hq/element-web/pull/30328)). Contributed by @florianduros.
* Fix: Prevent page refresh on Enter key in right panel member search ([#30312](https://github.com/element-hq/element-web/pull/30312)). Contributed by @AlirezaMrtz.
Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
====================================================================================================
## ✨ Features
* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot.
* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot.
* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave.
* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot.
* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown.
## 🐛 Bug Fixes
* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot.
* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii.
* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg.
* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy.
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
====================================================================================================
## ✨ Features
* New room list: add context menu to room list item ([#29952](https://github.com/element-hq/element-web/pull/29952)). Contributed by @florianduros.
* Support for custom message components via Module API ([#30074](https://github.com/element-hq/element-web/pull/30074)). Contributed by @Half-Shot.
* Prompt users to set up recovery ([#30075](https://github.com/element-hq/element-web/pull/30075)). Contributed by @uhoreg.
* Update `IconButton` colors ([#30124](https://github.com/element-hq/element-web/pull/30124)). Contributed by @florianduros.
* New room list: filter list can be collapsed ([#29992](https://github.com/element-hq/element-web/pull/29992)). Contributed by @florianduros.
* Show `EmptyRoomListView` when low priority filter matches zero rooms ([#30122](https://github.com/element-hq/element-web/pull/30122)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* Fix untranslatable string "People" in notifications beta ([#30165](https://github.com/element-hq/element-web/pull/30165)). Contributed by @t3chguy.
* Force verification even after logging in via delegate ([#30141](https://github.com/element-hq/element-web/pull/30141)). Contributed by @andybalaam.
* Hide add integrations button based on UIComponent.AddIntegrations ([#30140](https://github.com/element-hq/element-web/pull/30140)). Contributed by @t3chguy.
* Use nav for new room list and label sections ([#30134](https://github.com/element-hq/element-web/pull/30134)). Contributed by @dbkr.
* Spacestore should emit event after rebuilding home space ([#30132](https://github.com/element-hq/element-web/pull/30132)). Contributed by @MidhunSureshR.
* Handle m.room.pinned\_events being invalid ([#30129](https://github.com/element-hq/element-web/pull/30129)). Contributed by @t3chguy.
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17) Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
==================================================================================================== ====================================================================================================
## ✨ Features ## ✨ Features
+3 -3
View File
@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458 # syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
# Builder # Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop. # Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json RUN cp /src/config.sample.json /src/webapp/config.json
# App # App
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:2acffd86b1bdefb8fa6b48b6e9aadf75430e8ab9c43c54c515ea7df77897f987 FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00
# Need root user to install packages & manipulate the usr directory # Need root user to install packages & manipulate the usr directory
USER root USER root
-4
View File
@@ -127,7 +127,6 @@ Unless otherwise specified, the following applies to all code:
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable. 2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required. 20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations. 21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
1. Note that an explicit type is optional if not expected to be used outside of the function call, 1. Note that an explicit type is optional if not expected to be used outside of the function call,
unlike in this example: unlike in this example:
@@ -161,7 +160,6 @@ Unless otherwise specified, the following applies to all code:
28. Export only what can be reused. 28. Export only what can be reused.
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead 29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
of truly optional parameters. of truly optional parameters.
1. A notable exception is when the likelihood of a bug is minimal, such as when a function 1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should `?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -260,7 +258,6 @@ Inheriting all the rules of TypeScript, the following additionally apply:
12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities 12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
if at all possible. if at all possible.
13. A component should only use CSS class names in line with the component name. 13. A component should only use CSS class names in line with the component name.
1. When knowingly using a class name from another component, document it with a [comment](#comments). 1. When knowingly using a class name from another component, document it with a [comment](#comments).
14. Curly braces within JSX should be padded with a space, however properties on those components should not. 14. Curly braces within JSX should be padded with a space, however properties on those components should not.
@@ -388,7 +385,6 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
properties should be clearly documented. properties should be clearly documented.
4. Inside a function, there is no need to comment every line, but consider: 4. Inside a function, there is no need to comment every line, but consider:
- before a particular multiline section of code within the function, give an overview of what it does, - before a particular multiline section of code within the function, give an overview of what it does,
to make it easier for a reader to follow the flow through the function as a whole. to make it easier for a reader to follow the flow through the function as a whole.
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis - if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
+1 -2
View File
@@ -20,8 +20,7 @@
"https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api", "https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1", "https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api", "https://scalar-staging.vector.im/api"
"https://scalar-staging.riot.im/scalar/api"
], ],
"default_widget_container_height": 280, "default_widget_container_height": 280,
"default_country_code": "GB", "default_country_code": "GB",
+8
View File
@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
declare module "*.module.css";
+1 -1
View File
@@ -109,7 +109,7 @@ yarn test
### End-to-End tests ### End-to-End tests
See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests. See [`docs/playwright.md`](./docs/playwright.md) for how to run the end-to-end tests.
## General github guidelines ## General github guidelines
+22 -18
View File
@@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach
6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below. 6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below.
7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to 7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to
download the app instead. download the app instead.
8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory 8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted
the mobile guide will be configured for the new Element X apps. Allowed values are as follows:
1. `element`: Element X Android/iOS.
2. `element-classic`: Element Classic Android/iOS.
3. `element-pro`: Element Pro Android/iOS.
9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/` containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/`
in production. in production.
9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE` 10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
configuration found in the well-known location is used instead. configuration found in the well-known location is used instead.
10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created). 11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of 12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
`{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details. `{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details.
12. `branding`: Options for configuring various assets used within the app. Described in more detail down below. 13. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below. 14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to 15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
`true` to hide these options. `true` to hide these options.
15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true` 16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
to hide this dropdown. to hide this dropdown.
16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered 17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
users. Set to `true` to disable this functionality. users. Set to `true` to disable this functionality.
17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time. 18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
Takes a configuration object as below: Takes a configuration object as below:
1. `title`: Required. Title to show at the top of the notice. 1. `title`: Required. Title to show at the top of the notice.
2. `description`: Required. The description to use for the notice. 2. `description`: Required. The description to use for the notice.
3. `show_once`: Optional. If true then the notice will only be shown once per device. 3. `show_once`: Optional. If true then the notice will only be shown once per device.
18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. 19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. 20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) 21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
### `desktop_builds` and `mobile_builds` ### `desktop_builds` and `mobile_builds`
@@ -445,8 +450,7 @@ If you would like to use Scalar, the integration manager maintained by Element,
"https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api", "https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1", "https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api", "https://scalar-staging.vector.im/api"
"https://scalar-staging.riot.im/scalar/api"
] ]
} }
``` ```
+1 -2
View File
@@ -55,8 +55,7 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my-
"https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api", "https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1", "https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api", "https://scalar-staging.vector.im/api"
"https://scalar-staging.riot.im/scalar/api"
], ],
"bug_report_endpoint_url": "https://element.io/bugreports/submit", "bug_report_endpoint_url": "https://element.io/bugreports/submit",
"defaultCountryCode": "GB", "defaultCountryCode": "GB",
+1 -2
View File
@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api", "https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1", "https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api", "https://scalar-staging.vector.im/api"
"https://scalar-staging.riot.im/scalar/api"
], ],
"bug_report_endpoint_url": "https://element.io/bugreports/submit", "bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi", "uisi_autorageshake_app": "element-auto-uisi",
+1 -2
View File
@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api", "https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1", "https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api", "https://scalar-staging.vector.im/api"
"https://scalar-staging.riot.im/scalar/api"
], ],
"bug_report_endpoint_url": "https://element.io/bugreports/submit", "bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi", "uisi_autorageshake_app": "element-auto-uisi",
+1 -1
View File
@@ -17,7 +17,7 @@ const config: Config = {
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg // This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
customExportConditions: ["browser", "node"], customExportConditions: ["browser", "node"],
}, },
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts", globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
+36 -23
View File
@@ -1,6 +1,6 @@
{ {
"name": "element-web", "name": "element-web",
"version": "1.11.104", "version": "1.11.108",
"description": "Element: the future of secure communication", "description": "Element: the future of secure communication",
"author": "New Vector Ltd.", "author": "New Vector Ltd.",
"repository": { "repository": {
@@ -65,23 +65,27 @@
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
"postinstall": "patch-package" "postinstall": "patch-package",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
}, },
"resolutions": { "resolutions": {
"**/pretty-format/react-is": "19.1.0", "**/pretty-format/react-is": "19.1.0",
"@playwright/test": "1.52.0", "@playwright/test": "1.53.2",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.5", "@types/react-dom": "19.1.6",
"oidc-client-ts": "3.2.1", "oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0", "jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001720", "caniuse-lite": "1.0.30001724",
"testcontainers": "^11.0.0", "testcontainers": "^11.0.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0" "wrap-ansi": "npm:wrap-ansi@^7.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.0.0", "@element-hq/element-web-module-api": "1.3.0",
"@fontsource/inconsolata": "^5", "@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5", "@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7", "@formatjs/intl-segmenter": "^11.5.7",
@@ -92,9 +96,9 @@
"@sentry/browser": "^9.0.0", "@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2", "@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30", "@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0", "@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-web": "^7.11.0", "@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.38.3", "@vector-im/matrix-wysiwyg": "2.38.4",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.2",
@@ -130,7 +134,7 @@
"maplibre-gl": "^5.0.0", "maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3", "matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "37.9.0", "matrix-js-sdk": "37.12.0",
"matrix-widget-api": "^1.10.0", "matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"mime": "^4.0.4", "mime": "^4.0.4",
@@ -138,7 +142,7 @@
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"posthog-js": "1.248.1", "posthog-js": "1.256.2",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"re-resizable": "6.11.2", "re-resizable": "6.11.2",
"react": "^19.0.0", "react": "^19.0.0",
@@ -180,14 +184,19 @@
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7", "@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.12.0", "@element-hq/element-call-embedded": "0.13.1",
"@element-hq/element-web-playwright-common": "^1.1.5", "@element-hq/element-web-playwright-common": "^1.4.2",
"@peculiar/webcrypto": "^1.4.3", "@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7", "@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18", "@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0", "@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^4.0.0", "@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/icons": "^1.4.0",
"@storybook/react-vite": "^9.0.15",
"@storybook/test-runner": "^0.23.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0", "@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8", "@testing-library/jest-dom": "^6.4.8",
@@ -212,9 +221,9 @@
"@types/node-fetch": "^2.6.2", "@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0", "@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.1.5", "@types/react-dom": "19.1.6",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.16.0", "@types/sanitize-html": "2.16.0",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
@@ -231,10 +240,10 @@
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"copy-webpack-plugin": "^13.0.0", "copy-webpack-plugin": "^13.0.0",
"core-js": "^3.38.1", "core-js": "^3.38.1",
"cronstrue": "^2.41.0", "cronstrue": "^3.0.0",
"css-loader": "^7.0.0", "css-loader": "^7.0.0",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
"dotenv": "^16.0.2", "dotenv": "^17.0.0",
"eslint": "8.57.1", "eslint": "8.57.1",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
@@ -246,18 +255,19 @@
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0", "eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0", "express": "^5.0.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"fetch-mock": "9.11.0", "fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"glob": "^11.0.0",
"html-webpack-plugin": "^5.5.3", "html-webpack-plugin": "^5.5.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"jest": "^29.6.2", "jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2", "jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
@@ -275,17 +285,18 @@
"postcss-hexrgba": "2.1.0", "postcss-hexrgba": "2.1.0",
"postcss-import": "16.1.0", "postcss-import": "16.1.0",
"postcss-loader": "8.1.1", "postcss-loader": "8.1.1",
"postcss-mixins": "^11.0.0", "postcss-mixins": "^12.0.0",
"postcss-nested": "^7.0.0", "postcss-nested": "^7.0.0",
"postcss-preset-env": "^10.0.0", "postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3", "prettier": "3.6.2",
"process": "^0.11.10", "process": "^0.11.10",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rimraf": "^6.0.0", "rimraf": "^6.0.0",
"semver": "^7.5.2", "semver": "^7.5.2",
"source-map-loader": "^5.0.0", "source-map-loader": "^5.0.0",
"storybook": "^9.0.12",
"stylelint": "^16.13.0", "stylelint": "^16.13.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
@@ -295,6 +306,8 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"util": "^0.12.5", "util": "^0.12.5",
"vite": "^7.0.1",
"vite-plugin-node-polyfills": "^0.24.0",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",
+46
View File
@@ -0,0 +1,46 @@
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
index 498bb69..4e89216 100644
--- a/node_modules/@types/mdx/types.d.ts
+++ b/node_modules/@types/mdx/types.d.ts
@@ -5,7 +5,7 @@
*/
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
// defined or not.
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
/**
* This matches any function component types that ar part of `ElementType`.
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
/**
* A valid JSX string component.
*/
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
/**
* A JSX element returned by MDX content.
*/
-export type Element = JSX.Element;
+export type Element = React.JSX.Element;
/**
* A valid JSX function component.
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
*/
type ClassComponent<Props> = ElementType extends never
// If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass
- ? new(props: Props) => JSX.ElementClass
+ ? new(props: Props) => React.JSX.ElementClass
: ClassElementType extends never
// If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed.
? never
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
export type MDXComponents =
& NestedMDXComponents
& {
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
}
& {
/**
-31
View File
@@ -1,31 +0,0 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index d3318dc..c2b2c77 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -134,7 +134,7 @@ declare namespace React {
props: P,
) => ReactNode | Promise<ReactNode>)
// constructor signature must match React.Component
- | (new(props: P) => Component<any, any>);
+ | (new(props: P, context?: any) => Component<any, any>);
/**
* Created by {@link createRef}, or {@link useRef} when passed `null`.
@@ -945,7 +945,7 @@ declare namespace React {
context: unknown;
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
- constructor(props: P);
+ constructor(props: P, context?: unknown);
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
@@ -1117,7 +1117,7 @@ declare namespace React {
*/
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
// constructor signature must match React.Component
- new(props: P): Component<P, S>;
+ new(props: P, context?: any): Component<P, S>;
/**
* Ignored by React.
* @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.
+3
View File
@@ -158,6 +158,8 @@ test.describe("Cryptography", function () {
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
await checkDMRoom(page); await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob); const bobRoomId = await bobJoin(page, bob);
await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon-normal.png");
await testMessages(page, bob, bobRoomId); await testMessages(page, bob, bobRoomId);
await verify(app, bob); await verify(app, bob);
@@ -168,6 +170,7 @@ test.describe("Cryptography", function () {
// Take a snapshot of RoomSummaryCard with a verified E2EE icon // Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png"); await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
}, },
); );
@@ -48,31 +48,38 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
return promiseVerificationRequest; return promiseVerificationRequest;
} }
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => { test(
await logIntoElement(page, credentials); "Verify device with SAS during login",
{ tag: "@screenshot" },
async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials);
// Launch the verification request between alice and the bot // Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page); const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification // Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog"); const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification // the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); const verifier = await verificationRequest.evaluateHandle((request) =>
request.startVerification("m.sas.v1"),
);
// Handle emoji request and check that emojis are matching // Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier); await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click(); await infoDialog.getByRole("button", { name: "They match" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click(); await expect(page.locator(".mx_E2EIcon_verified")).toMatchScreenshot("device-verified-e2eIcon.png");
await infoDialog.getByRole("button", { name: "Got it" }).click();
// Check that our device is now cross-signed // Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app); await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup // Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky, // For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen. // as we need to wait for the secret gossiping to happen.
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
}); },
);
// Regression test for https://github.com/element-hq/element-web/issues/29110 // Regression test for https://github.com/element-hq/element-web/issues/29110
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
@@ -22,13 +22,21 @@ test.describe("Room list filters and sort", () => {
}); });
function getPrimaryFilters(page: Page): Locator { function getPrimaryFilters(page: Page): Locator {
return page.getByRole("listbox", { name: "Room list filters" }); return page.getByTestId("primary-filters");
} }
function getRoomOptionsMenu(page: Page): Locator { function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" }); return page.getByRole("button", { name: "Room Options" });
} }
function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}
function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}
/** /**
* Get the room list * Get the room list
* @param page * @param page
@@ -136,6 +144,7 @@ test.describe("Room list filters and sort", () => {
await tile.click(); await tile.click();
// Enable Favourite filter // Enable Favourite filter
await getFilterExpandButton(page).click();
const primaryFilters = getPrimaryFilters(page); const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Favourite" }).click(); await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(tile).not.toBeVisible(); await expect(tile).not.toBeVisible();
@@ -220,17 +229,13 @@ test.describe("Room list filters and sort", () => {
// only one room should be visible // only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(4); await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "People" }).click(); await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2); await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click(); await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
@@ -238,15 +243,24 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5); await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click(); await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1); await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click(); await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1); await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
}); });
test( test(
@@ -312,7 +326,9 @@ test.describe("Room list filters and sort", () => {
async ({ page, app, user }) => { async ({ page, app, user }) => {
const emptyRoomList = getEmptyRoomList(page); const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png"); await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png");
await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png"); await expect(page.getByRole("navigation", { name: "Room list" })).toMatchScreenshot(
"room-panel-empty-room-list.png",
);
}, },
); );
@@ -326,6 +342,8 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, user }) => { async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page); const primaryFilters = getPrimaryFilters(page);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: filter }).click(); await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page); const emptyRoomList = getEmptyRoomList(page);
@@ -343,6 +361,8 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, user }) => { async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page); const primaryFilters = getPrimaryFilters(page);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: filter }).click(); await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page); const emptyRoomList = getEmptyRoomList(page);
@@ -19,7 +19,7 @@ test.describe("Room list panel", () => {
* @param page * @param page
*/ */
function getRoomListView(page: Page) { function getRoomListView(page: Page) {
return page.getByTestId("room-list-panel"); return page.getByRole("navigation", { name: "Room list" });
} }
test.beforeEach(async ({ page, app, user }) => { test.beforeEach(async ({ page, app, user }) => {
@@ -44,7 +44,7 @@ test.describe("Room list panel", () => {
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
await page.setViewportSize({ width: 575, height: 600 }); await page.setViewportSize({ width: 575, height: 600 });
const roomListPanel = page.getByTestId("room-list-panel"); const roomListPanel = getRoomListView(page);
await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png"); await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png");
}); });
}); });
@@ -49,8 +49,7 @@ test.describe("Room list", () => {
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await page.mouse.wheel(0, 1000); await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
}); });
@@ -60,6 +59,12 @@ test.describe("Room list", () => {
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
}); });
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
});
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
@@ -109,10 +114,13 @@ test.describe("Room list", () => {
// It should make the room muted // It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click(); await page.getByRole("menuitem", { name: "Mute room" }).click();
await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await page.mouse.wheel(0, 1000); await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
// The room decoration should have the muted icon // The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible(); await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -133,8 +141,9 @@ test.describe("Room list", () => {
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await page.mouse.wheel(0, 1000); await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" }); const filters = page.getByRole("listbox", { name: "Room list filters" });
@@ -223,17 +232,17 @@ test.describe("Room list", () => {
await expect(notificationButton).toBeFocused(); await expect(notificationButton).toBeFocused();
// Open the menu // Open the menu
await notificationButton.click(); await page.keyboard.press("Enter");
// Wait for the menu to be open // Wait for the menu to be open
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute( await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected", "aria-selected",
"true", "true",
); );
// Close the menu await page.keyboard.press("ArrowDown");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
// Focus should be back on the room list item // Focus should be back on the notification button
await expect(room29).toBeFocused(); await expect(notificationButton).toBeFocused();
}); });
}); });
}); });
@@ -278,7 +287,7 @@ test.describe("Room list", () => {
}); });
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click(); await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click(); await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room"); await page.getByRole("textbox", { name: "Name" }).fill("video room");
await page.getByRole("button", { name: "Create video room" }).click(); await page.getByRole("button", { name: "Create video room" }).click();
@@ -0,0 +1,36 @@
/*
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 { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps";
const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro];
test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => {
for (const variant of variants) {
test.describe(`for variant ${variant}`, () => {
test.use({
config: {
default_server_config: {
"m.homeserver": {
base_url: "https://matrix.server.invalid",
server_name: "server.invalid",
},
},
mobile_guide_app_variant: variant,
},
viewport: { width: 390, height: 844 }, // iPhone 16e
});
test("should match the mobile_guide screenshot", async ({ page, axe }) => {
await page.goto("/mobile_guide/");
await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`);
await expect(axe).toHaveNoViolations();
});
});
}
});
@@ -0,0 +1,160 @@
/*
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 fs from "node:fs";
import { test, expect } from "../../element-web-test";
const screenshotOptions = (page: Page) => ({
mask: [page.locator(".mx_MessageTimestamp")],
// Hide the jump to bottom button in the timeline to avoid flakiness
// Exclude timestamp and read marker from snapshot
css: `
.mx_JumpToBottomButton {
display: none !important;
}
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
display: none !important;
}
`,
});
const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png");
test.describe("Custom Component API", () => {
test.use({
displayName: "Manny",
config: {
modules: ["/modules/custom-component-module.js"],
},
page: async ({ page }, use) => {
await page.route("/modules/custom-component-module.js", async (route) => {
await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
});
await use(page);
},
room: async ({ page, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name: "TestRoom" });
await use({ roomId });
},
});
test.describe("basic functionality", () => {
test(
"should replace the render method of a textual event",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Simple message");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile.png",
screenshotOptions(page),
);
},
);
test(
"should fall through if one module does not render a component",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Fall through here");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile-fall-through.png",
screenshotOptions(page),
);
},
);
test(
"should render the original content of a textual event conditionally",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Do not replace me");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile-original.png",
screenshotOptions(page),
);
},
);
test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Do not show edits");
await page.getByText("Do not show edits").hover();
await expect(
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
).not.toBeVisible();
});
test("should disallow downloading media when the allowDownloading hint is set to false", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "bad.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
});
test("should allow downloading media when the allowDownloading hint is set to true", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "good.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
});
test(
"should render the next registered component if the filter function throws",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Crash the filter!");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-crash-handle-filter.png",
screenshotOptions(page),
);
},
);
test(
"should render original component if the render function throws",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Crash the renderer!");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-crash-handle-renderer.png",
screenshotOptions(page),
);
},
);
});
});
+17
View File
@@ -11,6 +11,9 @@ import { type Page } from "@playwright/test";
import { expect } from "../../element-web-test"; import { expect } from "../../element-web-test";
/**
* Click through registering a new user in the MAS UI.
*/
export async function registerAccountMas( export async function registerAccountMas(
page: Page, page: Page,
mailpit: MailpitClient, mailpit: MailpitClient,
@@ -42,3 +45,17 @@ export async function registerAccountMas(
await expect(page.getByText("Allow access to your account?")).toBeVisible(); await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
} }
/**
* Click through entering username and password into the MAS login prompt.
*/
export async function logInAccountMas(page: Page, username: string, password: string): Promise<void> {
await expect(page.getByText("Please sign in to continue:")).toBeVisible();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
}
+161 -3
View File
@@ -6,8 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { type Config, CONFIG_JSON } from "@element-hq/element-web-playwright-common";
import { type Browser, type Page } from "@playwright/test";
import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
import { test, expect } from "../../element-web-test.ts"; import { test, expect } from "../../element-web-test.ts";
import { registerAccountMas } from "."; import { logInAccountMas, registerAccountMas } from ".";
import { ElementAppPage } from "../../pages/ElementAppPage.ts"; import { ElementAppPage } from "../../pages/ElementAppPage.ts";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
@@ -77,7 +81,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test( test(
"it should log out the user & wipe data when logging out via MAS", "it should log out the user & wipe data when logging out via MAS",
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ mas, page, mailpitClient }, testInfo) => { async ({ mas, page, mailpitClient, homeserver }, testInfo) => {
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one // We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
await page.goto("/#/login"); await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
@@ -91,14 +95,168 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const result = await mas.manage("kill-sessions", userId); const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session"); expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
// Workaround for Synapse's 2 minute cache on MAS token validity
// (https://github.com/element-hq/synapse/pull/18231)
await homeserver.restart();
await page.goto("http://localhost:8080"); await page.goto("http://localhost:8080");
await expect( await expect(
page.getByText("For security, this session has been signed out. Please sign in again."), page.getByText("For security, this session has been signed out. Please sign in again."),
).toBeVisible(); ).toBeVisible();
await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true }); //await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage)); const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
expect(localStorageKeys).toHaveLength(0); expect(localStorageKeys).toHaveLength(0);
}, },
); );
test("can log in to an existing MAS account", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
// Allow the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in (we see an error because we have no recovery key).
await expect(page.getByText("Unable to verify this device")).toBeVisible();
});
test.describe("with force_verification on", () => {
test.use({
config: {
force_verification: true,
},
});
test("verify dialog cannot be dismissed", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be being warned that we need to verify (but we can't)
await expect(page.getByText("Unable to verify this device")).toBeVisible();
// And there should be no way to close this prompt
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
});
test(
"continues to show verification prompt after cancelling device verification",
{ tag: "@screenshot" },
async ({ browser, config, homeserver, page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
const password = "Pa$sW0rD!";
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, password);
await expect(page.getByText("Welcome")).toBeVisible();
// Log in an additional account, and verify it.
//
// This means that when we log out and in again, we are offered
// to verify using another device.
const otherContext = await newContext(browser, config, homeserver);
const otherDevicePage = await otherContext.newPage();
await otherDevicePage.goto("/#/login");
await otherDevicePage.getByRole("button", { name: "Continue" }).click();
await logInAccountMas(otherDevicePage, userId, password);
await verifyUsingOtherDevice(otherDevicePage, page);
await otherDevicePage.close();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in, and not able to dismiss the verify dialog
await expect(page.getByText("Verify this device")).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
// When we start verifying with another device
await page.getByRole("button", { name: "Verify with another device" }).click();
// And then cancel it
await page.getByRole("button", { name: "Close dialog" }).click();
// Then we should still be at the unskippable verify prompt
await expect(page.getByText("Verify this device")).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
},
);
});
}); });
/**
* Perform interactive emoji verification for a new device.
*/
async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();
await deviceToVerifyPage.getByRole("button", { name: "They match" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Got it" }).click();
await deviceToVerifyPage.getByRole("button", { name: "Got it" }).click();
}
/**
* Create a new browser context which serves up the default config plus what you supplied, and sets m.homeserver to the
* supplied homeserver's URL.
*/
async function newContext(browser: Browser, config: Partial<Partial<Config>>, homeserver: StartedHomeserverContainer) {
const otherContext = await browser.newContext();
await otherContext.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
...config,
default_server_config: {
"m.homeserver": {
base_url: homeserver.baseUrl,
},
},
};
await route.fulfill({ json });
});
return otherContext;
}
+4 -1
View File
@@ -42,7 +42,10 @@ export class Helpers {
*/ */
async assertReleaseAnnouncementIsVisible(name: string) { async assertReleaseAnnouncementIsVisible(name: string) {
await expect(this.getReleaseAnnouncement(name)).toBeVisible(); await expect(this.getReleaseAnnouncement(name)).toBeVisible();
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { showTooltips: true }); await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, {
showTooltips: true,
hideJumpToBottomButton: true,
});
} }
/** /**
+9
View File
@@ -107,6 +107,7 @@ interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean; includeDialogBackground?: boolean;
showTooltips?: boolean; showTooltips?: boolean;
timeout?: number; timeout?: number;
hideJumpToBottomButton?: boolean;
} }
type Expectations = { type Expectations = {
@@ -165,6 +166,14 @@ export const expect = baseExpect.extend<Expectations>({
`; `;
} }
if (options?.hideJumpToBottomButton) {
css += `
.mx_JumpToBottomButton {
display: none !important;
}
`;
}
if (options?.css) { if (options?.css) {
css += options.css; css += options.css;
} }
+22
View File
@@ -213,4 +213,26 @@ export class ElementAppPage {
.getByRole("button", { name: "Dismiss" }) .getByRole("button", { name: "Dismiss" })
.click(); .click();
} }
/**
* Scroll an infinite list to the bottom.
* @param list The element to scroll
*/
public async scrollListToBottom(list: Locator): Promise<void> {
// First hover the mouse over the element that we want to scroll
await list.hover();
const needsScroll = async () => {
// From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const fullyScrolled = await list.evaluate(
(e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
);
return !fullyScrolled;
};
// Scroll the element until we detect that it is fully scrolled
do {
await this.page.mouse.wheel(0, 1000);
} while (await needsScroll());
}
} }
@@ -0,0 +1,74 @@
/*
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.
*/
// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we
// get around it with @typedef
/**
* @typedef {import("@element-hq/element-web-module-api").Api} Api
*/
export default class CustomComponentModule {
static moduleApiVersion = "^1.2.0";
/**
* Basic module for testing.
* @param {Api} api API object
*/
constructor(api) {
this.api = api;
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Do not show edits",
(_props, originalComponent) => {
return originalComponent();
},
{ allowEditingEvent: false },
);
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Fall through here",
(props) => {
const body = props.mxEvent.content.body;
return `Fallthrough text for ${body}`;
},
);
this.api.customComponents.registerMessageRenderer(
(evt) => {
if (evt.content.body === "Crash the filter!") {
throw new Error("Fail test!");
}
return false;
},
() => {
return `Should not render!`;
},
);
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Crash the renderer!",
() => {
throw new Error("Fail test!");
},
);
this.api.customComponents.registerMessageRenderer(
(mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image",
(_props, originalComponent) => {
return originalComponent();
},
{ allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" },
);
// Order is specific here to avoid this overriding the other renderers
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
const body = props.mxEvent.content.body;
if (body === "Do not replace me") {
return originalComponent();
} else if (body === "Fall through here") {
return null;
}
return `Custom text for ${body}`;
});
}
async load() {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

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