Compare commits

...

32 Commits

Author SHA1 Message Date
David Baker
61bf70195b Update TextualEvent.stories.tsx 2025-07-18 09:30:06 +01:00
ElementRobot
31fb23a170 [create-pull-request] automated change (#30335)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-18 06:19:14 +00:00
David Baker
69c2afe8e4 Upload visual diffs from storybook tests (#30298)
* Very first pass at shared component views

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

* Remove old TextualEvent

* Pass showHiddenEvents

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

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

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

* Change here too

* Workaround for incomatible types in rollup

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

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

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

* Add implements

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

* Fix listener interface

* Add implements

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

* Fix types

* Fix more types

* Revert useMemo

as this isn't a hook

* Unused import

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

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

* Add cache specifier for only shell playwright browsers

* Add copyright headers

* Upload visual diffs from storybook testing

* Replace tab

---------

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

* Add required globals.

* i18n-ize

* Add tests

* lint

* lint

* lint

* update copyrights

* Fix test

* lint

* Fixup

* lint

* remove unused string

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

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

* Remove old TextualEvent

* Pass showHiddenEvents

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

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

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

* Change here too

* Workaround for incomatible types in rollup

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

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

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

* Add implements

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

* Fix listener interface

* Add implements

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

* Fix types

* Fix more types

* Add a superclass that simple view models can extend

to reduce boilerplate

* Revert useMemo

as this isn't a hook

* Unused import

* Actually commit the file the branch is named after

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

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

* Add cache specifier for only shell playwright browsers

* Add copyright headers

* Better comment wording

* Make amit an arrow function

so it can be passed directly as a callback

* Add a test

---------

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

* fixed cosmetic comments

* fixed test

* refactored out downloading functionality from buttons to useDownloadMedia hook

* ImageView CTRL+S use button component

* added CTRL+S test & lint

* removed forwardRef

* fix lint

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

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

* Add space after quoted text in ComposerInsert action

* Add space after quoted text in MessageContextMenu test

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

* fix: circular deps

* feat: add language selector to storybook

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

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

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

* Remove old TextualEvent

* Pass showHiddenEvents

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

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

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

* Change here too

* Workaround for incomatible types in rollup

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

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

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

* Add implements

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

* Fix listener interface

* Add implements

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

* Fix types

* Fix more types

* Revert useMemo

as this isn't a hook

* Unused import

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

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

* Add cache specifier for only shell playwright browsers

* Add copyright headers

---------

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

* test(e2e): add non regression tests

* chore: remove unused CSS mask

(cherry picked from commit a05ca97409)

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

* test(e2e): add non regression tests

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

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

* fix typescript

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

View File

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

View File

@@ -0,0 +1,49 @@
# 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
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: "tree -L 1 --noreport -H '-.' -o received-images/index.html received-images"
- 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
prefix: "diffs-"

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

37
.storybook/main.ts Normal file
View File

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

18
.storybook/manager.js Normal file
View File

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

10
.storybook/preview.css Normal file
View File

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

90
.storybook/preview.tsx Normal file
View File

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

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

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

View File

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

View File

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

8
declaration.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent";
import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
@@ -44,6 +43,8 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEvent } from "../shared-components/event-tiles/TextualEvent";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -67,6 +68,7 @@ export interface EventTileTypeProps
maxImageHeight?: number; // pixels
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
showHiddenEvents: boolean;
}
type FactoryProps = Omit<EventTileTypeProps, "ref">;
@@ -77,7 +79,10 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
<LegacyCallEvent ref={ref} {...props} />
);
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => {
const vm = new TextualEventViewModel(props);
return <TextualEvent vm={vm} />;
};
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
@@ -252,12 +257,11 @@ export function pickFactory(
export function renderTile(
renderType: TimelineRenderingType,
props: EventTileTypeProps,
showHiddenEvents: boolean,
cli?: MatrixClient,
): Optional<JSX.Element> {
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
const factory = pickFactory(props.mxEvent, cli, props.showHiddenEvents);
if (!factory) {
// If we don't have a factory for this event, attempt
// to find a custom component that can render it.
@@ -286,6 +290,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
showHiddenEvents,
} = props;
switch (renderType) {
@@ -309,6 +314,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
inhibitInteraction,
showHiddenEvents,
}),
);
default:
@@ -332,6 +338,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
showHiddenEvents,
}),
);
}
@@ -394,6 +401,7 @@ export function renderReplyTile(
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
showHiddenEvents,
}),
);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2020-2024 New Vector Ltd.
Copyright 2020-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.
@@ -28,56 +28,19 @@ const defaults: IParams = {
isLeft: false,
};
// Allows dynamic rendering of a circular badge atop the loaded favicon
// supports colour, font and basic positioning parameters.
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
export default class Favicon {
private readonly browser = {
ff: typeof window.InstallTrigger !== "undefined",
opera: !!window.opera || navigator.userAgent.includes("Opera"),
};
private readonly params: IParams;
private readonly canvas: HTMLCanvasElement;
private readonly baseImage: HTMLImageElement;
private context!: CanvasRenderingContext2D;
private icons: HTMLLinkElement[];
private isReady = false;
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
private readyCb?: () => void;
public constructor(params: Partial<IParams> = {}) {
this.params = { ...defaults, ...params };
this.icons = Favicon.getIcons();
// create work canvas
abstract class IconRenderer {
protected readonly canvas: HTMLCanvasElement;
protected readonly context: CanvasRenderingContext2D;
public constructor(
protected readonly params: IParams = defaults,
protected readonly baseImage?: HTMLImageElement,
) {
this.canvas = document.createElement("canvas");
// create clone of favicon as a base
this.baseImage = document.createElement("img");
const lastIcon = this.icons[this.icons.length - 1];
if (lastIcon.hasAttribute("href")) {
this.baseImage.setAttribute("crossOrigin", "anonymous");
this.baseImage.onload = (): void => {
// get height and width of the favicon
this.canvas.height = this.baseImage.height > 0 ? this.baseImage.height : 32;
this.canvas.width = this.baseImage.width > 0 ? this.baseImage.width : 32;
this.context = this.canvas.getContext("2d")!;
this.ready();
};
this.baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
} else {
this.canvas.height = this.baseImage.height = 32;
this.canvas.width = this.baseImage.width = 32;
this.context = this.canvas.getContext("2d")!;
this.ready();
const context = this.canvas.getContext("2d");
if (!context) {
throw Error("Could not get canvas context");
}
}
private reset(): void {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
this.context = context;
}
private options(
@@ -125,11 +88,23 @@ export default class Favicon {
return opt;
}
private circle(n: number | string, opts?: Partial<IParams>): void {
/**
* Draws a circualr status icon, usually over the top of the application icon.
* @param n The content of the circle. Should be a number or a single character.
* @param opts Options to adjust.
*/
protected circle(n: number | string, opts?: Partial<IParams>): void {
const params = { ...this.params, ...opts };
const opt = this.options(n, params);
let more = false;
if (!this.baseImage) {
// If we omit the background, assume the entire canvas is our target.
opt.x = 0;
opt.y = 0;
opt.w = this.canvas.width;
opt.h = this.canvas.height;
}
if (opt.len === 2) {
opt.x = opt.x - opt.w * 0.4;
opt.w = opt.w * 1.4;
@@ -141,7 +116,9 @@ export default class Favicon {
}
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
if (this.baseImage) {
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
}
this.context.beginPath();
const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px";
this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`;
@@ -177,6 +154,86 @@ export default class Favicon {
this.context.closePath();
}
}
export class BadgeOverlayRenderer extends IconRenderer {
public constructor() {
super();
// Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
this.canvas.width = 16;
this.canvas.height = 16;
}
/**
* Generate an overlay badge without the application icon, and export
* as an ArrayBuffer
* @param contents The content of the circle. Should be a number or a single character.
* @param bgColor Optional alternative background colo.r
* @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn.
*/
public async render(contents: number | string, bgColor?: string): Promise<ArrayBuffer | null> {
if (contents === 0) {
return null;
}
this.circle(contents, { ...(bgColor ? { bgColor } : undefined) });
return new Promise((resolve, reject) => {
this.canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob.arrayBuffer());
}
reject(new Error("Could not render badge overlay as blob"));
},
"image/png",
1,
);
});
}
}
// Allows dynamic rendering of a circular badge atop the loaded favicon
// supports colour, font and basic positioning parameters.
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
export default class Favicon extends IconRenderer {
private readonly browser = {
ff: typeof window.InstallTrigger !== "undefined",
opera: !!window.opera || navigator.userAgent.includes("Opera"),
};
private icons: HTMLLinkElement[];
private isReady = false;
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
private readyCb?: () => void;
public constructor() {
const baseImage = document.createElement("img");
super(defaults, baseImage);
this.icons = Favicon.getIcons();
const lastIcon = this.icons[this.icons.length - 1];
if (lastIcon.hasAttribute("href")) {
baseImage.setAttribute("crossOrigin", "anonymous");
baseImage.onload = (): void => {
// get height and width of the favicon
this.canvas.height = baseImage.height > 0 ? baseImage.height : 32;
this.canvas.width = baseImage.width > 0 ? baseImage.width : 32;
this.ready();
};
baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
} else {
this.canvas.height = baseImage.height = 32;
this.canvas.width = baseImage.width = 32;
this.ready();
}
}
private reset(): void {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height);
}
private ready(): void {
if (this.isReady) return;

View File

@@ -0,0 +1,93 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { useRef, useState, useMemo, useEffect } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import { _t } from "../languageHandler";
import Modal from "../Modal";
import { FileDownloader } from "../utils/FileDownloader";
import { MediaEventHelper } from "../utils/MediaEventHelper";
import ModuleApi from "../modules/Api";
export interface UseDownloadMediaReturn {
download: () => Promise<void>;
loading: boolean;
canDownload: boolean;
}
export function useDownloadMedia(url: string, fileName?: string, mxEvent?: MatrixEvent): UseDownloadMediaReturn {
const downloader = useRef(new FileDownloader()).current;
const blobRef = useRef<Blob>(null);
const [loading, setLoading] = useState(false);
const [canDownload, setCanDownload] = useState<boolean>(true);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
useEffect(() => {
if (!mxEvent) return;
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
if (hints?.allowDownloadingMedia) {
setCanDownload(false);
hints
.allowDownloadingMedia()
.then(setCanDownload)
.catch((err: any) => {
logger.error(`Failed to check media download permission for ${mxEvent.event.event_id}`, err);
setCanDownload(false);
});
} else {
setCanDownload(true);
}
}, [mxEvent]);
const download = async (): Promise<void> => {
if (loading) return;
try {
setLoading(true);
if (blobRef.current) {
return downloadBlob(blobRef.current);
}
const res = await fetch(url);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
const blob = await res.blob();
blobRef.current = blob;
await downloadBlob(blob);
} catch (e) {
showError(e);
}
};
const downloadBlob = async (blob: Blob): Promise<void> => {
await downloader.download({
blob,
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
});
setLoading(false);
};
const showError = (e: unknown): void => {
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
});
setLoading(false);
};
return { download, loading, canDownload };
}

View File

@@ -1938,8 +1938,32 @@
"active_heading": "Arolygon gweithredol",
"empty_active": "Nid oes unrhyw arolygon gweithredol yn yr ystafell hon",
"empty_active_load_more": "Nid oes unrhyw arolygon gweithredol. Llwythwch fwy o arolygon barn y misoedd blaenorol",
"empty_active_load_more_n_days": {
"zero": "Does dim polau gweithredol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"one": "Does dim polau gweithredol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"two": "Does dim polau gweithredol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"few": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"many": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"other": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
},
"empty_past": "Nid oes arolygon o'r gorffennol yn yr ystafell hon",
"empty_past_load_more": "Nid oes unrhyw arolygon o'r gorffennol. Llwythwch fwy o arolygon barn ar gyfer y misoedd blaenorol",
"empty_past_load_more_n_days": {
"zero": "Does dim polau'r gorffennol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"one": "Does dim polau'r gorffennol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"two": "Does dim polau'r gorffennol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"few": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"many": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
"other": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
},
"final_result": {
"zero": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidleisiau",
"one": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
"two": "Canlyniadau terfynol yn seiliedig ar %(count)s bleidlais",
"few": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
"many": "Canlyniadau terfynol yn seiliedig ar %(count)s phleidlais",
"other": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais"
},
"load_more": "Llwytho mwy o arolygon barn",
"loading": "Wrthi'n llwytho arolygon",
"past_heading": "Arolygon y gorffennol",

View File

@@ -1450,6 +1450,7 @@
"room_list_navigate_down": "Navigate down in the room list",
"room_list_navigate_up": "Navigate up in the room list",
"room_list_select_room": "Select room from the room list",
"save": "Save",
"scroll_down_timeline": "Scroll down in the timeline",
"scroll_up_timeline": "Scroll up in the timeline",
"search": "Search (must be enabled)",
@@ -3370,7 +3371,6 @@
"unable_to_decrypt": "Unable to decrypt message"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file",

View File

@@ -1,49 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2017 MTRNord and Cooperative EITA
Copyright 2017 Vector Creations 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.
*/
* 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 counterpart from "counterpart";
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type Optional } from "matrix-events-sdk";
import { MapWithDefault } from "matrix-js-sdk/src/utils";
import { normalizeLanguageKey, type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
import _ from "lodash";
import type Translations from "./i18n/strings/en_EN.json";
import SettingsStore from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg";
import { SettingLevel } from "./settings/SettingLevel";
import { retry } from "./utils/promise";
import SdkConfig from "./SdkConfig";
import { ModuleRunner } from "./modules/ModuleRunner";
import {
_t,
normalizeLanguageKey,
type TranslationKey,
type IVariables,
KEY_SEPARATOR,
getLangsJson,
} from "./shared-components/i18n";
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
export { normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
export {
_t,
type IVariables,
type Tags,
type TranslationKey,
type TranslatedString,
_td,
_tDom,
lookupString,
sanitizeForTranslation,
normalizeLanguageKey,
getNormalizedLanguageKeys,
substitute,
} from "./shared-components/i18n";
const i18nFolder = "i18n/";
// Control whether to also return original, untranslated strings
// Useful for debugging and testing
const ANNOTATE_STRINGS = false;
// We use english strings as keys, some of which contain full stops
counterpart.setSeparator(KEY_SEPARATOR);
// see `translateWithFallback` for an explanation of fallback handling
const FALLBACK_LOCALE = "en";
counterpart.setFallbackLocale(FALLBACK_LOCALE);
export interface ErrorOptions {
// Because we're mixing the substitution variables and `cause` into the same object
// below, we want them to always explicitly say whether there is an underlying error
@@ -96,353 +96,6 @@ export function getUserLanguage(): string {
}
}
/**
* A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
* @example `common|error` to access `error` within the `common` sub-object.
* {
* "common": {
* "error": "Error"
* }
* }
*/
export type TranslationKey = _TranslationKey<typeof Translations>;
// Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s: TranslationKey): TranslationKey {
return s;
}
function isValidTranslation(translated: string): boolean {
return typeof translated === "string" && !translated.startsWith("missing translation:");
}
/**
* to improve screen reader experience translations that are not in the main page language
* eg a translation that fell back to english from another language
* should be wrapped with an appropriate `lang='en'` attribute
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
* is in the target locale or a fallback locale
* for this reason, force fallbackLocale === locale in the first call to translate
* and fallback 'manually' so we can mark fallback strings appropriately
* */
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
if (isValidTranslation(translated)) {
return { translated };
}
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
if (isValidTranslation(fallbackTranslated)) {
return { translated: fallbackTranslated, isFallback: true };
}
// Even the translation via FALLBACK_LOCALE failed; this can happen if
//
// 1. The string isn't in the translations dictionary, usually because you're in develop
// and haven't run yarn i18n
// 2. Loading the translation resources over the network failed, which can happen due to
// to network or if the client tried to load a translation that's been removed from the
// server.
//
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
return { translated: text, isFallback: true };
};
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
const options: IVariables & {
interpolate: boolean;
} = { ...variables, interpolate: false };
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
// in JS land passing undefined/null will simply stringify instead, and when converting
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
// if there are no existing null guards. To avoid this making the app completely inoperable,
// we'll check all the values for undefined/null and stringify them here.
if (options && typeof options === "object") {
Object.keys(options).forEach((k) => {
if (options[k] === undefined) {
logger.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
options[k] = "undefined";
}
if (options[k] === null) {
logger.warn("safeCounterpartTranslate called with null interpolation name: " + k);
options[k] = "null";
}
});
}
return translateWithFallback(text, options);
}
/**
* The value a variable or tag can take for a translation interpolation.
*/
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
export interface IVariables {
count?: number;
[key: string]: SubstitutionValue;
}
export type Tags = Record<string, SubstitutionValue>;
export type TranslatedString = string | React.ReactNode;
// For development/testing purposes it is useful to also output the original string
// Don't do that for release versions
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
if (!ANNOTATE_STRINGS) {
return result;
}
if (typeof result === "string") {
return `@@${translationKey}##${result}@@`;
} else {
return (
<span className="translated-string" data-orig-string={translationKey}>
{result}
</span>
);
}
};
/*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* In both variables and tags, the values to substitute with can be either simple strings, React components,
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
*
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
* substitution to insert React components, but you can't use it to translate text between tags.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
export function _t(text: TranslationKey, variables?: IVariables): string;
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
return annotateStrings(substituted, text);
}
/**
* Utility function to look up a string by its translation key without resolving variables & tags
* @param key - the translation key to return the value for
*/
export function lookupString(key: TranslationKey): string {
return safeCounterpartTranslate(key, {}).translated;
}
/*
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* @return a React <span> component if any non-strings were used in substitutions
* or translation used a fallback locale, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
// wrap en fallback translation with lang attribute for screen readers
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
return annotateStrings(result, text);
}
/**
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
* replaceable by the translation functions.
* @param {string} text The text to sanitize.
* @returns {string} The sanitized text.
*/
export function sanitizeForTranslation(text: string): string {
// Add a non-breaking space so the regex doesn't trigger when translating.
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
}
/*
* Similar to _t(), except only does substitutions, and no translation
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* The values to substitute with can be either simple strings, or functions that return the value to use in
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
* the argument the text inside the element corresponding to the tag.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function substitute(text: string, variables?: IVariables): string;
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) {
const regexpMapping: IVariables = {};
for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
}
result = replaceByRegexes(result as string, regexpMapping);
}
if (tags !== undefined) {
const regexpMapping: Tags = {};
for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
}
result = replaceByRegexes(result as string, regexpMapping);
}
return result;
}
/**
* Replace parts of a text using regular expressions
* @param text - The text on which to perform substitutions
* @param mapping - A mapping from regular expressions in string form to replacement string or a
* function which will receive as the argument the capture groups defined in the regexp. E.g.
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end
const output: SubstitutionValue[] = [text];
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
let shouldWrapInSpan = false;
for (const regexpString in mapping) {
// TODO: Cache regexps
const regexp = new RegExp(regexpString, "g");
// Loop over what output we have so far and perform replacements
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex];
if (typeof inputText !== "string") {
// We might have inserted objects earlier, don't try to replace them
continue;
}
// process every match in the string
// starting with the first
let match = regexp.exec(inputText);
if (!match) continue;
matchFoundSomewhere = true;
// The textual part before the first match
const head = inputText.slice(0, match.index);
const parts: SubstitutionValue[] = [];
// keep track of prevMatch
let prevMatch;
while (match) {
// store prevMatch
prevMatch = match;
const capturedGroups = match.slice(2);
let replaced: SubstitutionValue;
// If substitution is a function, call it
if (mapping[regexpString] instanceof Function) {
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
} else {
replaced = mapping[regexpString];
}
if (typeof replaced === "object") {
shouldWrapInSpan = true;
}
// Here we also need to check that it actually is a string before comparing against one
// The head and tail are always strings
if (typeof replaced !== "string" || replaced !== "") {
parts.push(replaced);
}
// try the next match
match = regexp.exec(inputText);
// add the text between prevMatch and this one
// or the end of the string if prevMatch is the last match
let tail;
if (match) {
const startIndex = prevMatch.index + prevMatch[0].length;
tail = inputText.slice(startIndex, match.index);
} else {
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
}
if (tail) {
parts.push(tail);
}
}
// Insert in reverse order as splice does insert-before and this way we get the final order correct
// remove the old element at the same time
output.splice(outputIndex, 1, ...parts);
if (head !== "") {
// Don't push empty nodes, they are of no use
output.splice(outputIndex, 0, head);
}
}
if (!matchFoundSomewhere) {
if (
// The current regexp did not match anything in the input. Missing
// matches is entirely possible because you might choose to show some
// variables only in the case of e.g. plurals. It's still a bit
// suspicious, and could be due to an error, so log it. However, not
// showing count is so common that it's not worth logging. And other
// commonly unused variables here, if there are any.
regexpString !== "%\\(count\\)s" &&
// Ignore the `locale` option which can be used to override the locale
// in counterpart
regexpString !== "%\\(locale\\)s"
) {
logger.log(`Could not find ${regexp} in ${text}`);
}
}
}
if (shouldWrapInSpan) {
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return output.join("");
}
}
// Allow overriding the text displayed when no translation exists
// Currently only used in unit tests to avoid having to load
// the translations in element-web
@@ -450,10 +103,6 @@ export function setMissingEntryGenerator(f: (value: string) => void): void {
counterpart.setMissingEntryGenerator(f);
}
type Languages = {
[lang: string]: string;
};
export async function setLanguage(...preferredLangs: string[]): Promise<void> {
PlatformPeg.get()?.setLanguage(preferredLangs);
@@ -554,24 +203,6 @@ export function pickBestLanguage(langs: string[]): string {
return langs[0];
}
async function getLangsJson(): Promise<Languages> {
let url: string;
if (typeof webpackLangJsonUrl === "string") {
// in Jest this 'url' isn't a URL, so just fall through
url = webpackLangJsonUrl;
} else {
url = i18nFolder + "languages.json";
}
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to load ${url}, got ${res.status}`);
}
return res.json();
}
interface ICounterpartTranslation {
[key: string]:
| string

View File

@@ -11,7 +11,8 @@ import React, { type ReactNode } from "react";
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
import { _t, _td, type TranslationKey } from "../languageHandler";
// Import i18n.tsx instead of languageHandler to avoid circular deps
import { _t, _td, type TranslationKey } from "../shared-components/i18n";
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
import {
NotificationBodyEnabledController,

View File

@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "./ViewModel";
/**
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
*/
export class MockViewModel<T> implements ViewModel<T> {
public constructor(private snapshot: T) {}
public getSnapshot = (): T => {
return this.snapshot;
};
public subscribe(listener: () => void): () => void {
return () => undefined;
}
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* The interface for a generic View Model passed to the shared components.
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
*/
export interface ViewModel<T> {
/**
* The current snapshot of the view model.
*/
getSnapshot: () => T;
/**
* Subscribes to changes in the view model.
* The listener will be called whenever the snapshot changes.
*/
subscribe: (listener: () => void) => () => void;
}

View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type Meta, type StoryFn } from "@storybook/react-vite";
import { TextualEvent as TextualEventComponent } from "./TextualEvent";
import { MockViewModel } from "../../MockViewModel";
export default {
title: "Event/TextualEvent",
component: TextualEventComponent,
tags: ["autodocs"],
args: {
vm: new MockViewModel("Not dummy textual event text"),
},
} as Meta<typeof TextualEventComponent>;
const Template: StoryFn<typeof TextualEventComponent> = (args) => <TextualEventComponent {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,21 @@
/*
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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./TextualEvent.stories.tsx";
const { Default } = composeStories(stories);
describe("TextualEvent", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,23 @@
/*
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, { type ReactNode, type JSX } from "react";
import { type ViewModel } from "../../ViewModel";
import { useViewModel } from "../../useViewModel";
export type TextualEventViewSnapshot = string | ReactNode;
export interface Props {
vm: ViewModel<TextualEventViewSnapshot>;
}
export function TextualEvent({ vm }: Props): JSX.Element {
const contents = useViewModel(vm);
return <div className="mx_TextualEvent">{contents}</div>;
}

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextualEvent renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"
>
Dummy textual event text
</div>
</div>
`;

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.
*/
export { TextualEvent } from "./TextualEvent";

View File

@@ -0,0 +1,432 @@
/*
* 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.
*/
/*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* In both variables and tags, the values to substitute with can be either simple strings, React components,
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
*
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
* substitution to insert React components, but you can't use it to translate text between tags.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
import React from "react";
import { type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
import counterpart from "counterpart";
import type Translations from "../i18n/strings/en_EN.json";
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
const i18nFolder = "i18n/";
// Control whether to also return original, untranslated strings
// Useful for debugging and testing
const ANNOTATE_STRINGS = false;
// We use english strings as keys, some of which contain full stops
counterpart.setSeparator(KEY_SEPARATOR);
// see `translateWithFallback` for an explanation of fallback handling
const FALLBACK_LOCALE = "en";
counterpart.setFallbackLocale(FALLBACK_LOCALE);
/**
* A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
* @example `common|error` to access `error` within the `common` sub-object.
* {
* "common": {
* "error": "Error"
* }
* }
*/
export type TranslationKey = _TranslationKey<typeof Translations>;
// Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s: TranslationKey): TranslationKey {
return s;
}
function isValidTranslation(translated: string): boolean {
return typeof translated === "string" && !translated.startsWith("missing translation:");
}
/**
* to improve screen reader experience translations that are not in the main page language
* eg a translation that fell back to english from another language
* should be wrapped with an appropriate `lang='en'` attribute
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
* is in the target locale or a fallback locale
* for this reason, force fallbackLocale === locale in the first call to translate
* and fallback 'manually' so we can mark fallback strings appropriately
* */
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
if (isValidTranslation(translated)) {
return { translated };
}
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
if (isValidTranslation(fallbackTranslated)) {
return { translated: fallbackTranslated, isFallback: true };
}
// Even the translation via FALLBACK_LOCALE failed; this can happen if
//
// 1. The string isn't in the translations dictionary, usually because you're in develop
// and haven't run yarn i18n
// 2. Loading the translation resources over the network failed, which can happen due to
// to network or if the client tried to load a translation that's been removed from the
// server.
//
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
return { translated: text, isFallback: true };
};
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
const options: IVariables & {
interpolate: boolean;
} = { ...variables, interpolate: false };
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
// in JS land passing undefined/null will simply stringify instead, and when converting
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
// if there are no existing null guards. To avoid this making the app completely inoperable,
// we'll check all the values for undefined/null and stringify them here.
if (options && typeof options === "object") {
Object.keys(options).forEach((k) => {
if (options[k] === undefined) {
console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
options[k] = "undefined";
}
if (options[k] === null) {
console.warn("safeCounterpartTranslate called with null interpolation name: " + k);
options[k] = "null";
}
});
}
return translateWithFallback(text, options);
}
/**
* The value a variable or tag can take for a translation interpolation.
*/
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
export interface IVariables {
count?: number;
[key: string]: SubstitutionValue;
}
export type Tags = Record<string, SubstitutionValue>;
export type TranslatedString = string | React.ReactNode;
// For development/testing purposes it is useful to also output the original string
// Don't do that for release versions
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
if (!ANNOTATE_STRINGS) {
return result;
}
if (typeof result === "string") {
return `@@${translationKey}##${result}@@`;
} else {
return (
<span className="translated-string" data-orig-string={translationKey}>
{result}
</span>
);
}
};
export function _t(text: TranslationKey, variables?: IVariables): string;
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
return annotateStrings(substituted, text);
}
/**
* Utility function to look up a string by its translation key without resolving variables & tags
* @param key - the translation key to return the value for
*/
export function lookupString(key: TranslationKey): string {
return safeCounterpartTranslate(key, {}).translated;
}
/*
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* @return a React <span> component if any non-strings were used in substitutions
* or translation used a fallback locale, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
// wrap en fallback translation with lang attribute for screen readers
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
return annotateStrings(result, text);
}
/**
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
* replaceable by the translation functions.
* @param {string} text The text to sanitize.
* @returns {string} The sanitized text.
*/
export function sanitizeForTranslation(text: string): string {
// Add a non-breaking space so the regex doesn't trigger when translating.
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
}
/*
* Similar to _t(), except only does substitutions, and no translation
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* The values to substitute with can be either simple strings, or functions that return the value to use in
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
* the argument the text inside the element corresponding to the tag.
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function substitute(text: string, variables?: IVariables): string;
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) {
const regexpMapping: IVariables = {};
for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
}
result = replaceByRegexes(result as string, regexpMapping);
}
if (tags !== undefined) {
const regexpMapping: Tags = {};
for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
}
result = replaceByRegexes(result as string, regexpMapping);
}
return result;
}
/**
* Replace parts of a text using regular expressions
* @param text - The text on which to perform substitutions
* @param mapping - A mapping from regular expressions in string form to replacement string or a
* function which will receive as the argument the capture groups defined in the regexp. E.g.
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
*
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end
const output: SubstitutionValue[] = [text];
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
let shouldWrapInSpan = false;
for (const regexpString in mapping) {
// TODO: Cache regexps
const regexp = new RegExp(regexpString, "g");
// Loop over what output we have so far and perform replacements
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex];
if (typeof inputText !== "string") {
// We might have inserted objects earlier, don't try to replace them
continue;
}
// process every match in the string
// starting with the first
let match = regexp.exec(inputText);
if (!match) continue;
matchFoundSomewhere = true;
// The textual part before the first match
const head = inputText.slice(0, match.index);
const parts: SubstitutionValue[] = [];
// keep track of prevMatch
let prevMatch;
while (match) {
// store prevMatch
prevMatch = match;
const capturedGroups = match.slice(2);
let replaced: SubstitutionValue;
// If substitution is a function, call it
if (mapping[regexpString] instanceof Function) {
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
} else {
replaced = mapping[regexpString];
}
if (typeof replaced === "object") {
shouldWrapInSpan = true;
}
// Here we also need to check that it actually is a string before comparing against one
// The head and tail are always strings
if (typeof replaced !== "string" || replaced !== "") {
parts.push(replaced);
}
// try the next match
match = regexp.exec(inputText);
// add the text between prevMatch and this one
// or the end of the string if prevMatch is the last match
let tail;
if (match) {
const startIndex = prevMatch.index + prevMatch[0].length;
tail = inputText.slice(startIndex, match.index);
} else {
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
}
if (tail) {
parts.push(tail);
}
}
// Insert in reverse order as splice does insert-before and this way we get the final order correct
// remove the old element at the same time
output.splice(outputIndex, 1, ...parts);
if (head !== "") {
// Don't push empty nodes, they are of no use
output.splice(outputIndex, 0, head);
}
}
if (!matchFoundSomewhere) {
if (
// The current regexp did not match anything in the input. Missing
// matches is entirely possible because you might choose to show some
// variables only in the case of e.g. plurals. It's still a bit
// suspicious, and could be due to an error, so log it. However, not
// showing count is so common that it's not worth logging. And other
// commonly unused variables here, if there are any.
regexpString !== "%\\(count\\)s" &&
// Ignore the `locale` option which can be used to override the locale
// in counterpart
regexpString !== "%\\(locale\\)s"
) {
console.log(`Could not find ${regexp} in ${text}`);
}
}
}
if (shouldWrapInSpan) {
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return output.join("");
}
}
type Languages = {
[lang: string]: string;
};
/**
* Sets the language for the application.
* In Element web,`languageHandler.setLanguage` should be used instead.
* @param language
*/
export async function setLanguage(language: string): Promise<void> {
const availableLanguages = await getLangsJson();
const chosenLanguage = language in availableLanguages ? language : "en";
const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]);
counterpart.registerTranslations(chosenLanguage, languageData);
counterpart.setLocale(chosenLanguage);
}
interface ICounterpartTranslation {
[key: string]:
| string
| {
[pluralisation: string]: string;
};
}
async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
console.log("Loading language from", langPath);
const res = await fetch(langPath, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to load ${langPath}, got ${res.status}`);
}
return res.json();
}
export async function getLangsJson(): Promise<Languages> {
let url: string;
if (typeof webpackLangJsonUrl === "string") {
// in Jest this 'url' isn't a URL, so just fall through
url = webpackLangJsonUrl;
} else {
url = i18nFolder + "languages.json";
}
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to load ${url}, got ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1,21 @@
/*
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 { useSyncExternalStore } from "react";
import { type ViewModel } from "./ViewModel";
/**
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
* @param vm The view model to use
* @returns The current snapshot
*/
export function useViewModel<T>(vm: ViewModel<T>): T {
// We need to pass the same getSnapshot function as getServerSnapshot as this
// is used when making the HTML chat export.
return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
}

View File

@@ -13,6 +13,7 @@ import {
type MatrixClient,
ClientEvent,
RoomStateEvent,
type ReceivedToDeviceMessage,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import {
@@ -360,7 +361,7 @@ export class StopGapWidget extends EventEmitter {
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.client.on(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
this.messaging.on(
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
@@ -493,7 +494,7 @@ export class StopGapWidget extends EventEmitter {
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.client.off(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
}
private onEvent = (ev: MatrixEvent): void => {
@@ -513,10 +514,10 @@ export class StopGapWidget extends EventEmitter {
});
};
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise<void> => {
const { message, encryptionInfo } = payload;
// TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent
await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null);
};
/**

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2018-2021 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
@@ -42,6 +42,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import { SeshatIndexManager } from "./SeshatIndexManager";
import { IPCManager } from "./IPCManager";
import { _t } from "../../languageHandler";
import { BadgeOverlayRenderer } from "../../favicon";
interface SquirrelUpdate {
releaseNotes: string;
@@ -87,10 +88,11 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
private readonly initialised: Promise<void>;
public readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
private badgeOverlayRenderer?: BadgeOverlayRenderer;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
@@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform {
}
private async initialise(): Promise<void> {
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } =
await this.electron.initialise();
this.protocol = protocol;
this.sessionId = sessionId;
this.config = config;
this.supportedSettings = supportedSettings;
if (supportsBadgeOverlay) {
this.badgeOverlayRenderer = new BadgeOverlayRenderer();
}
}
public async getConfig(): Promise<IConfigOptions | undefined> {
@@ -249,8 +255,42 @@ export default class ElectronPlatform extends BasePlatform {
public setNotificationCount(count: number): void {
if (this.notificationCount === count) return;
super.setNotificationCount(count);
if (this.badgeOverlayRenderer) {
this.badgeOverlayRenderer
.render(count)
.then((buffer) => {
this.electron.send("setBadgeCount", count, buffer);
})
.catch((ex) => {
logger.warn("Unable to generate badge overlay", ex);
});
} else {
this.electron.send("setBadgeCount", count);
}
}
this.electron.send("setBadgeCount", count);
public setErrorStatus(errorDidOccur: boolean): void {
if (!this.badgeOverlayRenderer) {
super.setErrorStatus(errorDidOccur);
return;
}
// Check before calling super so we don't override the previous state.
if (this.errorDidOccur !== errorDidOccur) {
super.setErrorStatus(errorDidOccur);
let promise: Promise<ArrayBuffer | null>;
if (errorDidOccur) {
promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00");
} else {
promise = this.badgeOverlayRenderer.render(this.notificationCount);
}
promise
.then((buffer) => {
this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur);
})
.catch((ex) => {
logger.warn("Unable to generate badge overlay", ex);
});
}
}
public supportsNotifications(): boolean {

View File

@@ -0,0 +1,56 @@
/*
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 ViewModel } from "../shared-components/ViewModel";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
export abstract class SubscriptionViewModel<T> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected constructor() {
this.subs = new ViewModelSubscriptions(
this.addDownstreamSubscriptionWrapper,
this.removeDownstreamSubscriptionWrapper,
);
}
public subscribe = (listener: () => void): (() => void) => {
return this.subs.add(listener);
};
/**
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
* has a bound `this` context.
*/
private addDownstreamSubscriptionWrapper = (): void => {
this.addDownstreamSubscription();
};
/**
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
* in the constructor.
*/
private removeDownstreamSubscriptionWrapper = (): void => {
this.removeDownstreamSubscription();
};
/**
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
* to call this.subs.emit() when the snapshot changes.
*/
protected abstract addDownstreamSubscription(): void;
/**
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
*/
protected abstract removeDownstreamSubscription(): void;
/**
* Returns the current snapshot of the view model.
*/
public abstract getSnapshot: () => T;
}

View File

@@ -0,0 +1,50 @@
/*
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.
*/
/**
* Utility class for view models to manage suscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* @param subscribeCallback Called when the first listener subscribes.
* @param unsubscribeCallback Called when the last listener unsubscribes.
*/
public constructor(
private subscribeCallback: () => void,
private unsubscribeCallback: () => void,
) {}
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
* @returns A function to unsubscribe from the view model updates.
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
if (this.listeners.size === 1) {
this.subscribeCallback();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.unsubscribeCallback();
}
};
};
/**
* Emit an update to all subscribed listeners.
*/
public emit = (): void => {
for (const listener of this.listeners) {
listener();
}
};
}

View File

@@ -0,0 +1,38 @@
/*
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 { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type EventTileTypeProps } from "../../events/EventTileFactory";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { textForEvent } from "../../TextForEvent";
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
import { SubscriptionViewModel } from "../SubscriptionViewModel";
export class TextualEventViewModel extends SubscriptionViewModel<TextualEventViewSnapshot> {
public constructor(private eventTileProps: EventTileTypeProps) {
super();
}
protected addDownstreamSubscription = (): void => {
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
};
protected removeDownstreamSubscription = (): void => {
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
};
public getSnapshot = (): TextualEventViewSnapshot => {
const text = textForEvent(
this.eventTileProps.mxEvent,
MatrixClientPeg.safeGet(),
true,
this.eventTileProps.showHiddenEvents,
);
return text;
};
}

View File

@@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
});
});
describe("quote button", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => {
mocked(getSelectedText).mockReturnValue("quoted text");
const isSelectionWithinSingleTextBody = jest
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
.mockReturnValue(true);
createRightClickMenuWithContent(createMessageEventContent("hello"));
const quoteButton = document.querySelector('li[aria-label="Quote"]');
expect(quoteButton).toBeTruthy();
isSelectionWithinSingleTextBody.mockRestore();
});
it("does not show quote button when getSelectedText returns empty", () => {
mocked(getSelectedText).mockReturnValue("");
const isSelectionWithinSingleTextBody = jest
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
.mockReturnValue(true);
createRightClickMenuWithContent(createMessageEventContent("hello"));
const quoteButton = document.querySelector('li[aria-label="Quote"]');
expect(quoteButton).toBeFalsy();
isSelectionWithinSingleTextBody.mockRestore();
});
it("does not show quote button when selection is not inside one MTextBody", () => {
mocked(getSelectedText).mockReturnValue("quoted text");
const isSelectionWithinSingleTextBody = jest
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
.mockReturnValue(false);
createRightClickMenuWithContent(createMessageEventContent("hello"));
const quoteButton = document.querySelector('li[aria-label="Quote"]');
expect(quoteButton).toBeFalsy();
isSelectionWithinSingleTextBody.mockRestore();
});
it("dispatches ComposerInsert with quoted text when quote button is clicked", () => {
mocked(getSelectedText).mockReturnValue("line1\nline2");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const isSelectionWithinSingleTextBody = jest
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
.mockReturnValue(true);
createRightClickMenuWithContent(createMessageEventContent("hello"));
const quoteButton = document.querySelector('li[aria-label="Quote"]')!;
fireEvent.mouseDown(quoteButton);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ComposerInsert,
text: "\n> line1\n> line2\n\n ",
}),
);
isSelectionWithinSingleTextBody.mockRestore();
});
it("does not show quote button when getSelectedText returns only whitespace", () => {
mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only
const isSelectionWithinSingleTextBody = jest
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
.mockReturnValue(true);
createRightClickMenuWithContent(createMessageEventContent("hello"));
const quoteButton = document.querySelector('li[aria-label="Quote"]');
expect(quoteButton).toBeFalsy();
isSelectionWithinSingleTextBody.mockRestore();
});
});
describe("isSelectionWithinSingleTextBody", () => {
let mockGetSelection: jest.SpyInstance;
let contextMenuInstance: MessageContextMenu;
beforeEach(() => {
jest.clearAllMocks();
mockGetSelection = jest.spyOn(window, "getSelection");
const eventContent = createMessageEventContent("hello");
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
contextMenuInstance = new MessageContextMenu({
mxEvent,
onFinished: jest.fn(),
rightClick: true,
} as any);
});
afterEach(() => {
mockGetSelection.mockRestore();
});
it("returns false when there is no selection", () => {
mockGetSelection.mockReturnValue(null);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(false);
});
it("returns false when selection has no ranges", () => {
mockGetSelection.mockReturnValue({
rangeCount: 0,
getRangeAt: jest.fn(),
} as any);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(false);
});
it("returns true when selection is within a single mx_MTextBody element", () => {
// Create a mock MTextBody element
const textBodyElement = document.createElement("div");
textBodyElement.classList.add("mx_MTextBody");
// Create mock text nodes within the MTextBody
const startTextNode = document.createTextNode("start");
const endTextNode = document.createTextNode("end");
textBodyElement.appendChild(startTextNode);
textBodyElement.appendChild(endTextNode);
// Create a mock range with the text nodes
const mockRange = {
startContainer: startTextNode,
endContainer: endTextNode,
} as unknown as Range;
mockGetSelection.mockReturnValue({
rangeCount: 1,
getRangeAt: jest.fn().mockReturnValue(mockRange),
} as any);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(true);
});
it("returns false when selection spans multiple mx_MTextBody elements", () => {
// Create two different MTextBody elements
const textBody1 = document.createElement("div");
textBody1.classList.add("mx_MTextBody");
const textBody2 = document.createElement("div");
textBody2.classList.add("mx_MTextBody");
const startTextNode = document.createTextNode("start");
const endTextNode = document.createTextNode("end");
textBody1.appendChild(startTextNode);
textBody2.appendChild(endTextNode);
// Create a mock range spanning different MTextBody elements
const mockRange = {
startContainer: startTextNode,
endContainer: endTextNode,
} as unknown as Range;
mockGetSelection.mockReturnValue({
rangeCount: 1,
getRangeAt: jest.fn().mockReturnValue(mockRange),
} as any);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(false);
});
it("returns false when selection is outside any mx_MTextBody element", () => {
// Create regular div elements without mx_MTextBody class
const regularDiv1 = document.createElement("div");
const regularDiv2 = document.createElement("div");
const startTextNode = document.createTextNode("start");
const endTextNode = document.createTextNode("end");
regularDiv1.appendChild(startTextNode);
regularDiv2.appendChild(endTextNode);
// Create a mock range outside MTextBody elements
const mockRange = {
startContainer: startTextNode,
endContainer: endTextNode,
} as unknown as Range;
mockGetSelection.mockReturnValue({
rangeCount: 1,
getRangeAt: jest.fn().mockReturnValue(mockRange),
} as any);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(false);
});
it("returns true when start and end are the same mx_MTextBody element", () => {
const textBodyElement = document.createElement("div");
textBodyElement.classList.add("mx_MTextBody");
const textNode = document.createTextNode("same text");
textBodyElement.appendChild(textNode);
// Create a mock range within the same MTextBody element
const mockRange = {
startContainer: textNode,
endContainer: textNode,
} as unknown as Range;
mockGetSelection.mockReturnValue({
rangeCount: 1,
getRangeAt: jest.fn().mockReturnValue(mockRange),
} as any);
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
expect(result).toBe(true);
});
});
describe("right click", () => {
it("copy button does work as expected", () => {
const text = "hello";

View File

@@ -44,6 +44,28 @@ describe("<ImageView />", () => {
expect(fetchMock).toHaveFetched("https://example.com/image.png");
});
it("should start download on Ctrl+S", async () => {
fetchMock.get("https://example.com/image.png", "TESTFILE");
const { container } = render(
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
);
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
dialog?.focus();
fireEvent.keyDown(dialog!, { key: "s", code: "KeyS", ctrlKey: true });
await waitFor(() => {
expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({
blob: expect.anything(),
name: "filename.png",
});
});
expect(fetchMock).toHaveFetched("https://example.com/image.png");
});
it("should handle download errors", async () => {
const modalSpy = jest.spyOn(Modal, "createDialog");
fetchMock.get("https://example.com/image.png", { status: 500 });

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { act } from "react";
import { waitFor } from "jest-matrix-react";
import { waitFor, fireEvent } from "jest-matrix-react";
import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
@@ -154,6 +154,16 @@ describe("MemberListView and MemberlistHeaderView", () => {
expect(root.container.querySelector(".mx_PresenceIconView_unavailable")).not.toBeNull();
});
});
it("should prevent default form submission", async () => {
const { root } = rendered;
const form = root.container.querySelector("form");
expect(form).not.toBeNull();
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
const preventDefaultSpy = jest.spyOn(submitEvent, "preventDefault");
fireEvent(form!, submitEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
describe.each([true, false])("does order members correctly (presence %s)", (enablePresence) => {

View File

@@ -742,6 +742,26 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
</kbd>
</div>
</li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Save
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
s
</kbd>
</div>
</li>
</ul>
</div>
</div>

View File

@@ -83,16 +83,42 @@ describe("StopGapWidget", () => {
});
it("feeds incoming to-device messages to the widget", async () => {
const event = mkEvent({
event: true,
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
});
const receivedToDevice = {
message: {
type: "org.example.foo",
sender: "@alice:example.org",
content: {
hello: "world",
},
},
encryptionInfo: null,
};
client.emit(ClientEvent.ToDeviceEvent, event);
client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice);
await Promise.resolve(); // flush promises
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, false);
});
it("feeds incoming encrypted to-device messages to the widget", async () => {
const receivedToDevice = {
message: {
type: "org.example.foo",
sender: "@alice:example.org",
content: {
hello: "world",
},
},
encryptionInfo: {
senderVerified: false,
sender: "@alice:example.org",
senderCurve25519KeyBase64: "",
senderDevice: "ABCDEFGHI",
},
};
client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice);
await Promise.resolve(); // flush promises
expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true);
});
it("feeds incoming state updates to the widget", () => {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
import { Action } from "../../../../src/dispatcher/actions";
@@ -26,18 +27,20 @@ jest.mock("../../../../src/rageshake/rageshake", () => ({
}));
describe("ElectronPlatform", () => {
const initialiseValues = jest.fn().mockReturnValue({
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportedSettings: { setting1: false, setting2: true },
supportsBadgeOverlay: false,
});
const defaultUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36";
const mockElectron = {
on: jest.fn(),
send: jest.fn(),
initialise: jest.fn().mockResolvedValue({
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportedSettings: { setting1: false, setting2: true },
}),
initialise: initialiseValues,
setSettingValue: jest.fn().mockResolvedValue(undefined),
getSettingValue: jest.fn().mockResolvedValue(undefined),
} as unknown as MockedObject<Electron>;
@@ -405,4 +408,101 @@ describe("ElectronPlatform", () => {
state: "connected",
});
});
describe("Notification overlay badges", () => {
beforeEach(() => {
initialiseValues.mockReturnValue({
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportsBadgeOverlay: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it("should send a badge with a notification count", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
// Badges are sent asynchronously
await waitFor(() => {
const ipcMessage = mockElectron.send.mock.lastCall;
expect(ipcMessage?.[1]).toEqual(1);
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
});
});
it("should update badge and skip duplicates", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
platform.setNotificationCount(1); // Test that duplicates do not fire.
platform.setNotificationCount(2);
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageA, ipcMessageB] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(1);
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
expect(ipcMessageB?.[1]).toEqual(2);
expect(ipcMessageB?.[2] instanceof ArrayBuffer).toEqual(true);
});
});
it("should remove badge when notification count zeros", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
platform.setNotificationCount(0); // Test that duplicates do not fire.
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(1);
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
expect(ipcMessageB?.[1]).toEqual(0);
expect(ipcMessageB?.[2]).toBeNull();
});
});
it("should show an error badge when the application errors", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setErrorStatus(true);
// Badges are sent asynchronously
await waitFor(() => {
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount");
expect(ipcMessage?.[1]).toEqual(0);
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
expect(ipcMessage?.[3]).toEqual(true);
});
});
it("should restore after error is resolved", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setErrorStatus(true);
platform.setErrorStatus(false);
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(0);
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
expect(ipcMessageA?.[3]).toEqual(true);
expect(ipcMessageB?.[1]).toEqual(0);
expect(ipcMessageB?.[2]).toBeNull();
});
});
});
});

View File

@@ -0,0 +1,29 @@
/*
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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
describe("TextualEventViewModel", () => {
it("should update when the sentinel updates", () => {
const fakeEvent = new MatrixEvent({});
const vm = new TextualEventViewModel({
showHiddenEvents: false,
mxEvent: fakeEvent,
});
const cb = jest.fn();
vm.subscribe(cb);
fakeEvent.emit(MatrixEventEvent.SentinelUpdated);
expect(cb).toHaveBeenCalledTimes(1);
});
});

View File

@@ -17,7 +17,8 @@
"lib": ["es2022", "es2024.promise", "dom", "dom.iterable"],
"strict": true,
"paths": {
"jest-matrix-react": ["./test/test-utils/jest-matrix-react"]
"jest-matrix-react": ["./test/test-utils/jest-matrix-react"],
"rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
}
},
"include": [
@@ -26,7 +27,8 @@
"./src/**/*.tsx",
"./test/**/*.ts",
"./test/**/*.tsx",
"./scripts/*.ts"
"./scripts/*.ts",
"./declaration.d.ts"
],
"ts-node": {
"files": true,

2302
yarn.lock

File diff suppressed because it is too large Load Diff