Compare commits

...

53 Commits

Author SHA1 Message Date
RiotRobot 186f7e71be v1.11.100
Docker / Docker Buildx (push) Failing after 4m9s
2025-05-06 14:03:49 +00:00
RiotRobot 9eb90a8204 Upgrade dependency to matrix-js-sdk@37.5.0 2025-05-06 13:50:34 +00:00
RiotRobot 9f560f1f89 v1.11.100-rc.0
Docker / Docker Buildx (push) Failing after 51s
2025-04-29 11:08:33 +00:00
RiotRobot 8e3fb5288b Upgrade dependency to matrix-js-sdk@37.5.0-rc.0 2025-04-29 10:44:08 +00:00
Johannes Marbach 160a7c1ae3 Move rich topics out of labs / stabilise MSC3765 (#29817)
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2025-04-28 16:05:36 +00:00
Matthew Hodgson c3c04323e1 spell out that EW does *not* work on mobile. (#29211)
* spell out that EW does *not* work on mobile.

see https://bsky.app/profile/jeroenheijmans.nl/post/3lhiwcrtdt22x

* lint

---------

Co-authored-by: David Langley <davidl@element.io>
Co-authored-by: David Langley <langley.dave@gmail.com>
2025-04-28 15:46:05 +00:00
David Langley d6a1d9aa3d Fix incorrect display of the user info display name (#29826)
* Fix incorrect display of the user info display name and truncation after two lines

* Update screenshot for single line name
2025-04-28 14:11:12 +00:00
ElementRobot a8ca4ff90c [create-pull-request] automated change (#29823)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-28 06:27:49 +00:00
R Midhun Suresh 83e6753c4e RoomListStore: Remove invite rooms on decline (#29804)
* Remove room when new membership is leave

It doesn't really matter what the previous membership was.

* Fix test

* Remove on join/invite only

* Exclude kicked rooms from being removed
2025-04-26 12:43:23 +00:00
Will Hunt adc110a8d9 Fix invite flake (#29812) 2025-04-25 13:09:53 +00:00
David Baker 6329f69557 Fix the buttons not being displayed with long preview text (#29811) 2025-04-25 10:03:34 +00:00
ElementRobot ce4b9860a8 [create-pull-request] automated change (#29810)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-25 06:21:57 +00:00
David Baker 714f8f40dd Add message preview support to the new room list (#29784)
* Add message preview support to the new room list

 * Support showing message previews in the room list items
 * Add the secondary filters bar with the '...' menu, containing
   just the option for message previews for now
 * Change message preview toggle hook to update when setting is updated

* Use new compund release

* Unused i18n keys

* Unused imports

* Fix test & update snapshot

* Fix more snapshots

* Fix test

Split into two tests that test setting & updating

* Type import

* Snapshots

* Remove unnecessary Flex container

and update screenshots as the room list has got shorter from the added bar

* More snapshots & screenshots

* More snapshots

* Add test and remove active filter that's not done yet

* Update snapshots & screenshots again

* Other screenshot

* Add more tests

* Fix syntax

* Fix tests

* Use setter directly

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix CSS

* Remopve filter button css for now

* Update to remove forwardRef

* Add comment on why lack of TypedEventEmitter

* snapshots again

* Screenshots again

* Use original screenshots, maybe they'll work now

* Add comment

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-24 15:03:39 +00:00
Michael Telatynski 22d5c00174 Replace usage of forwardRef with React 19 ref prop (#29803)
* Replace usage of `forwardRef` with React 19 ref prop

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

* Add lint rule

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-24 12:31:37 +00:00
RiotRobot 5e7b58a722 Merge branch 'master' into develop 2025-04-23 10:33:28 +00:00
ElementRobot ee59849307 [create-pull-request] automated change (#29799)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-23 06:22:39 +00:00
Florian Duros 5933f50930 New room list: fix missing/incorrect notification decoration (#29796)
* fix: recompute notification when room change in room list item vm

* test: add use case when room list change

* test(e2e): add screenshot to unread filter test
2025-04-22 13:21:50 +00:00
RiotRobot f6a3a429f7 Reset matrix-js-sdk back to develop branch 2025-04-22 13:00:32 +00:00
RiotRobot 5dba03dff2 Merge branch 'master' into develop 2025-04-22 13:00:19 +00:00
Will Hunt 75d9898dff Global configuration flag for media previews (#29582)
* Modify useMediaVisible to take a room.

* Add initial support for a account data level key.

* Update controls.

* Update settings

* Lint and fixes

* make some tests go happy

* lint

* i18n

* update preferences

* prettier

* Update settings tab.

* update screenshot

* Update docs

* Rewrite controller

* Rewrite tons of tests

* Rewrite RoomAvatar to be a functional component

This is so we can use hooks to determine the setting state.

* lint

* lint

* Tidy up comments

* Apply media visible hook to inline images.

* Move conditionals.

* copyright all the things

* Review changes

* Update html utils to properly discard media.

* Types fix

* Fixing tests that break settings getValue expectations

* Fix logic around media preview calculation

* Fix room header tests

* Fixup tests for timelinePanel

* Clear settings in matrixchat

* Update tests to use SettingsStore where possible.

* fix bug

* revert changes to client.ts

* copyright years

* Add header

* Add a test for MediaPreviewAccountSettingsTab

* Mark initMatrixClient as optional

* Improve on types

* Ensure we do not set the account data twice.

* lint

* Review changes

* Ensure we include the client on rendered messages.

* Fix test

* update labels

* clean designs

* update settings tab

* update snapshot

* copyright

* prevent mutation
2025-04-22 09:37:47 +00:00
Florian Duros da6ac36f11 New room list: add partial keyboard shortcuts support (#29783)
* feat: add support to `Action.ViewRoomDelta`

* test: add tests for support of `Action.ViewRoomDelta`

* test(e2e): add tests for shortcuts

* doc: improve comments in `useRoomListNavigation`
2025-04-22 08:31:12 +00:00
ElementRobot c1f145d802 [create-pull-request] automated change (#29788)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-21 06:22:28 +00:00
ElementRobot 81260fef57 [create-pull-request] automated change (#29787)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-18 06:21:39 +00:00
Marc 09ceb3c580 MVVM RoomSummaryCard Topic (#29710)
* feat: create roomSummaryCardTopic view model

* chore: add comments and small update on test mock
2025-04-17 15:56:19 +00:00
R Midhun Suresh 1077729a19 New Room List: Prevent potential scroll jump/flicker when switching spaces (#29781)
* Expose last active room in a given space from space store

* Calculate active index based on active room in new space

* Write test
2025-04-17 12:25:06 +00:00
Marc 4f32727829 feat: warn self change on roles settings (#28926)
* feat: warn self change on roles settings

* test: update RolesRoomSettingsTab to match new modal condition

* test: update e2e RolesRoomSettingsTab to add new modal

* feat: powerlevelselector reput initial value if cancel
2025-04-17 08:58:27 +00:00
Florian Duros fd455179f7 New room list: avoid extra render for room list item (#29752)
* fix: avoid extra render in the new room list

* fix: listen to room name changes

* fix: trigger render when notification state change

* test: fix room list item tests

* chore: fix typo `RoomNotificationState.isUnsentMessage`

* refactor: move `isNotificationDecorationVisible` into `useRoomListItemViewModel`

* refactor: recalculate notification values on notification state changes

* refactor: rename `isNotificationDecorationVisible` to `showNotificationDecoration`

* test: add test for room list item view

* test: add notification tests in room list item vm

* fix: listen to notification updates in `NotificationDecoration`

* test: update notification decoration tests

* refactor: display notification decoration according to vm

* test: update room list item view tests

* fix: a11y label computation after room name change

* refactor: improve notification handling
2025-04-16 21:40:36 +00:00
renovate[bot] 6767e4d6ad Update dependency posthog-js to v1.235.6 (#28906)
* Update dependency posthog-js to v1.235.6

* Iterate

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-16 09:53:27 +00:00
renovate[bot] 1743257ca0 Update dependency caniuse-lite to v1.0.30001714 (#29776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 09:53:16 +00:00
renovate[bot] d2fdd45c47 Update dependency maplibre-gl to v5.3.1 (#29777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 09:53:09 +00:00
renovate[bot] f250575b08 Update dependency @vector-im/compound-design-tokens to v4.0.2 (#29775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 09:53:00 +00:00
renovate[bot] db9428de87 Update react monorepo (#29765)
* Update react monorepo

* Update snapshots

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>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-16 09:34:01 +00:00
Florian Duros b511bf064d New room list: new visual for invitation (#29773)
* feat: rework invitation styling in room list item

* test: update notification decoration test

* test: add test for vm

* test(e2e): update to new invitation styling
2025-04-16 09:23:23 +00:00
David Langley 427e61309b Update team members in triage-assigned.yml (#29751) 2025-04-16 08:38:00 +00:00
David Langley aa821a5b6f Remove virtual rooms (#29635)
* Remove virtual rooms from the timelinePanel and RoomView

* Remove VoipUserMapper

* Remove some unneeded imports

* Remove tovirtual slash command test

* Remove getSupportsVirtualRooms and virtualLookup

* lint

* Remove PROTOCOL_SIP_NATIVE

* Remove native/virtual looks fields and fix tests

* Remove unused lookup fields
2025-04-16 09:36:34 +01:00
renovate[bot] 8b06714a02 Update dependency stylelint-config-standard to v38 (#29768)
* Update dependency stylelint-config-standard to v38

* Iterate

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-16 07:33:50 +00:00
renovate[bot] 079e1fcbc8 Update dependency caniuse-lite to v1.0.30001713 (#29756)
* Update dependency caniuse-lite to v1.0.30001713

* Iterate

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-16 07:30:17 +00:00
ElementRobot 07c1b406ac [create-pull-request] automated change (#29772)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-04-16 06:21:56 +00:00
Will Hunt af9bde5137 Fix invite test flake (#29753)
* Mask mxid from screenshot

* s/hot/not/

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Hide the mxid entirely

* Add new snapshot

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-15 18:57:07 +00:00
renovate[bot] ee8c1ffef4 Update all non-major dependencies (#29758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 18:45:41 +00:00
renovate[bot] fa1043426a Update dependency @stylistic/eslint-plugin to v4 (#29304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 16:57:59 +00:00
Florian Duros 18a7250cf9 New room list: fix incorrect decoration (#29770)
* fix(call): reset call value when the roomId changes

* fix(call): reset presence indicator when the room changes

* refactor: use existing `usePresence`

* test: fix room avatar view test

* test: update snapshots
2025-04-15 16:35:37 +00:00
renovate[bot] 7e5f96c85d Update dependency @sentry/browser to v9.12.0 (#29760)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:57:50 +00:00
renovate[bot] a8e0b54d8a Update dependency typescript to v5.8.3 (#29757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:46:34 +00:00
renovate[bot] 302e3e153e Update dependency @testing-library/react to v16.3.0 (#29761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:40:09 +00:00
renovate[bot] 7642054b74 Update dependency express to v5 (#29767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:30:58 +00:00
renovate[bot] f6955124ac Update dependency testcontainers to v10.24.2 (#29763)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:29:03 +00:00
renovate[bot] 9207f25dc3 Update typescript-eslint monorepo to v8.29.1 (#29766)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:28:39 +00:00
renovate[bot] f476da8bec Update dependency stylelint to v16.18.0 (#29762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:24:23 +00:00
renovate[bot] 34e08af274 Update dependency @matrix-org/spec to v1.14.0 (#29759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:23:00 +00:00
renovate[bot] 6c4bd0c8b1 Update peter-evans/dockerhub-description digest to 432a30c (#29755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:22:38 +00:00
renovate[bot] 88e06cdc55 Update guibranco/github-status-action-v2 digest to 5f2b01c (#29754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 15:20:17 +00:00
Michael Telatynski 8e3830acee Update check name to match draft action 2025-04-15 14:18:59 +01:00
300 changed files with 7610 additions and 4209 deletions
+9
View File
@@ -30,6 +30,10 @@ module.exports = {
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.",
),
...buildRestrictedPropertiesOptions(
["React.forwardRef", "*.forwardRef", "forwardRef"],
"Use ref props instead.",
),
...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.",
@@ -55,6 +59,11 @@ module.exports = {
"error",
{
paths: [
{
name: "react",
importNames: ["forwardRef"],
message: "Use ref props instead.",
},
{
name: "@testing-library/react",
message: "Please use jest-matrix-react instead",
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
cosign sign --yes ${images}
- name: Update repo description
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
if: github.event_name != 'pull_request'
continue-on-error: true
with:
+3 -3
View File
@@ -100,7 +100,7 @@ jobs:
repo: matrix-org/matrix-js-sdk
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10
check-name: draft
check-name: "draft / draft"
allowed-conclusions: success
- name: Wait for element-web draft
@@ -111,7 +111,7 @@ jobs:
repo: element-hq/element-web
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10
check-name: draft
check-name: "draft / draft"
allowed-conclusions: success
- name: Wait for element-desktop draft
@@ -122,5 +122,5 @@ jobs:
repo: element-hq/element-desktop
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10
check-name: draft
check-name: "draft / draft"
allowed-conclusions: success
+1 -1
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@9b1d102b3c32583174557f58c53e3b09d43d1b1d
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
runs-on: ubuntu-24.04
if: |
contains(github.event.issue.assignees.*.login, 't3chguy') ||
contains(github.event.issue.assignees.*.login, 'andybalaam') ||
contains(github.event.issue.assignees.*.login, 'florianduros') ||
contains(github.event.issue.assignees.*.login, 'dbkr') ||
contains(github.event.issue.assignees.*.login, 'MidhunSureshR')
steps:
- uses: actions/add-to-project@main
+23
View File
@@ -1,3 +1,26 @@
Changes in [1.11.100](https://github.com/element-hq/element-web/releases/tag/v1.11.100) (2025-05-06)
====================================================================================================
## ✨ Features
* Move rich topics out of labs / stabilise MSC3765 ([#29817](https://github.com/element-hq/element-web/pull/29817)). Contributed by @Johennes.
* Spell out that Element Web does \*not\* work on mobile. ([#29211](https://github.com/element-hq/element-web/pull/29211)). Contributed by @ara4n.
* Add message preview support to the new room list ([#29784](https://github.com/element-hq/element-web/pull/29784)). Contributed by @dbkr.
* Global configuration flag for media previews ([#29582](https://github.com/element-hq/element-web/pull/29582)). Contributed by @Half-Shot.
* New room list: add partial keyboard shortcuts support ([#29783](https://github.com/element-hq/element-web/pull/29783)). Contributed by @florianduros.
* MVVM RoomSummaryCard Topic ([#29710](https://github.com/element-hq/element-web/pull/29710)). Contributed by @MarcWadai.
* Warn on self change from settings > roles ([#28926](https://github.com/element-hq/element-web/pull/28926)). Contributed by @MarcWadai.
* New room list: new visual for invitation ([#29773](https://github.com/element-hq/element-web/pull/29773)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Fix incorrect display of the user info display name ([#29826](https://github.com/element-hq/element-web/pull/29826)). Contributed by @langleyd.
* RoomListStore: Remove invite rooms on decline ([#29804](https://github.com/element-hq/element-web/pull/29804)). Contributed by @MidhunSureshR.
* Fix the buttons not being displayed with long preview text ([#29811](https://github.com/element-hq/element-web/pull/29811)). Contributed by @dbkr.
* New room list: fix missing/incorrect notification decoration ([#29796](https://github.com/element-hq/element-web/pull/29796)). Contributed by @florianduros.
* New Room List: Prevent potential scroll jump/flicker when switching spaces ([#29781](https://github.com/element-hq/element-web/pull/29781)). Contributed by @MidhunSureshR.
* New room list: fix incorrect decoration ([#29770](https://github.com/element-hq/element-web/pull/29770)). Contributed by @florianduros.
Changes in [1.11.99](https://github.com/element-hq/element-web/releases/tag/v1.11.99) (2025-04-23)
==================================================================================================
No changes, just bumping the version to accommodate a new Element Desktop release
+1 -1
View File
@@ -1,4 +1,4 @@
# syntax=docker.io/docker/dockerfile:1.14-labs
# syntax=docker.io/docker/dockerfile:1.15-labs
# Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
-4
View File
@@ -101,10 +101,6 @@ Under the hood this stops Element Web from adding the `perParticipantE2EE` flag
This is useful while we experiment with encryption and to make calling compatible with platforms that don't use encryption yet.
## Rich text in room topics (`feature_html_topic`) [In Development]
Enables rendering of MD / HTML in room topics.
## Enable the notifications panel in the room header (`feature_notifications`)
Unreliable in encrypted rooms.
+16 -15
View File
@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.99",
"version": "1.11.100",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -68,14 +68,14 @@
"postinstall": "patch-package"
},
"resolutions": {
"**/pretty-format/react-is": "19.0.0",
"**/pretty-format/react-is": "19.1.0",
"@playwright/test": "1.51.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.2",
"oidc-client-ts": "3.2.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001707",
"testcontainers": "10.23.0",
"caniuse-lite": "1.0.30001714",
"testcontainers": "10.24.2",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -93,7 +93,7 @@
"@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.10.1",
"@vector-im/compound-web": "^7.10.2",
"@vector-im/matrix-wysiwyg": "2.38.3",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -130,7 +130,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "37.4.0",
"matrix-js-sdk": "37.5.0",
"matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
@@ -138,7 +138,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2",
"posthog-js": "1.236.1",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^19.0.0",
@@ -185,8 +185,9 @@
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^4.0.0",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
@@ -211,9 +212,9 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
"@types/react": "19.0.10",
"@types/react": "19.1.1",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.0.4",
"@types/react-dom": "19.1.2",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.15.0",
"@types/semver": "^7.5.8",
@@ -246,7 +247,7 @@
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^4.18.2",
"express": "^5.0.0",
"fake-indexeddb": "^6.0.0",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
@@ -286,13 +287,13 @@
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.20.0",
"ts-node": "^10.9.1",
"typescript": "5.8.2",
"typescript": "5.8.3",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
@@ -140,29 +140,35 @@ test.describe("Room list filters and sort", () => {
expect(await roomList.locator("role=gridcell").count()).toBe(3);
});
test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
test(
"unread filter should only match unread rooms that have a count",
{ tag: "@screenshot" },
async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
// Let's open a room other than unread room or unread dm
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click();
// Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm!
await expect(roomListView.getByRole("gridcell", { name: "Open room unread room" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
});
// Unread filter should only show unread room and not unread dm!
const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" });
await expect(unreadDm).toBeVisible();
await expect(unreadDm).toMatchScreenshot("unread-dm.png");
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
},
);
});
test.describe("Empty room list", () => {
@@ -142,6 +142,47 @@ test.describe("Room list", () => {
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
});
test.describe("Shortcuts", () => {
test("should select the next room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowDown");
await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible();
});
test("should select the previous room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should select the last room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible();
});
test("should select the next unread room", async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "1 notification" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
await roomListView.getByRole("gridcell", { name: "Open room room20" }).click();
await page.keyboard.press("Alt+Shift+ArrowDown");
await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible();
});
});
});
test.describe("Avatar decoration", () => {
@@ -230,6 +271,22 @@ test.describe("Room list", () => {
await expect(room).toMatchScreenshot("room-list-item-mention.png");
});
test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
await page.getByRole("button", { name: "Room Options" }).click();
await page.getByRole("menuitemcheckbox", { name: "Show message previews" }).click();
const roomId = await app.client.createRoom({ name: "activity" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByText("I am a robot. Beep.")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-message-preview.png");
});
test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
+29 -2
View File
@@ -11,6 +11,7 @@ import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room";
const ROOM_NAME_LONG =
@@ -21,20 +22,23 @@ const ROOM_NAME_LONG =
"officia deserunt mollit anim id est laborum.";
const SPACE_NAME = "Test space";
const NAME = "Alice";
const LONG_NAME = "Bob long long long long long long long long long long long long long long long name";
const ROOM_ADDRESS_LONG =
"loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name });
}
test.describe("RightPanel", () => {
let testRoomId: string;
test.use({
displayName: NAME,
});
test.beforeEach(async ({ app, user }) => {
await app.client.createRoom({ name: ROOM_NAME });
testRoomId = await app.client.createRoom({ name: ROOM_NAME });
await app.client.createSpace({ name: SPACE_NAME });
});
@@ -134,6 +138,29 @@ test.describe("RightPanel", () => {
await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
test(
"should handle viewing long room member name",
{ tag: "@screenshot" },
async ({ page, homeserver, app }) => {
const bobLongName = new Bot(page, homeserver, { displayName: LONG_NAME });
await bobLongName.prepareClient();
await app.client.inviteUser(testRoomId, bobLongName.credentials.userId);
await bobLongName.joinRoom(testRoomId);
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, LONG_NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(LONG_NAME)).toBeVisible();
await expect(page.locator(".mx_UserInfo")).toMatchScreenshot("with-long-name.png");
},
);
test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => {
+8 -1
View File
@@ -19,7 +19,14 @@ test.describe("Invites", () => {
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");
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
});
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
@@ -37,6 +37,15 @@ test.describe("Roles & Permissions room settings tab", () => {
// Change the role of Alice to Moderator (50)
await combobox.selectOption("Moderator");
await expect(combobox).toHaveValue("50");
// Should display a modal to warn that we are demoting the only admin user
const modal = await page.locator(".mx_Dialog", {
hasText: "Warning",
});
await expect(modal).toBeVisible();
// Click on the continue button in the modal
await modal.getByRole("button", { name: "Continue" }).click();
const respPromise = page.waitForRequest("**/state/**");
await applyButton.click();
await respPromise;
@@ -0,0 +1,139 @@
/*
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 * as fs from "node:fs";
import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
import { test, expect } from "../../element-web-test";
const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png");
test.describe("Media preview settings", () => {
test.use({
displayName: "Alan",
botCreateOpts: {
displayName: "Bob",
},
room: async ({ app, page, homeserver, bot, user }, use) => {
const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri;
const roomId = await bot.createRoom({
name: "Test room",
invite: [user.userId],
initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }],
});
await bot.sendEvent(roomId, null, "m.room.message" as EventType, {
msgtype: "m.image" as MsgType,
body: "image.png",
url: mxc,
});
await use({ roomId });
},
});
test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => {
let settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-no-avatar.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-no-avatar.png");
// And then go back to being visible
settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await page.goto("#/home");
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-with-avatar.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-with-avatar.png");
});
test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: "public",
});
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: joinRule,
} satisfies RoomJoinRulesEventContent);
await expect(page.getByText("Show image")).not.toBeVisible();
}
});
test("should be able to show media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).not.toBeVisible();
});
test("should be able to hide media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to show media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).not.toBeVisible();
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+2
View File
@@ -279,6 +279,7 @@
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";
@import "./views/rooms/_AuxPanel.pcss";
@@ -378,6 +379,7 @@
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_MediaPreviewAccountSettings.pcss";
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss";
@@ -16,7 +16,7 @@ Please see LICENSE files in the repository root for full details.
border: 1px solid $quinary-content;
border-radius: 8px;
box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05);
box-shadow: 0px 1px 3px rgb(23, 25, 28, 0.05);
background-color: $system;
@@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details.
width: 42px;
height: 42px;
border-radius: 50%;
filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2));
filter: drop-shadow(0px 3px 5px rgb(0, 0, 0, 0.2));
background-color: currentColor;
display: flex;
@@ -24,7 +24,7 @@ Please see LICENSE files in the repository root for full details.
height: $ZoomButtons_button-size;
width: $ZoomButtons_button-size;
background: $background;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25);
box-shadow: 0px 4px 12px rgb(0, 0, 0, 0.25);
.mx_ZoomButtons_icon {
$ZoomButtons_icon-size: 12px;
@@ -48,7 +48,7 @@ $height: 220px;
display: flex;
font-size: $font-12px;
font-weight: var(--cpd-font-weight-semibold);
background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0));
background: linear-gradient(rgb(0, 0, 0, 0.9), rgb(0, 0, 0, 0));
}
.mx_WidgetPip_backButton {
@@ -69,5 +69,5 @@ $height: 220px;
display: flex;
justify-content: flex-end;
align-items: flex-end;
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.9));
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.9));
}
+1 -1
View File
@@ -23,7 +23,7 @@ Please see LICENSE files in the repository root for full details.
.mx_ContextualMenu {
border-radius: 12px;
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
background-color: var(--cpd-color-bg-canvas-default);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
color: $primary-content;
+1 -1
View File
@@ -41,7 +41,7 @@ Please see LICENSE files in the repository root for full details.
padding-bottom: 10px;
border: 1px solid $quinary-content;
box-shadow: 0 1px 3px rgba(23, 25, 28, 0.05);
box-shadow: 0 1px 3px rgb(23, 25, 28, 0.05);
}
.mx_ContextualMenu_chevron_top {
+1 -1
View File
@@ -293,7 +293,7 @@ Please see LICENSE files in the repository root for full details.
> hr {
border: none;
height: 1px;
background-color: rgba(141, 151, 165, 0.2);
background-color: rgb(141, 151, 165, 0.2);
margin: 20px 0;
}
+6 -6
View File
@@ -352,9 +352,9 @@ Please see LICENSE files in the repository root for full details.
mask-image: linear-gradient(
to top,
transparent,
rgba(255, 255, 255, 30%) 4px,
rgba(255, 255, 255, 55%) 8px,
rgba(255, 255, 255, 75%) 12px,
rgb(255, 255, 255, 30%) 4px,
rgb(255, 255, 255, 55%) 8px,
rgb(255, 255, 255, 75%) 12px,
black 16px
);
}
@@ -370,9 +370,9 @@ Please see LICENSE files in the repository root for full details.
linear-gradient(
to top,
transparent,
rgba(255, 255, 255, 30%) 4px,
rgba(255, 255, 255, 55%) 8px,
rgba(255, 255, 255, 75%) 12px,
rgb(255, 255, 255, 30%) 4px,
rgb(255, 255, 255, 55%) 8px,
rgb(255, 255, 255, 75%) 12px,
black 16px
);
mask-position:
+7 -7
View File
@@ -19,12 +19,12 @@ Please see LICENSE files in the repository root for full details.
background-image:
radial-gradient(
53.85% 66.75% at 87.55% 0%,
hsla(250deg, 76%, 71%, 0.261) 0%,
hsla(250deg, 100%, 88%, 0) 100%
hsl(250deg, 76%, 71%, 0.261) 0%,
hsl(250deg, 100%, 88%, 0) 100%
),
radial-gradient(41.93% 41.93% at 0% 0%, hsla(222deg, 29%, 20%, 0.28) 0%, hsla(250deg, 100%, 88%, 0) 100%),
radial-gradient(100% 100% at 0% 0%, hsla(250deg, 100%, 88%, 0.174) 0%, hsla(0deg, 100%, 86%, 0) 100%),
radial-gradient(106.35% 96.26% at 100% 0%, hsla(250deg, 100%, 88%, 0.4) 0%, hsla(167deg, 76%, 82%, 0) 100%);
radial-gradient(41.93% 41.93% at 0% 0%, hsl(222deg, 29%, 20%, 0.28) 0%, hsl(250deg, 100%, 88%, 0) 100%),
radial-gradient(100% 100% at 0% 0%, hsl(250deg, 100%, 88%, 0.174) 0%, hsl(0deg, 100%, 86%, 0) 100%),
radial-gradient(106.35% 96.26% at 100% 0%, hsl(250deg, 100%, 88%, 0.4) 0%, hsl(167deg, 76%, 82%, 0) 100%);
/* blur to reduce color banding issues due to alpha-blending multiple gradients */
filter: blur(8px);
inset: -9px;
@@ -34,8 +34,8 @@ Please see LICENSE files in the repository root for full details.
/* gradient to apply different amounts of dithering to different parts of the gradient */
linear-gradient(
to bottom,
/* 10% dithering at the top */ rgba(0, 0, 0, 0.9) 20%,
/* 80% dithering at the bottom */ rgba(0, 0, 0, 0.2) 100%
/* 10% dithering at the top */ rgb(0, 0, 0, 0.9) 20%,
/* 80% dithering at the bottom */ rgb(0, 0, 0, 0.2) 100%
);
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ Please see LICENSE files in the repository root for full details.
grid-row: 2 / 4;
grid-column: 1;
background-color: $system;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5);
border-radius: 8px;
}
@@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details.
grid-column: 1;
background-color: var(--cpd-color-bg-canvas-default);
color: $primary-content;
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
border-radius: 12px;
overflow: hidden;
+1 -1
View File
@@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details.
justify-content: space-between;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid rgba(141, 151, 165, 0.2);
border-top: 1px solid rgb(141, 151, 165, 0.2);
> * {
flex-basis: content;
+1 -1
View File
@@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details.
font: var(--cpd-font-body-md-regular);
opacity: 0.72;
padding: 20px 0;
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8));
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.8));
}
.mx_AuthFooter a:link,
+1 -1
View File
@@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details.
display: flex;
margin: 100px auto auto;
border-radius: 4px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.33);
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
background-color: $authpage-modal-bg-color;
@media only screen and (max-height: 768px) {
+1 -1
View File
@@ -77,7 +77,7 @@ Please see LICENSE files in the repository root for full details.
max-height: 80%;
.mx_CompoundDialog_footer {
box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); /* hardcoded colour for both themes */
box-shadow: 0px -4px 4px rgb(0, 0, 0, 0.05); /* hardcoded colour for both themes */
z-index: 1; /* needed to make footer & shadow appear above dialog content */
}
}
@@ -34,13 +34,13 @@ Please see LICENSE files in the repository root for full details.
.mx_EditHistoryMessage_deletion {
color: rgb(255, 76, 85);
background-color: rgba(255, 76, 85, 0.1);
background-color: rgb(255, 76, 85, 0.1);
text-decoration: line-through;
}
.mx_EditHistoryMessage_insertion {
color: rgb(26, 169, 123);
background-color: rgba(26, 169, 123, 0.1);
background-color: rgb(26, 169, 123, 0.1);
text-decoration: underline;
}
+1 -1
View File
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
.mx_ServerPicker {
margin-bottom: 14px;
padding-bottom: $spacing-16;
border-bottom: 1px solid rgba(141, 151, 165, 0.2);
border-bottom: 1px solid rgb(141, 151, 165, 0.2);
display: grid;
grid-template-columns: auto min-content;
grid-template-rows: auto auto auto;
+2 -2
View File
@@ -20,7 +20,7 @@ Please see LICENSE files in the repository root for full details.
flex: 1;
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
}
.mx_EmojiPicker_header {
@@ -201,7 +201,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_EmojiPicker_item_selected {
color: rgba(0, 0, 0, 0.5);
color: rgb(0, 0, 0, 0.5);
border: 1px solid $accent;
padding: 4px;
}
+1 -1
View File
@@ -76,7 +76,7 @@ Please see LICENSE files in the repository root for full details.
pointer-events: none;
span {
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.15);
box-shadow: 0px 4px 15px rgb(0, 0, 0, 0.15);
border-radius: 8px;
padding: $spacing-8;
background-color: $background;
+1 -1
View File
@@ -25,7 +25,7 @@ Please see LICENSE files in the repository root for full details.
overflow: hidden;
/* Hardcoded colours because it's the same on all themes */
background-color: rgba(0, 0, 0, 0.6);
background-color: rgb(0, 0, 0, 0.6);
color: #ffffff;
}
+2 -2
View File
@@ -99,7 +99,7 @@ Please see LICENSE files in the repository root for full details.
.mx_AccessibleButton_kind_secondary {
color: $secondary-content;
background-color: rgba(141, 151, 165, 0.2);
background-color: rgb(141, 151, 165, 0.2);
font: var(--cpd-font-body-md-semibold);
}
@@ -125,7 +125,7 @@ Please see LICENSE files in the repository root for full details.
padding-bottom: 10px;
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
}
.mx_ContextualMenu_chevron_top {
+1 -1
View File
@@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details.
height: 775px;
right: -253.77px;
top: 0;
background: radial-gradient(49.95% 49.95% at 50% 50%, rgba(13, 189, 139, 0.12) 0%, rgba(18, 115, 235, 0) 100%);
background: radial-gradient(49.95% 49.95% at 50% 50%, rgb(13, 189, 139, 0.12) 0%, rgb(18, 115, 235, 0) 100%);
transform: rotate(-89.69deg);
overflow: hidden;
}
@@ -70,7 +70,7 @@ Please see LICENSE files in the repository root for full details.
top: var(--cpd-space-2x); /* equal to padding-top of parent */
left: 0;
border-radius: 12px;
background-color: rgba(141, 151, 165, 0.1);
background-color: rgb(141, 151, 165, 0.1);
}
}
@@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details.
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1);
box-shadow: 0 4px 24px 0 rgb(27, 29, 34, 0.1);
background: var(--cpd-color-bg-canvas-default);
}
+9 -9
View File
@@ -109,6 +109,15 @@ Please see LICENSE files in the repository root for full details.
font-size: $font-20px;
line-height: $font-25px;
/* E2E icon wrapper */
.mx_Flex > span {
display: inline-block;
}
}
.mx_UserInfo_profile_name {
min-height: 30px;
/* limit to 2 lines, show an ellipsis if it overflows */
/* this looks webkit specific but is supported by Firefox 68+ */
display: -webkit-box;
@@ -118,15 +127,6 @@ Please see LICENSE files in the repository root for full details.
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
/* E2E icon wrapper */
.mx_Flex > span {
display: inline-block;
}
}
.mx_UserInfo_profile_name {
height: 30px;
}
.mx_UserInfo_profile_mxid {
@@ -20,10 +20,6 @@
&:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
.mx_RoomListItemView_content {
padding-right: var(--cpd-space-1-5x);
}
}
.mx_RoomListItemView_container {
@@ -39,11 +35,23 @@
box-sizing: border-box;
min-width: 0;
.mx_RoomListItemView_text {
min-width: 0;
}
.mx_RoomListItemView_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@@ -0,0 +1,25 @@
/*
* 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_RoomListSecondaryFilters {
font: var(--cpd-font-body-md-medium);
margin: var(--cpd-space-2x);
margin-left: var(--cpd-space-1x);
}
.mx_RoomListSecondaryFilters_roomOptionsButton {
/* Size the button appropriately (should this be in em, maybe,
* so it gets bigger with font size? These values taken from the figma.
*/
width: 28px;
height: 28px;
margin-left: auto;
svg {
color: var(--cpd-color-icon-primary);
}
}
+1 -1
View File
@@ -312,7 +312,7 @@ Please see LICENSE files in the repository root for full details.
.mx_MessageTimestamp {
border-radius: var(--MBody-border-radius);
/* Hardcoded colours because it's the same on all themes */
background-color: rgba(0, 0, 0, 0.6);
background-color: rgb(0, 0, 0, 0.6);
color: #ffffff;
padding: 0px 4px 0px 4px;
}
@@ -19,7 +19,7 @@
border-bottom: 1px solid var(--cpd-color-gray-400);
/* From figma */
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgb(27, 29, 34, 0.1);
.mx_PinnedMessageBanner_main {
background: transparent;
@@ -12,7 +12,7 @@
padding: var(--cpd-space-10x);
border-radius: var(--cpd-space-4x);
/* From figma */
box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15);
box-shadow: 0 1.2px 2.4px 0 rgb(27, 29, 34, 0.15);
border: 1px solid var(--cpd-color-gray-400);
.mx_EncryptionCard_header {
@@ -0,0 +1,28 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MediaPreviewAccountSetting_Radio {
margin: var(--cpd-space-1x) 0;
}
.mx_MediaPreviewAccountSetting {
margin-top: var(--cpd-space-1x);
}
.mx_MediaPreviewAccountSetting_RadioHelp {
margin-top: 0;
margin-bottom: var(--cpd-space-1x);
}
.mx_MediaPreviewAccountSetting_Form {
width: 100%;
}
.mx_MediaPreviewAccountSetting_ToggleSwitch {
font: var(--cpd-font-body-md-medium);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
}
+2 -2
View File
@@ -95,7 +95,7 @@ Please see LICENSE files in the repository root for full details.
height: 100%;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.6);
background-color: rgb(0, 0, 0, 0.6);
}
}
@@ -144,7 +144,7 @@ Please see LICENSE files in the repository root for full details.
border-radius: 8px;
background-color: $system;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2);
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.2);
.mx_LegacyCallViewButtons {
bottom: 13px;
+1 -1
View File
@@ -56,7 +56,7 @@ Please see LICENSE files in the repository root for full details.
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.5); /* Same on both themes */
background-color: rgb(0, 0, 0, 0.5); /* Same on both themes */
border-radius: 100%;
&::before {
-2
View File
@@ -29,7 +29,6 @@ import type LegacyCallHandler from "../LegacyCallHandler";
import type UserActivity from "../UserActivity";
import { type ModalWidgetStore } from "../stores/ModalWidgetStore";
import { type WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import type VoipUserMapper from "../VoipUserMapper";
import { type SpaceStoreClass } from "../stores/spaces/SpaceStore";
import type TypingStore from "../stores/TypingStore";
import { type EventIndexPeg } from "../indexing/EventIndexPeg";
@@ -113,7 +112,6 @@ declare global {
mxLegacyCallHandler: LegacyCallHandler;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
+4 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -14,6 +14,7 @@ import type { EncryptedFile } from "matrix-js-sdk/src/types";
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
import { type MediaPreviewConfig } from "./media_preview.ts";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" {
@@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" {
"m.accepted_terms": {
accepted: string[];
};
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
}
export interface AudioContent {
+33
View File
@@ -0,0 +1,33 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export enum MediaPreviewValue {
/**
* Media previews should be enabled.
*/
On = "on",
/**
* Media previews should only be enabled for rooms with non-public join rules.
*/
Private = "private",
/**
* Media previews should be disabled.
*/
Off = "off",
}
export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config";
export interface MediaPreviewConfig extends Record<string, unknown> {
/**
* Media preview setting for thumbnails of media in rooms.
*/
media_previews: MediaPreviewValue;
/**
* Media preview settings for avatars of rooms we have been invited to.
*/
invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off;
}
+1 -8
View File
@@ -6,16 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type PropsWithChildren } from "react";
import type React from "react";
import { type ComponentType } from "react";
declare module "react" {
// Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012
function forwardRef<T, P extends object>(
render: (props: PropsWithChildren<P>, ref: React.ForwardedRef<T>) => React.ReactElement | null,
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
// Fix lazy types - https://stackoverflow.com/a/71017028
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;
+19 -5
View File
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
@@ -294,6 +294,10 @@ export interface EventRenderOpts {
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
forComposerQuote?: boolean;
/**
* Should inline media be rendered?
*/
mediaIsVisible?: boolean;
}
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
@@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
sanitizeParams = composerSanitizeHtmlParams;
}
if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = {
...sanitizeParams,
transformTags: {
...sanitizeParams.transformTags,
img: (tagName) => {
// Remove element
return { tagName, attribs: {} };
},
},
};
}
try {
const isFormattedBody =
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";
@@ -462,10 +480,6 @@ export function topicToHtml(
ref?: LegacyRef<HTMLSpanElement>,
allowExtendedHtml = false,
): ReactNode {
if (!SettingsStore.getValue("feature_html_topic")) {
htmlTopic = undefined;
}
let isFormattedTopic = !!htmlTopic;
let topicHasEmoji = false;
let safeTopic = "";
+7 -97
View File
@@ -39,14 +39,12 @@ import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { UIFeature } from "./settings/UIFeature";
import { Action } from "./dispatcher/actions";
import VoipUserMapper from "./VoipUserMapper";
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "./widgets/ManagedHybrid";
import SdkConfig from "./SdkConfig";
import { ensureDMExists } from "./createRoom";
import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast";
import ToastStore from "./stores/ToastStore";
import Resend from "./Resend";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { InviteKind } from "./components/views/dialogs/InviteDialogTypes";
import { type OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload";
@@ -59,8 +57,6 @@ import { Jitsi } from "./widgets/Jitsi.ts";
export const PROTOCOL_PSTN = "m.protocol.pstn";
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
export const PROTOCOL_SIP_NATIVE = "im.vector.protocol.sip_native";
export const PROTOCOL_SIP_VIRTUAL = "im.vector.protocol.sip_virtual";
const CHECK_PROTOCOLS_ATTEMPTS = 3;
@@ -107,27 +103,9 @@ const debuglog = (...args: any[]): void => {
}
};
interface ThirdpartyLookupResponseFields {
/* eslint-disable camelcase */
// im.vector.sip_native
virtual_mxid?: string;
is_virtual?: boolean;
// im.vector.sip_virtual
native_mxid?: string;
is_native?: boolean;
// common
lookup_success?: boolean;
/* eslint-enable camelcase */
}
interface ThirdpartyLookupResponse {
userid: string;
protocol: string;
fields: ThirdpartyLookupResponseFields;
}
export enum LegacyCallHandlerEvent {
@@ -158,7 +136,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private supportsPstnProtocol: boolean | null = null;
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
// Map of the asserted identity users after we've looked them up using the API.
// We need to be be able to determine the mapped room synchronously, so we
@@ -179,8 +156,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
}
/*
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config)
* Gets the user-facing room associated with a call
*/
public roomIdForCall(call?: MatrixCall): string | null {
if (!call) return null;
@@ -195,7 +171,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
}
}
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) ?? call.roomId ?? null;
return call.roomId ?? null;
}
public start(): void {
@@ -278,12 +254,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
this.supportsPstnProtocol = null;
}
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
this.supportsSipNativeVirtual = Boolean(
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
);
}
this.emit(LegacyCallHandlerEvent.ProtocolSupport);
} catch (e) {
if (maxTries === 1) {
@@ -305,10 +275,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
return this.supportsPstnProtocol ?? false;
}
public getSupportsVirtualRooms(): boolean | null {
return this.supportsSipNativeVirtual;
}
public async pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
try {
return await MatrixClientPeg.safeGet().getThirdpartyUser(
@@ -323,28 +289,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
}
}
public async sipVirtualLookup(nativeMxid: string): Promise<ThirdpartyLookupResponse[]> {
try {
return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_VIRTUAL, {
native_mxid: nativeMxid,
});
} catch (e) {
logger.warn("Failed to query SIP identity for user", e);
return Promise.resolve([]);
}
}
public async sipNativeLookup(virtualMxid: string): Promise<ThirdpartyLookupResponse[]> {
try {
return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_NATIVE, {
virtual_mxid: virtualMxid,
});
} catch (e) {
logger.warn("Failed to query identity for SIP user", e);
return Promise.resolve([]);
}
}
private onCallIncoming = (call: MatrixCall): void => {
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get()?.supportsVoip()) {
@@ -537,24 +481,16 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
}
const newAssertedIdentity = call.getRemoteAssertedIdentity()?.id;
let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length && response[0].fields.lookup_success) {
newNativeAssertedIdentity = response[0].userid;
}
}
logger.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
if (newNativeAssertedIdentity) {
this.assertedIdentityNativeUsers.set(call.callId, newNativeAssertedIdentity);
if (newAssertedIdentity) {
this.assertedIdentityNativeUsers.set(call.callId, newAssertedIdentity);
// If we don't already have a room with this user, make one. This will be slightly odd
// if they called us because we'll be inviting them, but there's not much we can do about
// this if we want the actual, native room to exist (which we do). This is why it's
// important to only obey asserted identity in trusted environments, since anyone you're
// on a call with can cause you to send a room invite to someone.
await ensureDMExists(MatrixClientPeg.safeGet(), newNativeAssertedIdentity);
await ensureDMExists(MatrixClientPeg.safeGet(), newAssertedIdentity);
const newMappedRoomId = this.roomIdForCall(call);
logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
@@ -810,24 +746,10 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
private async placeMatrixCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
// If we're using a virtual room nd there are any events pending, try to resend them,
// otherwise the call will fail and because its a virtual room, the user won't be able
// to see it to either retry or clear the pending events. There will only be call events
// in this queue, and since we're about to place a new call, they can only be events from
// previous calls that are probably stale by now, so just cancel them.
if (mappedRoomId !== roomId) {
const mappedRoom = cli.getRoom(mappedRoomId);
if (mappedRoom?.getPendingEvents().length) {
Resend.cancelUnsentEvents(mappedRoom);
}
}
const timeUntilTurnCresExpire = cli.getTurnServersExpiry() - Date.now();
logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = cli.createCall(mappedRoomId)!;
const call = cli.createCall(roomId)!;
try {
this.addCallForRoom(roomId, call);
@@ -978,19 +900,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
}
const userId = results[0].userid;
// Now check to see if this is a virtual user, in which case we should find the
// native user
let nativeUserId;
if (this.getSupportsVirtualRooms()) {
const nativeLookupResults = await this.sipNativeLookup(userId);
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
} else {
nativeUserId = userId;
}
const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), nativeUserId);
const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), userId);
if (!roomId) {
throw new Error("Failed to ensure DM exists for dialing number");
}
+2 -7
View File
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -12,7 +12,6 @@ import { merge } from "lodash";
import _Linkify from "linkify-react";
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
@@ -47,10 +46,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
// We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!src || !SettingsStore.getValue("showImages")) {
if (!src) {
return { tagName, attribs: {} };
}
@@ -78,7 +74,6 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
if (requestedHeight) {
attribs.style += "height: 100%;";
}
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
return { tagName, attribs };
},
+1 -10
View File
@@ -43,8 +43,6 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl
import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
import { SdkContextClass } from "./contexts/SDKContext";
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
@@ -447,14 +445,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
// XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void {
let roomId = ev.getRoomId()!;
if (LegacyCallHandler.instance.getSupportsVirtualRooms()) {
// Attempt to translate a virtual room to a native one
const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId);
if (nativeRoomId) {
roomId = nativeRoomId;
}
}
const roomId = ev.getRoomId()!;
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
// e.g we are in the process of joining a room.
-23
View File
@@ -52,7 +52,6 @@ import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpD
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
import { TimelineRenderingType } from "./contexts/RoomContext";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import VoipUserMapper from "./VoipUserMapper";
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
import { MatrixClientPeg } from "./MatrixClientPeg";
@@ -743,28 +742,6 @@ export const Commands = [
},
category: CommandCategories.advanced,
}),
new Command({
command: "tovirtual",
description: _td("slash_command|tovirtual"),
category: CommandCategories.advanced,
isEnabled(cli): boolean {
return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli);
},
runFn: (cli, roomId) => {
return success(
(async (): Promise<void> => {
const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId);
if (!room) throw new UserFriendlyError("slash_command|tovirtual_not_found");
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
});
})(),
);
},
}),
new Command({
command: "query",
description: _td("slash_command|query"),
-150
View File
@@ -1,150 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { ensureVirtualRoomExists } from "./createRoom";
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import LegacyCallHandler from "./LegacyCallHandler";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import { findDMForUser } from "./utils/dm/findDMForUser";
// Functions for mapping virtual users & rooms. Currently the only lookup
// is sip virtual: there could be others in the future.
export default class VoipUserMapper {
// We store mappings of virtual -> native room IDs here until the local echo for the
// account data arrives.
private virtualToNativeRoomIdCache = new Map<string, string>();
public static sharedInstance(): VoipUserMapper {
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
return window.mxVoipUserMapper;
}
private async userToVirtualUser(userId: string): Promise<string | null> {
const results = await LegacyCallHandler.instance.sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
}
private async getVirtualUserForRoom(roomId: string): Promise<string | null> {
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!userId) return null;
const virtualUser = await this.userToVirtualUser(userId);
if (!virtualUser) return null;
return virtualUser;
}
public async getOrCreateVirtualRoomForRoom(roomId: string): Promise<string | null> {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return null;
const cli = MatrixClientPeg.safeGet();
const virtualRoomId = await ensureVirtualRoomExists(cli, virtualUser, roomId);
cli.setRoomAccountData(virtualRoomId!, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: roomId,
});
this.virtualToNativeRoomIdCache.set(virtualRoomId!, roomId);
return virtualRoomId;
}
/**
* Gets the ID of the virtual room for a room, or null if the room has no
* virtual room
*/
public async getVirtualRoomForRoom(roomId: string): Promise<Room | undefined> {
const virtualUser = await this.getVirtualUserForRoom(roomId);
if (!virtualUser) return undefined;
return findDMForUser(MatrixClientPeg.safeGet(), virtualUser);
}
public nativeRoomForVirtualRoom(roomId: string): string | null {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
logger.log(
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
);
return cachedNativeRoomId;
}
const cli = MatrixClientPeg.safeGet();
const virtualRoom = cli.getRoom(roomId);
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
const nativeRoomID = virtualRoomEvent.getContent()["native_room"];
const nativeRoom = cli.getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== KnownMembership.Join) return null;
return nativeRoomID;
}
public isVirtualRoom(room: Room): boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
// also look in the create event for the claimed native room ID, which is the only
// way we can recognise a virtual room we've created when it first arrives down
// our stream. We don't trust this in general though, as it could be faked by an
// inviter: our main source of truth is the DM state.
const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
// we only look at this for rooms we created (so inviters can't just cause rooms
// to be invisible)
if (roomCreateEvent.getSender() !== MatrixClientPeg.safeGet().getUserId()) return false;
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
return Boolean(claimedNativeRoomId);
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
if (!inviterId) {
logger.error("Could not find DM inviter for room id: " + invitedRoom.roomId);
}
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId!);
if (result.length === 0) {
return;
}
if (result[0].fields.is_virtual) {
const cli = MatrixClientPeg.safeGet();
const nativeUser = result[0].userid;
const nativeRoom = findDMForUser(cli, nativeUser);
if (nativeRoom) {
// It's a virtual room with a matching native room, so set the room account data. This
// will make sure we know where how to map calls and also allow us know not to display
// it in the future.
cli.setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: nativeRoom.roomId,
});
// also auto-join the virtual room if we have a matching native room
// (possibly we should only join if we've also joined the native room, then we'd also have
// to make sure we joined virtual rooms on joining a native one)
cli.joinRoom(invitedRoom.roomId);
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
}
}
}
}
+6 -4
View File
@@ -6,18 +6,20 @@ 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, { forwardRef } from "react";
import React, { type Ref, type JSX } from "react";
import { RovingTabIndexProvider } from "./RovingTabIndex";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {}
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
ref?: Ref<HTMLDivElement>;
}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref) => {
const Toolbar = ({ children, ref, ...props }: IProps): JSX.Element => {
const onKeyDown = (ev: React.KeyboardEvent): void => {
const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour
@@ -55,6 +57,6 @@ const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref)
)}
</RovingTabIndexProvider>
);
});
};
export default Toolbar;
@@ -8,7 +8,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, { forwardRef, type Ref } from "react";
import React, { type Ref, type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
@@ -16,13 +16,19 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
label?: string;
// whether the context menu is currently open
isExpanded: boolean;
ref?: Ref<HTMLElementTagNameMap[T]>;
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>(
{ label, isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
ref: Ref<HTMLElementTagNameMap[T]>,
) {
export const ContextMenuButton = function <T extends keyof HTMLElementTagNameMap>({
label,
isExpanded,
children,
onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return (
<AccessibleButton
{...props}
@@ -36,4 +42,4 @@ export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElemen
{children}
</AccessibleButton>
);
});
};
@@ -8,7 +8,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, { forwardRef, type Ref } from "react";
import React, { type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
@@ -18,10 +18,14 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
};
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>(
{ isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
ref: Ref<HTMLElementTagNameMap[T]>,
) {
export const ContextMenuTooltipButton = function <T extends keyof HTMLElementTagNameMap>({
isExpanded,
children,
onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return (
<AccessibleButton
{...props}
@@ -35,4 +39,4 @@ export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTM
{children}
</AccessibleButton>
);
});
};
+21 -10
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, { forwardRef } from "react";
import React, { type Ref, type JSX } from "react";
import classNames from "classnames";
/* These were earlier stateless functional components but had to be converted
@@ -16,14 +16,24 @@ presumably wrap them in a <div> before rendering but I think this is the better
*/
interface ITextualCompletionProps {
title?: string;
subtitle?: string;
description?: string;
className?: string;
"title"?: string;
"subtitle"?: string;
"description"?: string;
"className"?: string;
"aria-selected"?: boolean;
"ref"?: Ref<HTMLDivElement>;
}
export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props, ref) => {
const { title, subtitle, description, className, "aria-selected": ariaSelectedAttribute, ...restProps } = props;
export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element => {
const {
title,
subtitle,
description,
className,
"aria-selected": ariaSelectedAttribute,
ref,
...restProps
} = props;
return (
<div
{...restProps}
@@ -37,13 +47,13 @@ export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
});
};
interface IPillCompletionProps extends ITextualCompletionProps {
children?: React.ReactNode;
}
export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref) => {
export const PillCompletion = (props: IPillCompletionProps): JSX.Element => {
const {
title,
subtitle,
@@ -51,6 +61,7 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
className,
children,
"aria-selected": ariaSelectedAttribute,
ref,
...restProps
} = props;
return (
@@ -67,4 +78,4 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
});
};
+1 -1
View File
@@ -127,7 +127,7 @@ export default class UserProvider extends AutocompleteProvider {
suffix: selection.beginning && range!.start === 0 ? ": " : " ",
href: makeUserPermalink(user.userId),
component: (
<PillCompletion title={displayName} description={description}>
<PillCompletion title={displayName} description={description ?? undefined}>
<MemberAvatar member={user} size="24px" />
</PillCompletion>
),
-4
View File
@@ -6,10 +6,6 @@ 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.
*/
// Event type for room account data and room creation content used to mark rooms as virtual rooms
// (and store the ID of their native room)
export const VIRTUAL_ROOM_EVENT_TYPE = "im.vector.is_virtual_room";
export const JitsiCallMemberEventType = "io.element.video.member";
export interface JitsiCallMemberContent {
@@ -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 PropsWithChildren, useEffect, useState } from "react";
import React, { type PropsWithChildren, useEffect, useState, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
@@ -85,7 +85,7 @@ interface Props {
* A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext}
* to its children.
*/
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): React.JSX.Element {
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): JSX.Element {
const verificationState = useLocalVerificationState(props.client);
return (
<MatrixClientContext.Provider value={props.client}>
+231 -225
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 JSX, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import React, { type JSX, type Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
type ISearchResults,
type IThreadBundledRelationship,
@@ -44,269 +44,275 @@ interface Props {
resizeNotifier: ResizeNotifier;
className: string;
onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void;
ref?: Ref<ScrollPanel>;
}
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => {
const client = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("showHiddenEvents");
const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
// A map from room ID to permalink creator
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
const innerRef = useRef<ScrollPanel>(null);
export const RoomSearchView = ({
term,
scope,
promise,
abortController,
resizeNotifier,
className,
onUpdate,
inProgress,
ref,
}: Props): JSX.Element => {
const client = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("showHiddenEvents");
const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
// A map from room ID to permalink creator
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
const innerRef = useRef<ScrollPanel>(null);
useEffect(() => {
return () => {
permalinkCreators.forEach((pc) => pc.stop());
permalinkCreators.clear();
};
}, [permalinkCreators]);
useEffect(() => {
return () => {
permalinkCreators.forEach((pc) => pc.stop());
permalinkCreators.clear();
};
}, [permalinkCreators]);
const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
onUpdate(true, null, null);
const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
onUpdate(true, null, null);
return searchPromise.then(
async (results): Promise<boolean> => {
debuglog("search complete");
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
return searchPromise.then(
async (results): Promise<boolean> => {
debuglog("search complete");
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
// postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
// postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
let highlights = results.highlights;
if (!highlights.includes(term)) {
highlights = highlights.concat(term);
}
let highlights = results.highlights;
if (!highlights.includes(term)) {
highlights = highlights.concat(term);
}
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function (a, b) {
return b.length - a.length;
});
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function (a, b) {
return b.length - a.length;
});
for (const result of results.results) {
for (const event of result.context.getTimeline()) {
const bundledRelationship =
event.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name,
);
if (!bundledRelationship || event.getThread()) continue;
const room = client.getRoom(event.getRoomId());
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
} else {
room?.createThread(event.getId()!, event, [], true);
}
for (const result of results.results) {
for (const event of result.context.getTimeline()) {
const bundledRelationship = event.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name,
);
if (!bundledRelationship || event.getThread()) continue;
const room = client.getRoom(event.getRoomId());
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
} else {
room?.createThread(event.getId()!, event, [], true);
}
}
}
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
onUpdate(false, results, null);
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
onUpdate(false, results, null);
return false;
},
(error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
},
(error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
logger.error("Search failed", error);
onUpdate(false, null, error);
return false;
},
);
},
[client, term, onUpdate],
);
// Mount & unmount effect
useEffect(() => {
aborted.current = false;
handleSearchResult(promise);
return () => {
aborted.current = true;
abortController?.abort();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// show searching spinner
if (results === null) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
}
logger.error("Search failed", error);
onUpdate(false, null, error);
return false;
},
);
}
},
[client, term, onUpdate],
);
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return false;
}
if (!results.next_batch) {
debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(client, results);
return handleSearchResult(searchPromise);
// Mount & unmount effect
useEffect(() => {
aborted.current = false;
handleSearchResult(promise);
return () => {
aborted.current = true;
abortController?.abort();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ret: JSX.Element[] = [];
// show searching spinner
if (results === null) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
);
}
if (inProgress) {
ret.push(
<li key="search-spinner">
<Spinner />
</li>,
);
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return false;
}
if (!results.next_batch) {
if (!results?.results?.length) {
debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(client, results);
return handleSearchResult(searchPromise);
};
const ret: JSX.Element[] = [];
if (inProgress) {
ret.push(
<li key="search-spinner">
<Spinner />
</li>,
);
}
if (!results.next_batch) {
if (!results?.results?.length) {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
</li>,
);
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
</li>,
);
}
}
const onRef = (e: ScrollPanel | null): void => {
if (typeof ref === "function") {
ref(e);
} else if (!!ref) {
ref.current = e;
}
innerRef.current = e;
};
let lastRoomId: string | undefined;
let mergedTimeline: MatrixEvent[] = [];
let ourEventsIndexes: number[] = [];
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId()!;
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
</li>,
);
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
<li key={mxEv.getId() + "-room"}>
<h2>
{_t("common|room")}: {room.name}
</h2>
</li>,
);
lastRoomId = roomId;
}
}
const onRef = (e: ScrollPanel | null): void => {
if (typeof ref === "function") {
ref(e);
} else if (!!ref) {
ref.current = e;
}
innerRef.current = e;
};
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
let lastRoomId: string | undefined;
let mergedTimeline: MatrixEvent[] = [];
let ourEventsIndexes: number[] = [];
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId()!;
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push(
<li key={mxEv.getId() + "-room"}>
<h2>
{_t("common|room")}: {room.name}
</h2>
</li>,
);
lastRoomId = roomId;
}
}
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
// merging two successive search result if the query is present in both of them
const currentTimeline = result.context.getTimeline();
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
// if this is the first searchResult we merge then add all values of the current searchResult
if (mergedTimeline.length == 0) {
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
mergedTimeline.push(currentTimeline[j]);
}
ourEventsIndexes.push(result.context.getOurEventIndex());
}
// merge the events of the next searchResult
for (let j = 1; j < nextTimeline.length; j++) {
mergedTimeline.push(nextTimeline[j]);
}
// add the index of the matching event of the next searchResult
ourEventsIndexes.push(
ourEventsIndexes[ourEventsIndexes.length - 1] +
results.results[i - 1].context.getOurEventIndex() +
1,
);
continue;
}
// merging two successive search result if the query is present in both of them
const currentTimeline = result.context.getTimeline();
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
// if this is the first searchResult we merge then add all values of the current searchResult
if (mergedTimeline.length == 0) {
mergedTimeline = result.context.getTimeline();
ourEventsIndexes = [];
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
mergedTimeline.push(currentTimeline[j]);
}
ourEventsIndexes.push(result.context.getOurEventIndex());
}
let permalinkCreator = permalinkCreators.get(roomId);
if (!permalinkCreator) {
permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.start();
permalinkCreators.set(roomId, permalinkCreator);
// merge the events of the next searchResult
for (let j = 1; j < nextTimeline.length; j++) {
mergedTimeline.push(nextTimeline[j]);
}
ret.push(
<SearchResultTile
key={mxEv.getId()}
timeline={mergedTimeline}
ourEventsIndexes={ourEventsIndexes}
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
/>,
// add the index of the matching event of the next searchResult
ourEventsIndexes.push(
ourEventsIndexes[ourEventsIndexes.length - 1] + results.results[i - 1].context.getOurEventIndex() + 1,
);
ourEventsIndexes = [];
mergedTimeline = [];
continue;
}
return (
<ScrollPanel
ref={onRef}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ret}
</ScrollPanel>
if (mergedTimeline.length == 0) {
mergedTimeline = result.context.getTimeline();
ourEventsIndexes = [];
ourEventsIndexes.push(result.context.getOurEventIndex());
}
let permalinkCreator = permalinkCreators.get(roomId);
if (!permalinkCreator) {
permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.start();
permalinkCreators.set(roomId, permalinkCreator);
}
ret.push(
<SearchResultTile
key={mxEv.getId()}
timeline={mergedTimeline}
ourEventsIndexes={ourEventsIndexes}
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
/>,
);
},
);
ourEventsIndexes = [];
mergedTimeline = [];
}
return (
<ScrollPanel
ref={onRef}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ret}
</ScrollPanel>
);
};
-12
View File
@@ -120,8 +120,6 @@ import { isVideoRoom } from "../../utils/video-rooms";
import { SDKContext } from "../../contexts/SDKContext";
import { RoomSearchView } from "./RoomSearchView";
import eventSearch, { type SearchInfo, SearchScope } from "../../Searching";
import VoipUserMapper from "../../VoipUserMapper";
import { isCallEvent } from "./LegacyCallEventGrouper";
import { WidgetType } from "../../widgets/WidgetType";
import WidgetUtils from "../../utils/WidgetUtils";
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
@@ -165,7 +163,6 @@ export { MainSplitContentType };
export interface IRoomState {
room?: Room;
virtualRoom?: Room;
roomId?: string;
roomAlias?: string;
roomLoading: boolean;
@@ -1344,12 +1341,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return this.messagePanel.canResetTimeline();
};
private loadVirtualRoom = async (room?: Room): Promise<void> => {
const virtualRoom = room?.roomId && (await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(room?.roomId));
this.setState({ virtualRoom: virtualRoom || undefined });
};
// called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room): void => {
@@ -1362,7 +1353,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.calculateRecommendedVersion(room);
this.updatePermissions(room);
this.checkWidgets(room);
this.loadVirtualRoom(room);
this.updateRoomEncrypted(room);
if (
@@ -2444,8 +2434,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<TimelinePanel
ref={this.gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
overlayTimelineSetFilter={isCallEvent}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
+54 -203
View File
@@ -30,7 +30,7 @@ import {
ThreadEvent,
ReceiptType,
} from "matrix-js-sdk/src/matrix";
import { debounce, findLastIndex } from "lodash";
import { debounce } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
@@ -73,25 +73,12 @@ const debuglog = (...args: any[]): void => {
}
};
const overlaysBefore = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
overlayEvent.localTimestamp < mainEvent.localTimestamp;
const overlaysAfter = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
overlayEvent.localTimestamp >= mainEvent.localTimestamp;
interface IProps {
// The js-sdk EventTimelineSet object for the timeline sequence we are
// representing. This may or may not have a room, depending on what it's
// a timeline representing. If it has a room, we maintain RRs etc for
// that room.
timelineSet: EventTimelineSet;
// overlay events from a second timelineset on the main timeline
// added to support virtual rooms
// events from the overlay timeline set will be added by localTimestamp
// into the main timeline
overlayTimelineSet?: EventTimelineSet;
// filter events from overlay timeline
overlayTimelineSetFilter?: (event: MatrixEvent) => boolean;
showReadReceipts?: boolean;
// Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts?: boolean;
@@ -251,7 +238,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readonly messagePanel = createRef<MessagePanel>();
private dispatcherRef?: string;
private timelineWindow?: TimelineWindow;
private overlayTimelineWindow?: TimelineWindow;
private unmounted = false;
private readReceiptActivityTimer: Timer | null = null;
private readMarkerActivityTimer: Timer | null = null;
@@ -349,16 +335,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
const differentEventId = prevProps.eventId != this.props.eventId;
const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId;
const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView;
const differentOverlayTimeline = prevProps.overlayTimelineSet !== this.props.overlayTimelineSet;
if (differentEventId || differentHighlightedEventId || differentAvoidJump) {
logger.log(
`TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` +
`scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`,
);
this.initTimeline(this.props);
} else if (differentOverlayTimeline) {
logger.log(`TimelinePanel updating overlay timeline.`);
this.initTimeline(this.props);
}
}
@@ -509,24 +491,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
// this particular event should be the first or last to be unpaginated.
const eventId = scrollToken;
// The event in question could belong to either the main timeline or
// overlay timeline; let's check both
const mainEvents = this.timelineWindow!.getEvents();
const overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
let marker = mainEvents.findIndex((ev) => ev.getId() === eventId);
let overlayMarker: number;
if (marker === -1) {
// The event must be from the overlay timeline instead
overlayMarker = overlayEvents.findIndex((ev) => ev.getId() === eventId);
marker = backwards
? findLastIndex(mainEvents, (ev) => overlaysAfter(overlayEvents[overlayMarker], ev))
: mainEvents.findIndex((ev) => overlaysBefore(overlayEvents[overlayMarker], ev));
} else {
overlayMarker = backwards
? findLastIndex(overlayEvents, (ev) => overlaysBefore(ev, mainEvents[marker]))
: overlayEvents.findIndex((ev) => overlaysAfter(ev, mainEvents[marker]));
}
const marker = mainEvents.findIndex((ev) => ev.getId() === eventId);
// The number of events to unpaginate from the main timeline
let count: number;
@@ -536,24 +503,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
count = backwards ? marker + 1 : mainEvents.length - marker;
}
// The number of events to unpaginate from the overlay timeline
let overlayCount: number;
if (overlayMarker === -1) {
overlayCount = 0;
} else {
overlayCount = backwards ? overlayMarker + 1 : overlayEvents.length - overlayMarker;
}
if (count > 0) {
debuglog("Unpaginating", count, "in direction", dir);
this.timelineWindow!.unpaginate(count, backwards);
}
if (overlayCount > 0) {
debuglog("Unpaginating", count, "from overlay timeline in direction", dir);
this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
}
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
this.setState({
@@ -610,10 +564,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
return false;
}
if (this.overlayTimelineWindow) {
await this.extendOverlayWindowToCoverMainWindow();
}
debuglog("paginate complete backwards:" + backwards + "; success:" + r);
const { events, liveEvents } = this.getEvents();
@@ -705,10 +655,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
data: IRoomTimelineData,
): void => {
// ignore events for other timeline sets
if (
data.timeline.getTimelineSet() !== this.props.timelineSet &&
data.timeline.getTimelineSet() !== this.props.overlayTimelineSet
) {
if (data.timeline.getTimelineSet() !== this.props.timelineSet) {
return;
}
@@ -748,69 +695,60 @@ class TimelinePanel extends React.Component<IProps, IState> {
// timeline window.
//
// see https://github.com/vector-im/vector-web/issues/1035
this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false)
.then(() => {
if (this.overlayTimelineWindow) {
return this.overlayTimelineWindow.paginate(EventTimeline.FORWARDS, 1, false);
this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
if (this.unmounted) {
return;
}
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
events,
liveEvents,
};
let callRMUpdated = false;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.safeGet().credentials.userId;
callRMUpdated = false;
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastLiveEvent.getId();
callRMUpdated = true;
}
})
.then(() => {
if (this.unmounted) {
return;
}
this.setState(updatedState as IState, () => {
this.messagePanel.current?.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated?.();
}
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
events,
liveEvents,
};
let callRMUpdated = false;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.safeGet().credentials.userId;
callRMUpdated = false;
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastLiveEvent.getId();
callRMUpdated = true;
}
}
this.setState(updatedState as IState, () => {
this.messagePanel.current?.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated?.();
}
});
});
});
};
private hasTimelineSetFor(roomId: string | undefined): boolean {
return (
(roomId !== undefined && roomId === this.props.timelineSet.room?.roomId) ||
roomId === this.props.overlayTimelineSet?.room?.roomId
);
return roomId !== undefined && roomId === this.props.timelineSet.room?.roomId;
}
private onRoomTimelineReset = (room: Room | undefined, timelineSet: EventTimelineSet): void => {
if (timelineSet !== this.props.timelineSet && timelineSet !== this.props.overlayTimelineSet) return;
if (timelineSet !== this.props.timelineSet) return;
if (this.canResetTimeline()) {
this.loadTimeline();
@@ -1475,48 +1413,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
});
}
private async extendOverlayWindowToCoverMainWindow(): Promise<void> {
const mainWindow = this.timelineWindow!;
const overlayWindow = this.overlayTimelineWindow!;
const mainEvents = mainWindow.getEvents();
if (mainEvents.length > 0) {
let paginationRequests: Promise<unknown>[];
// Keep paginating until the main window is covered
do {
paginationRequests = [];
const overlayEvents = overlayWindow.getEvents();
if (
overlayWindow.canPaginate(EventTimeline.BACKWARDS) &&
(overlayEvents.length === 0 ||
overlaysAfter(overlayEvents[0], mainEvents[0]) ||
!mainWindow.canPaginate(EventTimeline.BACKWARDS))
) {
// Paginating backwards could reveal more events to be overlaid in the main window
paginationRequests.push(
this.onPaginationRequest(overlayWindow, EventTimeline.BACKWARDS, PAGINATE_SIZE),
);
}
if (
overlayWindow.canPaginate(EventTimeline.FORWARDS) &&
(overlayEvents.length === 0 ||
overlaysBefore(overlayEvents.at(-1)!, mainEvents.at(-1)!) ||
!mainWindow.canPaginate(EventTimeline.FORWARDS))
) {
// Paginating forwards could reveal more events to be overlaid in the main window
paginationRequests.push(
this.onPaginationRequest(overlayWindow, EventTimeline.FORWARDS, PAGINATE_SIZE),
);
}
await Promise.all(paginationRequests);
} while (paginationRequests.length > 0);
}
}
/**
* (re)-load the event timeline, and initialise the scroll state, centered
* around the given event.
@@ -1536,9 +1432,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void {
const cli = MatrixClientPeg.safeGet();
this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap });
this.overlayTimelineWindow = this.props.overlayTimelineSet
? new TimelineWindow(cli, this.props.overlayTimelineSet, { windowLimit: this.props.timelineCap })
: undefined;
const onLoaded = (): void => {
if (this.unmounted) return;
@@ -1554,14 +1447,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState(
{
canBackPaginate:
(this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ||
this.overlayTimelineWindow?.canPaginate(EventTimeline.BACKWARDS)) ??
false,
canForwardPaginate:
(this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ||
this.overlayTimelineWindow?.canPaginate(EventTimeline.FORWARDS)) ??
false,
canBackPaginate: this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ?? false,
canForwardPaginate: this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ?? false,
timelineLoading: false,
},
() => {
@@ -1636,7 +1523,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// This is a hot-path optimization by skipping a promise tick
// by repeating a no-op sync branch in
// TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
if (this.props.timelineSet.getTimelineForEvent(eventId) && !this.overlayTimelineWindow) {
if (this.props.timelineSet.getTimelineForEvent(eventId)) {
// if we've got an eventId, and the timeline exists, we can skip
// the promise tick.
this.timelineWindow.load(eventId, INITIAL_SIZE);
@@ -1645,14 +1532,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
return;
}
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => {
if (this.overlayTimelineWindow) {
// TODO: use timestampToEvent to load the overlay timeline
// with more correct position when main TL eventId is truthy
await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE);
await this.extendOverlayWindowToCoverMainWindow();
}
});
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
this.buildLegacyCallEventGroupers();
this.setState({
events: [],
@@ -1683,38 +1563,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.reloadEvents();
}
// get the list of events from the timeline windows and the pending event list
// get the list of events from the timeline window and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents"> {
const mainEvents = this.timelineWindow!.getEvents();
let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
if (this.props.overlayTimelineSetFilter !== undefined) {
overlayEvents = overlayEvents.filter(this.props.overlayTimelineSetFilter);
}
// maintain the main timeline event order as returned from the HS
// merge overlay events at approximately the right position based on local timestamp
const events = overlayEvents.reduce(
(acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
// find the first main tl event with a later timestamp
const index = acc.findIndex((event) => overlaysBefore(overlayEvent, event));
// insert overlay event into timeline at approximately the right place
// if it's beyond the edge of the main window, hide it so that expanding
// the main window doesn't cause new events to pop in and change its position
if (index === -1) {
if (!this.timelineWindow!.canPaginate(EventTimeline.FORWARDS)) {
acc.push(overlayEvent);
}
} else if (index === 0) {
if (!this.timelineWindow!.canPaginate(EventTimeline.BACKWARDS)) {
acc.unshift(overlayEvent);
}
} else {
acc.splice(index, 0, overlayEvent);
}
return acc;
},
[...mainEvents],
);
const events = this.timelineWindow!.getEvents();
// We want the last event to be decrypted first
const client = MatrixClientPeg.safeGet();
@@ -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 from "react";
import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
@@ -43,13 +43,13 @@ type MigrationState = {
/**
* The view that is displayed after we have logged in, before the first /sync is completed.
*/
export function LoginSplashView(props: Props): React.JSX.Element {
export function LoginSplashView(props: Props): JSX.Element {
const migrationState = useTypedEventEmitterState(
props.matrixClient,
CryptoEvent.LegacyCryptoStoreMigrationProgress,
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
);
let errorBox: React.JSX.Element | undefined;
let errorBox: JSX.Element | undefined;
if (props.syncError) {
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
}
@@ -5,32 +5,11 @@
* 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 { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } 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;
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
export interface RoomAvatarViewState {
/**
@@ -50,7 +29,7 @@ export interface RoomAvatarViewState {
* 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;
presence: Presence | null;
}
/**
@@ -59,7 +38,8 @@ export interface RoomAvatarViewState {
*/
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
const presence = useDMPresence(room);
const roomMember = useDmMember(room);
const presence = usePresence(room, roomMember);
const isPublic = useIsPublic(room);
const hasDecoration = isPublic || isVideoRoom || presence !== null;
@@ -97,48 +77,3 @@ function useIsPublic(room: Room): boolean {
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;
}
@@ -0,0 +1,84 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type SyntheticEvent, useState } from "react";
import { EventType, type Room, type ContentHelpers } from "matrix-js-sdk/src/matrix";
import { type Optional } from "matrix-events-sdk";
import { useRoomState } from "../../../hooks/useRoomState";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { onRoomTopicLinkClick } from "../../views/elements/RoomTopic";
import { useTopic } from "../../../hooks/room/useTopic";
export interface RoomTopicState {
/**
* The topic of the room, the value is taken from the room state
*/
topic: Optional<ContentHelpers.TopicState>;
/**
* Whether the topic is expanded or not
*/
expanded: boolean;
/**
* Whether the user have the permission to edit the topic
*/
canEditTopic: boolean;
/**
* The callback when the edit button is clicked
*/
onEditClick: (e: SyntheticEvent) => void;
/**
* When the expand button is clicked, it changes expanded state
*/
onExpandedClick: (ev: SyntheticEvent) => void;
/**
* The callback when the topic link is clicked
*/
onTopicLinkClick: React.MouseEventHandler<HTMLElement>;
}
/**
* The view model for the room topic used in the RoomSummaryCard
* @param room - the room to get the topic from
* @returns the room topic state
*/
export function useRoomTopicViewModel(room: Room): RoomTopicState {
const [expanded, setExpanded] = useState(true);
const topic = useTopic(room);
const canEditTopic = useRoomState(room, (state) =>
state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()),
);
const onEditClick = (e: SyntheticEvent): void => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
const onExpandedClick = (e: SyntheticEvent): void => {
e.preventDefault();
e.stopPropagation();
setExpanded((_expanded) => !_expanded);
};
const onTopicLinkClick = (e: React.MouseEvent): void => {
if (e.target instanceof HTMLAnchorElement) {
onRoomTopicLinkClick(e);
return;
}
};
return {
topic,
expanded,
canEditTopic,
onEditClick,
onExpandedClick,
onTopicLinkClick,
};
}
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
@@ -16,12 +16,20 @@ 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 { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
export interface RoomListItemViewState {
/**
* The name of the room.
*/
name: string;
/**
* Whether the hover menu should be shown.
*/
@@ -55,6 +63,15 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Pre-rendered and translated preview for the latest message in the room, or undefined
* if no preview should be shown.
*/
messagePreview: string | undefined;
/**
* Whether the notification decoration should be shown.
*/
showNotificationDecoration: boolean;
}
/**
@@ -65,12 +82,37 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);
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;
const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
getNotificationValues(notificationState),
);
useEffect(() => {
setA11yLabel(getA11yLabel(name, notificationState));
}, [name, notificationState]);
// Listen to changes in the notification state and update the values
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
setA11yLabel(getA11yLabel(name, notificationState));
setNotificationValues(getNotificationValues(notificationState));
});
// If the notification reference change due to room change, update the values
useEffect(() => {
setNotificationValues(getNotificationValues(notificationState));
}, [notificationState]);
// We don't want to show the hover menu if
// - there is an invitation for this room
// - the user doesn't have access to both notification and more options menus
const showHoverMenu =
!invited &&
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
const messagePreview = useRoomMessagePreview(room);
// Video room
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
@@ -80,6 +122,8 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const hasParticipantInCall = useParticipantCount(call) > 0;
const callConnectionState = call ? connectionState : null;
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
// Actions
const openRoom = useCallback((): void => {
@@ -91,6 +135,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
}, [room]);
return {
name,
notificationState,
showHoverMenu,
openRoom,
@@ -99,34 +144,93 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isVideoRoom,
callConnectionState,
hasParticipantInCall,
messagePreview,
showNotificationDecoration,
};
}
/**
* Calculate the values from the notification state
* @param notificationState
*/
function getNotificationValues(notificationState: RoomNotificationState): {
computeA11yLabel: (name: string) => string;
isBold: boolean;
invited: boolean;
hasVisibleNotification: boolean;
} {
const invited = notificationState.invited;
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;
return {
computeA11yLabel,
isBold,
invited,
hasVisibleNotification,
};
}
/**
* Get the a11y label for the room list item
* @param room
* @param roomName
* @param notificationState
*/
function getA11yLabel(room: Room, notificationState: RoomNotificationState): string {
if (notificationState.isUnsetMessage) {
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
if (notificationState.isUnsentMessage) {
return _t("a11y|room_messsage_not_sent", {
roomName: room.name,
roomName,
});
} else if (notificationState.invited) {
return _t("a11y|room_n_unread_invite", {
roomName: room.name,
roomName,
});
} else if (notificationState.isMention) {
return _t("a11y|room_n_unread_messages_mentions", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else if (notificationState.hasUnreadCount) {
return _t("a11y|room_n_unread_messages", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else {
return _t("room_list|room|open_room", { roomName: room.name });
return _t("room_list|room|open_room", { roomName });
}
}
function useRoomMessagePreview(room: Room): string | undefined {
const { shouldShowMessagePreview } = useMessagePreviewToggle();
const [previewText, setPreviewText] = useState<string | undefined>(undefined);
const updatePreview = useCallback(async () => {
if (!shouldShowMessagePreview) {
setPreviewText(undefined);
return;
}
const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
// For the tag, we only care about whether the room is a DM or not as we don't show
// display names in previewsd for DMs, so anything else we just say is 'untagged'
// (even though it could actually be have other tags: we don't care about them).
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(
room,
roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged,
);
if (messagePreview) setPreviewText(messagePreview.text);
}, [room, shouldShowMessagePreview]);
// MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
updatePreview();
});
useEffect(() => {
updatePreview();
}, [updatePreview]);
return previewText;
}
@@ -19,6 +19,7 @@ import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation";
export interface RoomListViewState {
/**
@@ -106,6 +107,8 @@ export function useRoomListViewModel(): RoomListViewState {
} = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
useRoomListNavigation(rooms);
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useState } from "react";
import { useCallback } from "react";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import { useSettingValue } from "../../../hooks/useSettings";
interface MessagePreviewToggleState {
shouldShowMessagePreview: boolean;
@@ -20,17 +21,12 @@ interface MessagePreviewToggleState {
* - Provides a function to toggle message previews.
*/
export function useMessagePreviewToggle(): MessagePreviewToggleState {
const [shouldShowMessagePreview, setShouldShowMessagePreview] = useState(() =>
SettingsStore.getValue("RoomList.showMessagePreview"),
);
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
const toggleMessagePreview = useCallback((): void => {
setShouldShowMessagePreview((current) => {
const toggled = !current;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
return toggled;
});
}, []);
const toggled = !shouldShowMessagePreview;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
}, [shouldShowMessagePreview]);
return { toggleMessagePreview, shouldShowMessagePreview };
}
@@ -0,0 +1,56 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { Action } from "../../../dispatcher/actions";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
/**
* Hook to navigate the room list using keyboard shortcuts.
* It listens to the ViewRoomDelta action and updates the room list accordingly.
* @param rooms
*/
export function useRoomListNavigation(rooms: Room[]): void {
useDispatcher(dispatcher, (payload) => {
if (payload.action !== Action.ViewRoomDelta) return;
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!roomId) return;
const { delta, unread } = payload as ViewRoomDeltaPayload;
const filteredRooms = unread
? // Filter the rooms to only include unread ones and the active room
rooms.filter((room) => {
const state = RoomNotificationStateStore.instance.getRoomState(room);
return room.roomId === roomId || state.isUnread;
})
: rooms;
const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId);
if (currentIndex === -1) return;
// Get the next/previous new room according to the delta
// Use slice to loop on the list
// If delta is -1 at the start of the list, it will go to the end
// If delta is 1 at the end of the list, it will go to the start
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
if (!newRoom) return;
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: newRoom.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
});
}
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
@@ -13,6 +13,7 @@ import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk";
import SpaceStore from "../../../stores/spaces/SpaceStore";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
@@ -90,8 +91,10 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
roomsWithStickyRoom: rooms,
});
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
const updateRoomsAndIndex = useCallback(
(newRoomId?: string, isRoomChange: boolean = false) => {
(newRoomId: string | null, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
@@ -110,7 +113,21 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
updateRoomsAndIndex();
let newRoomId: string | null = null;
let isRoomChange = false;
const newSpace = SpaceStore.instance.activeSpace;
if (currentSpaceRef.current !== newSpace) {
/*
If the space has changed, we check if we can immediately set the active
index to the last opened room in that space. Otherwise, we might see a
flicker because of the delay between the space change event and
active room change dispatch.
*/
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace);
isRoomChange = true;
currentSpaceRef.current = newSpace;
}
updateRoomsAndIndex(newRoomId, isRoomChange);
}, [rooms, updateRoomsAndIndex]);
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
+5 -3
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 AriaRole, forwardRef, useCallback, useContext, useEffect, useState } from "react";
import React, { type AriaRole, type JSX, type Ref, useCallback, useContext, useEffect, useState } from "react";
import classNames from "classnames";
import { ClientEvent, type SyncState } from "matrix-js-sdk/src/matrix";
import { Avatar } from "@vector-im/compound-web";
@@ -34,6 +34,7 @@ interface IProps {
tabIndex?: number;
altText?: string;
role?: AriaRole;
ref?: Ref<HTMLElement>;
}
const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => {
@@ -87,7 +88,7 @@ const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [
return [imageUrl, onError];
};
const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
const BaseAvatar = (props: IProps): JSX.Element => {
const {
name,
idName,
@@ -99,6 +100,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
className,
type = "round",
altText = _t("common|avatar"),
ref,
...otherProps
} = props;
@@ -134,7 +136,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
data-testid="avatar-img"
/>
);
});
};
export default BaseAvatar;
export type BaseAvatarType = React.FC<IProps>;
+13 -16
View File
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, forwardRef, type ReactNode, type Ref, useContext } from "react";
import React, { type JSX, type ReactNode, type Ref, useContext } from "react";
import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix";
import dis from "../../../dispatcher/dispatcher";
@@ -33,21 +33,20 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
hideTitle?: boolean;
children?: ReactNode;
ref?: Ref<HTMLElement>;
}
function MemberAvatar(
{
size,
resizeMethod = "crop",
viewUserOnClick,
forceHistorical,
fallbackUserId,
hideTitle,
member: propsMember,
...props
}: IProps,
ref: Ref<HTMLElement>,
): JSX.Element {
export default function MemberAvatar({
size,
resizeMethod = "crop",
viewUserOnClick,
forceHistorical,
fallbackUserId,
hideTitle,
member: propsMember,
ref,
...props
}: IProps): JSX.Element {
const cli = useContext(MatrixClientContext);
const card = useContext(CardContext);
@@ -101,5 +100,3 @@ function MemberAvatar(
/>
);
}
export default forwardRef(MemberAvatar);
+3 -2
View File
@@ -20,6 +20,7 @@ import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
import { MediaPreviewValue } from "../../../@types/media_preview";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
// Room may be left unset here, but if it is,
@@ -40,7 +41,8 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
const roomIdName = useRoomIdName(room, oobData);
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
const showAvatarsOnInvites =
useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On;
const onRoomAvatarClick = useCallback(() => {
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
@@ -63,7 +65,6 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
// parseInt ignores suffixes.
const sizeInt = parseInt(size, 10);
let oobAvatar: string | null = null;
if (oobData?.avatarUrl) {
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
}
@@ -15,8 +15,9 @@ import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presenc
import classNames from "classnames";
import RoomAvatar from "./RoomAvatar";
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
import { _t } from "../../../languageHandler";
import { Presence } from "./WithPresenceIndicator";
interface RoomAvatarViewProps {
/**
@@ -83,7 +84,7 @@ type PresenceDecorationProps = {
*/
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
switch (presence) {
case "online":
case Presence.Online:
return (
<OnlineOrUnavailableIcon
width="8px"
@@ -93,7 +94,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
aria-label={_t("presence|online")}
/>
);
case "unavailable":
case Presence.Away:
return (
<OnlineOrUnavailableIcon
width="8px"
@@ -103,7 +104,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
aria-label={_t("presence|away")}
/>
);
case "offline":
case Presence.Offline:
return (
<OfflineIcon
width="8px"
@@ -113,7 +114,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
aria-label={_t("presence|offline")}
/>
);
case "busy":
case Presence.Busy:
return (
<BusyIcon
width="8px"
@@ -26,7 +26,7 @@ interface Props {
children: ReactNode;
}
enum Presence {
export enum Presence {
// Note: the names here are used in CSS class names
Online = "ONLINE",
Away = "AWAY",
@@ -86,7 +86,7 @@ function getPresence(member: RoomMember | null): Presence | null {
return null;
}
const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
export const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
const [presence, setPresence] = useState<Presence | null>(getPresence(member));
const updatePresence = (): void => {
setPresence(getPresence(member));

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