Compare commits

..

73 Commits

Author SHA1 Message Date
Half-Shot
dac03d0af2 Refactor other tests to stop mocking SettingsStore 2025-04-14 17:29:11 +01:00
Half-Shot
4196ea2018 Refactor notifications-test.ts 2025-04-14 16:42:46 +01:00
Andrew Ferrazzutti
d52b0a1467 Remove contribute.json (#29707)
as the contribute.json project is now decommissioned.
2025-04-14 14:47:54 +00:00
Florian Duros
986be9c00d Fix flaky MatrixChat tests (#29739)
* test: fix flaky MatrixChat `should persist login credentials` test

* test: fix flaky MatrixChat `should log and return to welcome page with correct error when login state is not found` test

* test: fix flaky MatrixChat `should store clientId and issuer in session storage` test
2025-04-14 14:22:46 +00:00
Julien CLEMENT
475e449e81 print better errors in the search view instead of a blocking modal (#29724)
* print better errors in the search view instead of a blocking modal

* update tests and i18n

* fix unused variable

* fix unused variable again
2025-04-14 13:36:34 +00:00
Florian Duros
7ce0a76414 New room list: fix public room icon visibility when filter change (#29737)
* fix: recompute public variable when room changes in room list item view model

* test: add test to check that isPublic is computed correctly when the room changes
2025-04-14 11:59:31 +00:00
Michael Telatynski
2e71ec748f Update to Twemoji 16 (#29735)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-14 11:50:35 +00:00
Florian Duros
07d5a72f26 New room list: video room and video call decoration (#29693)
* feat: add video call and EC call to room list item vm

* feat: add video call notification decoration to notification decoration component

* feat: add video call support to room list item view

* feat: add new RoomAvatarView component

* feat: deprecate `DecoratedRoomAvatar`

* feat: use `RoomAvatarView` in room list item

* feat: allow custom class for `RoomAvatar`

* test: update notification decoration

* test: update room list item view

* test: update room list snapshot

* test: add tests for room avatar vm

* test: add tests for room avatar view

* test(e2e): update snapshots

* fix: video room creation rights

* test: e2e add test for public and video room
2025-04-14 09:27:43 +00:00
Michael Telatynski
1430fd5af6 Fix custom theme support for short hex & rgba hex strings (#29726)
* Fix custom theme support for hex colours other than 6-char

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-04-14 08:31:21 +00:00
ElementRobot
779543fa0f [create-pull-request] automated change (#29733)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-14 08:09:43 +00:00
Andy Balaam
6b052fd067 Extract ResetIdentityBody into a separate object to allow re-using it (#29700) 2025-04-14 07:47:19 +00:00
Florian Duros
f39f3d2164 New room list: minor visual fixes (#29723)
* fix: use correct color for room list header

* fix: use error solid icon

* fix: rename Unread as Unreads

* test: update jest snapshots

* test(e2e): update screenshots

* test: fix test
2025-04-14 07:45:32 +00:00
Michael Telatynski
c929eedd81 Fix getOidcCallbackUrl for Element Desktop (#29711)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-11 14:44:22 +00:00
Florian Duros
bcd396e19e test: fix flaky SetIdServer test (#29719) 2025-04-11 14:26:48 +00:00
Peter Smit
ca56c2e091 Fix some webp images improperly marked as animated (#29713)
* Fix some webp images improperly marked as animated

* Add unit test for an unanimated webp file in extended file format

* Apply linting to webp test
2025-04-11 13:32:41 +00:00
Julien CLEMENT
d594441b53 Revert deletion of hydrateSession (#29703)
* Revert deletion of hydrateSession

* remove line break to make prettier happy :-)

* add tests for hydrateSession on soft logout

* fix coding style

---------

Co-authored-by: Florian Duros <florianduros@element.io>
2025-04-11 08:40:00 +00:00
ElementRobot
d4f25e8e13 [create-pull-request] automated change (#29717)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-11 06:22:22 +00:00
Michael Telatynski
d70d4486f0 Fix converttoroom & converttodm not working (#29705)
* Fix converttoroom & converttodm not working

setAccountData uses `deepCompare` within to avoid writing no-op updates

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

* Update tests

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

* Use filterValidMDirect utility in setDMRoom

Ensure we do not mutate the account data as this would then upset `setAccountData`'s deepCompare later

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-04-10 15:54:41 +00:00
Michael Telatynski
60117b92d8 Ensure forceCloseAllModals also closes priority/static modals (#29706)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-10 11:19:32 +00:00
Kim Brose
afc8536d1c Don't crash the build when trying to docker-package from a shallow clone of a commit (#28503) 2025-04-10 07:59:44 +00:00
Giwayume
b5993aaabb Continue button is disabled when uploading a recovery key file (#29695)
* Wait for setState to complete before validating recovery key

* Linter fix

* Pass in recovery key to validateRecoveryKey function
2025-04-10 07:30:37 +00:00
renovate[bot]
e1b2e3a101 Update react monorepo to v19 (major) (#28914)
* Update react monorepo to v19

* Import JSX explicitly for React 19 compatibility

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

* Update usages of refs for React 19 compatibility

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

* Update react imports

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

* Avoid legacy contexts as much as possible

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

* Avoid deprecated React symbols

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

* Stash

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

* Update usages of refs for React 19 compatibility

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>

* Switch pillify to use a html-react-parser approach rather than DOM muddling

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

* Iterate

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

* Iterate react html parsing

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

* Iterate react html parsing

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

* Iterate html parsing

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

* Memoize the EventContentBody component

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

* Iterate html parsing

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>

* Simplify

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

* Iterate

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

* Discard changes to src/Linkify.tsx

* Discard changes to src/components/views/messages/TextualBody.tsx

* Discard changes to src/settings/handlers/AbstractLocalStorageSettingsHandler.ts

* 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>

* Iterate

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

* Prepare for React 19 upgrade

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

* Iterate

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

* Remove stale comment

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-04-09 19:03:09 +00:00
ElementRobot
f54fbf7231 [create-pull-request] automated change (#29697)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-09 06:21:28 +00:00
Andy Balaam
01bfaec729 Catch errors after syncing recovery (#29691)
* Allow setting the Encryption settings tab to any initial state

* Add a variant to the reset flow for 'sync_failed'

* Catch errors after syncing recovery

Fixes #29229

* fixup! Allow setting the Encryption settings tab to any initial state

* fixup! Add a variant to the reset flow for 'sync_failed'

* Move docs for identity panel variants to ResetIdentityPanelVariant
2025-04-08 14:09:04 +00:00
Florian Duros
ab51ff6b7e Remove Secure Backup, Cross-signing and Cryptography sections in Security & Privacy user settings (#29088)
* feat(security tab)!: remove secure backup panel

BREAKING CHANGE: the key storage user interaction are moved into the Encryption tab. The debugging information are moved into the devtools.

* feat(security tab)!: remove cross signing section

BREAKING CHANGE: the cryptographic identity can be reseted in the Encryption tab. The debugging information are moved into the devtools

* feat(security tab)!: remove cryptography section

BREAKING CHANGE: this section can be found in the Advanced section of the encryption tab.

* test(security tab): update snapshot

* chore(security tab): remove unused component and function

* chore(security tab): update i18n

* test(e2e): remove `backups.spec.ts`
2025-04-08 12:40:06 +00:00
RiotRobot
803cb36d60 Reset matrix-js-sdk back to develop branch 2025-04-08 12:46:56 +00:00
RiotRobot
24167871e6 Merge branch 'master' into develop 2025-04-08 12:46:44 +00:00
RiotRobot
1fdd313ae9 v1.11.97 2025-04-08 12:43:24 +00:00
RiotRobot
18cd641cf6 Upgrade dependency to matrix-js-sdk@37.3.0 2025-04-08 12:39:32 +00:00
ElementRobot
2bc7223c1c Localazy Download (#29675)
* [create-pull-request] automated change

* test: fix `RoomListItemView` test

---------

Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
2025-04-08 09:51:15 +00:00
Will Hunt
8fc6638d6e Allow reporting a room when rejecting an invite. (#29570)
* Add report room dialog button/dialog.

* Update copy

* fixup tests / lint

* Fix title in test.

* update snapshot

* Add unit tests for dialog

* lint

* First pass at adding a report room on invite.

* Use a single line input field for reason to avoid bumping the layout.

* Fixups

* Embed reason to make it clear on grouping

* Revert accidental commit

* lint

* Add some playwright tests.

* tweaks

* Make ignored users list more accessible.

* i18n

* Fix sliding sync test.

* Add unit test

* Even more unit tests.

* move test

* Update to match designs.

* remove console statements

* fix css

* tidy up

* improve comments

* fix css

* updates
2025-04-08 09:08:00 +00:00
Florian Duros
e2b7852998 test e2e: use encryption tab instead of Security & Settings tab in crypto.spec.ts (#29595)
* test(e2e crypto): use encryption tab instead of Security & Settings tab in crypto.spec.ts

* test(e2e): remove wrong comment

* test(e2e crypto): keep `downloadKeysForUsers`

* test(e2e crypto): enter only password

* test: fix typo
2025-04-08 08:02:00 +00:00
R Midhun Suresh
c24a1baf38 RoomListViewModel: Reset primary and secondary filters on space change (#29672)
* Reset filters when space changes

* Write test
2025-04-04 08:40:25 +00:00
Florian Duros
d337106eed New room list: fix multiple visual issues (#29673)
* fix(room list item): add bold when there is a notification

* fix(room list item menu): fix color of check icon

* fix(menu): remove chevron

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

* test(room list): update tests

* test(e2e): update snapshots
2025-04-04 07:45:45 +00:00
renovate[bot]
5ce5e9092b Update dependency stylelint to v16.17.0 (#29659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 13:44:40 +00:00
Will Hunt
cb657d6848 Update report room dialog to match designs (#29669)
* Rework for designs

* Update report room position

* lint

* Improve test coverage
2025-04-03 13:25:19 +00:00
renovate[bot]
1f9db9fa1a Update dependency @sentry/browser to v9.10.1 (#29658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 13:22:48 +00:00
R Midhun Suresh
ac3667508f New Room List: Fix mentions filter matching rooms with any highlight (#29668)
* Use new isMention instead of deprecated hasMention

So that only rooms with mentions (think @ symbol) are shown.

* Fix test
2025-04-03 12:56:06 +00:00
R Midhun Suresh
149b3b1049 RoomListStore: Support specific sorting requirements for muted rooms (#29665)
* Sort muted rooms to the bottom of the room list

* Re-insert room on mute/unmute

* Write tests

* Fix broken playwright test

Muted rooms are at the bottom, so we need to scroll.
2025-04-03 12:56:00 +00:00
renovate[bot]
d07a02fe3d Update dependency testcontainers to v10.23.0 (#29660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 08:50:52 +00:00
renovate[bot]
9d8d407019 Update dependency caniuse-lite to v1.0.30001707 (#29656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 15:30:40 +00:00
renovate[bot]
617fcdd4ce Update dependency @vector-im/matrix-wysiwyg to v2.38.3 (#29655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 15:01:45 +00:00
renovate[bot]
df38e16dbb Update babel monorepo to v7.27.0 (#29657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 14:18:57 +00:00
Florian Duros
817d7b78b8 New room list: add notification options menu (#29639)
* feat: add `utils.hasAccessToNotificationMenu`

* feat(room list item view model): use `hasAccessToNotificationMenu` to compute `showHoverMenu`

* feat(room list item menu view model): add notification options menu attributes

* feat(room list item menu view): add notification options

* test: add tests for `utils.hasAccessToNotificationMenu`

* test(room list item view model): add test for `showHoverMenu`

* test(room list item menu view model): add tests for new attributes

* test(room list item menu view): add tests for notification options menu

* chore: update i18n

* test(e2e): update screenshots

* test(e2e): add tests for notification options menu
2025-04-02 12:30:27 +00:00
renovate[bot]
31a59a5fa3 Update dependency @formatjs/intl-segmenter to v11.7.10 (#29648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 12:18:46 +00:00
R Midhun Suresh
55f1c27184 Room List: Scroll to top of the list when active room is not in the list (#29650)
* Scroll to top when active room is not in list

So that when filters are applied and the active room is not in the list
anymore, the list is scrolled to the top.

* Write test
2025-04-02 10:15:24 +00:00
renovate[bot]
92b85fcb13 Update definitelyTyped (#29647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 10:14:56 +00:00
Florian Duros
82d93695a2 Update @vector-im/compound-web (#29641)
* chore: update `@vector-im/compound-web`

* test: update snapshots
2025-04-02 10:09:18 +00:00
Florian Duros
637ba3222e fix(SAS emoji): fix truncated emoji label (#29643) 2025-04-02 10:09:10 +00:00
Michael Telatynski
abbc1c0947 Update types for React 19 update (#29638)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-02 10:05:05 +00:00
renovate[bot]
602e65ff52 Update peter-evans/dockerhub-description digest to 0505d8b (#29645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 09:28:48 +00:00
renovate[bot]
e915e40e39 Update guibranco/github-status-action-v2 digest to 9b1d102 (#29644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 08:59:18 +00:00
renovate[bot]
35bf6afe55 Update all non-major dependencies (#29646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 08:34:10 +00:00
ElementRobot
52c8867e67 [create-pull-request] automated change (#29626)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-02 06:22:17 +00:00
David Baker
b217271027 Remove duplicate jitsi link (#29642)
jitsi.md was linked both here and in the 'setup' section and I think
it's more relevant to setup. The duplicate links are now breaking the
deploy for some reason. We probably shouldn't have both.
2025-04-01 14:28:34 +00:00
RiotRobot
286231aa37 v1.11.97-rc.0 2025-04-01 12:41:59 +00:00
RiotRobot
3f20df5e08 Upgrade dependency to matrix-js-sdk@37.3.0-rc.0 2025-04-01 12:30:05 +00:00
R Midhun Suresh
d5e070b300 Increase overscan count (#29392) 2025-04-01 09:21:15 +00:00
Florian Duros
d8ecb6362a New room list: reduce padding between avatar and room list border (#29634)
* feat(room list): reduce padding between avatar and room list border

* test(e2e): update screenshots
2025-03-31 20:33:07 +00:00
Michael Telatynski
bcc4ecf0cb Ensure clicks on spoilers do not get handled by the hidden content (#29618)
* Ensure clicks on spoilers do not get handled by the hidden content

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

* Add test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-31 13:37:25 +00:00
Florian Duros
24d9a174d7 fix(room list): add cursor pointer on room list item (#29627) 2025-03-31 13:15:56 +00:00
Michael Telatynski
7970b968c2 Prepare for React 19 upgrade (#29612)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-31 11:02:27 +00:00
Michael Telatynski
59e591c462 Fix missing ambiguous url tooltips on Element Desktop (#29619)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-31 11:02:13 +00:00
Florian Duros
804cb62698 test: fix BST/UTC time in preferences (#29628) 2025-03-31 10:54:51 +00:00
Michael Telatynski
8bb4d44532 Bundle Element Call with Element Web packages (#29309)
* Embed Element Call into Element Web packages

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>

* Pass rageshakeSubmitUrl & posthogApiHost to EC widget

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

* Iterate

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

* Improve coverage

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>

* Improve coverage

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

* Update snapshots

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

* Use @vector-im/element-call-embedded

* Only pass posthog params to EC if Analytics is enabled

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

* Fix test mock

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

* Update EC params to match https://github.com/element-hq/element-call/pull/3089

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

* Update to latest element-call package

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

* yarn.lock

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

* Iterate

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

* Update to element-call-embedded@ v0.9.0-rc.1

* Gate Sentry params behind analytics consent

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

* Update to element-call-embedded v0.9.0-rc.4

* Update Element Call embedded to 0.9.0 release

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
2025-03-28 20:34:32 +00:00
Michael Telatynski
209ab59978 Replace onHeightChanged with ResizeObserver (#29602)
* Replace onHeightChanged with ResizeObserver

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-03-28 10:36:10 +00:00
Florian Duros
6ae11dab52 New room list: fix spacing and padding (#29607)
* fix(menu): reduce gap between button and button size

* fix(notification decoration): increase gap between icons

* fix(room list item): different right padding depending on the menu, notification decoration and the regular case

* test: update snapshots

* test(e2e): update snapshots
2025-03-28 10:21:31 +00:00
Michael Telatynski
fac982811c Update usages of refs for React 19 compatibility (#29536)
* Update usages of refs for React 19 compatibility

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>

* Simplify

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-28 10:07:41 +00:00
Will Hunt
d7730f417b Hide an event notification if it is redacted (#29605)
* Hide notifications from events that have been redacted.

* lint

* add a void

* Remove ?.
2025-03-28 08:46:43 +00:00
ElementRobot
829b588dbf [create-pull-request] automated change (#29610)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-28 06:28:09 +00:00
ElementRobot
9a7cc7eb34 [create-pull-request] automated change (#29611)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-03-28 06:21:36 +00:00
Andrew Ferrazzutti
e537da4251 Docker: Use nginx-unprivileged as base image (#29353)
Instead of manually tweaking directory ownership & pidfile config to
enable running as non-root, use the official first-party base image for
achieving non-root.

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-27 20:43:13 +00:00
David Baker
094a7071e2 Make fetchdep check out matching branch name (#29601)
* Make fetchdep check out matching branch name

on a push to a branch.

* Remove buildkite support entirely
2025-03-27 13:54:05 +00:00
302 changed files with 6685 additions and 6973 deletions

View File

@@ -132,7 +132,7 @@ jobs:
cosign sign --yes ${images}
- name: Update repo description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
if: github.event_name != 'pull_request'
continue-on-error: true
with:

View File

@@ -51,6 +51,7 @@ jobs:
error|invalid_json
error|misconfigured
welcome_to_element
devtools|settings|elementCallUrl
rethemendex_lint:
name: "Rethemendex Check"

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@fe98467f9071758c7fc214af9dbac7f301bd23d4
uses: guibranco/github-status-action-v2@9b1d102b3c32583174557f58c53e3b09d43d1b1d
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success

View File

@@ -1,3 +1,41 @@
Changes in [1.11.97](https://github.com/element-hq/element-web/releases/tag/v1.11.97) (2025-04-08)
==================================================================================================
## ✨ Features
* New room list: reduce padding between avatar and room list border ([#29634](https://github.com/element-hq/element-web/pull/29634)). Contributed by @florianduros.
* Bundle Element Call with Element Web packages ([#29309](https://github.com/element-hq/element-web/pull/29309)). Contributed by @t3chguy.
* Hide an event notification if it is redacted ([#29605](https://github.com/element-hq/element-web/pull/29605)). Contributed by @Half-Shot.
* Docker: Use nginx-unprivileged as base image ([#29353](https://github.com/element-hq/element-web/pull/29353)). Contributed by @AndrewFerr.
* Switch away from nesting React trees and mangling the DOM ([#29586](https://github.com/element-hq/element-web/pull/29586)). Contributed by @t3chguy.
* New room list: add notification decoration ([#29552](https://github.com/element-hq/element-web/pull/29552)). Contributed by @florianduros.
* RoomListStore: Unread filter should match rooms that were marked as unread ([#29580](https://github.com/element-hq/element-web/pull/29580)). Contributed by @MidhunSureshR.
* Add support for hiding videos ([#29496](https://github.com/element-hq/element-web/pull/29496)). Contributed by @Half-Shot.
* Use an outline icon for the report room button ([#29573](https://github.com/element-hq/element-web/pull/29573)). Contributed by @robintown.
* Generate/load pickle key on SSO ([#29568](https://github.com/element-hq/element-web/pull/29568)). Contributed by @Jujure.
* Add report room dialog button/dialog. ([#29513](https://github.com/element-hq/element-web/pull/29513)). Contributed by @Half-Shot.
* RoomListViewModel: Make the active room sticky in the list ([#29551](https://github.com/element-hq/element-web/pull/29551)). Contributed by @MidhunSureshR.
* Replace checkboxes with Compound checkboxes, and appropriately label each checkbox. ([#29363](https://github.com/element-hq/element-web/pull/29363)). Contributed by @Half-Shot.
* New room list: add selection decoration ([#29531](https://github.com/element-hq/element-web/pull/29531)). Contributed by @florianduros.
* Simplified Sliding Sync ([#28515](https://github.com/element-hq/element-web/pull/28515)). Contributed by @dbkr.
* Add ability to hide images after clicking "show image" ([#29467](https://github.com/element-hq/element-web/pull/29467)). Contributed by @Half-Shot.
## 🐛 Bug Fixes
* Fix scroll issues in memberlist ([#29392](https://github.com/element-hq/element-web/pull/29392)). Contributed by @MidhunSureshR.
* Ensure clicks on spoilers do not get handled by the hidden content ([#29618](https://github.com/element-hq/element-web/pull/29618)). Contributed by @t3chguy.
* New room list: add cursor pointer on room list item ([#29627](https://github.com/element-hq/element-web/pull/29627)). Contributed by @florianduros.
* Fix missing ambiguous url tooltips on Element Desktop ([#29619](https://github.com/element-hq/element-web/pull/29619)). Contributed by @t3chguy.
* New room list: fix spacing and padding ([#29607](https://github.com/element-hq/element-web/pull/29607)). Contributed by @florianduros.
* Make fetchdep check out matching branch name ([#29601](https://github.com/element-hq/element-web/pull/29601)). Contributed by @dbkr.
* Fix MFileBody fileName not considering `filename` ([#29589](https://github.com/element-hq/element-web/pull/29589)). Contributed by @t3chguy.
* Fix token expiry racing with login causing wrong error to be shown ([#29566](https://github.com/element-hq/element-web/pull/29566)). Contributed by @t3chguy.
* Fix bug which caused startup to hang if the clock was wound back since a previous session ([#29558](https://github.com/element-hq/element-web/pull/29558)). Contributed by @richvdh.
* RoomListViewModel: Reset any primary filter on secondary filter change ([#29562](https://github.com/element-hq/element-web/pull/29562)). Contributed by @MidhunSureshR.
* RoomListStore: Unread filter should only filter rooms having unread counts ([#29555](https://github.com/element-hq/element-web/pull/29555)). Contributed by @MidhunSureshR.
* In force-verify mode, prevent bypassing by cancelling device verification ([#29487](https://github.com/element-hq/element-web/pull/29487)). Contributed by @andybalaam.
* Add title attribute to user identifier ([#29547](https://github.com/element-hq/element-web/pull/29547)). Contributed by @arpitbatra123.
Changes in [1.11.96](https://github.com/element-hq/element-web/releases/tag/v1.11.96) (2025-03-25)
==================================================================================================
## ✨ Features

View File

@@ -19,7 +19,10 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
FROM nginx:alpine-slim
FROM nginxinc/nginx-unprivileged:alpine-slim
# Need root user to install packages & manipulate the usr directory
USER root
# Install jq and moreutils for sponge, both used by our entrypoints
RUN apk add jq moreutils
@@ -31,13 +34,6 @@ COPY --from=builder /src/webapp /app
COPY /docker/nginx-templates/* /etc/nginx/templates/
COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/
# Tell nginx to put its pidfile elsewhere, so it can run as non-root
RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf
# nginx user must own the cache and etc directory to write cache and tweak the nginx config
RUN chown -R nginx:0 /var/cache/nginx /etc/nginx
RUN chmod -R g+w /var/cache/nginx /etc/nginx
RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html

View File

@@ -1,13 +0,0 @@
{
"name": "Element",
"description": "A glossy Matrix collaboration client for the web.",
"repository": {
"url": "https://github.com/element-hq/element-web",
"license": "AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial"
},
"bugs": {
"list": "https://github.com/element-hq/element-web/issues",
"report": "https://github.com/element-hq/element-web/issues/new/choose"
},
"keywords": ["chat", "riot", "matrix"]
}

View File

@@ -46,7 +46,6 @@
- [Skinning](skinning.md)
- [Cider editor](ciderEditor.md)
- [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md)
- [Media](media-handling.md)
- [Room List Store](room-list-store.md)

View File

@@ -384,8 +384,6 @@ The VoIP and Jitsi options are:
5. `audio_stream_url`: Optional URL to pass to Jitsi to enable live streaming. This option is considered experimental and may be removed
at any time without notice.
6. `element_call`: Optional configuration for native group calls using Element Call, with the following subkeys:
- `url`: The URL of the Element Call instance to use for native group calls. This option is considered experimental
and may be removed at any time without notice. Defaults to `https://call.element.io`.
- `use_exclusively`: A boolean specifying whether Element Call should be used exclusively as the only VoIP stack in
the app, removing the ability to start legacy 1:1 calls or Jitsi calls. Defaults to `false`.
- `participant_limit`: The maximum number of users who can join a call; if

View File

@@ -40,6 +40,8 @@ export default {
// Used by webpack
"process",
"util",
// Embedded into webapp
"@element-hq/element-call-embedded",
],
ignoreBinaries: [
// Used in scripts & workflows

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.96",
"version": "1.11.97",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -22,8 +22,7 @@
"LICENSE",
"README.md",
"AUTHORS.rst",
"package.json",
"contribute.json"
"package.json"
],
"style": "bundle.css",
"matrix_i18n_extra_translation_funcs": [
@@ -69,13 +68,14 @@
"postinstall": "patch-package"
},
"resolutions": {
"**/pretty-format/react-is": "19.0.0",
"@playwright/test": "1.51.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"oidc-client-ts": "3.2.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001704",
"testcontainers": "10.21.0",
"caniuse-lite": "1.0.30001707",
"testcontainers": "10.23.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -86,15 +86,15 @@
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
"@matrix-org/analytics-events": "^0.29.2",
"@matrix-org/emojibase-bindings": "^1.3.4",
"@matrix-org/emojibase-bindings": "^1.4.0",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.9.0",
"@vector-im/matrix-wysiwyg": "2.38.2",
"@vector-im/compound-web": "^7.10.1",
"@vector-im/matrix-wysiwyg": "2.38.3",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -109,7 +109,7 @@
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
"domutils": "^3.2.2",
"emojibase-regex": "15.3.2",
"emojibase-regex": "16.0.0",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.6",
@@ -141,19 +141,19 @@
"posthog-js": "1.157.2",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^18.3.1",
"react": "^19.0.0",
"react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0",
"react-dom": "^18.3.1",
"react-dom": "^19.0.0",
"react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.14.0",
"sanitize-html": "2.15.0",
"tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5",
"temporal-polyfill": "^0.3.0",
"ua-parser-js": "^1.0.2",
"uuid": "^11.0.0",
"what-input": "^5.2.10"
@@ -180,6 +180,7 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.9.0",
"@element-hq/element-web-playwright-common": "^1.1.5",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
@@ -210,11 +211,11 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "18.3.18",
"@types/react": "19.0.10",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "18.3.5",
"@types/react-dom": "19.0.4",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.13.0",
"@types/sanitize-html": "2.15.0",
"@types/semver": "^7.5.8",
"@types/tar-js": "^0.3.5",
"@types/ua-parser-js": "^0.7.36",

View File

@@ -1,76 +0,0 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index 6ea73ef..cb51757 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -151,7 +151,7 @@ declare namespace React {
/**
* The current value of the ref.
*/
- readonly current: T | null;
+ current: T;
}
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES {
@@ -186,7 +186,7 @@ declare namespace React {
* @see {@link RefObject}
*/
- type Ref<T> = RefCallback<T> | RefObject<T> | null;
+ type Ref<T> = RefCallback<T> | RefObject<T | null> | null;
/**
* A legacy implementation of refs where you can pass a string to a ref prop.
*
@@ -300,7 +300,7 @@ declare namespace React {
*
* @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
*/
- ref?: LegacyRef<T> | undefined;
+ ref?: LegacyRef<T | null> | undefined;
}
/**
@@ -1234,7 +1234,7 @@ declare namespace React {
*
* @see {@link ForwardRefRenderFunction}
*/
- type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
+ type ForwardedRef<T> = ((instance: T | null) => void) | RefObject<T | null> | null;
/**
* The type of the function passed to {@link forwardRef}. This is considered different
@@ -1565,7 +1565,7 @@ declare namespace React {
[propertyName: string]: any;
}
- function createRef<T>(): RefObject<T>;
+ function createRef<T>(): RefObject<T | null>;
/**
* The type of the component returned from {@link forwardRef}.
@@ -1989,7 +1989,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T): MutableRefObject<T>;
+ function useRef<T>(initialValue: T): RefObject<T>;
// convenience overload for refs given as a ref prop as they typically start with a null value
/**
* `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
@@ -2004,7 +2004,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T | null): RefObject<T>;
+ function useRef<T>(initialValue: T | null): RefObject<T | null>;
// convenience overload for potentially undefined initialValue / call with 0 arguments
// has a default to stop it from defaulting to {} instead
/**
@@ -2017,7 +2017,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;
+ function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
/**
* The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations.
* Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside

View File

@@ -0,0 +1,31 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index 2272032..18bd20a 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`.
@@ -941,7 +941,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
@@ -1113,7 +1113,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

@@ -0,0 +1,22 @@
diff --git a/node_modules/react-blurhash/dist/index.d.ts b/node_modules/react-blurhash/dist/index.d.ts
index 3adbd0a..32e8c13 100644
--- a/node_modules/react-blurhash/dist/index.d.ts
+++ b/node_modules/react-blurhash/dist/index.d.ts
@@ -19,7 +19,7 @@ declare class Blurhash extends React.PureComponent<Props$1> {
resolutionY: number;
};
componentDidUpdate(): void;
- render(): JSX.Element;
+ render(): React.JSX.Element;
}
declare type Props = React.CanvasHTMLAttributes<HTMLCanvasElement> & {
@@ -37,7 +37,7 @@ declare class BlurhashCanvas extends React.PureComponent<Props> {
componentDidUpdate(): void;
handleRef: (canvas: HTMLCanvasElement) => void;
draw: () => void;
- render(): JSX.Element;
+ render(): React.JSX.Element;
}
export { Blurhash, BlurhashCanvas };

View File

@@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog } from "./utils.ts";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Hanako",
});
test(
"Create, delete and recreate a keys backup",
{ tag: "@no-webkit" },
async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const securityKey = await completeCreateSecretStorageDialog(page);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
},
);
});

View File

@@ -8,14 +8,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test";
import {
autoJoin,
completeCreateSecretStorageDialog,
copyAndContinue,
createSharedRoomWithUser,
enableKeyBackup,
verify,
} from "./utils";
import { autoJoin, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import { type Bot } from "../../pages/bot";
import { type ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
@@ -84,86 +77,43 @@ test.describe("Cryptography", function () {
},
});
for (const isDeviceVerified of [true, false]) {
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => {
/**
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType
*/
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType,
);
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
/**
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType
*/
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType,
);
test("by recovery code", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
await completeCreateSecretStorageDialog(page);
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Select passphrase option
await dialog.getByText("Enter a Security Phrase").click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Fill passphrase input
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
// Confirm passphrase
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await copyAndContinue(page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
});
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
test("Setting up key backup by recovery key", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await enableKeyBackup(app);
// Wait for the cross signing keys to be uploaded
// Waiting for "Change the recovery key" button ensure that all the secrets are uploaded and cached locally
const encryptionTab = await app.settings.openUserSettings("Encryption");
await expect(encryptionTab.getByRole("button", { name: "Change recovery key" })).toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
const secretStorageKey = await enableKeyBackup(app);
await enableKeyBackup(app);
// Fetch the current cross-signing keys
async function fetchMasterKey() {
@@ -177,18 +127,15 @@ test.describe("Cryptography", function () {
return k;
});
}
const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it
await app.settings.openUserSettings("Security & Privacy");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
// Find "the Reset cryptographic identity" button
const encryptionTab = await app.settings.openUserSettings("Encryption");
await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click();
// Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
await encryptionTab.getByRole("button", { name: "Continue" }).click();
// Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
@@ -198,9 +145,6 @@ test.describe("Cryptography", function () {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
}).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
});
test(

View File

@@ -22,20 +22,67 @@ test.describe("Room list filters and sort", () => {
return page.getByRole("listbox", { name: "Room list filters" });
}
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test.describe("Room list", () => {
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.describe("Scroll behaviour", () => {
test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({
page,
app,
}) => {
const createFavouriteRoom = async (name: string) => {
const id = await app.client.createRoom({
name,
});
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, id);
};
// Create 5 favourite rooms
let i = 0;
for (; i < 5; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Create a non-favourite room
await app.client.createRoom({ name: `room-non-fav` });
// Create rest of the favourite rooms
for (; i < 20; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Open the non-favourite room
const roomListView = getRoomList(page);
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" });
await tile.scrollIntoViewIfNeeded();
await tile.click();
// Enable Favourite filter
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(tile).not.toBeVisible();
// Ensure the room list is not scrolled
const isScrolledDown = await page
.getByRole("grid", { name: "Room list" })
.evaluate((e) => e.scrollTop !== 0);
expect(isScrolledDown).toStrictEqual(false);
});
});
test.describe("Room list", () => {
let unReadDmId: string | undefined;
let unReadRoomId: string | undefined;

View File

@@ -7,7 +7,7 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
import { expect, test } from "../../../element-web-test";
test.describe("Room list", () => {
test.use({
@@ -85,6 +85,48 @@ test.describe("Room list", () => {
await expect(roomItem).not.toBeVisible();
});
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// Default settings should be selected
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png");
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
// Remove hover on the room list item
await roomListView.hover();
// Scroll to the bottom of the list
await page.getByRole("grid", { name: "Room list" }).evaluate((e) => {
e.scrollTop = e.scrollHeight;
});
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
await roomItem.hover();
// On hover, the room should show the muted icon
await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png");
roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// The Mute room option should be selected
await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true");
await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png");
});
test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.hover();
@@ -102,6 +144,32 @@ test.describe("Room list", () => {
});
});
test.describe("Avatar decoration", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] });
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
// @ts-ignore Visibility enum is not accessible
await app.client.createRoom({ name: "public room", visibility: "public" });
const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" });
await expect(publicRoom).toBeVisible();
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
});
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room");
await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page);
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" });
await expect(videoRoom).toBeVisible();
await expect(videoRoom).toMatchScreenshot("room-list-item-video.png");
});
});
test.describe("Notification decoration", () => {
test("should render the invitation decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);

View File

@@ -43,6 +43,7 @@ test.describe("Pills", () => {
// go back to the message room and try to click on the pill text, as a user would
await app.viewRoomByName(messageRoom);
await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`));
const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text");
await expect(pillText).toHaveCSS("pointer-events", "none");
await pillText.click({ force: true }); // force is to ensure we bypass pointer-events

View File

@@ -136,13 +136,30 @@ test.describe("RightPanel", () => {
});
test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", async ({ page, app }) => {
test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report Room" });
await dialog.getByLabel("reason").fill("This room should be reported");
await expect(dialog).toMatchScreenshot("room-report-dialog.png");
await dialog.getByRole("button", { name: "Send report" }).click();
await expect(page.getByText("Your report was sent.")).toBeVisible();
// Dialog should have gone
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
});
test("should handle reporting a room and leaving the room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report room" });
await dialog.getByRole("switch", { name: "Leave room" }).click();
await dialog.getByLabel("reason").fill("This room should be reported");
await dialog.getByRole("button", { name: "Send report" }).click();
await page.getByRole("dialog", { name: "Leave room" }).getByRole("button", { name: "Leave" }).click();
// Dialog should have gone
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
});
});
});

View File

@@ -0,0 +1,67 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Invites", () => {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
},
});
test("should render an invite view", { tag: "@screenshot" }, async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png");
});
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Decline", exact: true }).click();
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Bob", exact: true }),
).not.toBeVisible();
});
test(
"should be able to decline an invite, report the room and ignore the user",
{ tag: "@screenshot" },
async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Decline and block" }).click();
await page.getByLabel("Ignore user").click();
await page.getByLabel("Report room").click();
await page.getByLabel("Reason").fill("Do not want the room");
const roomReported = page.waitForRequest(
(req) =>
req.url().endsWith(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/report`) &&
req.method() === "POST",
);
await expect(page.getByRole("dialog", { name: "Decline invitation" })).toMatchScreenshot(
"Invites_reject_dialog.png",
);
await page.getByRole("button", { name: "Decline invite" }).click();
// Check room was reported.
await roomReported;
// Check user is ignored.
await app.settings.openUserSettings("Security & Privacy");
const ignoredUsersList = page.getByRole("list", { name: "Ignored users" });
await ignoredUsersList.scrollIntoViewIfNeeded();
await expect(ignoredUsersList.getByRole("listitem", { name: bot.credentials.userId })).toBeVisible();
},
);
});

View File

@@ -28,7 +28,10 @@ test.describe("Preferences user settings tab", () => {
const tab = await app.settings.openUserSettings("Preferences");
// Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png", {
// masked due to daylight saving time
mask: [tab.locator("#mx_dropdownUserTimezone_value")],
});
});
test("should be able to change the app language", { tag: ["@no-firefox", "@no-webkit"] }, async ({ uut, user }) => {

View File

@@ -255,8 +255,8 @@ test.describe("Sliding Sync", () => {
// Select the room to reject
await page.getByRole("treeitem", { name: "Room to Reject" }).click();
// Reject the invite
await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click();
// Decline the invite
await page.locator(".mx_RoomView").getByRole("button", { name: "Decline", exact: true }).click();
await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),

View File

@@ -1341,4 +1341,44 @@ test.describe("Timeline", () => {
);
});
});
test.describe("spoilers", { tag: "@screenshot" }, () => {
test("clicking a spoiler containing the pill de-spoilers on 1st click, then follows link on 2nd", async ({
page,
user,
app,
room,
}) => {
// View room
await page.goto(`/#/room/${room.roomId}`);
// Send a spoilered pill
await app.client.sendMessage(room.roomId, {
msgtype: "m.text",
body: user.userId,
format: "org.matrix.custom.html",
formatted_body: `<span data-mx-spoiler>https://matrix.to/#/${user.userId}</span>`,
});
const screenshotOptions = {
css: `
.mx_MessageTimestamp {
display: none !important;
}
`,
};
const eventTile = page.locator(".mx_RoomView_body .mx_EventTile_last");
await expect(eventTile).toMatchScreenshot("spoiler.png", screenshotOptions);
const rightPanelButton = page.getByText("Share profile");
const pill = page.locator(".mx_UserPill");
await pill.click({ force: true }); // force to click the spoiler wrapper instead
await expect(eventTile).toMatchScreenshot("spoiler-uncovered.png", screenshotOptions);
await expect(rightPanelButton).not.toBeVisible(); // assert the right panel is not yet open
await pill.click();
await expect(rightPanelButton).toBeVisible(); // assert the right panel is open
});
});
});

View File

@@ -114,7 +114,7 @@ export class ElementAppPage {
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposerField(isRightPanel?: boolean): Locator {
return this.getComposer(isRightPanel).locator("[contenteditable]");
return this.getComposer(isRightPanel).locator("div[contenteditable]");
}
/**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

View File

@@ -116,6 +116,7 @@
@import "./views/auth/_Welcome.pcss";
@import "./views/avatars/_BaseAvatar.pcss";
@import "./views/avatars/_DecoratedRoomAvatar.pcss";
@import "./views/avatars/_RoomAvatarView.pcss";
@import "./views/avatars/_WidgetAvatar.pcss";
@import "./views/avatars/_WithPresenceIndicator.pcss";
@import "./views/beta/_BetaCard.pcss";
@@ -340,8 +341,6 @@
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss";
@import "./views/settings/_AvatarSetting.pcss";
@import "./views/settings/_CrossSigningPanel.pcss";
@import "./views/settings/_CryptographyPanel.pcss";
@import "./views/settings/_FontScalingPanel.pcss";
@import "./views/settings/_ImageSizePanel.pcss";
@import "./views/settings/_IntegrationManager.pcss";
@@ -354,7 +353,6 @@
@import "./views/settings/_PhoneNumbers.pcss";
@import "./views/settings/_PowerLevelSelector.pcss";
@import "./views/settings/_RoomProfileSettings.pcss";
@import "./views/settings/_SecureBackupPanel.pcss";
@import "./views/settings/_SetIntegrationManager.pcss";
@import "./views/settings/_SettingsFieldset.pcss";
@import "./views/settings/_SettingsHeader.pcss";

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomAvatarView {
--room-avatar-size: 32px;
position: relative;
/* Keep the container to the same size than the avatar */
inline-size: var(--room-avatar-size);
block-size: var(--room-avatar-size);
.mx_RoomAvatarView_RoomAvatar {
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
}
.mx_RoomAvatarView_RoomAvatar_icon {
mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-icon-mask.svg");
}
.mx_RoomAvatarView_RoomAvatar_presence {
mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-presence-mask.svg");
}
.mx_RoomAvatarView_icon {
position: absolute;
/* Place half the icon inside the avatar */
/* Avatar size - (icon size (16px) / 2) */
left: calc((var(--room-avatar-size) - 8px));
bottom: var(--cpd-space-0-5x);
}
.mx_RoomAvatarView_PresenceDecoration {
position: absolute;
/* Place half the icon inside the avatar */
/* Avatar size - (icon size (8px) / 2) */
left: calc((var(--room-avatar-size) - 4px));
bottom: var(--cpd-space-0-5x);
}
}

View File

@@ -5,7 +5,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.
*/
.mx_ReportRoomDialog {
.mx_ReportRoomDialog,
.mx_DeclineAndBlockInviteDialog {
textarea {
font: var(--cpd-font-body-md-regular);
border: 1px solid var(--cpd-color-border-interactive-primary);
@@ -13,4 +14,28 @@ Please see LICENSE files in the repository root for full details.
border-radius: 0.5rem;
padding: var(--cpd-space-3x) var(--cpd-space-4x);
}
/*
Workaround to fix labels appearing with the wrong color.
.mx_Dialog (in res/css/_common.pcss) redefines the body color
as $light-fg-color rather than the standard primary color.
This forces the colour to match the Compound style, but
in the future the Dialogs should not force a color.
*/
form label {
color: var(--cpd-color-text-primary);
}
}
.mx_DeclineAndBlockInviteDialog {
div[aria-disabled="true"] > label {
color: var(--cpd-color-text-secondary);
}
.mx_SettingsFlag_label {
color: var(--cpd-color-text-primary);
font-weight: var(--cpd-font-weight-semibold);
}
}

View File

@@ -21,10 +21,6 @@
}
}
button {
color: var(--cpd-color-icon-secondary);
}
.mx_SpaceMenu_button {
svg {
transition: transform 0.1s linear;

View File

@@ -16,18 +16,22 @@
*/
.mx_RoomListItemView {
all: unset;
cursor: pointer;
&:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-1-5x);
}
}
.mx_RoomListItemView_container {
padding-left: var(--cpd-space-3x);
padding-left: var(--cpd-space-2x);
font: var(--cpd-font-body-md-regular);
height: 100%;
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-3x);
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
@@ -35,7 +39,7 @@
box-sizing: border-box;
min-width: 0;
span {
.mx_RoomListItemView_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -46,8 +50,28 @@
.mx_RoomListItemView_menu_open {
background-color: var(--cpd-color-bg-action-secondary-hovered);
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-1-5x);
}
}
.mx_RoomListItemView_selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.mx_RoomListItemView_notification_decoration {
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-2x);
}
}
.mx_RoomListItemView_empty {
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-3x);
}
}
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
font: var(--cpd-font-body-md-semibold);
}

View File

@@ -816,11 +816,13 @@ $left-gutter: 64px;
.mx_EventTile_spoiler_content {
filter: blur(5px) saturate(0.1) sepia(1);
transition-duration: 0.5s;
pointer-events: none;
}
&.visible > .mx_EventTile_spoiler_content {
filter: none;
user-select: auto;
pointer-events: auto;
}
}

View File

@@ -1,36 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_CrossSigningPanel_statusList {
border-spacing: 0;
th {
text-align: start;
}
td,
th {
padding: 0;
&:first-of-type {
padding-inline-end: 1em;
}
}
}
.mx_CrossSigningPanel_buttonRow {
margin: 1em 0;
:nth-child(n + 1) {
margin-inline-end: 10px;
}
}
.mx_CrossSigningPanel_advanced {
width: fit-content;
}

View File

@@ -1,32 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_CryptographyPanel_sessionInfo {
padding: 0em;
border-spacing: 0px;
}
.mx_CryptographyPanel_sessionInfo > tr {
vertical-align: baseline;
padding: 0em;
th {
text-align: start;
}
td,
th {
padding: 0 1em 0 0;
}
}
.mx_CryptographyPanel_importExportButtons {
display: inline-flex;
flex-flow: wrap;
row-gap: $spacing-8;
column-gap: $spacing-8;
}

View File

@@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 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_SecureBackupPanel_deviceName {
font-style: italic;
}
.mx_SecureBackupPanel_buttonRow {
margin: 1em 0;
display: inline-flex;
flex-flow: wrap;
row-gap: 10px;
:nth-child(n + 1) {
margin-inline-end: 10px;
}
}
.mx_SecureBackupPanel_statusList {
border-spacing: 0;
th {
text-align: start;
}
td,
th {
padding: 0;
&:first-of-type {
padding-inline-end: 1em;
}
}
}
.mx_SecureBackupPanel_advanced {
width: fit-content;
}

View File

@@ -11,6 +11,12 @@ Please see LICENSE files in the repository root for full details.
column-gap: $spacing-8;
}
.mx_SecurityUserSettingsTab_ignoredUsers {
padding-left: 0;
margin: 0;
list-style: none;
}
.mx_SecurityUserSettingsTab_ignoredUser {
margin-bottom: $spacing-4;
}

View File

@@ -46,10 +46,8 @@ Please see LICENSE files in the repository root for full details.
}
.mx_VerificationShowSas_emojiSas_label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: $font-12px;
word-break: break-all;
}
.mx_VerificationShowSas_emojiSas_break {

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M32 0H0V32H32C26.4772 32 22 27.5228 22 22C22 16.4772 26.4772 12 32 12V0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 180 B

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M36 -4H-4V36H36V30.4722C34.9385 31.4223 33.5367 32 32 32C28.6863 32 26 29.3137 26 26C26 22.6863 28.6863 20 32 20C33.5367 20 34.9385 20.5777 36 21.5278V-4Z" />
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -3,7 +3,6 @@
set -ex
BRANCH=$(git rev-parse --abbrev-ref HEAD)
DIST_VERSION=$(git describe --abbrev=0 --tags)
DIR=$(dirname "$0")
@@ -13,6 +12,8 @@ DIR=$(dirname "$0")
if [[ $BRANCH != HEAD && ! $BRANCH =~ heads/v.+ ]]
then
DIST_VERSION=$("$DIR"/get-version-from-git.sh)
else
DIST_VERSION=$(git describe --abbrev=0 --tags)
fi
DIST_VERSION=$("$DIR"/normalize-version.sh "$DIST_VERSION")

View File

@@ -45,10 +45,7 @@ getPRInfo() {
# Some CIs don't give us enough info, so we just get the PR number and ask the
# GH API for more info - "fork:branch". Some give us this directly.
if [ -n "$BUILDKITE_BRANCH" ]; then
# BuildKite
head=$BUILDKITE_BRANCH
elif [ -n "$PR_NUMBER" ]; then
if [ -n "$PR_NUMBER" ]; then
# GitHub
getPRInfo $PR_NUMBER
elif [ -n "$REVIEW_ID" ]; then
@@ -79,11 +76,14 @@ if [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
clone $deforg $defrepo ${withoutPrefix%%/pr-*}
fi
# Try the target branch of the push or PR.
if [ -n "$GITHUB_BASE_REF" ]; then
clone $deforg $defrepo $GITHUB_BASE_REF
elif [ -n "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" ]; then
clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
# Try the target branch of the push or PR, or the branch that was pushed to
# (ie. the 'master' branch should use matching 'master' dependencies)
base_or_branch=$GITHUB_BASE_REF
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
base_or_branch=${GITHUB_REF}
fi
if [ -n "$base_or_branch" ]; then
clone $deforg $defrepo $base_or_branch
fi
# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)

View File

@@ -18,4 +18,9 @@ declare module "react" {
// Fix lazy types - https://stackoverflow.com/a/71017028
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;
// Standardize defaultProps for FunctionComponent so we can write generics assuming `defaultProps` exists on ComponentType
interface FunctionComponent {
defaultProps?: unknown;
}
}

View File

@@ -16,6 +16,7 @@ import {
type SSOAction,
encodeUnpaddedBase64,
type OidcRegistrationClientMetadata,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
@@ -228,6 +229,16 @@ export default abstract class BasePlatform {
window.focus();
};
const closeHandler = (): void => notification.close();
// Clear a notification from a redacted event.
if (ev) {
ev.once(MatrixEventEvent.BeforeRedaction, closeHandler);
notification.onclose = () => {
ev.off(MatrixEventEvent.BeforeRedaction, closeHandler);
};
}
return notification;
}

View File

@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type LegacyRef, type ReactNode } from "react";
import React, { type JSX, type Key, type LegacyRef, type ReactNode } from "react";
import sanitizeHtml, { type IOptions } from "sanitize-html";
import classNames from "classnames";
import katex from "katex";
@@ -239,7 +239,7 @@ class HtmlHighlighter extends BaseHighlighter<string> {
const emojiToHtmlSpan = (emoji: string): string =>
`<span class='mx_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`;
const emojiToJsxSpan = (emoji: string, key: number): JSX.Element => (
const emojiToJsxSpan = (emoji: string, key: Key): JSX.Element => (
<span key={key} className="mx_Emoji" title={unicodeToShortcode(emoji)}>
{emoji}
</span>

View File

@@ -117,7 +117,6 @@ export interface IConfigOptions {
obey_asserted_identity?: boolean; // MSC3086
};
element_call: {
url?: string;
guest_spa_url?: string;
use_exclusively?: boolean;
participant_limit?: number;

View File

@@ -321,7 +321,7 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise<boolean>
} catch (error) {
logger.error("Failed to login via OIDC", error);
await onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
return false;
}
}
@@ -468,7 +468,7 @@ type TryAgainFunction = () => void;
* @param description error description
* @param tryAgain OPTIONAL function to call on try again button from error dialog
*/
async function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): Promise<void> {
function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): void {
Modal.createDialog(ErrorDialog, {
title: _t("auth|oidc|error_title"),
description,
@@ -701,6 +701,43 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
return doSetLoggedIn(credentials, true, true);
}
/**
* Hydrates an existing session by using the credentials provided. This will
* not clear any local storage, unlike setLoggedIn().
*
* Stops the existing Matrix client (without clearing its data) and starts a
* new one in its place. This additionally starts all other react-sdk services
* which use the new Matrix client.
*
* If the credentials belong to a different user from the session already stored,
* the old session will be cleared automatically.
*
* @param {IMatrixClientCreds} credentials The credentials to use
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export async function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.safeGet().getUserId();
const oldDeviceId = MatrixClientPeg.safeGet().getDeviceId();
stopMatrixClient(); // unsets MatrixClientPeg.get()
localStorage.removeItem("mx_soft_logout");
_isLoggingOut = false;
const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId;
if (overwrite) {
logger.warn("Clearing all data: Old session belongs to a different user/session");
}
if (!credentials.pickleKey && credentials.deviceId !== undefined) {
logger.info("Lifecycle#hydrateSession: Pickle key not provided - trying to get one");
credentials.pickleKey =
(await PlatformPeg.get()?.getPickleKey(credentials.userId, credentials.deviceId)) ?? undefined;
}
return doSetLoggedIn(credentials, overwrite, false);
}
/**
* When we have a authenticated via OIDC-native flow and have a refresh token
* try to create a token refresher.

View File

@@ -18,6 +18,7 @@ import defaultDispatcher from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
import { type Defaultize } from "./@types/common";
import { type ActionPayload } from "./dispatcher/payloads";
import { filterBoolean } from "./utils/arrays.ts";
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
@@ -160,13 +161,16 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
* situations like the user logging out of the app.
*/
public forceCloseAllModals(): void {
for (const modal of this.modals) {
const modals = filterBoolean([...this.modals, this.staticModal, this.priorityModal]);
for (const modal of modals) {
modal.deferred?.resolve([]);
if (modal.onFinished) modal.onFinished.apply(null);
this.emitClosed();
}
this.modals = [];
this.staticModal = null;
this.priorityModal = null;
this.reRender();
}

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type Key, type RefObject, type ReactElement, type RefCallback } from "react";
import React, { type Key, type RefObject, type ReactElement, type RefCallback, type HTMLAttributes } from "react";
interface IChildProps {
style: React.CSSProperties;
@@ -57,7 +57,8 @@ export default class NodeAnimator extends React.Component<IProps> {
* @param {React.CSSProperties} styles a key/value pair of CSS properties
* @returns {void}
*/
private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
private applyStyles(node: HTMLElement, styles?: React.CSSProperties): void {
if (!styles) return;
Object.entries(styles).forEach(([property, value]) => {
node.style[property as keyof Omit<CSSStyleDeclaration, "length" | "parentRule">] = value;
});
@@ -68,21 +69,22 @@ export default class NodeAnimator extends React.Component<IProps> {
this.children = {};
React.Children.toArray(newChildren).forEach((c) => {
if (!isReactElement(c)) return;
const props = c.props as HTMLAttributes<HTMLElement>;
if (oldChildren[c.key!]) {
const old = oldChildren[c.key!];
const oldNode = this.nodes[old.key!];
if (oldNode && oldNode.style.left !== c.props.style.left) {
this.applyStyles(oldNode, { left: c.props.style.left });
if (oldNode && props.style && oldNode.style.left !== props.style.left) {
this.applyStyles(oldNode, { left: props.style.left });
}
// clone the old element with the props (and children) of the new element
// so prop updates are still received by the children.
this.children[c.key!] = React.cloneElement(old, c.props, c.props.children);
this.children[c.key!] = React.cloneElement(old, props, props.children);
} else {
// new element. If we have a startStyle, use that as the style and go through
// the enter animations
const newProps: Partial<IChildProps> = {};
const restingStyle = c.props.style;
const restingStyle = props.style;
const startStyles = this.props.startStyles;
if (startStyles.length > 0) {
@@ -97,7 +99,7 @@ export default class NodeAnimator extends React.Component<IProps> {
});
}
private collectNode(k: Key, domNode: HTMLElement | null, restingStyle: React.CSSProperties): void {
private collectNode(k: Key, domNode: HTMLElement | null, restingStyle?: React.CSSProperties): void {
const key = typeof k === "bigint" ? Number(k) : k;
if (domNode && this.nodes[key] === undefined && this.props.startStyles.length > 0) {
const startStyles = this.props.startStyles;

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { type Room, EventType, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix";
import AliasCustomisations from "./customisations/Alias";
import { filterValidMDirect } from "./utils/dm/filterValidMDirect.ts";
/**
* Given a room object, return the alias we should use for it,
@@ -56,39 +57,23 @@ export async function setDMRoom(client: MatrixClient, roomId: string, userId: st
if (client.isGuest()) return;
const mDirectEvent = client.getAccountData(EventType.Direct);
const currentContent = mDirectEvent?.getContent() || {};
const { filteredContent } = filterValidMDirect(mDirectEvent?.getContent() ?? {});
const dmRoomMap = new Map(Object.entries(currentContent));
let modified = false;
// remove it from the lists of any others users
// (it can only be a DM room for one person)
for (const thisUserId of dmRoomMap.keys()) {
const roomList = dmRoomMap.get(thisUserId) || [];
if (thisUserId != userId) {
const indexOfRoom = roomList.indexOf(roomId);
if (indexOfRoom > -1) {
roomList.splice(indexOfRoom, 1);
modified = true;
}
}
// remove it from the lists of all users (it can only be a DM room for one person)
for (const thisUserId in filteredContent) {
if (!filteredContent[thisUserId]) continue;
filteredContent[thisUserId] = filteredContent[thisUserId].filter((room) => room !== roomId);
}
// now add it, if it's not already there
// now add it if the caller asked for it to be a DM room
if (userId) {
const roomList = dmRoomMap.get(userId) || [];
if (roomList.indexOf(roomId) == -1) {
roomList.push(roomId);
modified = true;
if (!filteredContent[userId]) {
filteredContent[userId] = [];
}
dmRoomMap.set(userId, roomList);
filteredContent[userId].push(roomId);
}
// prevent unnecessary calls to setAccountData
if (!modified) return;
await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap));
await client.setAccountData(EventType.Direct, filteredContent);
}
/**

View File

@@ -30,7 +30,6 @@ export const DEFAULTS: DeepReadonly<IConfigOptions> = {
preferred_domain: "meet.element.io",
},
element_call: {
url: "https://call.element.io",
use_exclusively: false,
participant_limit: 8,
brand: "Element Call",

View File

@@ -718,4 +718,8 @@ export interface SearchInfo {
* The total count of matching results as returned by the backend.
*/
count?: number;
/**
* Describe the error if any occured.
*/
error?: Error;
}

View File

@@ -212,7 +212,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
scrollIntoView,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
nodes: [],
});

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ReactElement } from "react";
import { type ReactElement, type RefAttributes, type HTMLAttributes } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import CommandProvider from "./CommandProvider";
@@ -31,7 +31,7 @@ export interface ICompletion {
type?: "at-room" | "command" | "community" | "room" | "user";
completion: string;
completionId?: string;
component: ReactElement;
component: ReactElement<RefAttributes<HTMLElement> & HTMLAttributes<HTMLElement>>;
range: ISelectionRange;
command?: string;
suffix?: string;

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { type HTMLAttributes, type JSX, type ReactHTML, type ReactNode, type WheelEvent } from "react";
import React, { type HTMLAttributes, type JSX, type ReactNode, type WheelEvent } from "react";
type DynamicHtmlElementProps<T extends keyof JSX.IntrinsicElements> =
JSX.IntrinsicElements[T] extends HTMLAttributes<object> ? DynamicElementProps<T> : DynamicElementProps<"div">;
@@ -27,7 +27,7 @@ export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<DynamicHtmlElem
export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> extends React.Component<IProps<T>> {
public static defaultProps = {
element: "div" as keyof ReactHTML,
element: "div" as keyof HTMLElementTagNameMap,
};
public readonly containerRef = React.createRef<HTMLDivElement>();

View File

@@ -440,7 +440,7 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
);
}
public render(): React.ReactChild {
public render(): JSX.Element {
if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();

View File

@@ -21,8 +21,6 @@ export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<AutoHideScrollb
// scroll horizontally rather than vertically. This should only be used on components
// with no vertical scroll opportunity.
verticalScrollsHorizontally?: boolean;
children: React.ReactNode;
};
interface IState {

View File

@@ -165,12 +165,6 @@ interface IProps {
initialScreenAfterLogin?: IScreen;
// displayname, if any, to set on the device when logging in/registering.
defaultDeviceDisplayName?: string;
// Used by tests, this function is called when session initialisation starts
// with a promise that resolves or rejects once the initialiation process
// has finished, so that tests can wait for this to avoid them executing over
// each other.
initPromiseCallback?: (p: Promise<void>) => void;
}
interface IState {
@@ -291,9 +285,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
*/
private startInitSession = (): void => {
const initProm = this.initSession();
if (this.props.initPromiseCallback) {
this.props.initPromiseCallback(initProm);
}
initProm.catch((err) => {
// TODO: show an error screen, rather than a spinner of doom
@@ -711,36 +702,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case "copy_room":
this.copyRoom(payload.room_id);
break;
case "reject_invite":
Modal.createDialog(QuestionDialog, {
title: _t("reject_invitation_dialog|title"),
description: _t("reject_invitation_dialog|confirmation"),
onFinished: (confirm) => {
if (confirm) {
// FIXME: controller shouldn't be loading a view :(
const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner");
MatrixClientPeg.safeGet()
.leave(payload.room_id)
.then(
() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({ action: Action.ViewHomePage });
}
},
(err) => {
modal.close();
Modal.createDialog(ErrorDialog, {
title: _t("reject_invitation_dialog|failed"),
description: err.toString(),
});
},
);
}
},
});
break;
case "view_user_info":
this.viewUser(payload.userId, payload.subAction);
break;
@@ -1032,10 +993,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved.
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
logger.warn("Cannot view a room before first sync. room_id:", roomInfo.room_id);
return;
}
await this.firstSyncPromise.promise;
}
@@ -1146,8 +1103,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private viewUser(userId: string, subAction: string): void {
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitForSync = this.firstSyncPromise ? this.firstSyncPromise.promise : Promise.resolve();
waitForSync.then(() => {
this.firstSyncPromise.promise.then(() => {
if (subAction === "chat") {
this.chatCreateOrReuse(userId);
return;
@@ -1510,11 +1466,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* (useful for setting listeners)
*/
private onWillStartClient(): void {
// reset the 'have completed first sync' flag,
// since we're about to start the client and therefore about
// to do the first sync
// Reset the 'have completed first sync' flag,
// since we're about to start the client and therefore about to do the first sync
// We resolve the existing promise with the new one to update any existing listeners
if (!this.firstSyncComplete) {
const firstSyncPromise = defer<void>();
this.firstSyncPromise.resolve(firstSyncPromise.promise);
this.firstSyncPromise = firstSyncPromise;
} else {
this.firstSyncPromise = defer();
}
this.firstSyncComplete = false;
this.firstSyncPromise = defer();
const cli = MatrixClientPeg.safeGet();
// Allow the JS SDK to reap timeline events. This reduces the amount of

View File

@@ -292,6 +292,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
this.props.room?.currentState.off(RoomStateEvent.Update, this.calculateRoomMembersCount);
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
this.readReceiptMap = {};
this.resizeObserver.disconnect();
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
@@ -800,7 +801,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing ? this.props.editState : undefined}
onHeightChanged={this.onHeightChanged}
resizeObserver={this.resizeObserver}
readReceipts={readReceipts}
readReceiptMap={this.readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
@@ -953,15 +954,13 @@ export default class MessagePanel extends React.Component<IProps, IState> {
this.eventTiles[eventId] = node;
};
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
// Once dynamic content in the events load, make the scrollPanel check the scroll offsets.
public onHeightChanged = (): void => {
const scrollPanel = this.scrollPanel.current;
if (scrollPanel) {
scrollPanel.checkScroll();
}
this.scrollPanel.current?.checkScroll();
};
private resizeObserver = new ResizeObserver(this.onHeightChanged);
private onTypingShown = (): void => {
const scrollPanel = this.scrollPanel.current;
// this will make the timeline grow, so checkScroll

View File

@@ -21,8 +21,6 @@ import { _t } from "../../languageHandler";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import SearchResultTile from "../views/rooms/SearchResultTile";
import { searchPagination, SearchScope } from "../../Searching";
import Modal from "../../Modal";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import type ResizeNotifier from "../../utils/ResizeNotifier";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
@@ -45,7 +43,7 @@ interface Props {
abortController?: AbortController;
resizeNotifier: ResizeNotifier;
className: string;
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void;
}
// XXX: todo: merge overlapping results somehow?
@@ -70,7 +68,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
onUpdate(true, null);
onUpdate(true, null, null);
return searchPromise.then(
async (results): Promise<boolean> => {
@@ -116,7 +114,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
onUpdate(false, results);
onUpdate(false, results, null);
return false;
},
(error) => {
@@ -125,11 +123,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
return false;
}
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("error_dialog|search_failed|title"),
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
});
onUpdate(false, null);
onUpdate(false, null, error);
return false;
},
);
@@ -198,12 +192,6 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
}
}
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = (): void => {
innerRef.current?.checkScroll();
};
const onRef = (e: ScrollPanel | null): void => {
if (typeof ref === "function") {
ref(e);
@@ -302,7 +290,6 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
onHeightChanged={onHeightChanged}
/>,
);

View File

@@ -134,6 +134,7 @@ import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -1715,11 +1716,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.onSearch(this.state.search?.term ?? "", scope);
};
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null, error: Error | null): void => {
this.setState({
search: {
...this.state.search!,
count: searchResults?.count,
error: error ?? undefined,
inProgress,
},
});
@@ -1732,48 +1734,61 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
};
private onRejectButtonClicked = (): void => {
const roomId = this.getRoomId();
if (!roomId) return;
private onDeclineAndBlockButtonClicked = async (): Promise<void> => {
if (!this.state.room || !this.context.client) return;
const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, {
roomName: this.state.room.name,
}).finished;
if (!shouldReject) {
return;
}
this.setState({
rejecting: true,
});
this.context.client?.leave(roomId).then(
() => {
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
this.setState({
rejecting: false,
});
},
(error) => {
logger.error(`Failed to reject invite: ${error}`);
const msg = error.message ? error.message : JSON.stringify(error);
Modal.createDialog(ErrorDialog, {
title: _t("room|failed_reject_invite"),
description: msg,
});
const actions: Promise<unknown>[] = [];
this.setState({
rejecting: false,
});
},
);
if (ignoreUser) {
const myMember = this.state.room.getMember(this.context.client!.getSafeUserId());
const inviteEvent = myMember!.events.member;
const ignoredUsers = this.context.client.getIgnoredUsers();
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
actions.push(this.context.client.setIgnoredUsers(ignoredUsers));
}
if (reportRoom !== false) {
actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom));
}
actions.push(this.context.client.leave(this.state.room.roomId));
try {
await Promise.all(actions);
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
this.setState({
rejecting: false,
});
} catch (error) {
logger.error(`Failed to reject invite: ${error}`);
const msg = error instanceof Error ? error.message : JSON.stringify(error);
Modal.createDialog(ErrorDialog, {
title: _t("room|failed_reject_invite"),
description: msg,
});
this.setState({
rejecting: false,
});
}
};
private onRejectAndIgnoreClick = async (): Promise<void> => {
this.setState({
rejecting: true,
});
private onDeclineButtonClicked = async (): Promise<void> => {
if (!this.state.room || !this.context.client) {
return;
}
try {
const myMember = this.state.room!.getMember(this.context.client!.getSafeUserId());
const inviteEvent = myMember!.events.member;
const ignoredUsers = this.context.client!.getIgnoredUsers();
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
await this.context.client!.setIgnoredUsers(ignoredUsers);
await this.context.client!.leave(this.state.roomId!);
await this.context.client.leave(this.state.room.roomId);
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
this.setState({
rejecting: false,
@@ -2126,7 +2141,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false}
error={this.state.roomLoadError}
roomAlias={roomAlias}
@@ -2154,7 +2169,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<RoomPreviewCard
room={this.state.room}
onJoinButtonClicked={this.onJoinButtonClicked}
onRejectButtonClicked={this.onRejectButtonClicked}
onRejectButtonClicked={this.onDeclineButtonClicked}
/>
</div>
;
@@ -2196,8 +2211,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
onDeclineClick={this.onDeclineButtonClicked}
onDeclineAndBlockClick={this.onDeclineAndBlockButtonClicked}
promptRejectionOptions={true}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
@@ -2312,7 +2328,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
promptRejectionOptions={true}
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
@@ -2350,7 +2367,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
onRejectButtonClicked={
this.props.threepidInvite
? this.onRejectThreepidInviteButtonClicked
: this.onRejectButtonClicked
: this.onDeclineButtonClicked
}
/>
);

View File

@@ -27,14 +27,14 @@ export class Tab<T extends string> {
* @param {string} id The tab's ID.
* @param {string} label The untranslated tab label.
* @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 {React.ReactNode} body The JSX for the tab container.
* @param {JSX.Element} body The JSX for the tab container.
* @param {string} screenName The screen name to report to Posthog.
*/
public constructor(
public readonly id: T,
public readonly label: TranslationKey,
public readonly icon: string | JSX.Element | null,
public readonly body: React.ReactNode,
public readonly body: JSX.Element,
public readonly screenName?: ScreenName,
) {}
}

View File

@@ -168,7 +168,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
return;
}
Lifecycle.setLoggedIn(credentials).catch((e) => {
Lifecycle.hydrateSession(credentials).catch((e) => {
logger.error(e);
this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") });
});
@@ -204,7 +204,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
return false;
}
return Lifecycle.setLoggedIn(credentials)
return Lifecycle.hydrateSession(credentials)
.then(() => {
if (this.props.onTokenLoginCompleted) {
this.props.onTokenLoginCompleted();

View File

@@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { createContext, type Dispatch, type ReducerAction, type ReducerState } from "react";
import { createContext, type Dispatch, type Reducer, type ReducerState } from "react";
import type { AuthHeaderReducer } from "./AuthHeaderProvider";
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
interface AuthHeaderContextType {
state: ReducerState<AuthHeaderReducer>;
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;

View File

@@ -25,7 +25,7 @@ interface AuthHeaderAction {
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element {
const [state, dispatch] = useReducer<AuthHeaderReducer>(
const [state, dispatch] = useReducer<ComponentProps<typeof AuthHeaderModifier>[], [AuthHeaderAction]>(
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
switch (action.type) {
case AuthHeaderActionType.Add:

View File

@@ -0,0 +1,144 @@
/*
* 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 {
EventType,
JoinRule,
type MatrixEvent,
type Room,
RoomEvent,
type User,
UserEvent,
} from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import DMRoomMap from "../../../utils/DMRoomMap";
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
import { BUSY_PRESENCE_NAME } from "../../views/rooms/PresenceLabel";
import { isPresenceEnabled } from "../../../utils/presence";
/**
* The presence of a user in a DM room.
* - "online": The user is online.
* - "offline": The user is offline.
* - "busy": The user is busy.
* - "unavailable": the presence is unavailable.
* - null: the user is not in a DM room or presence is not enabled.
*/
export type Presence = "online" | "offline" | "busy" | "unavailable" | null;
export interface RoomAvatarViewState {
/**
* Whether the room avatar has a decoration.
* A decoration can be a public or a video call icon or an indicator of presence.
*/
hasDecoration: boolean;
/**
* Whether the room is public.
*/
isPublic: boolean;
/**
* Whether the room is a video room.
*/
isVideoRoom: boolean;
/**
* The presence of the user in the DM room.
* If null, the user is not in a DM room or presence is not enabled.
*/
presence: Presence;
}
/**
* Hook to get the state of the room avatar.
* @param room
*/
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
const presence = useDMPresence(room);
const isPublic = useIsPublic(room);
const hasDecoration = isPublic || isVideoRoom || presence !== null;
return { hasDecoration, isPublic, isVideoRoom, presence };
}
/**
* Hook listening to the room join rules.
* Return true if the room is public.
* @param room
*/
function useIsPublic(room: Room): boolean {
const [isPublic, setIsPublic] = useState(isRoomPublic(room));
// We don't use `useTypedEventEmitterState` because we don't want to update `isPublic` value at every `RoomEvent.Timeline` event.
useTypedEventEmitter(room, RoomEvent.Timeline, (ev: MatrixEvent, _room: Room) => {
if (room.roomId !== _room.roomId) return;
if (ev.getType() !== EventType.RoomJoinRules && ev.getType() !== EventType.RoomMember) return;
setIsPublic(isRoomPublic(_room));
});
// Reset the value when the room changes
useEffect(() => {
setIsPublic(isRoomPublic(room));
}, [room]);
return isPublic;
}
/**
* Whether the room is public.
* @param room
*/
function isRoomPublic(room: Room): boolean {
return room.getJoinRule() === JoinRule.Public;
}
/**
* Hook listening to the presence of the DM user.
* @param room
*/
function useDMPresence(room: Room): Presence {
const dmUser = getDMUser(room);
const [presence, setPresence] = useState<Presence>(getPresence(dmUser));
useTypedEventEmitter(dmUser, UserEvent.Presence, () => setPresence(getPresence(dmUser)));
useTypedEventEmitter(dmUser, UserEvent.CurrentlyActive, () => setPresence(getPresence(dmUser)));
return presence;
}
/**
* Get the DM user of the room.
* Return undefined if the room is not a DM room, if we can't find the user or if the presence is not enabled.
* @param room
* @returns found user
*/
function getDMUser(room: Room): User | undefined {
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (!otherUserId) return;
if (getJoinedNonFunctionalMembers(room).length !== 2) return;
if (!isPresenceEnabled(room.client)) return;
return room.client.getUser(otherUserId) || undefined;
}
/**
* Get the presence of the DM user.
* @param dmUser
*/
function getPresence(dmUser: User | undefined): Presence {
if (!dmUser) return null;
if (BUSY_PRESENCE_NAME.matches(dmUser.presence)) return "busy";
const isOnline = dmUser.currentlyActive || dmUser.presence === "online";
if (isOnline) return "online";
if (dmUser.presence === "offline") return "offline";
if (dmUser.presence === "unavailable") return "unavailable";
return null;
}

View File

@@ -128,8 +128,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const isSpaceRoom = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom;
const displayComposeMenu = canCreateRoom;
const displaySpaceMenu = isSpaceRoom;
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),

View File

@@ -11,7 +11,7 @@ import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { hasAccessToOptionsMenu } from "./utils";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { DefaultTagID } from "../../../stores/room-list/models";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
@@ -21,12 +21,18 @@ import dispatcher from "../../../dispatcher/dispatcher";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import PosthogTrackers from "../../../PosthogTrackers";
import { tagRoom } from "../../../utils/room/tagRoom";
import { RoomNotifState } from "../../../RoomNotifs";
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
export interface RoomListItemMenuViewState {
/**
* Whether the more options menu should be shown.
*/
showMoreOptionsMenu: boolean;
/**
* Whether the notification menu should be shown.
*/
showNotificationMenu: boolean;
/**
* Whether the room is a favourite room.
*/
@@ -47,6 +53,22 @@ export interface RoomListItemMenuViewState {
* Can mark the room as unread.
*/
canMarkAsUnread: boolean;
/**
* Whether the notification is set to all messages.
*/
isNotificationAllMessage: boolean;
/**
* Whether the notification is set to all messages loud.
*/
isNotificationAllMessageLoud: boolean;
/**
* Whether the notification is set to mentions and keywords only.
*/
isNotificationMentionOnly: boolean;
/**
* Whether the notification is muted.
*/
isNotificationMute: boolean;
/**
* Mark the room as read.
* @param evt
@@ -81,6 +103,11 @@ export interface RoomListItemMenuViewState {
* @param evt
*/
leaveRoom: (evt: Event) => void;
/**
* Set the room notification state.
* @param state
*/
setRoomNotifState: (state: RoomNotifState) => void;
}
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
@@ -88,12 +115,13 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const { level: notificationLevel } = useUnreadNotifications(room);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
const canMarkAsRead = notificationLevel > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
@@ -101,6 +129,12 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
// Actions
const markAsRead = useCallback(
@@ -164,11 +198,16 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
return {
showMoreOptionsMenu,
showNotificationMenu,
isFavourite,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
isNotificationAllMessage,
isNotificationAllMessageLoud,
isNotificationMentionOnly,
isNotificationMute,
markAsRead,
markAsUnread,
toggleFavorite,
@@ -176,5 +215,6 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
invite,
copyRoomLink,
leaveRoom,
setRoomNotifState,
};
}

View File

@@ -6,15 +6,20 @@
*/
import { useCallback, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { hasAccessToOptionsMenu } from "./utils";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
export interface RoomListItemViewState {
/**
@@ -33,6 +38,23 @@ export interface RoomListItemViewState {
* The notification state of the room.
*/
notificationState: RoomNotificationState;
/**
* Whether the room should be bolded.
*/
isBold: boolean;
/**
* Whether the room is a video room
*/
isVideoRoom: boolean;
/**
* The connection state of the call.
* `null` if there is no call in the room.
*/
callConnectionState: ConnectionState | null;
/**
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
}
/**
@@ -40,10 +62,23 @@ export interface RoomListItemViewState {
* @see {@link RoomListItemViewState} for more information about what this view model returns.
*/
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// incoming: Check notification menu rights
const showHoverMenu = hasAccessToOptionsMenu(room);
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const showHoverMenu =
hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const a11yLabel = getA11yLabel(room, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
// Video room
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
// EC video call or video room
const call = useCall(room.roomId);
const connectionState = useConnectionState(call);
const hasParticipantInCall = useParticipantCount(call) > 0;
const callConnectionState = call ? connectionState : null;
// Actions
@@ -60,6 +95,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
showHoverMenu,
openRoom,
a11yLabel,
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall,
};
}

View File

@@ -13,6 +13,8 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
/**
* Provides information about a primary filter.
@@ -119,6 +121,12 @@ export function useFilteredRooms(): FilteredRooms {
setRooms(newRooms);
}, []);
// Reset filters when active space changes
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
setPrimaryFilter(undefined);
activateSecondaryFilter(SecondaryFilters.AllActivity);
});
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];

View File

@@ -27,6 +27,16 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
);
}
/**
* Check if the user has access to the notification menu.
* @param room
* @param isGuest
* @param isArchived
*/
export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean {
return !isGuest && !isArchived && hasAccessToOptionsMenu(room);
}
/**
* Create a room
* @param space - The space to create the room in

View File

@@ -79,6 +79,9 @@ function tooltipText(variant: Icon): string | undefined {
}
}
/**
* @deprecated Use {@link RoomAvatarView} instead.
*/
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
private _dmUser: User | null = null;
private isUnmounted = false;

View File

@@ -18,6 +18,7 @@ import { CardContext } from "../right_panel/context";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember | null;
@@ -47,6 +48,7 @@ function MemberAvatar(
}: IProps,
ref: Ref<HTMLElement>,
): JSX.Element {
const cli = useContext(MatrixClientContext);
const card = useContext(CardContext);
const member = useRoomMemberProfile({
@@ -60,7 +62,7 @@ function MemberAvatar(
let imageUrl: string | null | undefined;
if (member?.name) {
if (member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "").getThumbnailOfSourceHttp(
imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "", cli).getThumbnailOfSourceHttp(
parseInt(size, 10),
parseInt(size, 10),
resizeMethod,

View File

@@ -144,7 +144,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const roomName = room?.name ?? oobData.name ?? "?";
return (

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import RoomAvatar from "./RoomAvatar";
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
import { _t } from "../../../languageHandler";
interface RoomAvatarViewProps {
/**
* The room to display the avatar for.
*/
room: Room;
}
/**
* Component to display the avatar of a room.
* Currently only 32px size is supported.
*/
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
const vm = useRoomAvatarViewModel(room);
// No decoration, we just show the avatar
if (!vm.hasDecoration) return <RoomAvatar size="32px" room={room} />;
return (
<div className="mx_RoomAvatarView">
<RoomAvatar
className={classNames("mx_RoomAvatarView_RoomAvatar", {
// Presence indicator and video/public icons don't have the same size
// We use different masks
mx_RoomAvatarView_RoomAvatar_icon: vm.isVideoRoom || vm.isPublic,
mx_RoomAvatarView_RoomAvatar_presence: Boolean(vm.presence),
})}
size="32px"
room={room}
/>
{/* If the room is a public video room, we prefer to display only the video icon */}
{vm.isPublic && !vm.isVideoRoom && (
<PublicIcon
width="16px"
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|header|room_is_public")}
/>
)}
{vm.isVideoRoom && (
<VideoIcon
width="16px"
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|video_room")}
/>
)}
{vm.presence && <PresenceDecoration presence={vm.presence} />}
</div>
);
}
type PresenceDecorationProps = {
/**
* The presence of the user in the DM room.
*/
presence: NonNullable<Presence>;
};
/**
* Component to display the presence of a user in a DM room.
*/
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
switch (presence) {
case "online":
return (
<OnlineOrUnavailableIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-accent-primary)"
aria-label={_t("presence|online")}
/>
);
case "unavailable":
return (
<OnlineOrUnavailableIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-quaternary)"
aria-label={_t("presence|away")}
/>
);
case "offline":
return (
<OfflineIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|offline")}
/>
);
case "busy":
return (
<BusyIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|busy")}
/>
);
}
}

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