Compare commits

..

102 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
renovate[bot]
7f97f46686 Update all non-major dependencies (#30281)
* Update all non-major dependencies

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-09 11:33:20 +00:00
ElementRobot
287a064127 [create-pull-request] automated change (#30219)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-09 08:58:44 +00:00
renovate[bot]
cfd3a968d4 Update dependency testcontainers to v11.1.0 (#30284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 08:38:08 +00:00
renovate[bot]
6fbc2e7078 Update dependency @vector-im/compound-design-tokens to v5 (#30286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 08:28:35 +00:00
ElementRobot
31e6f15941 [create-pull-request] automated change (#30294)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-09 08:28:30 +00:00
renovate[bot]
f6e8350522 Update babel monorepo to v7.28.0 (#30282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 07:36:08 +00:00
renovate[bot]
afb8e38fd7 Update typescript-eslint monorepo to v8.35.1 (#30280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 17:45:57 +00:00
renovate[bot]
6ce5228044 Update dependency cronstrue to v3 (#30287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 17:32:37 +00:00
renovate[bot]
a1db6f5f6e Update nginxinc/nginx-unprivileged:alpine-slim Docker digest to ef0100e (#30273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 16:47:10 +00:00
renovate[bot]
02f7c9b52d Update definitelyTyped (#30277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 15:21:04 +00:00
renovate[bot]
8c7daae19f Update playwright to v1.53.2 (#30279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 15:19:06 +00:00
renovate[bot]
c1f291347c Update dependency dotenv to v17 (#30288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 15:14:40 +00:00
renovate[bot]
a75c5e2b2b Update dependency postcss-mixins to v12 (#30289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:57:40 +00:00
renovate[bot]
14d1141e8d Update dependency @stylistic/eslint-plugin to v5 (#30285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:55:43 +00:00
renovate[bot]
09cea4ad3a Update dependency @sentry/browser to v9.35.0 (#30283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:51:24 +00:00
renovate[bot]
39dcaaaaee Update dependency @vector-im/compound-design-tokens to v4.0.5 (#30278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:43:32 +00:00
renovate[bot]
fd45eaaa8e Update Node.js to 125996c (#30274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:43:04 +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
ElementRobot
df50a50741 [create-pull-request] automated change (#30269)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-08 13:23:10 +00:00
Banbuii
aa2dc8e574 Fix transparent verification checkmark in dark mode (#30235)
* Fix transparent verification checkmark in dark mode

Fixes Issue https://github.com/element-hq/element-web/issues/28285

* Add white background to E2E Warning Icon

Also adapted the testcases to the new background.
2025-07-07 11:35:03 +00:00
ElementRobot
0f7e394487 [create-pull-request] automated change (#30218)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-07-07 10:39:39 +00:00
Will Hunt
9f313fcc14 Add support for module message hint allowDownloadingMedia (#30252)
* Add support for allowDownloadingMedia

* Add tests.

* Allow downloading when no event is associated.

* fix lint

* Update module API

* Update lock file too

* force CI
2025-07-07 09:03:46 +00:00
Florian Duros
1cb068a91e Fix e2e flakes in new room list (#30254)
* test: retry failing assertion in room list

* test: fix click on room not visible after scroll
2025-07-04 13:37:59 +00:00
Doug
5dd31685bb Rename the mobile_guide_app_variant config values to be clearer. (#30258)
* Fix the default mobile_guide links.

Whilst the script should update these if it fails these should link to Element X which is now the default app that we link out to from this page.

* Rename the mobile_guide_app_variant values to be clearer.

Also handle invalid config values by defaulting to Element X.

* Rename snapshots to match new app variant identifiers.
2025-07-04 12:27:30 +00:00
Hubert Chathi
9095ebdb1b Avoid using accessSecretStorage to create 4S (#30244)
* remove resetCrossSigning flag, which is no longer in use

* drop unnecessary check for cross-signing

The only place where verifyUser is called already checks that cross-signing is
set up.  (The function name is also incorrect, since it checks for the
cross-signing key, and not for 4S.)

* avoid calling accessSecretStorage to set up cross-signing or 4S

Send the user to the Encryption settings tab instead

* only create secret storage when specifically asked to

* deprecate using accessSecretStorage to create new 4S

* also remove the obsolete snapshot

* add tests

* Tweak comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-07-03 13:34:05 +00:00
Florian Duros
66d7c6a100 Fix release announcement test flake (#30250)
* chore: add `hideJumpToBottomButton` option to playwright screenshot

* test: hide jump to bottom button in release announcement test
2025-07-03 10:51:35 +00:00
Doug
90f4d34fbb Update the mobile_guide page to the new design and link out to Element X by default. (#30172)
* Reapply "Update the mobile_guide page to the new design. (#30006)" (#30104)

This reverts commit c51823db5e.

* Use Element X as the default mobile_guide_app_variant when omitted.

* Fix a build error on Windows.

Additionally revert "Remove unnecessary <%= require %> usages" and let webpack handle all of the assets (without a manual copy rule).

* Exclude mobile_guide from coverage gate

It has playwright tests

* Revert the re-introduction of <%= require %>

* Fix snapshot tests on mobile_guide.

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-03 08:28:07 +00:00
Will Hunt
e1fea71c97 Filter settings exported when rageshaking (#30236)
* Submit filtered settings to rageshakes and sentry.

* Add flag to omit some settings from being exported.

* Hide user timezone

* Hide recent searches and media event ids

* Lint

* use better wording

* lint

* Prevent language from being sent

* Add tests to check keys are prevented from being uploaded.

* don't export invite rules

* Update tests
2025-07-02 08:03:31 +00:00
RiotRobot
99f7656d09 Reset matrix-js-sdk back to develop branch 2025-07-01 15:12:56 +00:00
RiotRobot
0768534885 Merge branch 'master' into develop 2025-07-01 15:12:42 +00:00
RiotRobot
d83c619e65 v1.11.105 2025-07-01 15:09:02 +00:00
RiotRobot
fe1cddd34b Upgrade dependency to matrix-js-sdk@37.10.0 2025-07-01 14:55:45 +00:00
Simon Knott
3f931d317b Fix link to e2e docs (#30234) 2025-07-01 09:53:00 +00:00
Hubert Chathi
37df62aa4e fix typo in comment and reinstate logging of variables (#30231) 2025-06-30 21:09:50 +00:00
Hubert Chathi
3d56aa7ff6 Fix logic in DeviceListener (#30230)
* remove incorrect check for cross-signing

SETUP_ENCRYPTION tries to set up everything (4S, cross-signing and key backup),
rather than just setting up encryption, as its name would imply.
crossSigningReady == false happens when the user's device isn't verified, so it
should trigger VERIFY_THIS_SESSION rather than SETUP_ENCRYPTION

* reorder conditions in allSystemsReady to match the order in the if statements

* explicitly handle secrets missing from 4S

rather than falling back to the SETUP_ENCRYPTION catch-all.  Also, remove
SETUP_ENCRYPTION since it is no longer used.

* convert button handlers to switch statements for consistency

(almost) all the other functions that use make decisions based on Kind use
switch statements

* update i18n (remove obsolete string)
2025-06-30 14:01:06 +00:00
Marc
58875e5cf2 Mvvm split user info, create powerlevels component (#30005)
* feat: mvvm user info powerlevels

* chore: remove unecesssary comments and add new

* chore: fix lint and rebase

* fix: lint error
2025-06-30 13:26:37 +00:00
renovate[bot]
4a8b365bf8 Update playwright to v1.53.1 (#30205)
* Update playwright to v1.53.1

* Update snapshots

Presumably chrome's font rendering has changed slightly in the new major version

* Scroll until room list item is in view

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-06-27 13:46:20 +00:00
Florian Duros
18ac6b92fa test: use forceCloseAllModals instead of closeCurrentModal (#30211) 2025-06-26 08:02:42 +00:00
Robin
6ce149a7a8 Allow Element Call to learn the room name (#30213)
The latest mobile designs for Element Call have it displaying the room name in an "app bar". So the Element Call widget will soon start requesting the capability to learn the room name, and Element Web should auto-approve this capability.
2025-06-26 07:50:23 +00:00
ElementRobot
75d7a1d644 [create-pull-request] automated change (#30215)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-26 06:18:40 +00:00
Florian Duros
d0ddc92908 fix: use correct translation for content protection in settings (#30210) 2025-06-25 15:24:25 +00:00
renovate[bot]
4f13242de2 Update dependency @sentry/browser to v9.30.0 (#30204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 12:31:18 +00:00
renovate[bot]
900c4d60bc Update typescript-eslint monorepo to v8.34.1 (#30201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 12:21:28 +00:00
renovate[bot]
925f4f65c7 Update dependency @types/react to v19.1.8 (#30199)
* Update dependency @types/react to v19.1.8

* Remove patch that's no longer needed

(yay!)

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-06-25 10:59:52 +00:00
renovate[bot]
088d8121e7 Update vector-im (#30202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 09:57:36 +00:00
renovate[bot]
d216d68e3f Update dependency caniuse-lite to v1.0.30001724 (#30200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 09:37:18 +00:00
ElementRobot
f6e28cb3c7 [create-pull-request] automated change (#30208)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-25 06:23:44 +00:00
ElementRobot
434e58de52 [create-pull-request] automated change (#30207)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-25 06:17:36 +00:00
renovate[bot]
2c299fe24e Update all non-major dependencies (#30203)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 17:43:32 +00:00
renovate[bot]
3965a36819 Update definitelyTyped (#30198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 17:42:14 +00:00
renovate[bot]
d4dc89cd38 Update sigstore/cosign-installer digest to 398d4b0 (#30197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:47:00 +00:00
renovate[bot]
fd199b94af Update Node.js to 9ba013a (#30196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:46:37 +00:00
renovate[bot]
7eefb30750 Update nginxinc/nginx-unprivileged:alpine-slim Docker digest to ec6b8b1 (#30195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:45:36 +00:00
renovate[bot]
5486a1f235 Update guibranco/github-status-action-v2 digest to 741ea90 (#30194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:55:36 +00:00
renovate[bot]
73fd91dabd Update docker (#30193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:55:15 +00:00
David Baker
e9922ee84f Support m.topic in topic update script (#30192) 2025-06-24 14:38:06 +00:00
David Baker
53eff065e4 Update the public room ID (#30191)
It got upgraded at some point but not changed here
2025-06-24 14:53:11 +01:00
Michael Telatynski
2b8f95a25b Disable file drag-and-drop if insufficient permissions (#30186)
* Disable file drag-and-drop if insufficient permissions

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-24 13:26:03 +00:00
RiotRobot
e956bb5b6d v1.11.105-rc.0 2025-06-24 12:47:31 +00:00
RiotRobot
1349726d52 Upgrade dependency to matrix-js-sdk@37.10.0-rc.0 2025-06-24 12:42:23 +00:00
Florian Duros
f707bb410e New room list: add context menu to room list item (#29952)
* chore: update compound-web

* chore: remove unused export

* feat: export content of more option menu

* feat: add context menu

* feat: add `showContextMenu` to vm

* feat: use context menu in new room list

* test: add tests for room list item

* test: fix room list test

* test: add `showContextMenu` test for `useRoomListItemViewModel`

* test: add e2e test for context menu

* chore: update compound

* test: update snapshots and e2e test

* fix: avoid icon blinking when we reopen the context menu

* test: add test for menu closing

* doc: remove useless tsdoc param

* chore: update `@vector-im/compound-web`

* refactor: remove manual focus

* test(e2e): fix focus after closing notification menu

* doc: remove useless jobs
2025-06-24 09:50:27 +00:00
ElementRobot
52f836a0dd [create-pull-request] automated change (#30190)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-24 06:18:05 +00:00
ElementRobot
c50000d124 Playwright Docker image updates (#29653)
* [create-pull-request] automated change

* Restart homeserver to clear MAS token cache

as commented

---------

Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-06-23 15:57:45 +00:00
Will Hunt
0edaef3f7c Support for custom message components via Module API (#30074)
* Add new custom component api.

* Remove context menu, refactor

* fix types

* Add a test for custom modules.

* tidy

* Rewrite for new API

* Update tests

* lint

* Allow passing in props to original component

* Add hinting

* Update tests to be complete

* lint a bit more

* update docstring

* update @element-hq/element-web-module-api to 1.1.0

* fix types

* updates

* hide jump to bottom button that was causing flakes

* lint

* lint

* Use module matrix event interface instead.

* update to new module sdk

* adapt custom module sample

* Issues caught by Sonar

* lint

* fix issues

* make the comment make sense

* fix import
2025-06-23 11:55:22 +00:00
ElementRobot
ac9c6f11fb [create-pull-request] automated change (#30182)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-23 06:25:15 +00:00
Michael Telatynski
8705efec40 Use stale-screenshot-reporter from playwright-common (#30175)
* Use stale-screenshot-reporter from playwright-common

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-20 12:27:40 +00:00
ElementRobot
f5f9d68f3c [create-pull-request] automated change (#30173)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-20 06:25:36 +00:00
Michael Telatynski
a3b51edc51 Fix untranslatable string "People" in notifications beta (#30165)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-19 12:38:36 +00:00
Philipp Fruck
5ad0dceae0 docs: Remove scalar for riot.im (#30158) 2025-06-19 09:58:18 +00:00
Hubert Chathi
af984c0e80 Prompt users to set up recovery (#30075)
* Show indicator in settings dialog when user doesn't have recovery set up

* Update settings headers to use red dot for recommended settings

* update recovery setup toast and remember if the user dismisses it

* update playwright snapshots

* use typed event emitters

* reverse logic for the account data flag

* fix comment and type
2025-06-18 16:20:17 +00:00
ElementRobot
2034f8b6bb [create-pull-request] automated change (#30159)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-06-18 06:23:38 +00:00
233 changed files with 7934 additions and 3895 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

@@ -25,14 +25,14 @@ jobs:
fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
if: github.event_name != 'pull_request'
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with:
install: true
@@ -53,7 +53,7 @@ jobs:
- name: Build and load
id: test-build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
load: true
@@ -110,7 +110,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name != 'pull_request'
with:
context: .

View File

@@ -227,7 +227,7 @@ jobs:
- name: Merge into HTML Report
if: inputs.skip != true
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}

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

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

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

View File

@@ -26,7 +26,7 @@ jobs:
env:
HS_URL: ${{ secrets.BETABOT_HS_URL }}
LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }}
PUBLIC_ROOM_ID: "!YTvKGNlinIzlkMTVRl:matrix.org"
PUBLIC_ROOM_ID: "!IemiTbwVankHTFiEoh:matrix.org"
ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org"
TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }}
RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}"
@@ -81,6 +81,11 @@ jobs:
d.body = d.body.replace(regex, releaseTopic);
});
}
if (data["m.topic"]) {
data["m.topic"].forEach(d => {
d.body = d.body.replace(regex, releaseTopic);
});
}
res = await fetch(apiUrl, {
method: "PUT",

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,42 @@
Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
====================================================================================================
## ✨ Features
* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot.
* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot.
* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave.
* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot.
* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown.
## 🐛 Bug Fixes
* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot.
* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii.
* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg.
* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy.
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
====================================================================================================
## ✨ Features
* New room list: add context menu to room list item ([#29952](https://github.com/element-hq/element-web/pull/29952)). Contributed by @florianduros.
* Support for custom message components via Module API ([#30074](https://github.com/element-hq/element-web/pull/30074)). Contributed by @Half-Shot.
* Prompt users to set up recovery ([#30075](https://github.com/element-hq/element-web/pull/30075)). Contributed by @uhoreg.
* Update `IconButton` colors ([#30124](https://github.com/element-hq/element-web/pull/30124)). Contributed by @florianduros.
* New room list: filter list can be collapsed ([#29992](https://github.com/element-hq/element-web/pull/29992)). Contributed by @florianduros.
* Show `EmptyRoomListView` when low priority filter matches zero rooms ([#30122](https://github.com/element-hq/element-web/pull/30122)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* Fix untranslatable string "People" in notifications beta ([#30165](https://github.com/element-hq/element-web/pull/30165)). Contributed by @t3chguy.
* Force verification even after logging in via delegate ([#30141](https://github.com/element-hq/element-web/pull/30141)). Contributed by @andybalaam.
* Hide add integrations button based on UIComponent.AddIntegrations ([#30140](https://github.com/element-hq/element-web/pull/30140)). Contributed by @t3chguy.
* Use nav for new room list and label sections ([#30134](https://github.com/element-hq/element-web/pull/30134)). Contributed by @dbkr.
* Spacestore should emit event after rebuilding home space ([#30132](https://github.com/element-hq/element-web/pull/30132)). Contributed by @MidhunSureshR.
* Handle m.room.pinned\_events being invalid ([#30129](https://github.com/element-hq/element-web/pull/30129)). Contributed by @t3chguy.
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
====================================================================================================
## ✨ Features

View File

@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458
# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:66e34aa81c2faf290ea4e4c28a490f2b35a07478265a2d5994c8637506045eee
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00
# Need root user to install packages & manipulate the usr directory
USER root

View File

@@ -127,7 +127,6 @@ Unless otherwise specified, the following applies to all code:
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
1. Note that an explicit type is optional if not expected to be used outside of the function call,
unlike in this example:
@@ -161,7 +160,6 @@ Unless otherwise specified, the following applies to all code:
28. Export only what can be reused.
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
of truly optional parameters.
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -260,7 +258,6 @@ Inheriting all the rules of TypeScript, the following additionally apply:
12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
if at all possible.
13. A component should only use CSS class names in line with the component name.
1. When knowingly using a class name from another component, document it with a [comment](#comments).
14. Curly braces within JSX should be padded with a space, however properties on those components should not.
@@ -388,7 +385,6 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
properties should be clearly documented.
4. Inside a function, there is no need to comment every line, but consider:
- before a particular multiline section of code within the function, give an overview of what it does,
to make it easier for a reader to follow the flow through the function as a whole.
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis

View File

@@ -20,8 +20,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
"https://scalar-staging.vector.im/api"
],
"default_widget_container_height": 280,
"default_country_code": "GB",

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

@@ -109,7 +109,7 @@ yarn test
### End-to-End tests
See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests.
See [`docs/playwright.md`](./docs/playwright.md) for how to run the end-to-end tests.
## General github guidelines

View File

@@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach
6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below.
7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to
download the app instead.
8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted
the mobile guide will be configured for the new Element X apps. Allowed values are as follows:
1. `element`: Element X Android/iOS.
2. `element-classic`: Element Classic Android/iOS.
3. `element-pro`: Element Pro Android/iOS.
9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/`
in production.
9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
configuration found in the well-known location is used instead.
10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
configuration found in the well-known location is used instead.
11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
`{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details.
12. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
13. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
`true` to hide these options.
15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
to hide this dropdown.
16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
users. Set to `true` to disable this functionality.
17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
Takes a configuration object as below:
1. `title`: Required. Title to show at the top of the notice.
2. `description`: Required. The description to use for the notice.
3. `show_once`: Optional. If true then the notice will only be shown once per device.
18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
### `desktop_builds` and `mobile_builds`
@@ -445,8 +450,7 @@ If you would like to use Scalar, the integration manager maintained by Element,
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
"https://scalar-staging.vector.im/api"
]
}
```

View File

@@ -55,8 +55,7 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my-
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"defaultCountryCode": "GB",

View File

@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",

View File

@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
"https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",

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.104",
"version": "1.11.106",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -65,23 +65,27 @@
"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",
"@playwright/test": "1.52.0",
"@types/react": "19.1.6",
"@playwright/test": "1.53.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"oidc-client-ts": "3.2.1",
"oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001721",
"caniuse-lite": "1.0.30001724",
"testcontainers": "^11.0.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.2.0",
"@element-hq/element-web-module-api": "1.3.0",
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
@@ -92,9 +96,9 @@
"@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vector-im/matrix-wysiwyg": "2.38.3",
"@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.38.4",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -138,7 +142,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.249.4",
"posthog-js": "1.256.2",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^19.0.0",
@@ -180,14 +184,19 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.12.2",
"@element-hq/element-web-playwright-common": "^1.1.5",
"@element-hq/element-call-embedded": "0.13.1",
"@element-hq/element-web-playwright-common": "^1.4.2",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^4.0.0",
"@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/icons": "^1.4.0",
"@storybook/react-vite": "^9.0.15",
"@storybook/test-runner": "^0.23.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
@@ -212,7 +221,7 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "19.1.6",
"@types/react": "19.1.8",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.1.6",
"@types/react-transition-group": "^4.4.0",
@@ -231,10 +240,10 @@
"concurrently": "^9.0.0",
"copy-webpack-plugin": "^13.0.0",
"core-js": "^3.38.1",
"cronstrue": "^2.41.0",
"cronstrue": "^3.0.0",
"css-loader": "^7.0.0",
"css-minimizer-webpack-plugin": "^7.0.0",
"dotenv": "^16.0.2",
"dotenv": "^17.0.0",
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
@@ -246,18 +255,19 @@
"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",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
"file-loader": "^6.0.0",
"glob": "^11.0.0",
"html-webpack-plugin": "^5.5.3",
"husky": "^9.0.0",
"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",
@@ -275,17 +285,18 @@
"postcss-hexrgba": "2.1.0",
"postcss-import": "16.1.0",
"postcss-loader": "8.1.1",
"postcss-mixins": "^11.0.0",
"postcss-mixins": "^12.0.0",
"postcss-nested": "^7.0.0",
"postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3",
"prettier": "3.6.2",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"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",
@@ -295,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

@@ -1,31 +0,0 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index d3318dc..c2b2c77 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -134,7 +134,7 @@ declare namespace React {
props: P,
) => ReactNode | Promise<ReactNode>)
// constructor signature must match React.Component
- | (new(props: P) => Component<any, any>);
+ | (new(props: P, context?: any) => Component<any, any>);
/**
* Created by {@link createRef}, or {@link useRef} when passed `null`.
@@ -945,7 +945,7 @@ declare namespace React {
context: unknown;
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
- constructor(props: P);
+ constructor(props: P, context?: unknown);
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
@@ -1117,7 +1117,7 @@ declare namespace React {
*/
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
// constructor signature must match React.Component
- new(props: P): Component<P, S>;
+ new(props: P, context?: any): Component<P, S>;
/**
* Ignored by React.
* @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.

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

@@ -229,13 +229,13 @@ test.describe("Room list filters and sort", () => {
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(4);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
@@ -243,21 +243,21 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");

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");
});
@@ -60,6 +59,12 @@ test.describe("Room list", () => {
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
});
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
@@ -109,10 +114,13 @@ test.describe("Room list", () => {
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
// Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -133,8 +141,9 @@ 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();
const filters = page.getByRole("listbox", { name: "Room list filters" });
@@ -223,17 +232,17 @@ test.describe("Room list", () => {
await expect(notificationButton).toBeFocused();
// Open the menu
await notificationButton.click();
await page.keyboard.press("Enter");
// Wait for the menu to be open
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
// Close the menu
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Escape");
// Focus should be back on the room list item
await expect(room29).toBeFocused();
// Focus should be back on the notification button
await expect(notificationButton).toBeFocused();
});
});
});

View File

@@ -0,0 +1,36 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps";
const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro];
test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => {
for (const variant of variants) {
test.describe(`for variant ${variant}`, () => {
test.use({
config: {
default_server_config: {
"m.homeserver": {
base_url: "https://matrix.server.invalid",
server_name: "server.invalid",
},
},
mobile_guide_app_variant: variant,
},
viewport: { width: 390, height: 844 }, // iPhone 16e
});
test("should match the mobile_guide screenshot", async ({ page, axe }) => {
await page.goto("/mobile_guide/");
await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`);
await expect(axe).toHaveNoViolations();
});
});
}
});

View File

@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import fs from "node:fs";
import { test, expect } from "../../element-web-test";
@@ -22,6 +23,9 @@ const screenshotOptions = (page: Page) => ({
}
`,
});
const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png");
test.describe("Custom Component API", () => {
test.use({
displayName: "Manny",
@@ -84,6 +88,50 @@ test.describe("Custom Component API", () => {
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
).not.toBeVisible();
});
test("should disallow downloading media when the allowDownloading hint is set to false", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "bad.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
});
test("should allow downloading media when the allowDownloading hint is set to true", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "good.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
});
test(
"should render the next registered component if the filter function throws",
{ tag: "@screenshot" },

View File

@@ -81,7 +81,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test(
"it should log out the user & wipe data when logging out via MAS",
{ tag: "@screenshot" },
async ({ mas, page, mailpitClient }, testInfo) => {
async ({ mas, page, mailpitClient, homeserver }, testInfo) => {
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
@@ -95,11 +95,15 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
// Workaround for Synapse's 2 minute cache on MAS token validity
// (https://github.com/element-hq/synapse/pull/18231)
await homeserver.restart();
await page.goto("http://localhost:8080");
await expect(
page.getByText("For security, this session has been signed out. Please sign in again."),
).toBeVisible();
await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
//await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
expect(localStorageKeys).toHaveLength(0);

View File

@@ -42,7 +42,10 @@ export class Helpers {
*/
async assertReleaseAnnouncementIsVisible(name: string) {
await expect(this.getReleaseAnnouncement(name)).toBeVisible();
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { showTooltips: true });
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, {
showTooltips: true,
hideJumpToBottomButton: true,
});
}
/**

View File

@@ -107,6 +107,7 @@ interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
hideJumpToBottomButton?: boolean;
}
type Expectations = {
@@ -165,6 +166,14 @@ export const expect = baseExpect.extend<Expectations>({
`;
}
if (options?.hideJumpToBottomButton) {
css += `
.mx_JumpToBottomButton {
display: none !important;
}
`;
}
if (options?.css) {
css += options.css;
}

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());
}
}

View File

@@ -5,8 +5,18 @@ 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.
*/
// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we
// get around it with @typedef
/**
* @typedef {import("@element-hq/element-web-module-api").Api} Api
*/
export default class CustomComponentModule {
static moduleApiVersion = "^1.2.0";
/**
* Basic module for testing.
* @param {Api} api API object
*/
constructor(api) {
this.api = api;
this.api.customComponents.registerMessageRenderer(
@@ -40,6 +50,15 @@ export default class CustomComponentModule {
throw new Error("Fail test!");
},
);
this.api.customComponents.registerMessageRenderer(
(mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image",
(_props, originalComponent) => {
return originalComponent();
},
{ allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" },
);
// Order is specific here to avoid this overriding the other renderers
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
const body = props.mxEvent.content.body;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -1,71 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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.
*/
/**
* Test reporter which compares the reported screenshots vs those on disk to find stale screenshots
* Only intended to run from within GitHub Actions
*/
import path from "node:path";
import { glob } from "glob";
import type { Reporter, TestCase } from "@playwright/test/reporter";
const snapshotRoot = path.join(__dirname, "snapshots");
class StaleScreenshotReporter implements Reporter {
private screenshots = new Set<string>();
private failing = false;
private success = true;
public onTestEnd(test: TestCase): void {
if (!test.ok()) {
this.failing = true;
}
for (const annotation of test.annotations) {
if (annotation.type === "_screenshot") {
this.screenshots.add(annotation.description);
}
}
}
private error(msg: string, file: string) {
if (process.env.GITHUB_ACTIONS) {
console.log(`::error file=${file}::${msg}`);
}
console.error(msg, file);
this.success = false;
}
public async onExit(): Promise<void> {
if (this.failing) return;
const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot }));
for (const screenshot of screenshotFiles) {
if (screenshot.split("-").at(-1) !== "linux.png") {
this.error(
"Found screenshot belonging to different platform, this should not be checked in",
screenshot,
);
}
}
for (const screenshot of this.screenshots) {
screenshotFiles.delete(screenshot);
}
if (screenshotFiles.size > 0) {
for (const screenshot of screenshotFiles) {
this.error("Stale screenshot file", screenshot);
}
}
if (!this.success) {
process.exit(1);
}
}
}
export default StaleScreenshotReporter;

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

View File

@@ -177,7 +177,6 @@
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss";
@import "./views/dialogs/security/_CreateSecretStorageDialog.pcss";
@import "./views/dialogs/security/_KeyBackupFailedDialog.pcss";
@import "./views/dialogs/security/_RestoreKeyBackupDialog.pcss";

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

@@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details.
/* colliding harshly with the dialog when scrolled down. */
padding-bottom: 100px;
}
.mx_SettingsDialog_tabLabelsAlert::after {
display: inline-block;
content: "";
width: 8px;
height: 8px;
background-color: var(--cpd-color-icon-critical-primary);
clip-path: circle(4px);
position: absolute;
right: var(--cpd-space-4x);
}
}
/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */
@media (max-width: 1024px) {
.mx_UserSettingsDialog,
.mx_RoomSettingsDialog,
.mx_SpaceSettingsDialog,
.mx_SpacePreferencesDialog {
.mx_SettingsDialog_tabLabelsAlert::after {
right: var(--cpd-space-1x);
top: var(--cpd-space-1x);
}
}
}

View File

@@ -1,73 +0,0 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_CreateKeyBackupDialog .mx_Dialog_title {
/* TODO: Consider setting this for all dialog titles. */
margin-bottom: 1em;
}
.mx_CreateKeyBackupDialog_primaryContainer {
/* FIXME: plinth colour in new theme(s). background-color: $accent; */
padding: 20px;
}
.mx_CreateKeyBackupDialog_primaryContainer::after {
content: "";
clear: both;
display: block;
}
.mx_CreateKeyBackupDialog_passPhraseContainer {
display: flex;
align-items: flex-start;
}
.mx_CreateKeyBackupDialog_passPhraseInput {
flex: none;
width: 250px;
border: 1px solid $accent;
border-radius: 5px;
padding: 10px;
margin-bottom: 1em;
}
.mx_CreateKeyBackupDialog_passPhraseMatch {
margin-left: 20px;
}
.mx_CreateKeyBackupDialog_recoveryKeyHeader {
margin-bottom: 1em;
}
.mx_CreateKeyBackupDialog_recoveryKeyContainer {
display: flex;
}
.mx_CreateKeyBackupDialog_recoveryKey {
width: 262px;
padding: 20px;
color: $info-plinth-fg-color;
background-color: $info-plinth-bg-color;
margin-right: 12px;
}
.mx_CreateKeyBackupDialog_recoveryKeyButtons {
flex: 1;
display: flex;
align-items: center;
}
.mx_CreateKeyBackupDialog_recoveryKeyButtons button {
flex: 1;
white-space: nowrap;
}
.mx_CreateKeyBackupDialog {
details .mx_AccessibleButton {
margin: 1em 0; /* emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules */
}
}

View File

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

View File

@@ -16,4 +16,13 @@
font: var(--cpd-font-body-sm-medium);
color: var(--cpd-color-text-action-accent);
}
&.mx_SettingsHeader_recommended::after {
display: inline-block;
content: "";
width: 8px;
height: 8px;
background-color: var(--cpd-color-icon-critical-primary);
clip-path: circle(4px);
}
}

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
@@ -19,5 +20,6 @@ sonar.coverage.exclusions=\
src/vector/modernizr.js,\
src/components/views/dialogs/devtools/**/*,\
src/utils/SessionLock.ts,\
src/**/*.d.ts
src/**/*.d.ts,\
src/vector/mobile_guide/**/*
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml

View File

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

View File

@@ -92,6 +92,9 @@ declare module "matrix-js-sdk/src/types" {
// MSC4155: Invite filtering
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
// Indicate whether recovery is enabled or disabled
"io.element.recovery": { enabled: boolean };
}
export interface AudioContent {

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";
@@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
*/
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
/**
* Account data key to indicate whether the user has chosen to enable or disable recovery.
*/
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
const logger = baseLogger.getChild("DeviceListener:");
export default class DeviceListener {
@@ -165,6 +170,13 @@ export default class DeviceListener {
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
/**
* Set the account data to indicate that recovery is disabled
*/
public async recordRecoveryDisabled(): Promise<void> {
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -201,6 +213,7 @@ export default class DeviceListener {
};
private onKeyBackupStatusChanged = (): void => {
logger.info("Backup status changed");
this.cachedKeyBackupUploadActive = undefined;
this.recheck();
};
@@ -220,7 +233,8 @@ export default class DeviceListener {
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1" ||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
) {
this.recheck();
}
@@ -300,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;
@@ -332,6 +347,9 @@ export default class DeviceListener {
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
const recoveryIsOk = secretStorageReady || recoveryDisabled;
const isCurrentDeviceTrusted =
crossSigningReady &&
@@ -339,15 +357,14 @@ 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
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
const allSystemsReady =
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk;
await this.reportCryptoSessionStateToAnalytics(cli);
@@ -360,13 +377,8 @@ export default class DeviceListener {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
if (!crossSigningReady) {
// This account is legacy and doesn't have cross-signing set up at all.
// Prompt the user to set it up.
logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
} else if (!isCurrentDeviceTrusted) {
// cross signing is ready but the current device is not trusted: prompt the user to verify
if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else if (!allCrossSigningSecretsCached) {
@@ -384,7 +396,10 @@ export default class DeviceListener {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (keyBackupUploadActive) {
if (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
hideSetupEncryptionToast();
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
@@ -392,16 +407,17 @@ export default class DeviceListener {
hideSetupEncryptionToast();
}
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?
logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
// If we get here, then we are verified, have key backup, and
// 4S, but crypto.isSecretStorageReady returned false, which
// means that 4S doesn't have all the secrets.
logSpan.warn("4S is missing secrets", {
crossSigningReady,
secretStorageReady,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
defaultKeyId,
});
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE);
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
@@ -482,6 +498,20 @@ export default class DeviceListener {
return !!backupDisabled?.disabled;
}
/**
* Check whether the user has disabled recovery. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
// Recovery is disabled only if the `enabled` flag is set to `false`.
// If it is missing, or set to any other value, we consider it as
// not-disabled, and will prompt the user to create recovery (if
// missing).
return recoveryStatus?.enabled === false;
}
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
@@ -551,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 });
}
};
@@ -559,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.
@@ -583,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

@@ -81,6 +81,7 @@ export interface IConfigOptions {
};
mobile_guide_toast?: boolean;
mobile_guide_app_variant?: "element" | "element-classic" | "element-pro";
default_theme?: "light" | "dark" | string; // custom themes are strings
default_country_code?: string; // ISO 3166 alpha2 country code

View File

@@ -176,10 +176,11 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
}
export interface AccessSecretStorageOpts {
/** Reset secret storage even if it's already set up. */
/**
* Reset secret storage even if it's already set up.
* @deprecated send the user to the Encryption settings tab to reset secret storage
*/
forceReset?: boolean;
/** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
resetCrossSigning?: boolean;
}
/**
@@ -189,8 +190,8 @@ export interface AccessSecretStorageOpts {
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
* 1. Create secret storage from a passphrase and store cross-signing keys
* in secret storage.
* 1. (Only if `opts.forceReset` is set) create secret storage from a passphrase
* and store cross-signing keys in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
@@ -199,6 +200,8 @@ export interface AccessSecretStorageOpts {
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
* Throws an error if secret storage is not set up (and `opts.forceReset` is not set)
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param [opts] The options to use when accessing secret storage.
@@ -219,16 +222,8 @@ async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecr
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
}
let createNew = false;
if (opts.forceReset) {
logger.debug("accessSecretStorage: resetting 4S");
createNew = true;
} else if (!(await cli.secretStorage.hasKey())) {
logger.debug("accessSecretStorage: no 4S key configured, creating a new one");
createNew = true;
}
if (createNew) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createDialog(
@@ -251,6 +246,9 @@ async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecr
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else if (!(await cli.secretStorage.hasKey())) {
logger.debug("accessSecretStorage: no 4S key configured");
throw new Error("Secret storage has not been created yet.");
} else {
logger.debug("accessSecretStorage: bootstrapCrossSigning");
await crypto.bootstrapCrossSigning({

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

@@ -1,186 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 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 JSX } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler";
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
import Spinner from "../../../../components/views/elements/Spinner";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
enum Phase {
BackingUp = "backing_up",
Done = "done",
}
interface IProps {
onFinished(done?: boolean): void;
}
interface IState {
phase: Phase;
passPhrase: string;
passPhraseValid: boolean;
passPhraseConfirm: string;
copied: boolean;
downloaded: boolean;
error?: boolean;
}
/**
* Walks the user through the process of setting up e2e key backups to a new backup, and storing the decryption key in
* SSSS.
*
* Uses {@link accessSecretStorage}, which means that if 4S is not already configured, it will be bootstrapped (which
* involves displaying an {@link CreateSecretStorageDialog} so the user can enter a passphrase and/or download the 4S
* key).
*/
export default class CreateKeyBackupDialog extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.BackingUp,
passPhrase: "",
passPhraseValid: false,
passPhraseConfirm: "",
copied: false,
downloaded: false,
};
}
public componentDidMount(): void {
this.createBackup();
}
private createBackup = async (): Promise<void> => {
this.setState({
error: undefined,
});
const cli = MatrixClientPeg.safeGet();
try {
// Check if 4S already set up
const secretStorageAlreadySetup = await cli.secretStorage.hasKey();
if (!secretStorageAlreadySetup) {
// bootstrap secret storage; that will also create a backup version
await accessSecretStorage(async (): Promise<void> => {
// do nothing, all is now set up correctly
});
} else {
await withSecretStorageKeyCache(async () => {
const crypto = cli.getCrypto();
if (!crypto) {
throw new Error("End-to-end encryption is disabled - unable to create backup.");
}
// Before we reset the backup, let's make sure we can access secret storage, to
// reduce the chance of us getting into a broken state where we have an outdated
// secret in secret storage.
// `SecretStorage.get` will ask the user to enter their passphrase/key if necessary;
// it will then be cached for the actual backup reset operation.
await cli.secretStorage.get("m.megolm_backup.v1");
// We now know we can store the new backup key in secret storage, so it is safe to
// go ahead with the reset.
await crypto.resetKeyBackup();
});
}
this.setState({
phase: Phase.Done,
});
} catch (e) {
logger.error("Error creating key backup", e);
// TODO: If creating a version succeeds, but backup fails, should we
// delete the version, disable backup, or do nothing? If we just
// disable without deleting, we'll enable on next app reload since
// it is trusted.
this.setState({
error: true,
});
}
};
private onCancel = (): void => {
this.props.onFinished(false);
};
private onDone = (): void => {
this.props.onFinished(true);
};
private renderBusyPhase(): JSX.Element {
return (
<div>
<Spinner />
</div>
);
}
private renderPhaseDone(): JSX.Element {
return (
<div>
<p>{_t("settings|key_backup|backup_in_progress")}</p>
<DialogButtons primaryButton={_t("action|ok")} onPrimaryButtonClick={this.onDone} hasCancel={false} />
</div>
);
}
private titleForPhase(phase: Phase): string {
switch (phase) {
case Phase.BackingUp:
return _t("settings|key_backup|backup_starting");
case Phase.Done:
return _t("settings|key_backup|backup_success");
default:
return _t("settings|key_backup|create_title");
}
}
public render(): React.ReactNode {
let content;
if (this.state.error) {
content = (
<div>
<p>{_t("settings|key_backup|cannot_create_backup")}</p>
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={this.createBackup}
hasCancel={true}
onCancel={this.onCancel}
/>
</div>
);
} else {
switch (this.state.phase) {
case Phase.BackingUp:
content = this.renderBusyPhase();
break;
case Phase.Done:
content = this.renderPhaseDone();
break;
}
}
return (
<BaseDialog
className="mx_CreateKeyBackupDialog"
onFinished={this.props.onFinished}
title={this.titleForPhase(this.state.phase)}
hasCancel={[Phase.Done].includes(this.state.phase)}
>
<div>{content}</div>
</BaseDialog>
);
}
}

View File

@@ -56,7 +56,6 @@ const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow,
interface IProps {
forceReset?: boolean;
resetCrossSigning?: boolean;
onFinished(ok?: boolean): void;
}
@@ -80,11 +79,12 @@ interface IState {
* If the user already has a key backup, follows a "migration" flow (aka "Upgrade your encryption") which
* prompts the user to enter their backup decryption password (a Curve25519 private key, possibly derived
* from a passphrase), and uses that as the (AES) 4S encryption key.
*
* @deprecated send the user to EncryptionUserSettingsTab instead
*/
export default class CreateSecretStorageDialog extends React.PureComponent<IProps, IState> {
public static defaultProps: Partial<IProps> = {
forceReset: false,
resetCrossSigning: false,
};
private recoveryKey?: GeneratedSecretStorageKey;
private recoveryKeyNode = createRef<HTMLElement>();
@@ -211,7 +211,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
private bootstrapSecretStorage = async (): Promise<void> => {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto()!;
const { forceReset, resetCrossSigning } = this.props;
const { forceReset } = this.props;
let backupInfo;
// First, unless we know we want to do a reset, we see if there is an existing key backup
@@ -246,13 +246,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
createSecretStorageKey: async () => this.recoveryKey!,
setupNewSecretStorage: true,
});
if (resetCrossSigning) {
logger.log("Resetting cross signing");
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
setupNewCrossSigning: true,
});
}
logger.log("Resetting key backup");
await crypto.resetKeyBackup();
} else {

View File

@@ -7,14 +7,15 @@ 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, { lazy } from "react";
import React from "react";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import { Action } from "../../../../dispatcher/actions";
import { UserTab } from "../../../../components/views/dialogs/UserTab";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import { type OpenToTabPayload } from "../../../../dispatcher/payloads/OpenToTabPayload";
interface IProps {
onFinished(): void;
@@ -28,13 +29,12 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent<IPr
private onSetupClick = (): void => {
this.props.onFinished();
Modal.createDialog(
lazy(() => import("./CreateKeyBackupDialog")),
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
dis.dispatch(payload);
};
public render(): React.ReactNode {

View File

@@ -7,11 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../languageHandler";
import UploadBigSvg from "../../../res/img/upload-big.svg";
import { useRoomState } from "../../hooks/useRoomState.ts";
interface IProps {
room: Room;
parent: HTMLElement | null;
onFileDrop(dataTransfer: DataTransfer): void;
}
@@ -21,14 +24,15 @@ interface IState {
counter: number;
}
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
const [state, setState] = useState<IState>({
dragging: false,
counter: 0,
});
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!));
useEffect(() => {
if (!parent || parent.ondrop) return;
if (!hasPermission || !parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation();
@@ -102,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
parent?.removeEventListener("dragenter", onDragEnter);
parent?.removeEventListener("dragleave", onDragLeave);
};
}, [parent, onFileDrop]);
}, [parent, onFileDrop, hasPermission]);
if (state.dragging) {
if (hasPermission && state.dragging) {
return (
<div className="mx_FileDropTarget">
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />

View File

@@ -316,7 +316,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
<ErrorBoundary>
<RoomHeader room={room} />
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={props.resizeNotifier}>
{encryptionTile}
@@ -2564,7 +2564,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses}>
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
<FileDropTarget
parent={this.roomView.current}
onFileDrop={this.onFileDrop}
room={this.state.room}
/>
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}

View File

@@ -29,6 +29,7 @@ export class Tab<T extends string> {
* @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask.
* @param {JSX.Element} body The JSX for the tab container.
* @param {string} screenName The screen name to report to Posthog.
* @param {string} labelClassName Additional class to add to the tab label.
*/
public constructor(
public readonly id: T,
@@ -36,6 +37,7 @@ export class Tab<T extends string> {
public readonly icon: string | JSX.Element | null,
public readonly body: JSX.Element,
public readonly screenName?: ScreenName,
public readonly labelClassName?: string,
) {}
}
@@ -85,7 +87,7 @@ interface ITabLabelProps<T extends string> {
}
function TabLabel<T extends string>({ tab, isActive, showToolip, onClick }: ITabLabelProps<T>): JSX.Element {
const classes = classNames("mx_TabbedView_tabLabel", {
const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, {
mx_TabbedView_tabLabel_active: isActive,
});

View File

@@ -169,8 +169,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
useEffect(() => {
const room = mxClient.getRoom(roomId);
room
?.createThreadsTimelineSets()
room?.createThreadsTimelineSets()
.then(() => room.fetchRoomThreads())
.then(() => {
setFilterOption(ThreadFilterType.All);

View File

@@ -388,7 +388,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
timeline = (
<>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} />
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
<TimelinePanel
key={this.state.thread.id}
ref={this.timelinePanel}

View File

@@ -0,0 +1,108 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useEffect, useState, useCallback } from "react";
import { logger } from "@sentry/browser";
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import { warnSelfDemote } from "../../views/right_panel/UserInfo";
/**
*
*/
export interface UserInfoPowerLevelState {
/**
* default power level value of the selected user
*/
powerLevelUsersDefault: number;
/**
* The new power level to apply
*/
selectedPowerLevel: number;
/**
* Method to call When power level selection change
*/
onPowerChange: (powerLevel: number) => void;
}
export const useUserInfoPowerlevelViewModel = (user: RoomMember, room: Room): UserInfoPowerLevelState => {
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
useEffect(() => {
setSelectedPowerLevel(user.powerLevel);
}, [user]);
const cli = useContext(MatrixClientContext);
const onPowerChange = useCallback(
async (powerLevel: number) => {
setSelectedPowerLevel(powerLevel);
const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise<unknown> => {
return cli.setPowerLevel(roomId, target, powerLevel).then(
function () {
logger.info("Power change success");
},
function (err) {
logger.error("Failed to change power level " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("error|update_power_level"),
});
},
);
};
const roomId = user.roomId;
const target = user.userId;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return;
const myUserId = cli.getUserId();
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("common|warning"),
description: (
<div>
{_t("user_info|promote_warning")}
<br />
{_t("common|are_you_sure")}
</div>
),
button: _t("action|continue"),
});
const [confirmed] = await finished;
if (!confirmed) return;
} else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
logger.error("Failed to warn about self demotion: " + e);
}
}
await applyPowerChange(roomId, target, powerLevel);
},
[user.roomId, user.userId, cli, room],
);
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
return {
powerLevelUsersDefault,
onPowerChange,
selectedPowerLevel,
};
};

View File

@@ -30,6 +30,10 @@ export interface RoomListItemViewState {
* The name of the room.
*/
name: string;
/**
* Whether the context menu should be shown.
*/
showContextMenu: boolean;
/**
* Whether the hover menu should be shown.
*/
@@ -105,12 +109,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
setNotificationValues(getNotificationValues(notificationState));
}, [notificationState]);
// We don't want to show the hover menu if
// We don't want to show the menus if
// - there is an invitation for this room
// - the user doesn't have access to both notification and more options menus
// - the user doesn't have access to notification and more options menus
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
const showHoverMenu =
!invited &&
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
const messagePreview = useRoomMessagePreview(room);
@@ -137,6 +141,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
return {
name,
notificationState,
showContextMenu,
showHoverMenu,
openRoom,
a11yLabel,

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

@@ -13,9 +13,11 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../../../components/views/dialogs/UserTab";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RestoreKeyBackupDialog from "./security/RestoreKeyBackupDialog";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
@@ -138,26 +140,12 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
};
private onSetRecoveryMethodClick = (): void => {
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
Modal.createDialog(
RestoreKeyBackupDialog,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
} else {
Modal.createDialog(
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
}
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
dis.dispatch(payload);
// close dialog
this.props.onFinished(true);
@@ -190,22 +178,13 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
</div>
);
let setupButtonCaption;
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
setupButtonCaption = _t("settings|security|key_backup_connect");
} else {
// if there's an error fetching the backup info, we'll just assume there's
// no backup for the purpose of the button caption
setupButtonCaption = _t("auth|logout_dialog|use_key_backup");
}
const dialogContent = (
<div>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{description}
</div>
<DialogButtons
primaryButton={setupButtonCaption}
primaryButton={_t("common|go_to_settings")}
hasCancel={false}
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true}

View File

@@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Toast } from "@vector-im/compound-web";
import React, { type JSX, useState } from "react";
import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
@@ -44,6 +45,7 @@ import { UserTab } from "./UserTab";
import { type NonEmptyArray } from "../../../@types/common";
import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext";
import { useSettingValue } from "../../../hooks/useSettings";
import { NoChange, useEventEmitterAsyncState, type AsyncStateCallbackResult } from "../../../hooks/useEventEmitter";
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab";
@@ -100,6 +102,26 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState);
// If the user doesn't have Recovery set up (no default Secret Storage key),
// we show an indicator on the Encryption tab.
const showSetupRecoveryIndicator = useEventEmitterAsyncState(
props.sdkContext.client,
ClientEvent.AccountData,
async (event?: MatrixEvent): AsyncStateCallbackResult<boolean> => {
if (event === undefined || event.getType() === "m.secret_storage.default_key") {
const client = props.sdkContext.client;
if (!client) {
return false;
}
return !(await client.secretStorage.getDefaultKeyId());
}
return new NoChange();
},
[],
false,
);
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
const tabs: Tab<UserTab>[] = [];
@@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
<KeyIcon />,
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
"UserSettingsEncryption",
showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
),
);

View File

@@ -8,9 +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 } 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 { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
@@ -30,10 +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 { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@@ -121,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;
@@ -300,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;
}
};
@@ -325,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.
@@ -550,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"
@@ -583,69 +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 {
const downloader = useRef(new FileDownloader()).current;
const [loading, setLoading] = useState(false);
const blobRef = useRef<Blob>(undefined);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
onDownloadReady?: (download: () => Promise<void>) => void;
}
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);
}
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
const onDownloadClick = async (): Promise<void> => {
try {
if (loading) return;
setLoading(true);
useEffect(() => {
if (onDownloadReady) onDownloadReady(download);
}, [download, onDownloadReady]);
if (blobRef.current) {
// Cheat and trigger a download, again.
return downloadBlob(blobRef.current);
}
const res = await fetch(url);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
const blob = await res.blob();
blobRef.current = blob;
await downloadBlob(blob);
} catch (e) {
showError(e);
}
};
async function downloadBlob(blob: Blob): Promise<void> {
await downloader.download({
blob,
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
});
setLoading(false);
}
if (!canDownload) return null;
return (
<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}
/>
);
}
};

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