Compare commits

...

79 Commits

Author SHA1 Message Date
Hugh Nimmo-Smith
4fc85bda34 Disable test for 33de20f22e 2024-12-02 17:33:12 +00:00
Timo
a21acb1962 backgroundBlur squashed 2024-12-02 17:31:56 +00:00
Hugh Nimmo-Smith
ab0d7dea33 Merge branch 'livekit' into hughns/preview-2 2024-12-02 17:30:49 +00:00
Will Hunt
cc260811e2 Add a lower limit to duplicate tiles. (#2854)
* Add a limit to duplicate tiles.

* missed a change
2024-12-02 17:15:33 +00:00
Hugh Nimmo-Smith
a37c3bfb48 Show hostname in developer settings (#2857)
Also refactored developer settings labels into own locale section
2024-12-02 15:43:19 +00:00
Will Hunt
ea6f2952af Add sound effect for call joined / left (#2794)
* Add renderer for call joined / left

* lint

* Add new sounds

* Updates sounds in renderer

* lint

* move import

* pad sounds with silence

* lint

* tidy

* Drop autoplay since we now subscribe correctly.

* Comitting test files I am going to be going to lunch so will tidy up in a little while.

* finish up tests

* Add support for multiple channels per sound.

* lint
2024-12-02 15:16:58 +00:00
renovate[bot]
e142493380 Update all non-major dependencies (#2852)
* Update all non-major dependencies

* prettier

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Timo <toger5@hotmail.de>
2024-12-02 12:10:20 +01:00
fkwp
34cc50b84d Merge pull request #2851 from element-hq/toger5/light-raise-hand
Use light background
2024-11-29 18:22:38 +01:00
Timo
84b2dc8680 Use light background
- the icons and the font is dark so the bg should not be darkened. But making it lighter is making it easier to read.
2024-11-29 17:48:18 +01:00
Timo
33de20f22e Don't care about skip lobby flag and always use the config.json default audio setup 2024-11-29 17:13:22 +01:00
Timo
e12f4fada0 Bump js-sdk (#2850)
-  This fixes, that clients dont resend keys if other join right after someone else just left.
2024-11-29 11:58:36 +01:00
fkwp
8cd681c6cc Merge pull request #2849 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.7.0
2024-11-29 11:51:05 +01:00
renovate[bot]
82360c674c Update dependency livekit-client to v2.7.0 2024-11-29 10:44:54 +00:00
Hugh Nimmo-Smith
0c898001a0 update delay event settings to match dev 2024-11-28 17:40:22 +00:00
Hugh Nimmo-Smith
c0f05717c3 Mitigation for gappy/limited sync responses in SPA mode 2024-11-28 17:40:22 +00:00
Hugh Nimmo-Smith
32ccfe8813 Fix Cache-Control header for docker images (#2847) 2024-11-28 17:40:06 +00:00
Timo
65e962bf1b Fix wrong hand raise margins in pip tile. (#2840) 2024-11-28 17:24:24 +00:00
Timo
28da8c48da Mute state improvements including muting if skipLobby=true in SPA (Based on #2834) (#2846) 2024-11-28 18:05:12 +01:00
Hugh Nimmo-Smith
f7c7f41afd Upgrade js-sdk to develop with MSC4222 state_after support (#2841)
Diff 2210255d6f...8fc77c595a
2024-11-27 20:43:56 +00:00
Hugh Nimmo-Smith
cf174261c9 Some simple initial tests for MediaView (#2813)
* Some simple tests for MediaView

* Use jest-dom assertions

* Add tests for videoMuted

* Add test case for placeholder video track

* Revert yarn.lock changes

* More revert

* Deduplicate test case logic and improve names

* Use role and label
2024-11-25 20:22:02 +00:00
renovate[bot]
e36029c9c0 Update all non-major dependencies (#2835)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-25 18:46:30 +01:00
Hugh Nimmo-Smith
0469d8ef56 Add explicit code split on matrix-sdk-crypto-wasm to allow caching between deploys (#2823)
* Add explicit code split on matrix-sdk-crypto-wasm to allow caching between deploys

* Comment on removing once https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/167 lands
2024-11-23 09:00:43 +00:00
Hugh Nimmo-Smith
fc8da6ef58 Use hot marbles for speaker tests (#2815)
* Refactor the speaker detection logic into observeSpeaker and add tests

@robintown the tests pass, but some of the values were off by 1ms from what I was expecting. Please can you sanity check them?

* Extra test cases and clean up

* Make distinctUntilChanged part of the observable itself

* More suggestions from code review

* Use hot marbles for speaker tests

This was originally part of https://github.com/element-hq/element-call/pull/2810

* Only feed speaking mocks to observables that ask for IsSpeakingChanged
2024-11-23 08:59:33 +00:00
Hugh Nimmo-Smith
4e1b4fae19 Refactor the speaker detection logic into observeSpeaker and add tests (#2814)
* Refactor the speaker detection logic into observeSpeaker and add tests

@robintown the tests pass, but some of the values were off by 1ms from what I was expecting. Please can you sanity check them?

* Extra test cases and clean up

* Make distinctUntilChanged part of the observable itself

* More suggestions from code review
2024-11-23 08:59:15 +00:00
Hugh Nimmo-Smith
5c18868aa4 The preload URL param shouldn't be used in SPA mode, so ignore it if not in widget (#2832)
* Refactor URL parameters into table

This is for readability and ahead of some possible changes

* Whitespace

* Lint

* The preload URL param shouldn't be used in SPA mode, so ignore it
2024-11-23 08:55:03 +00:00
Hugh Nimmo-Smith
9ce16b68e9 Refactor URL parameters into table (#2827)
* Refactor URL parameters into table

This is for readability and ahead of some possible changes

* Whitespace

* Lint

* Cleanup and correct fontScale
2024-11-23 08:51:27 +00:00
Robin
ad9114a6e4 Merge pull request #2833 from robintown/depluralize
Fix a singular string using the plural form
2024-11-22 13:58:12 -05:00
Robin
44e1c136df Fix a singular string using the plural form 2024-11-22 13:45:39 -05:00
Robin
1bbbd519c7 Merge pull request #2831 from robintown/shortcut-modifiers
Don't trigger keyboard shortcuts if modifiers are held
2024-11-22 13:25:24 -05:00
Robin
51e4a3b14b Don't trigger keyboard shortcuts if modifiers are held
None of these keyboard shortcuts expect modifier keys, so they should in fact expect the absence of modifiers.
2024-11-22 13:17:05 -05:00
Hugh Nimmo-Smith
8da38d173a Remove .well-known files from build process (#2830)
These don't get included in the docker images anyhow (due to them being excluded by defautl by https://github.com/actions/upload-artifact#uploading-hidden-files).

We need to inject the right values into our managed deployments elsewhere.
2024-11-22 18:12:29 +00:00
Robin
13887a8943 Merge pull request #2817 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v7.4.0
2024-11-22 09:31:35 -05:00
fkwp
03721f078b Merge pull request #2819 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.6.9
2024-11-22 12:43:25 +01:00
renovate[bot]
d146bedf05 Update dependency @livekit/components-react to v2.6.9 2024-11-22 11:08:25 +00:00
fkwp
63f595f958 Merge pull request #2820 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.6.3
2024-11-22 12:07:10 +01:00
renovate[bot]
279912d951 Update dependency livekit-client to v2.6.3 2024-11-22 09:14:56 +00:00
renovate[bot]
94e5d032a1 Update dependency @vector-im/compound-web to v7.4.0 2024-11-22 02:01:23 +00:00
Robin
720c400e5f Merge pull request #2802 from robintown/device-fall-back
Don't fall back as eagerly to unselected devices
2024-11-21 11:39:10 -05:00
Robin
0718774cef Clarify how preferred devices work 2024-11-21 11:32:15 -05:00
Robin
a2b452c1d8 Don't override the camera without pressing "switch camera" 2024-11-21 11:24:45 -05:00
Robin
465a784345 Merge branch 'livekit' into device-fall-back 2024-11-21 11:04:36 -05:00
Robin
8de96878c0 Merge pull request #2803 from robintown/device-radio
Replace device dropdowns with radio buttons
2024-11-21 10:47:44 -05:00
Hugh Nimmo-Smith
b7b9771577 Use hot test input marbles instead of cold (#2810)
* Use hot test input marbles instead of cold

These will be needed for https://github.com/element-hq/element-call/pull/2701

* Revert for "spotlight speakers swap places" test
2024-11-21 11:03:16 +00:00
Hugh Nimmo-Smith
3885eefa4c Disambiguate between types of "member" (#2807)
We have Matrix room members and MatrixRTC session memberships. Livekit also has rooms.

So, this attempts to make it more obvious as to what type you are referring to.
2024-11-21 11:02:05 +00:00
Hugh Nimmo-Smith
9176e06195 Some tsdoc and explicit typing (#2809)
* Some tsdoc and explicit typing

Pulled out of https://github.com/element-hq/element-call/pull/2701

* Extra typing
2024-11-21 11:01:43 +00:00
Robin
380fa67f57 Merge pull request #2811 from robintown/revert-memory-store
Revert memory store and update matrix-js-sdk
2024-11-20 10:42:41 -05:00
Robin
8830bd74cb Update matrix-js-sdk
Return matrix-js-sdk to the develop branch.
2024-11-20 10:40:08 -05:00
Robin
3ab9bb72e2 Revert "Init client with a memory store (for MSC4222) (#2764)"
This reverts commit 83fa9661cf. We won't need this hack once we're no longer using a matrix-js-sdk branch with MSC4222 support.
2024-11-20 10:22:42 -05:00
Robin
38085baab8 Replace device dropdowns with radio buttons
This is closer to what the designs actually want device settings to look like, and it avoids the visual glitch in which the dropdown would render underneath the slider.
2024-11-20 09:44:32 -05:00
Hugh Nimmo-Smith
8891a9a70e Make it clearer what are inputs and what are expected outputs in test cases (#2808)
Rationale: try and make the marbles section of each test case more readable
2024-11-20 14:44:23 +00:00
Hugh Nimmo-Smith
826d0ee40d Rxjs subjects should not be exposed (#2805)
This rule will encourage us to do the right thing and better abstract.

See https://github.com/cartant/eslint-plugin-rxjs/blob/main/docs/rules/no-exposed-subjects.md
2024-11-20 10:32:21 +00:00
Hugh Nimmo-Smith
fbc2cd3e97 Remove redundant distinctUntilChanged (#2804)
Because this.scope.state() does this for us
2024-11-20 10:31:36 +00:00
Robin
770730ba6c Don't fall back as eagerly to unselected devices
Somewhere around version 131 or 132, Firefox has started being more paranoid about media device fingerprinting, and will not even give you the IDs of available devices until you've requested a media stream. Instead you only get a single audio input and video input each with the empty string as their device ID, representing the system's default device. We can recognize this case and avoid resetting the device selection.
2024-11-19 13:26:40 -05:00
Will Hunt
b3ceb5300c Add keyboard shortcuts for raised hand / reactions (#2784)
* Add support for reactions / raised-hands via keyboard shortcuts.

* Add tests

* Fixup shortcuts

* update snapshotr

* fix type

* keyshortcuts

* remove mistakenly commited file

* fix label logic

* Add renderer for call joined / left

* Use caption

* lint

* remove unexpected file

* remove other unexpected change

* Remove other other unexpected change.
2024-11-19 16:57:57 +00:00
ElementRobot
ffbc48fe86 Merge pull request #2801 from element-hq/actions/localazy-download
Localazy Download
2024-11-19 16:00:11 +00:00
fkwp
4655da3c35 Translations updates 2024-11-19 15:58:53 +00:00
Robin
585998cb87 Merge pull request #2800 from robintown/reactions-button
Update icon and label of reactions button
2024-11-19 10:44:58 -05:00
Robin
8ec2f9bb31 Update tests 2024-11-19 10:43:42 -05:00
Robin
2ee468ba44 Update icon and label of reactions button
They've been updated in the designs.
2024-11-19 09:56:46 -05:00
fkwp
17eb435a7a Merge pull request #2799 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.6.2
2024-11-19 15:55:19 +01:00
renovate[bot]
1678ca71c1 Update dependency livekit-client to v2.6.2 2024-11-19 14:53:23 +00:00
Robin
90fe8e0529 Merge pull request #2734 from element-hq/renovate/compound
Update Compound
2024-11-19 09:48:17 -05:00
Hugh Nimmo-Smith
64749d0b7a Expose TestScheduler as global (#2796) 2024-11-18 22:39:59 +00:00
fkwp
79cd3047e9 Merge pull request #2798 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.6.8
2024-11-18 20:40:30 +01:00
renovate[bot]
9078c35e06 Update dependency @livekit/components-react to v2.6.8 2024-11-18 19:39:01 +00:00
renovate[bot]
2833d9a816 Update Compound 2024-11-18 19:37:15 +00:00
fkwp
8598be1d92 Merge pull request #2797 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.6.1
2024-11-18 20:36:18 +01:00
renovate[bot]
02d2de3831 Update dependency livekit-client to v2.6.1 2024-11-18 19:34:22 +00:00
fkwp
6d1aa0087b Merge pull request #2729 from element-hq/renovate/migrate-config
Migrate renovate config
2024-11-18 13:17:07 +01:00
renovate[bot]
8b4b9f4494 Update all non-major dependencies (#2795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 10:09:21 +01:00
Timo
a551cde47b Make reactions background use the frosted glass look (#2792)
* Make reactions background use the frosted glass look

* fix spacing if there is raise hand + reaction.
2024-11-15 18:58:40 +01:00
ElementRobot
01b9d7a70e Merge pull request #2793 from element-hq/actions/localazy-download
Localazy Download
2024-11-15 16:23:56 +00:00
fkwp
f56d4a32a3 Translations updates 2024-11-15 16:22:27 +00:00
Will Hunt
eed1b98cf9 Update reactions to new design (#2751)
* Replace search strings

* Add expander for reactions view

* lint

* Remove useless list

* Update tests

* lint

* Only shrink buttons on web version, mobile never shrinks

* Revert accidental change.

* Remove border from button

* Tidy up views.

* Finish matching designs.

* Fix height

* Remove a unneeded class

* Remove more cruft

* Remove unnessacery chunk

* Evenly space reactions

* lint

* Disable reaction buttons when busy

* Try to make menu a bit more responsive

* Update test

* further screen size tweaks
2024-11-15 16:02:06 +00:00
Will Hunt
c1208ac7b6 Small improvements to settings tab (#2789)
* align profile tab.

* Fix descriptions
2024-11-15 14:09:52 +00:00
Quentin Gliech
d77e9e8f08 Merge pull request #2790 from element-hq/quenting/preload-config
Pre-fetch the config.json to improve startup time
2024-11-15 13:55:41 +01:00
Quentin Gliech
2e172aa3be Pre-fetch the config.json to improve startup time 2024-11-15 13:36:36 +01:00
fkwp
9650ca9687 Merge pull request #2787 from element-hq/quenting/docker-vary-encoding
Send 'Vary: Content-Encoding' header from nginx
2024-11-15 09:22:25 +01:00
renovate[bot]
e37f36937e Migrate config renovate.json 2024-11-06 18:46:12 +00:00
65 changed files with 2429 additions and 1547 deletions

View File

@@ -37,6 +37,7 @@ module.exports = {
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/await-thenable": "error",
"rxjs/no-exposed-subjects": "error",
},
settings: {
react: {

View File

@@ -17,5 +17,10 @@
},
"rageshake": {
"submit_url": "https://element.io/bugreports/submit"
},
"matrix_rtc_session": {
"key_rotation_on_leave_delay": 15000,
"membership_keep_alive_period": 5000,
"membership_server_side_expiry_timeout": 15000
}
}

View File

@@ -5,7 +5,7 @@
<Location "/">
# disable cache entriely by default (apart from Etag which is accurate enough)
Header add Cache-Control "private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
Header add Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
CacheDisable on
ExpiresActive off

View File

@@ -8,7 +8,7 @@ server {
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Cache-Control "public, max-age=30, stale-while-revalidate=30";
}
# assets can be cached because they have hashed filenames

View File

@@ -25,230 +25,38 @@ There are two formats for Element Call urls.
```
With this format the livekit alias that will be used is the `<room_name>`.
All ppl connecting to this url will end up in the same unencrypted room.
All people connecting to this URL will end up in the same unencrypted room.
This does not scale, is super unsecure
(ppl could end up in the same room by accident) and it also is not really
(people could end up in the same room by accident) and it also is not really
possible to support encryption.
The url parameters are spit into two categories: **general** and **widget related**.
## Widget related params
## Parameters
**widgetId**
The id used by the widget. The presence of this parameter implies that element
call will not connect to a homeserver directly and instead tries to establish
postMessage communication via the `parentUrl`.
```ts
widgetId: string | null;
```
**parentUrl**
The url used to send widget action postMessages. This should be the domain of
the client or the webview the widget is hosted in. (in case the widget is not
in an Iframe but in a dedicated webview we send the postMessages same webview
the widget lives in. Filtering is done in the widget so it ignores the messages
it receives from itself)
```ts
parentUrl: string | null;
```
**userId**
The user's ID (only used in matryoshka mode).
```ts
userId: string | null;
```
**deviceId**
The device's ID (only used in matryoshka mode).
```ts
deviceId: string | null;
```
**baseUrl**
The base URL of the homeserver to use for media lookups in matryoshka mode.
```ts
baseUrl: string | null;
```
### General url parameters
**roomId**
Anything about what room we're pointed to should be from useRoomIdentifier which
parses the path and resolves alias with respect to the default server name, however
roomId is an exception as we need the room ID in embedded (matroyska) mode, and not
the room alias (or even the via params because we are not trying to join it). This
is also not validated, where it is in useRoomIdentifier().
```ts
roomId: string | null;
```
**confineToRoom**
Whether the app should keep the user confined to the current call/room.
```ts
confineToRoom: boolean; (default: false)
```
**appPrompt**
Whether upon entering a room, the user should be prompted to launch the
native mobile app. (Affects only Android and iOS.)
The app prompt must also be enabled in the config for this to take effect.
```ts
appPrompt: boolean; (default: true)
```
**preload**
Whether the app should pause before joining the call until it sees an
io.element.join widget action, allowing it to be preloaded.
```ts
preload: boolean; (default: false)
```
**hideHeader**
Whether to hide the room header when in a call.
```ts
hideHeader: boolean; (default: false)
```
**showControls**
Whether to show the buttons to mute, screen-share, invite, hangup are shown
when in a call.
```ts
showControls: boolean; (default: true)
```
**hideScreensharing**
Whether to hide the screen-sharing button.
```ts
hideScreensharing: boolean; (default: false)
```
**enableE2EE** (Deprecated)
Whether to use end-to-end encryption. This is a legacy flag for the full mesh branch.
It is not used on the livekit branch and has no impact there!
```ts
enableE2EE: boolean; (default: true)
```
**perParticipantE2EE**
Whether to use per participant encryption.
Keys will be exchanged over encrypted matrix room messages.
```ts
perParticipantE2EE: boolean; (default: false)
```
**password**
E2EE password when using a shared secret.
(For individual sender keys in embedded mode this is not required.)
```ts
password: string | null;
```
**displayName**
The display name to use for auto-registration.
```ts
displayName: string | null;
```
**lang**
The BCP 47 code of the language the app should use.
```ts
lang: string | null;
```
**fonts**
The font/fonts which the interface should use.
There can be multiple font url parameters: `?font=font-one&font=font-two...`
```ts
font: string;
font: string;
...
```
**fontScale**
The factor by which to scale the interface's font size.
```ts
fontScale: number | null;
```
**analyticsID**
The Posthog analytics ID. It is only available if the user has given consent for
sharing telemetry in element web.
```ts
analyticsID: string | null;
```
**allowIceFallback**
Whether the app is allowed to use fallback STUN servers for ICE in case the
user's homeserver doesn't provide any.
```ts
allowIceFallback: boolean; (default: false)
```
**skipLobby**
Setting this flag skips the lobby and brings you in the call directly.
In the widget this can be combined with preload to pass the device settings
with the join widget action.
```ts
skipLobby: boolean; (default: false)
```
**returnToLobby**
Setting this flag makes element call show the lobby in widget mode after leaving
a call.
This is useful for video rooms.
If set to false, the widget will show a blank page after leaving the call.
```ts
returnToLobby: boolean; (default: false)
```
**theme**
The theme to use for element call.
can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
If not set element call will use the dark theme.
```ts
theme: string | null;
```
**viaServers**
This defines the homeserver that is going to be used when joining a room.
It has to be set to a non default value for links to rooms
that are not on the default homeserver,
that is in use for the current user.
```ts
viaServers: string; (default: undefined)
```
**homeserver**
This defines the homeserver that is going to be used when registering
a new (guest) user.
This can be user to configure a non default guest user server when
creating a spa link.
```ts
homeserver: string; (default: undefined)
```
| Name | Values | Required for widget | Required for SPA | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `analyticsID` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
| `baseUrl` | | Yes | Not applicable | The base URL of the homeserver to use for media lookups. |
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
| `deviceId` | Matrix device ID | Yes | Not applicable | The Matrix device ID for the widget host. |
| `displayName` | | No | No | Display name used for auto-registration. |
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `parentUrl` | | Yes | Not applicable | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (in case the widget is not in an Iframe but in a dedicated webview we send the postMessages same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself) |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `preload` | `true` or `false` | No, defaults to `false` | Not applicable | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Not applicable | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | Not applicable | The Matrix user ID. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | Not applicable | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |

View File

@@ -24,6 +24,10 @@ export default {
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
// We need this so the eslint is happy with @livekit/track-processors.
// This might be a bug in the livekit repo but for now we fix it on the
// element call side.
"@types/dom-mediacapture-transform",
"matrix-widget-api",
],
ignoreExportsUsedInFile: true,

View File

@@ -4,19 +4,18 @@
},
"action": {
"close": "Schließen",
"close_search": "Suche beenden",
"copy_link": "Link kopieren",
"edit": "Bearbeiten",
"go": "Los gehts",
"invite": "Einladen",
"lower_hand": "Handmeldung zurücknehmen",
"no": "Nein",
"open_search": "Suchen",
"pick_reaction": "Reaktion auswählen",
"raise_hand": "Handmeldung",
"raise_hand_or_send_reaction": "Handmeldung oder Reaktion senden",
"register": "Registrieren",
"remove": "Entfernen",
"show_less": "Weniger anzeigen",
"show_more": "Mehr anzeigen",
"sign_in": "Anmelden",
"sign_out": "Abmelden",
"submit": "Absenden",
@@ -62,15 +61,19 @@
"preferences": "Einstellungen",
"profile": "Profil",
"reaction": "Reaktion",
"search": "Suche",
"reactions": "Reaktionen",
"settings": "Einstellungen",
"something_went_wrong": "Etwas ist schief gelaufen",
"unencrypted": "Nicht verschlüsselt",
"username": "Benutzername",
"video": "Video"
},
"crypto_version": "Krypto-Version:{{version}}",
"device_id": "Geräte-ID: {{id}}",
"developer_mode": {
"crypto_version": "Krypto-Version:{{version}}",
"device_id": "Geräte-ID: {{id}}",
"duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer",
"matrix_id": "Matrix-ID: {{id}}"
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"full_screen_view_description": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.</0>",
"full_screen_view_h1": "<0>Hoppla, etwas ist schiefgelaufen.</0>",
@@ -112,7 +115,6 @@
"login_auth_links_prompt": "Noch nicht registriert?",
"login_subheading": "Weiter zu Element",
"login_title": "Anmelden",
"matrix_id": "Matrix-ID: {{id}}",
"microphone_off": "Mikrofon aus",
"microphone_on": "Mikrofon an",
"mute_microphone_button_label": "Mikrofon stumm schalten",
@@ -128,7 +130,6 @@
"rageshake_sending": "Senden …",
"rageshake_sending_logs": "Sende Debug-Protokolle …",
"rageshake_sent": "Danke!",
"reaction_search": "Reaktionen suchen...",
"recaptcha_caption": "Diese Seite wird durch reCAPTCHA geschützt und es gelten Googles <2>Datenschutzerklärung</2> und <6>Nutzungsbedingungen</6>. <9></9>Mit einem Klick auf „Registrieren“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
"recaptcha_dismissed": "Recaptcha abgelehnt",
"recaptcha_not_loaded": "Recaptcha nicht geladen",
@@ -151,7 +152,6 @@
"developer_settings_label": "Entwicklereinstellungen",
"developer_settings_label_description": "Zeige die Entwicklereinstellungen im Einstellungsfenster.",
"developer_tab_title": "Entwickler",
"duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer",
"feedback_tab_body": "Falls du auf Probleme stößt oder einfach nur eine Rückmeldung geben möchtest, sende uns bitte eine kurze Beschreibung.",
"feedback_tab_description_label": "Deine Rückmeldung",
"feedback_tab_h4": "Rückmeldung geben",
@@ -163,8 +163,8 @@
"preferences_tab": {
"reactions_play_sound_description": "Einen Soundeffekt abspielen, wenn jemand eine Reaktion sendet",
"reactions_play_sound_label": "Reaktionstöne abspielen",
"reactions_show_description": "Reaktionen anzeigen",
"reactions_show_label": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
"reactions_show_description": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
"reactions_show_label": "Reaktionen anzeigen",
"reactions_title": "Reaktionen"
},
"preferences_tab_body": "Hier können zusätzliche Optionen für individuelle Anforderungen eingestellt werden",

View File

@@ -4,19 +4,18 @@
},
"action": {
"close": "Close",
"close_search": "Close search",
"copy_link": "Copy link",
"edit": "Edit",
"go": "Go",
"invite": "Invite",
"lower_hand": "Lower hand",
"no": "No",
"open_search": "Open search",
"pick_reaction": "Pick reaction",
"raise_hand": "Raise hand",
"raise_hand_or_send_reaction": "Raise hand or send reaction",
"register": "Register",
"remove": "Remove",
"show_less": "Show less",
"show_more": "Show more",
"sign_in": "Sign in",
"sign_out": "Sign out",
"submit": "Submit",
@@ -62,15 +61,20 @@
"preferences": "Preferences",
"profile": "Profile",
"reaction": "Reaction",
"search": "Search",
"reactions": "Reactions",
"settings": "Settings",
"something_went_wrong": "Something went wrong",
"unencrypted": "Not encrypted",
"username": "Username",
"video": "Video"
},
"crypto_version": "Crypto version: {{version}}",
"device_id": "Device ID: {{id}}",
"developer_mode": {
"crypto_version": "Crypto version: {{version}}",
"device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"hostname": "Hostname: {{hostname}}",
"matrix_id": "Matrix ID: {{id}}"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
@@ -112,7 +116,6 @@
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"matrix_id": "Matrix ID: {{id}}",
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
@@ -128,7 +131,6 @@
"rageshake_sending": "Sending…",
"rageshake_sending_logs": "Sending debug logs…",
"rageshake_sent": "Thanks!",
"reaction_search": "Search reactions…",
"recaptcha_caption": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>",
"recaptcha_dismissed": "Recaptcha dismissed",
"recaptcha_not_loaded": "Recaptcha not loaded",
@@ -148,10 +150,12 @@
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play",
"effect_volume_label": "Sound effect volume"
},
"background_blur_header": "Background",
"background_blur_label": "Enable background blurring",
"blur_not_supported_by_browser": "(Background blur is not supported by this browser.)",
"developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.",
"developer_tab_title": "Developer",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"feedback_tab_description_label": "Your feedback",
"feedback_tab_h4": "Submit feedback",
@@ -163,8 +167,8 @@
"preferences_tab": {
"reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.",
"reactions_play_sound_label": "Play reaction sounds",
"reactions_show_description": "Show reactions",
"reactions_show_label": "Show an animation when anyone sends a reaction.",
"reactions_show_description": "Show an animation when anyone sends a reaction.",
"reactions_show_label": "Show reactions",
"reactions_title": "Reactions"
},
"preferences_tab_body": "Here you can configure extra options for an improved experience",
@@ -173,7 +177,7 @@
"preferences_tab_show_hand_raised_timer_label": "Show hand raise duration",
"speaker_device_selection_label": "Speaker"
},
"star_rating_input_label_one": "{{count}} stars",
"star_rating_input_label_one": "{{count}} star",
"star_rating_input_label_other": "{{count}} stars",
"start_new_call": "Start new call",
"start_video_button_label": "Start video",

View File

@@ -31,9 +31,10 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@livekit/track-processors": "^0.3.2",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^1.25.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-web": "^1.9.1",
@@ -45,10 +46,12 @@
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^2.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/dom-mediacapture-transform": "^0.1.10",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12",
@@ -90,7 +93,7 @@
"livekit-client": "^2.5.7",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.10.0",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",

View File

@@ -1,26 +0,0 @@
{
"applinks": {
"details": [
{
"appIDs": [
"7J4U792NQT.io.element.elementx",
"7J4U792NQT.io.element.elementx.nightly",
"7J4U792NQT.io.element.elementx.pr"
],
"components": [
{
"?": {
"no_universal_links": "?*"
},
"exclude": true,
"comment": "Opt out of universal links"
},
{
"/": "/*",
"comment": "Matches any URL"
}
]
}
]
}
}

View File

@@ -1,32 +0,0 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.element.android.x.debug",
"sha256_cert_fingerprints": [
"B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E"
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.element.android.x.nightly",
"sha256_cert_fingerprints": [
"CA:D3:85:16:84:3A:05:CC:EB:00:AB:7B:D3:80:0F:01:BA:8F:E0:4B:38:86:F3:97:D8:F7:9A:1B:C4:54:E4:0F"
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.element.android.x",
"sha256_cert_fingerprints": [
"C6:DB:9B:9C:8C:BD:D6:5D:16:E8:EC:8C:8B:91:C8:31:B9:EF:C9:5C:BF:98:AE:41:F6:A9:D8:35:15:1A:7E:16"
]
}
}
]

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.png" />
<link rel="preload" href="/config.json" as="fetch" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"extends": ["config:recommended"],
"packageRules": [
{
"groupName": "all non-major dependencies",
@@ -26,8 +26,8 @@
},
{
"groupName": "Compound",
"matchPackagePrefixes": ["@vector-im/compound-"],
"schedule": "before 5am on Tuesday and Friday"
"schedule": "before 5am on Tuesday and Friday",
"matchPackageNames": ["@vector-im/compound-{/,}**"]
},
{
"groupName": "LiveKit client",
@@ -35,7 +35,7 @@
},
{
"groupName": "LiveKit components",
"matchPackagePrefixes": ["@livekit/components-"]
"matchPackageNames": ["@livekit/components-{/,}**"]
},
{
"groupName": "Vaul",

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { useMemo, FC } from "react";
import { useMemo, FC, CSSProperties } from "react";
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { getAvatarUrl } from "./utils/matrix";
@@ -33,6 +33,7 @@ interface Props {
className?: string;
src?: string;
size?: Size | number;
style?: CSSProperties;
}
export const Avatar: FC<Props> = ({
@@ -41,6 +42,8 @@ export const Avatar: FC<Props> = ({
name,
src,
size = Size.MD,
style,
...props
}) => {
const { client } = useClient();
@@ -64,6 +67,8 @@ export const Avatar: FC<Props> = ({
name={name}
size={`${sizePx}px`}
src={resolvedSrc}
style={style}
{...props}
/>
);
};

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
.modal {
--inset-inline: 520px;
display: flex;
flex-direction: column;
}
@@ -35,7 +36,7 @@ Please see LICENSE in the repository root for full details.
.drawer {
background: var(--cpd-color-bg-canvas-default);
inset-block-end: 0;
inset-inline: max(0px, calc((100% - 520px) / 2));
inset-inline: max(0px, calc((100% - var(--inset-inline)) / 2));
max-block-size: 90%;
border-start-start-radius: var(--border-radius);
border-start-end-radius: var(--border-radius);

View File

@@ -42,8 +42,9 @@ Please see LICENSE in the repository root for full details.
}
.overlay.animate {
--overlay-top: 50%;
left: 50%;
top: 50%;
top: var(--overlay-top);
transform: translate(-50%, -50%);
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it } from "vitest";
import { getRoomIdentifierFromUrl } from "../src/UrlParams";
import { getRoomIdentifierFromUrl, getUrlParams } from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "!d45f138fsd";
@@ -86,4 +86,18 @@ describe("UrlParams", () => {
.roomAlias,
).toBeFalsy();
});
describe("preload", () => {
it("defaults to false", () => {
expect(getUrlParams().preload).toBe(false);
});
it("ignored in SPA mode", () => {
expect(getUrlParams("?preload=true").preload).toBe(false);
});
it("respected in widget mode", () => {
expect(getUrlParams("?preload=true&widgetId=12345").preload).toBe(true);
});
});
});

View File

@@ -211,8 +211,11 @@ export const getUrlParams = (
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
const widgetId = parser.getParam("widgetId");
const isWidget = !!widgetId;
return {
widgetId: parser.getParam("widgetId"),
widgetId,
parentUrl: parser.getParam("parentUrl"),
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
@@ -224,7 +227,7 @@ export const getUrlParams = (
confineToRoom:
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true),
preload: parser.getFlagParam("preload"),
preload: isWidget ? parser.getFlagParam("preload") : false,
hideHeader: parser.getFlagParam("hideHeader"),
showControls: parser.getFlagParam("showControls", true),
hideScreensharing: parser.getFlagParam("hideScreensharing"),

View File

@@ -3,78 +3,99 @@
}
.reactionPopupMenu {
--reaction-button-padding: 10px;
--reaction-button-fontsize: 20px;
--reaction-button-gap: var(--cpd-separator-spacing);
display: flex;
width: fit-content;
}
.reactionPopupMenuModal {
width: fit-content !important;
top: 82vh !important;
@media (max-width: 420px) {
.reactionPopupMenu {
--reaction-button-padding: 8px;
--reaction-button-fontsize: 16px;
--reaction-button-gap: 6px;
}
}
.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x) !important;
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
div.reactionPopupMenuRoot.reactionPopupMenuModal {
--overlay-top: 82vh;
width: fit-content;
}
.reactionPopupMenu menu {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--cpd-separator-spacing);
div.reactionPopupMenuRoot {
/* Center the drawer */
--inset-inline: 30em;
}
.reactionPopupMenuRoot > div {
width: fit-content;
max-width: 100vw;
}
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x);
padding-block: var(--cpd-space-6x);
}
.reactionPopupMenu section {
height: fit-content;
margin-top: auto;
margin-bottom: auto;
flex: 1;
max-width: fit-content;
}
.reactionPopupMenuItem {
list-style: none;
.reactionPopupMenu section.reactionsMenuSection {
margin: auto 0;
flex: auto;
}
.reactionsMenu {
min-height: 3em;
margin: 0;
padding: 0;
flex-grow: 1;
gap: var(--reaction-button-gap);
/* Height of 3 rows plus padding. */
max-height: calc(
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 3
);
max-width: calc(
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 5
);
overflow-x: hidden;
overflow-y: auto;
list-style: none;
flex-wrap: wrap;
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: start;
align-items: auto;
align-content: start;
width: fit-content;
}
.reactionsMenu > * {
flex: 0 0 auto;
}
.reactionButton {
padding: 1em;
font-size: 1.6em;
width: 1.4em;
height: 1.4em;
padding: var(--reaction-button-padding);
border-radius: var(--cpd-radius-pill-effect);
}
@media (max-width: 800px) {
.reactionButton {
padding: 1em;
font-size: 1em;
width: 1em;
height: 1em;
min-block-size: unset;
}
font-size: var(--reaction-button-fontsize);
min-block-size: unset;
border: none;
aspect-ratio: 1 / 1;
height: 100%;
}
.verticalSeperator {
background-color: var(--cpd-color-gray-800);
width: 1px;
width: 2px;
height: auto;
margin-left: var(--cpd-separator-spacing);
margin-right: var(--cpd-separator-spacing);
}
.searchForm {
display: flex;
flex-direction: row;
gap: var(--cpd-separator-spacing);
margin-bottom: var(--cpd-space-3x);
}
.searchForm > label {
flex: auto;
}
.alert {
margin-bottom: var(--cpd-space-3x);
animation: grow-in 200ms;

View File

@@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { fireEvent, render } from "@testing-library/react";
import { act } from "react";
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react";
@@ -30,18 +28,13 @@ const membership: Record<string, string> = {
function TestComponent({
rtcSession,
room,
}: {
rtcSession: MockRTCSession;
room: MockRoom;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
/>
<ReactionToggleButton userId={memberUserIdAlice} />
</TestReactionsWrapper>
</TooltipProvider>
);
@@ -52,9 +45,9 @@ test("Can open menu", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
expect(container).toMatchSnapshot();
});
@@ -63,9 +56,9 @@ test("Can raise hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand"));
expect(room.testSentEvents).toEqual([
[
@@ -88,10 +81,10 @@ test("Can lower hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.lower_hand"));
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
expect(container).toMatchSnapshot();
@@ -102,9 +95,9 @@ test("Can react with emoji", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByText("🐶"));
expect(room.testSentEvents).toEqual([
[
@@ -122,17 +115,15 @@ test("Can react with emoji", async () => {
]);
});
test("Can search for and send emoji", async () => {
test("Can fully expand emoji picker", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByText, container, getByLabelText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
// Search should autofocus.
await user.keyboard("crickets");
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));
expect(container).toMatchSnapshot();
await user.click(getByText("🦗"));
@@ -152,63 +143,15 @@ test("Can search for and send emoji", async () => {
]);
});
test("Can search for and send emoji with the keyboard", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByPlaceholderText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
// Search should autofocus.
await user.keyboard("crickets");
expect(container).toMatchSnapshot();
act(() => {
fireEvent.keyDown(searchField, { key: "Enter" });
});
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
},
],
]);
});
test("Can close search", async () => {
test("Can close reaction dialog", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
await user.click(getByLabelText("action.close_search"));
expect(container).toMatchSnapshot();
});
test("Can close search with the escape key", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container, getByPlaceholderText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
act(() => {
fireEvent.keyDown(searchField, { key: "Escape" });
});
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));
await user.click(getByLabelText("action.show_less"));
expect(container).toMatchSnapshot();
});

View File

@@ -5,24 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { Button as CpdButton, Tooltip, Alert } from "@vector-im/compound-web";
import {
Button as CpdButton,
Tooltip,
Search,
Form,
Alert,
} from "@vector-im/compound-web";
import {
SearchIcon,
CloseIcon,
RaisedHandSolidIcon,
ReactionIcon,
ChevronDownIcon,
ChevronUpIcon,
ReactionSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
@@ -31,19 +23,11 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import styles from "./ReactionToggleButton.module.css";
import {
ReactionOption,
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
import { Modal } from "../Modal";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
@@ -55,15 +39,14 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
<Tooltip label={t("common.reactions")}>
<CpdButton
className={classNames(raised && styles.raisedButton)}
aria-expanded={open}
aria-haspopup
aria-label={t("action.raise_hand_or_send_reaction")}
kind={raised || open ? "primary" : "secondary"}
iconOnly
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
Icon={raised ? RaisedHandSolidIcon : ReactionSolidIcon}
{...props}
/>
</Tooltip>
@@ -84,43 +67,11 @@ export function ReactionPopupMenu({
canReact: boolean;
}): ReactNode {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [isSearching, setIsSearching] = useState(false);
const onSearch = useCallback<ChangeEventHandler<HTMLInputElement>>((ev) => {
ev.preventDefault();
setSearchText(ev.target.value.trim().toLocaleLowerCase());
}, []);
const [isFullyExpanded, setExpanded] = useState(false);
const filteredReactionSet = useMemo(
() =>
ReactionSet.filter(
(reaction) =>
!isSearching ||
(!!searchText &&
(reaction.name.startsWith(searchText) ||
reaction.alias?.some((a) => a.startsWith(searchText)))),
).slice(0, 6),
[searchText, isSearching],
);
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (!canReact) {
return;
}
if (filteredReactionSet.length !== 1) {
return;
}
sendReaction(filteredReactionSet[0]);
setIsSearching(false);
} else if (ev.key === "Escape") {
ev.preventDefault();
setIsSearching(false);
}
},
[sendReaction, filteredReactionSet, canReact, setIsSearching],
() => (isFullyExpanded ? ReactionSet : ReactionSet.slice(0, 5)),
[isFullyExpanded],
);
const label = isHandRaised ? t("action.lower_hand") : t("action.raise_hand");
return (
@@ -136,9 +87,10 @@ export function ReactionPopupMenu({
)}
<div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}>
<Tooltip label={label}>
<Tooltip label={label} caption="H">
<CpdButton
kind={isHandRaised ? "primary" : "secondary"}
aria-keyshortcuts="H"
aria-pressed={isHandRaised}
aria-label={label}
onClick={() => toggleRaisedHand()}
@@ -148,41 +100,33 @@ export function ReactionPopupMenu({
</Tooltip>
</section>
<div className={styles.verticalSeperator} />
<section>
{isSearching ? (
<>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder={t("reaction_search")}
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<CpdButton
Icon={CloseIcon}
aria-label={t("action.close_search")}
size="sm"
kind="destructive"
onClick={() => setIsSearching(false)}
/>
</Form.Root>
</>
) : null}
<menu className={styles.reactionsMenu}>
{filteredReactionSet.map((reaction) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
<section className={styles.reactionsMenuSection}>
<menu
className={classNames(
isFullyExpanded && styles.reactionsMenuExpanded,
styles.reactionsMenu,
)}
>
{filteredReactionSet.map((reaction, index) => (
<li key={reaction.name}>
<Tooltip
label={reaction.name}
caption={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
<CpdButton
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendReaction(reaction)}
aria-keyshortcuts={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
{reaction.emoji}
</CpdButton>
@@ -191,73 +135,56 @@ export function ReactionPopupMenu({
))}
</menu>
</section>
{!isSearching ? (
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
<li key="search" className={styles.reactionPopupMenuItem}>
<Tooltip label={t("common.search")}>
<CpdButton
iconOnly
aria-label={t("action.open_search")}
Icon={SearchIcon}
kind="tertiary"
onClick={() => setIsSearching(true)}
/>
</Tooltip>
</li>
</section>
) : null}
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
<Tooltip
label={
isFullyExpanded ? t("action.show_less") : t("action.show_more")
}
>
<CpdButton
iconOnly
aria-label={
isFullyExpanded ? t("action.show_less") : t("action.show_more")
}
Icon={isFullyExpanded ? ChevronUpIcon : ChevronDownIcon}
kind="tertiary"
onClick={() => setExpanded(!isFullyExpanded)}
/>
</Tooltip>
</section>
</div>
</>
);
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
rtcSession: MatrixRTCSession;
client: MatrixClient;
userId: string;
}
export function ReactionToggleButton({
client,
rtcSession,
userId,
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
const { raisedHands, lowerHand, reactions } = useReactions();
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!raisedHands[userId];
const canReact = !reactions[userId];
useEffect(() => {
// Clear whenever the reactions menu state changes.
setErrorText(undefined);
}, [showReactionsMenu]);
const canReact = !reactions[userId];
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true);
await client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: parentEventId,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
await sendReaction(reaction);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
@@ -267,59 +194,25 @@ export function ReactionToggleButton({
setBusy(false);
}
},
[memberships, client, userId, rtcSession],
[sendReaction],
);
const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
try {
setBusy(true);
await lowerHand();
setShowReactionsMenu(false);
} finally {
setBusy(false);
}
} else {
try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: parentEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise hand", ex);
} finally {
setBusy(false);
}
const wrappedToggleRaisedHand = useCallback(() => {
const toggleHand = async (): Promise<void> => {
try {
setBusy(true);
await toggleRaisedHand();
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise/lower hand", ex);
} finally {
setBusy(false);
}
};
void raiseHand();
}, [
client,
isHandRaised,
memberships,
lowerHand,
rtcSession.room.roomId,
userId,
]);
void toggleHand();
}, [toggleRaisedHand]);
return (
<>
@@ -335,14 +228,15 @@ export function ReactionToggleButton({
title={t("action.pick_reaction")}
hideHeader
classNameModal={styles.reactionPopupMenuModal}
className={styles.reactionPopupMenuRoot}
onDismiss={() => setShowReactionsMenu(false)}
>
<ReactionPopupMenu
errorText={errorText}
isHandRaised={isHandRaised}
canReact={canReact}
canReact={!busy && canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
toggleRaisedHand={wrappedToggleRaisedHand}
/>
</Modal>
</>

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Can close search 1`] = `
exports[`Can close reaction dialog 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
@@ -9,8 +9,7 @@ exports[`Can close search 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rec:"
aria-labelledby=":r9l:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -26,26 +25,27 @@ exports[`Can close search 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
`;
exports[`Can close search with the escape key 1`] = `
<div>
exports[`Can fully expand emoji picker 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rhh:"
aria-labelledby=":r6c:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
@@ -59,10 +59,9 @@ exports[`Can close search with the escape key 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
@@ -75,8 +74,7 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r3i:"
aria-labelledby=":r36:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -108,7 +106,6 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
@@ -125,10 +122,9 @@ exports[`Can open menu 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
@@ -141,8 +137,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r1p:"
aria-labelledby=":r1j:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-size="lg"
@@ -158,82 +153,9 @@ exports[`Can raise hand 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r74:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji with the keyboard 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":ra3:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>

View File

@@ -28,11 +28,11 @@ export class Config {
const internalInstance = new Config();
Config.internalInstance = internalInstance;
Config.internalInstance.initPromise = downloadConfig(
"../config.json",
).then((config) => {
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
});
Config.internalInstance.initPromise = downloadConfig("/config.json").then(
(config) => {
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
},
);
}
return Config.internalInstance.initPromise;
}
@@ -74,11 +74,7 @@ async function downloadConfig(
configJsonFilename: string,
): Promise<ConfigOptions> {
const url = new URL(configJsonFilename, window.location.href);
url.searchParams.set("cachebuster", Date.now().toString());
const res = await fetch(url, {
cache: "no-cache",
method: "GET",
});
const res = await fetch(url);
if (!res.ok || res.status === 404 || res.status === 0) {
// Lack of a config isn't an error, we should just use the defaults.

View File

@@ -73,6 +73,7 @@ interface InputFieldProps {
defaultValue?: string;
placeholder?: string;
defaultChecked?: boolean;
min?: number;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
}
@@ -91,6 +92,7 @@ export const InputField = forwardRef<
suffix,
description,
disabled,
min,
...rest
},
ref,
@@ -127,6 +129,7 @@ export const InputField = forwardRef<
checked={checked}
disabled={disabled}
aria-describedby={descriptionId}
min={min}
{...rest}
/>
)}

View File

@@ -24,6 +24,7 @@ import {
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
Setting,
} from "../settings/settings";
import { isFirefox } from "../Platform";
@@ -58,7 +59,7 @@ function useObservableState<T>(
function useMediaDevice(
kind: MediaDeviceKind,
fallbackDevice: string | undefined,
setting: Setting<string | undefined>,
usingNames: boolean,
alwaysDefault: boolean = false,
): MediaDevice {
@@ -84,15 +85,21 @@ function useMediaDevice(
[kind, requestPermissions],
);
const available = useObservableState(deviceObserver, []);
const [selectedId, select] = useState(fallbackDevice);
const [preferredId, select] = useSetting(setting);
return useMemo(() => {
let devId;
if (available) {
devId = available.some((d) => d.deviceId === selectedId)
? selectedId
: available.some((d) => d.deviceId === fallbackDevice)
? fallbackDevice
let selectedId: string | undefined = undefined;
if (!alwaysDefault && available) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
selectedId =
available.some((d) => d.deviceId === preferredId) ||
available.every((d) => d.deviceId === "")
? preferredId
: available.at(0)?.deviceId;
}
@@ -102,10 +109,10 @@ function useMediaDevice(
// device entries for the exact same device ID; deduplicate them
[...new Map(available.map((d) => [d.deviceId, d])).values()]
: [],
selectedId: alwaysDefault ? undefined : devId,
selectedId,
select,
};
}, [available, selectedId, fallbackDevice, select, alwaysDefault]);
}, [available, preferredId, select, alwaysDefault]);
}
const deviceStub: MediaDevice = {
@@ -121,7 +128,7 @@ const devicesStub: MediaDevices = {
stopUsingDeviceNames: () => {},
};
const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
interface Props {
children: JSX.Element;
@@ -141,36 +148,22 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
// for ouput devices because the selector wont be shown on FF.
const useOutputNames = usingNames && !isFirefox();
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
const [storedAudioOutput, setStoredAudioOutput] =
useSetting(audioOutputSetting);
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
const audioInput = useMediaDevice(
"audioinput",
audioInputSetting,
usingNames,
);
const audioOutput = useMediaDevice(
"audiooutput",
storedAudioOutput,
audioOutputSetting,
useOutputNames,
alwaysUseDefaultAudio,
);
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
useEffect(() => {
if (audioInput.selectedId !== undefined)
setStoredAudioInput(audioInput.selectedId);
}, [setStoredAudioInput, audioInput.selectedId]);
useEffect(() => {
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
// but makes it clear while debugging that this is not happening on FF. + perf ;)
if (audioOutput.selectedId !== undefined && !isFirefox())
setStoredAudioOutput(audioOutput.selectedId);
}, [setStoredAudioOutput, audioOutput.selectedId]);
useEffect(() => {
if (videoInput.selectedId !== undefined)
setStoredVideoInput(videoInput.selectedId);
}, [setStoredVideoInput, videoInput.selectedId]);
const videoInput = useMediaDevice(
"videoinput",
videoInputSetting,
usingNames,
);
const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1),

View File

@@ -9,7 +9,9 @@ import {
ConnectionState,
E2EEOptions,
ExternalE2EEKeyProvider,
LocalTrackPublication,
Room,
RoomEvent,
RoomOptions,
Track,
} from "livekit-client";
@@ -17,6 +19,7 @@ import { useEffect, useMemo, useRef } from "react";
import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { BackgroundBlur as backgroundBlur } from "@livekit/track-processors";
import { defaultLiveKitOptions } from "./options";
import { SFUConfig } from "./openIDSFU";
@@ -26,6 +29,7 @@ import {
MediaDevices,
useMediaDevices,
} from "./MediaDevicesContext";
import { backgroundBlur as backgroundBlurSettings } from "../settings/settings";
import {
ECConnectionState,
useECConnectionState,
@@ -33,6 +37,7 @@ import {
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { E2eeType } from "../e2ee/e2eeType";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { useSetting } from "../settings/settings";
interface UseLivekitResult {
livekitRoom?: Room;
@@ -78,13 +83,22 @@ export function useLiveKit(
const initialMuteStates = useRef<MuteStates>(muteStates);
const devices = useMediaDevices();
const initialDevices = useRef<MediaDevices>(devices);
const blur = useMemo(() => {
let b = undefined;
try {
b = backgroundBlur(15, { delegate: "GPU" });
} catch (e) {
logger.error("disable background blur", e);
}
return b;
}, []);
const roomOptions = useMemo(
(): RoomOptions => ({
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: initialDevices.current.videoInput.selectedId,
processor: blur,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
@@ -95,7 +109,7 @@ export function useLiveKit(
},
e2ee: e2eeOptions,
}),
[e2eeOptions],
[blur, e2eeOptions],
);
// Store if audio/video are currently updating. If to prohibit unnecessary calls
@@ -129,6 +143,58 @@ export function useLiveKit(
sfuConfig,
);
const [showBackgroundBlur] = useSetting(backgroundBlurSettings);
const videoTrackPromise = useRef<
undefined | Promise<LocalTrackPublication | undefined>
>(undefined);
useEffect(() => {
// Don't even try if we cannot blur on this platform
if (!blur) return;
if (!room || videoTrackPromise.current) return;
const update = async (): Promise<void> => {
let publishCallback: undefined | ((track: LocalTrackPublication) => void);
videoTrackPromise.current = new Promise<
LocalTrackPublication | undefined
>((resolve) => {
const videoTrack = Array.from(
room.localParticipant.videoTrackPublications.values(),
).find((v) => v.source === Track.Source.Camera);
if (videoTrack) {
resolve(videoTrack);
}
publishCallback = (videoTrack: LocalTrackPublication): void => {
if (videoTrack.source === Track.Source.Camera) {
resolve(videoTrack);
}
};
room.on(RoomEvent.LocalTrackPublished, publishCallback);
});
const videoTrack = await videoTrackPromise.current;
if (publishCallback)
room.off(RoomEvent.LocalTrackPublished, publishCallback);
if (videoTrack !== undefined) {
if (
showBackgroundBlur &&
videoTrack.track?.getProcessor()?.name !== "background-blur"
) {
logger.info("Blur: set blur");
void videoTrack.track?.setProcessor(blur);
} else if (
videoTrack.track?.getProcessor()?.name === "background-blur"
) {
void videoTrack.track?.stopProcessor();
}
}
videoTrackPromise.current = undefined;
};
void update();
}, [blur, room, showBackgroundBlur]);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the

View File

@@ -1,19 +1,28 @@
.reactionIndicatorWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
background-color: #00000030;
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
background: "ffffff40";
backdrop-filter: blur(10px);
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
outline-offset: calc(-1 * var(--cpd-border-width-1));
}
.reactionIndicatorWidget > p {
padding: none;
margin-top: auto;
margin-bottom: auto;
width: 4em;
width: 3em;
padding-right: var(--cpd-space-2x);
margin-left: calc(var(--cpd-space-2x) * -1);
}
.reactionIndicatorWidgetLarge > p {
padding: var(--cpd-space-2x);
padding-right: var(--cpd-space-4x);
padding-left: 0;
margin-left: 0;
}
.reactionLarge {
@@ -24,14 +33,12 @@
.reaction {
margin: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
background-color: var(--cpd-color-icon-secondary);
color: white;
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
max-width: fit-content;

View File

@@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = {
},
};
// The first 6 reactions are always visible.
export const ReactionsRowSize = 5;
// The first {ReactionsRowSize} reactions are always visible.
export const ReactionSet: ReactionOption[] = [
{
emoji: "👍",

View File

@@ -0,0 +1,225 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { beforeEach, expect, test } from "vitest";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { ConnectionState, RemoteParticipant, Room } from "livekit-client";
import { of } from "rxjs";
import { afterEach } from "node:test";
import { act } from "react";
import { soundEffectVolumeSetting } from "../settings/settings";
import {
EmittableMockLivekitRoom,
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaPlay,
mockRemoteParticipant,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import {
CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer";
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
const aliceId = `${alice.userId}:AAAA`;
const bobId = `${bob.userId}:BBBB`;
const localParticipant = mockLocalParticipant({ identity: "" });
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
const bobParticipant = mockRemoteParticipant({ identity: bobId });
const originalPlayFn = window.HTMLMediaElement.prototype.play;
const enterSound = "http://localhost:3000/src/sound/join_call.ogg";
const leaveSound = "http://localhost:3000/src/sound/left_call.ogg";
beforeEach(() => {
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
});
afterEach(() => {
window.HTMLMediaElement.prototype.play = originalPlayFn;
});
test("plays a sound when entering a call", () => {
const audioIsPlaying: string[] = mockMediaPlay();
const members = new Map([alice, bob].map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<CallEventAudioRenderer vm={vm} />);
expect(audioIsPlaying).toEqual([
// Joining the call
enterSound,
]);
});
test("plays no sound when muted", () => {
soundEffectVolumeSetting.setValue(0);
const audioIsPlaying: string[] = mockMediaPlay();
const members = new Map([alice, bob].map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant, bobParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<CallEventAudioRenderer vm={vm} />);
// Play a sound when joining a call.
expect(audioIsPlaying).toHaveLength(0);
});
test("plays a sound when a user joins", () => {
const audioIsPlaying: string[] = mockMediaPlay();
const members = new Map([alice].map((p) => [p.userId, p]));
const remoteParticipants = new Map(
[aliceParticipant].map((p) => [p.identity, p]),
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom as unknown as Room,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
liveKitRoom.addParticipant(bobParticipant);
});
// Play a sound when joining a call.
expect(audioIsPlaying).toEqual([
// Joining the call
enterSound,
// Bob leaves
enterSound,
]);
});
test("plays a sound when a user leaves", () => {
const audioIsPlaying: string[] = mockMediaPlay();
const members = new Map([alice].map((p) => [p.userId, p]));
const remoteParticipants = new Map(
[aliceParticipant].map((p) => [p.identity, p]),
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom as unknown as Room,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
liveKitRoom.removeParticipant(aliceParticipant);
});
expect(audioIsPlaying).toEqual([
// Joining the call
enterSound,
// Alice leaves
leaveSound,
]);
});
test("plays no sound when the participant list", () => {
const audioIsPlaying: string[] = mockMediaPlay();
const members = new Map([alice].map((p) => [p.userId, p]));
const remoteParticipants = new Map<string, RemoteParticipant>([
[aliceParticipant.identity, aliceParticipant],
...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map<
[string, RemoteParticipant]
>((_, index) => {
const p = mockRemoteParticipant({ identity: `user${index}` });
return [p.identity, p];
}),
]);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const vm = new CallViewModel(
mockMatrixRoom({
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom as unknown as Room,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<CallEventAudioRenderer vm={vm} />);
expect(audioIsPlaying).toEqual([]);
// When the count drops
act(() => {
liveKitRoom.removeParticipant(aliceParticipant);
});
expect(audioIsPlaying).toEqual([leaveSound]);
});

View File

@@ -0,0 +1,117 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ReactNode, useEffect, useRef } from "react";
import { filter } from "rxjs";
import {
soundEffectVolumeSetting as effectSoundVolumeSetting,
useSetting,
} from "../settings/settings";
import { CallViewModel } from "../state/CallViewModel";
import enterCallSoundMp3 from "../sound/join_call.mp3";
import enterCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3";
import leftCallSoundOgg from "../sound/left_call.ogg";
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const CONCURRENT_AUDIO_CHANNELS = 2;
export function CallEventAudioRenderer({
vm,
}: {
vm: CallViewModel;
}): ReactNode {
const [effectSoundVolume] = useSetting(effectSoundVolumeSetting);
const callEntered = useRef<(HTMLAudioElement | null)[]>([]);
const callLeft = useRef<(HTMLAudioElement | null)[]>([]);
useEffect(() => {
if (effectSoundVolume === 0) {
return;
}
const joinSub = vm.memberChanges
.pipe(
filter(
({ joined, ids }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
),
)
.subscribe(({ joined }) => {
const availablePlayer = callEntered.current.find((v) => v?.paused);
void availablePlayer?.play();
});
const leftSub = vm.memberChanges
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
)
.subscribe(() => {
const availablePlayer = callLeft.current.find((v) => v?.paused);
void availablePlayer?.play();
});
return (): void => {
joinSub.unsubscribe();
leftSub.unsubscribe();
};
}, [effectSoundVolume, callEntered, callLeft, vm]);
// Set volume.
useEffect(() => {
callEntered.current.forEach((a) => {
if (a) {
a.volume = effectSoundVolume;
}
});
callLeft.current.forEach((a) => {
if (a) {
a.volume = effectSoundVolume;
}
});
}, [callEntered, callLeft, effectSoundVolume]);
// Do not render any audio elements if playback is disabled. Will save
// audio file fetches.
if (effectSoundVolume === 0) {
return null;
}
return (
// Will play as soon as it's mounted, which is what we want as this will
// play when the call is entered.
<>
{Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => (
<audio
key={index}
ref={(r) => (callEntered.current[index] = r)}
preload="auto"
hidden
>
<source src={enterCallSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={enterCallSoundMp3} type="audio/mpeg" />
</audio>
))}
{Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => (
<audio
key={index}
ref={(r) => (callLeft.current[index] = r)}
preload="auto"
hidden
>
<source src={leftCallSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={leftCallSoundMp3} type="audio/mpeg" />
</audio>
))}
</>
);
}

View File

@@ -131,48 +131,46 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext;
// TODO: why do we use a ref here instead of using muteStates directly?
const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates;
useEffect(() => {
const defaultDeviceSetup = async (
requestedDeviceData: JoinCallData,
): Promise<void> => {
const defaultDeviceSetup = async ({
audioInput,
videoInput,
}: JoinCallData): Promise<void> => {
// XXX: I think this is broken currently - LiveKit *won't* request
// permissions and give you device names unless you specify a kind, but
// here we want all kinds of devices. This needs a fix in livekit-client
// for the following name-matching logic to do anything useful.
const devices = await Room.getLocalDevices(undefined, true);
const { audioInput, videoInput } = requestedDeviceData;
if (audioInput === null) {
latestMuteStates.current!.audio.setEnabled?.(false);
} else {
if (audioInput) {
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
if (!deviceId) {
logger.warn("Unknown audio input: " + audioInput);
// override the default mute state
latestMuteStates.current!.audio.setEnabled?.(false);
} else {
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`,
);
latestDevices.current!.audioInput.select(deviceId);
latestMuteStates.current!.audio.setEnabled?.(true);
}
}
if (videoInput === null) {
latestMuteStates.current!.video.setEnabled?.(false);
} else {
if (videoInput) {
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
if (!deviceId) {
logger.warn("Unknown video input: " + videoInput);
// override the default mute state
latestMuteStates.current!.video.setEnabled?.(false);
} else {
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`,
);
latestDevices.current!.videoInput.select(deviceId);
latestMuteStates.current!.video.setEnabled?.(true);
}
}
};
@@ -199,7 +197,6 @@ export const GroupCallView: FC<Props> = ({
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await defaultDeviceSetup({ audioInput: null, videoInput: null });
await enterRTCSession(rtcSession, perParticipantE2EE);
})().catch((e) => {
logger.error("Error joining RTC session", e);

View File

@@ -36,10 +36,9 @@ Please see LICENSE in the repository root for full details.
inset-block-end: 0;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, var(--inline-content-inset)) 1fr auto 1fr minmax(
0,
var(--inline-content-inset)
);
grid-template-columns:
minmax(0, var(--inline-content-inset))
1fr auto 1fr minmax(0, var(--inline-content-inset));
grid-template-areas: ". logo buttons layout .";
align-items: center;
gap: var(--cpd-space-3x);

View File

@@ -87,6 +87,7 @@ import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { soundEffectVolumeSetting, useSetting } from "../settings/settings";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -183,7 +184,8 @@ export const InCallView: FC<InCallViewProps> = ({
onShareClick,
}) => {
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { supportsReactions, raisedHands } = useReactions();
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
useReactions();
const raisedHandCount = useMemo(
() => Object.keys(raisedHands).length,
[raisedHands],
@@ -227,6 +229,8 @@ export const InCallView: FC<InCallViewProps> = ({
toggleMicrophone,
toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted),
(reaction) => void sendReaction(reaction),
() => void toggleRaisedHand(),
);
const windowMode = useObservableEagerState(vm.windowMode);
@@ -572,8 +576,7 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionToggleButton
key="raise_hand"
className={styles.raiseHand}
client={client}
rtcSession={rtcSession}
userId={client.getUserId()!}
onTouchEnd={onControlsTouchEnd}
/>,
);
@@ -668,6 +671,7 @@ export const InCallView: FC<InCallViewProps> = ({
))}
<RoomAudioRenderer />
{renderContent()}
<CallEventAudioRenderer vm={vm} />
<audio ref={handRaisePlayer} preload="auto" hidden>
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={handSoundMp3} type="audio/mpeg" />

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { FC, useCallback, useMemo, useState } from "react";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button } from "@vector-im/compound-web";
@@ -16,6 +16,7 @@ import { usePreviewTracks } from "@livekit/components-react";
import { LocalVideoTrack, Track } from "livekit-client";
import { useObservable } from "observable-hooks";
import { map } from "rxjs";
import { BackgroundBlur as backgroundBlur } from "@livekit/track-processors";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -32,12 +33,14 @@ import {
VideoButton,
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { backgroundBlur as backgroundBlurSettings } from "../settings/settings";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useInitial } from "../useInitial";
import { useSwitchCamera } from "./useSwitchCamera";
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
import { useSetting } from "../settings/settings";
interface Props {
client: MatrixClient;
@@ -108,6 +111,20 @@ export const LobbyView: FC<Props> = ({
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
);
// eslint-disable-next-line new-cap
const blur = useMemo(() => {
let b = undefined;
try {
b = backgroundBlur(15, { delegate: "GPU" });
} catch (e) {
logger.error(
"disable background blur because its not supported by the platform.",
e,
);
}
return b;
}, []);
const localTrackOptions = useMemo(
() => ({
// The only reason we request audio here is to get the audio permission
@@ -119,12 +136,14 @@ export const LobbyView: FC<Props> = ({
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
// It should be possible to set a processor here:
// processor: blur,
},
}),
[
initialAudioOptions,
devices.videoInput.selectedId,
muteStates.video.enabled,
devices.videoInput.selectedId,
],
);
@@ -146,7 +165,22 @@ export const LobbyView: FC<Props> = ({
[tracks],
);
const switchCamera = useSwitchCamera(
const [showBackgroundBlur] = useSetting(backgroundBlurSettings);
useEffect(() => {
// Fon't even try if we cannot blur on this platform
if (!blur) return;
const updateBlur = async (showBlur: boolean): Promise<void> => {
if (showBlur && !videoTrack?.getProcessor()) {
await videoTrack?.setProcessor(blur);
} else {
await videoTrack?.stopProcessor();
}
};
if (videoTrack) void updateBlur(showBackgroundBlur);
}, [videoTrack, showBackgroundBlur, blur]);
const showSwitchCamera = useShowSwitchCamera(
useObservable(
(inputs) => inputs.pipe(map(([video]) => video)),
[videoTrack],
@@ -208,7 +242,9 @@ export const LobbyView: FC<Props> = ({
onClick={onVideoPress}
disabled={muteStates.video.setEnabled === null}
/>
{switchCamera && <SwitchCameraButton onClick={switchCamera} />}
{showSwitchCamera && (
<SwitchCameraButton onClick={showSwitchCamera} />
)}
<SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>

View File

@@ -0,0 +1,172 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import React, { ReactNode } from "react";
import { beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { useMuteStates } from "./MuteStates";
import {
MediaDevice,
MediaDevices,
MediaDevicesContext,
} from "../livekit/MediaDevicesContext";
import { mockConfig } from "../utils/test";
function TestComponent(): ReactNode {
const muteStates = useMuteStates();
return (
<div>
<div data-testid="audio-enabled">
{muteStates.audio.enabled.toString()}
</div>
<div data-testid="video-enabled">
{muteStates.video.enabled.toString()}
</div>
</div>
);
}
const mockMicrophone: MediaDeviceInfo = {
deviceId: "",
kind: "audioinput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
const mockSpeaker: MediaDeviceInfo = {
deviceId: "",
kind: "audiooutput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
const mockCamera: MediaDeviceInfo = {
deviceId: "",
kind: "videoinput",
label: "",
groupId: "",
toJSON() {
return {};
},
};
function mockDevices(available: MediaDeviceInfo[]): MediaDevice {
return {
available,
selectedId: "",
select: (): void => {},
};
}
function mockMediaDevices(
{
microphone,
speaker,
camera,
}: {
microphone?: boolean;
speaker?: boolean;
camera?: boolean;
} = { microphone: true, speaker: true, camera: true },
): MediaDevices {
return {
audioInput: mockDevices(microphone ? [mockMicrophone] : []),
audioOutput: mockDevices(speaker ? [mockSpeaker] : []),
videoInput: mockDevices(camera ? [mockCamera] : []),
startUsingDeviceNames: (): void => {},
stopUsingDeviceNames: (): void => {},
};
}
describe("useMuteStates", () => {
beforeEach(() => {
vi.spyOn(React, "useContext").mockReturnValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
vi.clearAllMocks();
});
it("disabled when no input devices", () => {
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext.Provider
value={mockMediaDevices({
microphone: false,
camera: false,
})}
>
<TestComponent />
</MediaDevicesContext.Provider>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("should be enabled by default", () => {
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext.Provider>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
it("uses defaults from config", () => {
mockConfig({
media_devices: {
enable_audio: false,
enable_video: false,
},
});
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext.Provider>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
// it("skipLobby mutes inputs", () => {
// mockConfig();
// render(
// <MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
// <MediaDevicesContext.Provider value={mockMediaDevices()}>
// <TestComponent />
// </MediaDevicesContext.Provider>
// </MemoryRouter>,
// );
// expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
// expect(screen.getByTestId("video-enabled").textContent).toBe("false");
// });
});

View File

@@ -72,10 +72,9 @@ function useMuteState(
export function useMuteStates(): MuteStates {
const devices = useMediaDevices();
const audio = useMuteState(
devices.audioInput,
() => Config.get().media_devices.enable_audio,
);
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio;
});
const video = useMuteState(
devices.videoInput,
() => Config.get().media_devices.enable_video,

View File

@@ -21,6 +21,7 @@ import {
playReactionsSound,
soundEffectVolumeSetting,
} from "../settings/settings";
import { mockMediaPlay } from "../utils/test";
const memberUserIdAlice = "@alice:example.org";
const memberUserIdBob = "@bob:example.org";
@@ -80,11 +81,7 @@ test("loads no audio elements when disabled in settings", () => {
});
test("will play an audio sound when there is a reaction", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
const audioIsPlaying: string[] = mockMediaPlay();
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
@@ -105,11 +102,7 @@ test("will play an audio sound when there is a reaction", () => {
});
test("will play the generic audio sound when there is soundless reaction", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
const audioIsPlaying: string[] = mockMediaPlay();
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
@@ -152,11 +145,7 @@ test("will play an audio sound with the correct volume", () => {
});
test("will play multiple audio sounds when there are multiple different reactions", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
const audioIsPlaying: string[] = mockMediaPlay();
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);

View File

@@ -20,7 +20,6 @@ import {
TrackEvent,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
@@ -35,6 +34,7 @@ export function useSwitchCamera(
video: Observable<LocalVideoTrack | null>,
): (() => void) | null {
const mediaDevices = useMediaDevices();
const setVideoInput = useLatest(mediaDevices.videoInput.select);
// Produce an observable like the input 'video' observable, except make it
// emit whenever the track is muted or the device changes
@@ -75,6 +75,12 @@ export function useSwitchCamera(
.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
})
.then(() => {
// Inform the MediaDeviceContext which camera was chosen
const deviceId =
track.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined) setVideoInput.current(deviceId);
})
.catch((e) =>
logger.error("Failed to switch camera", facingMode, e),
);
@@ -83,16 +89,5 @@ export function useSwitchCamera(
[videoTrack],
);
const setVideoInput = useLatest(mediaDevices.videoInput.select);
useEffect(() => {
// Watch for device changes due to switching the camera and feed them back
// into the MediaDeviceContext
const subscription = videoTrack.subscribe((track) => {
const deviceId = track?.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined) setVideoInput.current(deviceId);
});
return (): void => subscription.unsubscribe();
}, [videoTrack, setVideoInput]);
return useObservableEagerState(switchCamera);
}

View File

@@ -9,8 +9,7 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { expect, test, vi } from "vitest";
import { enterRTCSession } from "../src/rtcSessionHelpers";
import { Config } from "../src/config/Config";
import { DEFAULT_CONFIG } from "./config/ConfigOptions";
import { mockConfig } from "./utils/test";
test("It joins the correct Session", async () => {
const focusFromOlderMembership = {
@@ -34,8 +33,7 @@ test("It joins the correct Session", async () => {
],
};
vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG,
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
const mockedSession = vi.mocked({

View File

@@ -0,0 +1,18 @@
.selection {
gap: 0;
}
.title {
color: var(--cpd-color-text-secondary);
margin-block: var(--cpd-space-3x) 0;
}
.separator {
margin-block: 6px var(--cpd-space-4x);
}
.options {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
}

View File

@@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ChangeEvent, FC, useCallback, useId } from "react";
import {
Heading,
InlineField,
Label,
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { MediaDevice } from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css";
interface Props {
devices: MediaDevice;
caption: string;
}
export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
const groupId = useId();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
devices.select(e.target.value);
},
[devices],
);
if (devices.available.length == 0) return null;
return (
<div className={styles.selection}>
<Heading
type="body"
weight="semibold"
size="sm"
as="h4"
className={styles.title}
>
{caption}
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{devices.available.map(({ deviceId, label }, index) => (
<InlineField
key={deviceId}
name={groupId}
control={
<RadioControl
checked={deviceId === devices.selectedId}
onChange={onChange}
value={deviceId}
/>
}
>
<Label>
{!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`}
</Label>
</InlineField>
))}
</div>
</div>
);
};

View File

@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
.content {
width: 100%;
max-width: 350px;
align-self: center;
margin-left: auto;
margin-right: auto;
}
.avatarFieldRow {

View File

@@ -8,7 +8,9 @@ Please see LICENSE in the repository root for full details.
import { ChangeEvent, FC, ReactNode, useCallback } from "react";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Dropdown, Separator, Text } from "@vector-im/compound-web";
import { Root as Form, Separator, Text } from "@vector-im/compound-web";
import { BackgroundBlur } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/src/logger";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
@@ -19,7 +21,6 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import {
useMediaDevices,
MediaDevice,
useMediaDeviceNames,
} from "../livekit/MediaDevicesContext";
import { widget } from "../widget";
@@ -27,12 +28,14 @@ import {
useSetting,
developerSettingsTab as developerSettingsTabSetting,
duplicateTiles as duplicateTilesSetting,
backgroundBlur as backgroundBlurSetting,
useOptInAnalytics,
soundEffectVolumeSetting,
} from "./settings";
import { isFirefox } from "../Platform";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
type SettingsTab =
| "audio"
@@ -70,37 +73,39 @@ export const SettingsModal: FC<Props> = ({
);
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
// Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = (
devices: MediaDevice,
caption: string,
): ReactNode => {
if (devices.available.length == 0) return null;
const values = devices.available.map(
({ deviceId, label }, index) =>
[
deviceId,
!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`,
] as [string, string],
);
// Generate a `Checkbox` input to turn blur on or off.
const BlurCheckbox: React.FC = (): ReactNode => {
const [blur, setBlur] = useSetting(backgroundBlurSetting);
let canBlur = true;
try {
// eslint-disable-next-line new-cap
BackgroundBlur(15);
} catch (e) {
logger.debug(
"Cannot blur, so we do not show the option in settings. error: ",
e,
);
canBlur = false;
setBlur(false);
}
return (
<Dropdown
label={caption}
defaultValue={
devices.selectedId === "" || !devices.selectedId
? "default"
: devices.selectedId
}
onValueChange={(id): void => devices.select(id)}
values={values}
// XXX This is unused because we set a defaultValue. The component
// shouldn't require this prop.
placeholder=""
/>
<>
<h4>{t("settings.background_blur_header")}</h4>
<FieldRow>
<InputField
id="activateBackgroundBlur"
label={t("settings.background_blur_label")}
description={
canBlur ? "" : t("settings.blur_not_supported_by_browser")
}
type="checkbox"
checked={blur}
onChange={(b): void => setBlur(b.target.checked)}
disabled={!canBlur}
/>
</FieldRow>
</>
);
};
@@ -125,25 +130,30 @@ export const SettingsModal: FC<Props> = ({
name: t("common.audio"),
content: (
<>
{generateDeviceSelection(devices.audioInput, t("common.microphone"))}
{!isFirefox() &&
generateDeviceSelection(
devices.audioOutput,
t("settings.speaker_device_selection_label"),
)}
<Separator />
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider
label={t("video_tile.volume")}
value={soundVolume}
onValueChange={setSoundVolume}
min={0}
max={1}
step={0.01}
<Form>
<DeviceSelection
devices={devices.audioInput}
caption={t("common.microphone")}
/>
</div>
{!isFirefox() && (
<DeviceSelection
devices={devices.audioOutput}
caption={t("settings.speaker_device_selection_label")}
/>
)}
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider
label={t("video_tile.volume")}
value={soundVolume}
onValueChange={setSoundVolume}
min={0}
max={1}
step={0.01}
/>
</div>
</Form>
</>
),
};
@@ -151,7 +161,18 @@ export const SettingsModal: FC<Props> = ({
const videoTab: Tab<SettingsTab> = {
key: "video",
name: t("common.video"),
content: generateDeviceSelection(devices.videoInput, t("common.camera")),
content: (
<>
<Form>
<DeviceSelection
devices={devices.videoInput}
caption={t("common.camera")}
/>
</Form>
<Separator />
<BlurCheckbox />
</>
),
};
const preferencesTab: Tab<SettingsTab> = {
@@ -217,6 +238,11 @@ export const SettingsModal: FC<Props> = ({
name: t("settings.developer_tab_title"),
content: (
<>
<p>
{t("developer_mode.hostname", {
hostname: window.location.hostname || "unknown",
})}
</p>
<p>
{t("version", {
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
@@ -224,17 +250,17 @@ export const SettingsModal: FC<Props> = ({
})}
</p>
<p>
{t("crypto_version", {
{t("developer_mode.crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
})}
</p>
<p>
{t("matrix_id", {
{t("developer_mode.matrix_id", {
id: client.getUserId() || "unknown",
})}
</p>
<p>
{t("device_id", {
{t("developer_mode.device_id", {
id: client.getDeviceId() || "unknown",
})}
</p>
@@ -242,11 +268,15 @@ export const SettingsModal: FC<Props> = ({
<InputField
id="duplicateTiles"
type="number"
label={t("settings.duplicate_tiles_label")}
label={t("developer_mode.duplicate_tiles_label")}
value={duplicateTiles.toString()}
min={0}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const value = event.target.valueAsNumber;
if (value < 0) {
return;
}
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
},
[setDuplicateTiles],

View File

@@ -88,6 +88,8 @@ export const videoInput = new Setting<string | undefined>(
undefined,
);
export const backgroundBlur = new Setting<boolean>("background-blur", false);
export const showHandRaisedTimer = new Setting<boolean>(
"hand-raised-show-timer",
false,

View File

@@ -19,4 +19,6 @@ The following sound effects have been originally created by Element.
- `end_talk`
- `start_talk_local`
- `start_talk_remote`
- `join_call`
- `end_call`
- `reactions/rock`

BIN
src/sound/join_call.mp3 Normal file

Binary file not shown.

BIN
src/sound/join_call.ogg Normal file

Binary file not shown.

BIN
src/sound/left_call.mp3 Normal file

Binary file not shown.

BIN
src/sound/left_call.ogg Normal file

Binary file not shown.

View File

@@ -20,6 +20,7 @@ import {
ConnectionState,
LocalParticipant,
Participant,
ParticipantEvent,
RemoteParticipant,
} from "livekit-client";
import * as ComponentsCore from "@livekit/components-core";
@@ -30,7 +31,7 @@ import {
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMember,
mockMatrixRoomMember,
mockRemoteParticipant,
withTestScheduler,
} from "../utils/test";
@@ -42,10 +43,10 @@ import { E2eeType } from "../e2ee/e2eeType";
vi.mock("@livekit/components-core");
const alice = mockMember({ userId: "@alice:example.org" });
const bob = mockMember({ userId: "@bob:example.org" });
const carol = mockMember({ userId: "@carol:example.org" });
const dave = mockMember({ userId: "@dave:example.org" });
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
const carol = mockMatrixRoomMember({ userId: "@carol:example.org" });
const dave = mockMatrixRoomMember({ userId: "@dave:example.org" });
const aliceId = `${alice.userId}:AAAA`;
const bobId = `${bob.userId}:BBBB`;
@@ -188,11 +189,15 @@ function withCallViewModel(
);
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p) =>
(speaking.get(p) ?? of(false)).pipe(
map((s) => ({ ...p, isSpeaking: s }) as Participant),
),
);
.mockImplementation((p, ...eventTypes) => {
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
return (speaking.get(p) ?? of(false)).pipe(
map((s) => ({ ...p, isSpeaking: s }) as Participant),
);
} else {
return of(p);
}
});
const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector")
@@ -229,52 +234,55 @@ function withCallViewModel(
}
test("participants are retained during a focus switch", () => {
withTestScheduler(({ cold, expectObservable }) => {
withTestScheduler(({ hot, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3
const participantMarbles = "a-ba";
const participantInputMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3
const connectionMarbles = " cs-c";
const connectionInputMarbles = " cs-c";
// The visible participants should remain the same throughout the switch
const layoutMarbles = " a";
const expectedLayoutMarbles = " a";
withCallViewModel(
cold(participantMarbles, {
hot(participantInputMarbles, {
a: [aliceParticipant, bobParticipant],
b: [],
}),
cold(connectionMarbles, {
hot(connectionInputMarbles, {
c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus,
}),
new Map(),
(vm) => {
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
},
});
);
},
);
});
});
test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit
const participantMarbles = " abcda-ba";
const participantInputMarbles = " abcda-ba";
// While there are no screen shares, switch to spotlight manually, and then
// switch back to grid at the end
const modeMarbles = " -----s--g";
const modeInputMarbles = " -----s--g";
// We should automatically enter spotlight for the first round of screen
// sharing, then return to grid, then manually go into spotlight, and
// remain in spotlight until we manually go back to grid
const layoutMarbles = " abcdaefeg";
const showSpeakingMarbles = "y----nyny";
const expectedLayoutMarbles = " abcdaefeg";
const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel(
cold(participantMarbles, {
hot(participantInputMarbles, {
a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen],
@@ -283,80 +291,89 @@ test("screen sharing activates spotlight layout", () => {
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule(modeMarbles, {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"),
});
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [
`${aliceId}:0:screen-share`,
`${bobId}:0:screen-share`,
],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
e: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
f: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
g: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
},
b: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
);
expectObservable(vm.showSpeakingIndicators).toBe(
expectedShowSpeakingMarbles,
{
y: true,
n: false,
},
c: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`, `${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
e: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
f: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
g: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
});
expectObservable(vm.showSpeakingIndicators).toBe(showSpeakingMarbles, {
y: true,
n: false,
});
);
},
);
});
});
test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => {
const modeMarbles = "a";
withTestScheduler(({ hot, schedule, expectObservable }) => {
const modeInputMarbles = " a";
// First Bob speaks, then Dave, then Alice
const aSpeakingMarbles = "n- 1998ms - 1999ms y";
const bSpeakingMarbles = "ny 1998ms n 1999ms ";
const dSpeakingMarbles = "n- 1998ms y 1999ms n";
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = "ny 1998ms n 1999ms ";
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who
// hasn't spoken at all. Then when Alice speaks, she should return to her
// place at the top.
const layoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]),
of(ConnectionState.Connected),
new Map([
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
(vm) => {
schedule(modeMarbles, {
schedule(modeInputMarbles, {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
@@ -369,75 +386,81 @@ test("participants stay in the same order unless to appear/disappear", () => {
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
},
},
b: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
},
});
);
},
);
});
});
test("spotlight speakers swap places", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test
const modeMarbles = " s";
const modeInputMarbles = " s";
// First Bob speaks, then Dave, then Alice
const aSpeakingMarbles = "n--y";
const bSpeakingMarbles = "nyn";
const dSpeakingMarbles = "n-yn";
const aSpeakingInputMarbles = "n--y";
const bSpeakingInputMarbles = "nyn";
const dSpeakingInputMarbles = "n-yn";
// Alice should start in the spotlight, then Bob, then Dave, then Alice
// again. However, the positions of Dave and Bob in the grid should be
// reversed by the end because they've been swapped in and out of the
// spotlight.
const layoutMarbles = " abcd";
const expectedLayoutMarbles = "abcd";
withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]),
of(ConnectionState.Connected),
new Map([
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
(vm) => {
schedule(modeMarbles, { s: () => vm.setGridMode("spotlight") });
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0`],
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [`${daveId}:0`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
},
},
b: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0`],
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [`${daveId}:0`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
},
});
);
},
);
});
@@ -446,31 +469,34 @@ test("spotlight speakers swap places", () => {
test("layout enters picture-in-picture mode when requested", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// Enable then disable picture-in-picture
const pipControlMarbles = "-ed";
const pipControlInputMarbles = "-ed";
// Should go into picture-in-picture layout then back to grid
const layoutMarbles = " aba";
const expectedLayoutMarbles = " aba";
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule(pipControlMarbles, {
schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(),
d: () => window.controls.disablePip(),
});
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "pip",
spotlight: [`${aliceId}:0`],
},
},
b: {
type: "pip",
spotlight: [`${aliceId}:0`],
},
});
);
},
);
});
@@ -480,23 +506,23 @@ test("spotlight remembers whether it's expanded", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// Start in spotlight mode, then switch to grid and back to spotlight a
// couple times
const modeMarbles = " s-gs-gs";
const modeInputMarbles = " s-gs-gs";
// Expand and collapse the spotlight
const expandMarbles = "-a--a";
const expandInputMarbles = " -a--a";
// Spotlight should stay expanded during the first mode switch, and stay
// collapsed during the second mode switch
const layoutMarbles = "abcbada";
const expectedLayoutMarbles = "abcbada";
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule(modeMarbles, {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"),
});
schedule(expandMarbles, {
schedule(expandInputMarbles, {
a: () => {
let toggle: () => void;
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
@@ -504,28 +530,31 @@ test("spotlight remembers whether it's expanded", () => {
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
a: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: "local:0",
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
},
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: "local:0",
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
});
);
},
);
});

View File

@@ -27,7 +27,6 @@ import {
EMPTY,
Observable,
Subject,
audit,
combineLatest,
concat,
distinctUntilChanged,
@@ -76,6 +75,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -248,23 +248,7 @@ class UserMedia {
livekitRoom,
);
this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
// continuous silence to stop being considered a speaker
audit((s) =>
merge(
timer(s ? 1000 : 60000),
// If the speaking flag resets to its original value during this time,
// end the silencing window to stick with that original value
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
),
),
startWith(false),
distinctUntilChanged(),
// Make this Observable hot so that the timers don't reset when you
// resubscribe
this.scope.state(),
);
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
this.presenter = observeParticipantEvents(
participant,
@@ -307,7 +291,7 @@ class ScreenShare {
type MediaItem = UserMedia | ScreenShare;
function findMatrixMember(
function findMatrixRoomMember(
room: MatrixRoom,
id: string,
): RoomMember | undefined {
@@ -342,12 +326,16 @@ export class CallViewModel extends ViewModel {
}),
);
private readonly rawRemoteParticipants = connectedParticipantsObserver(
this.livekitRoom,
).pipe(this.scope.state());
/**
* The raw list of RemoteParticipants as reported by LiveKit
*/
private readonly rawRemoteParticipants: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
// Lists of participants to "hold" on display, even if LiveKit claims that
// they've left
/**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left
*/
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
this.connectionState.pipe(
withLatestFrom(this.rawRemoteParticipants),
@@ -382,6 +370,9 @@ export class CallViewModel extends ViewModel {
),
);
/**
* The RemoteParticipants including those that are being "held" on the screen
*/
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds],
@@ -403,6 +394,9 @@ export class CallViewModel extends ViewModel {
},
);
/**
* List of MediaItems that we want to display
*/
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
@@ -419,7 +413,7 @@ export class CallViewModel extends ViewModel {
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) {
const id = p === localParticipant ? "local" : p.identity;
const member = findMatrixMember(this.matrixRoom, id);
const member = findMatrixRoomMember(this.matrixRoom, id);
if (member === undefined)
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
@@ -472,17 +466,36 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
/**
* List of MediaItems that we want to display, that are of type UserMedia
*/
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
);
public readonly memberChanges = this.userMedia
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
(prev, ids) => {
const left = prev.ids.filter((id) => !ids.includes(id));
const joined = ids.filter((id) => !prev.ids.includes(id));
return { ids, joined, left };
},
{ ids: [], joined: [], left: [] },
),
);
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
this.mediaItems.pipe(
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
map((mediaItems) =>
@@ -941,7 +954,7 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
public readonly showFooter = this.windowMode.pipe(
public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":

View File

@@ -0,0 +1,119 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { describe, test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { observeSpeaker } from "./observeSpeaker";
const yesNo = {
y: true,
n: false,
};
describe("observeSpeaker", () => {
describe("does not activate", () => {
const expectedOutputMarbles = "n";
test("starts correctly", () => {
// should default to false when no input is given
const speakingInputMarbles = "";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("after no speaking", () => {
const speakingInputMarbles = "n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("with speaking for 1ms", () => {
const speakingInputMarbles = "y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("with speaking for 999ms", () => {
const speakingInputMarbles = "y 999ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("with speaking intermittently", () => {
const speakingInputMarbles =
"y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("with consecutive speaking then stops speaking", () => {
const speakingInputMarbles = "y y y y y y y y y y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
});
describe("activates", () => {
test("after 1s", () => {
// this will active after 1s as no `n` follows it:
const speakingInputMarbles = " y";
const expectedOutputMarbles = "n 999ms y";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("speaking for 1001ms activates for 60s", () => {
const speakingInputMarbles = " y 1s n ";
const expectedOutputMarbles = "n 999ms y 60s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
test("speaking for 5s activates for 64s", () => {
const speakingInputMarbles = " y 5s n ";
const expectedOutputMarbles = "n 999ms y 64s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
});
});
});
});

View File

@@ -0,0 +1,36 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
Observable,
audit,
merge,
timer,
filter,
startWith,
distinctUntilChanged,
} from "rxjs";
/**
* Require 1 second of continuous speaking to become a speaker, and 60 second of
* continuous silence to stop being considered a speaker
*/
export function observeSpeaker(
isSpeakingObservable: Observable<boolean>,
): Observable<boolean> {
const distinct = isSpeakingObservable.pipe(distinctUntilChanged());
return distinct.pipe(
// Either change to the new value after the timer or re-emit the same value if it toggles back
// (audit will return the latest (toggled back) value) before the timeout.
audit((s) =>
merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))),
),
// Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->..
startWith(false),
distinctUntilChanged(),
);
}

View File

@@ -97,7 +97,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
},
[vm],
);
const { raisedHands, lowerHand, reactions } = useReactions();
const { raisedHands, toggleRaisedHand, reactions } = useReactions();
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
@@ -127,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
const currentReaction: ReactionOption | undefined =
reactions[vm.member?.userId ?? ""];
const raisedHandOnClick =
vm.local && handRaised ? (): void => void lowerHand() : undefined;
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;

View File

@@ -34,10 +34,6 @@ Please see LICENSE in the repository root for full details.
object-fit: contain;
}
.media.videoMuted video {
display: none;
}
.bg {
background-color: var(--cpd-color-bg-subtle-secondary);
inline-size: 100%;
@@ -47,7 +43,6 @@ Please see LICENSE in the repository root for full details.
}
.avatar {
display: none;
position: absolute;
top: 50%;
left: 50%;
@@ -55,10 +50,6 @@ Please see LICENSE in the repository root for full details.
pointer-events: none;
}
.media.videoMuted .avatar {
display: initial;
}
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cqmin units */
@container mediaView (width > 0) {

117
src/tile/MediaView.test.tsx Normal file
View File

@@ -0,0 +1,117 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { TooltipProvider } from "@vector-im/compound-web";
import {
TrackReference,
TrackReferencePlaceholder,
} from "@livekit/components-core";
import { Track, TrackPublication } from "livekit-client";
import { type ComponentProps } from "react";
import { MediaView } from "./MediaView";
import { EncryptionStatus } from "../state/MediaViewModel";
import { mockLocalParticipant } from "../utils/test";
describe("MediaView", () => {
const participant = mockLocalParticipant({});
const trackReferencePlaceholder: TrackReferencePlaceholder = {
participant,
source: Track.Source.Camera,
};
const trackReference: TrackReference = {
...trackReferencePlaceholder,
publication: new TrackPublication(Track.Kind.Video, "id", "name"),
};
const baseProps: ComponentProps<typeof MediaView> = {
displayName: "some name",
videoEnabled: true,
videoFit: "contain",
targetWidth: 300,
targetHeight: 200,
encryptionStatus: EncryptionStatus.Connecting,
mirror: false,
unencryptedWarning: false,
video: trackReference,
member: undefined,
};
test("is accessible", async () => {
const { container } = render(<MediaView {...baseProps} />);
expect(await axe(container)).toHaveNoViolations();
});
describe("placeholder track", () => {
test("neither video nor avatar are shown", () => {
render(<MediaView {...baseProps} video={trackReferencePlaceholder} />);
expect(screen.queryByTestId("video")).toBeNull();
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
0,
);
});
});
describe("name tag", () => {
test("is shown with name", () => {
render(<MediaView {...baseProps} displayName="Bob" />);
expect(screen.getByTestId("name_tag")).toHaveTextContent("Bob");
});
});
describe("unencryptedWarning", () => {
test("is shown and accessible", async () => {
const { container } = render(
<TooltipProvider>
<MediaView {...baseProps} unencryptedWarning={true} />
</TooltipProvider>,
);
expect(await axe(container)).toHaveNoViolations();
expect(
screen.getByRole("img", { name: "common.unencrypted" }),
).toBeTruthy();
});
test("is not shown", () => {
render(
<TooltipProvider>
<MediaView {...baseProps} unencryptedWarning={false} />
</TooltipProvider>,
);
expect(
screen.queryAllByRole("img", { name: "common.unencrypted" }).length,
).toBe(0);
});
});
describe("videoEnabled", () => {
test("just video is visible", () => {
render(
<TooltipProvider>
<MediaView {...baseProps} videoEnabled={true} />
</TooltipProvider>,
);
expect(screen.getByTestId("video")).toBeVisible();
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
0,
);
});
test("just avatar is visible", () => {
render(
<TooltipProvider>
<MediaView {...baseProps} videoEnabled={false} />
</TooltipProvider>,
);
expect(screen.getByRole("img", { name: "some name" })).toBeVisible();
expect(screen.getByTestId("video")).not.toBeVisible();
});
});
});

View File

@@ -76,7 +76,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={ref}
@@ -91,6 +90,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
size={avatarSize}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
style={{ display: videoEnabled ? "none" : "initial" }}
/>
{video.publication !== undefined && (
<VideoTrack
@@ -98,11 +98,13 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
style={{ display: videoEnabled ? "block" : "none" }}
data-testid="video"
/>
)}
</div>
<div className={styles.fg}>
<div style={{ display: "flex" }}>
<div style={{ display: "flex", gap: "var(--cpd-space-1x)" }}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
@@ -133,7 +135,13 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)*/}
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
<Text
as="span"
size="sm"
weight="medium"
className={styles.name}
data-testid="name_tag"
>
{displayName}
</Text>
{unencryptedWarning && (
@@ -146,6 +154,8 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
width={20}
height={20}
className={styles.errorIcon}
role="img"
aria-label={t("common.unencrypted")}
/>
</Tooltip>
)}

View File

@@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
// The TestComponent just wraps a button around that hook.
interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void;
setMicrophoneMuted?: (muted: boolean) => void;
onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
}
const TestComponent: FC<TestComponentProps> = ({
setMicrophoneMuted,
setMicrophoneMuted = (): void => {},
onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
@@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
() => {},
() => {},
setMicrophoneMuted,
sendReaction,
toggleHandRaised,
);
return (
<div ref={ref}>
@@ -74,6 +81,38 @@ test("spacebar prioritizes pressing a button", async () => {
expect(onClick).toBeCalled();
});
test("reactions can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const sendReaction = vi.fn();
render(<TestComponent sendReaction={sendReaction} />);
for (let index = 1; index <= ReactionsRowSize; index++) {
await user.keyboard(index.toString());
expect(sendReaction).toHaveBeenNthCalledWith(index, ReactionSet[index - 1]);
}
});
test("reaction is not sent when modifier key is held", async () => {
const user = userEvent.setup();
const sendReaction = vi.fn();
render(<TestComponent sendReaction={sendReaction} />);
await user.keyboard("{Meta>}1{/Meta}");
expect(sendReaction).not.toHaveBeenCalled();
});
test("raised hand can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
render(<TestComponent toggleHandRaised={toggleHandRaised} />);
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { RefObject, useCallback, useMemo, useRef } from "react";
import { useEventTarget } from "./useEvents";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
/**
* Determines whether focus is in the same part of the tree as the given
@@ -18,11 +19,17 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
return focusedElement !== null && focusedElement.contains(e);
};
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
);
export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>,
toggleMicrophoneMuted: () => void,
toggleLocalVideoMuted: () => void,
setMicrophoneMuted: (muted: boolean) => void,
sendReaction: (reaction: ReactionOption) => void,
toggleHandRaised: () => void,
): void {
const spacebarHeld = useRef(false);
@@ -36,6 +43,8 @@ export function useCallViewKeyboardShortcuts(
(event: KeyboardEvent) => {
if (focusElement.current === null) return;
if (!mayReceiveKeyEvents(focusElement.current)) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
if (event.key === "m") {
event.preventDefault();
@@ -49,6 +58,12 @@ export function useCallViewKeyboardShortcuts(
spacebarHeld.current = true;
setMicrophoneMuted(false);
}
} else if (event.key === "h") {
event.preventDefault();
toggleHandRaised();
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
}
},
[
@@ -56,6 +71,8 @@ export function useCallViewKeyboardShortcuts(
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setMicrophoneMuted,
sendReaction,
toggleHandRaised,
],
),
// Because this is set on the window, to prevent shortcuts from activating

View File

@@ -40,7 +40,8 @@ interface ReactionsContextType {
raisedHands: Record<string, Date>;
supportsReactions: boolean;
reactions: Record<string, ReactionOption>;
lowerHand: () => Promise<void>;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
@@ -104,7 +105,6 @@ export const ReactionsProvider = ({
),
[raisedHands],
);
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
@@ -181,9 +181,18 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
const myMembership = useMemo(
() => memberships.find((m) => m.sender === myUserId)?.eventId,
[memberships, myUserId],
);
// This effect handles any *live* reaction/redactions in the room.
useEffect(() => {
const reactionTimeouts = new Set<number>();
// TODO: this should be somewhere more sensible
const handleTimelineReset = (): void => {
logger.warn("Received TimelineReset indicating limited sync response");
};
const handleReactionEvent = (event: MatrixEvent): void => {
// Decrypted events might come from a different room
if (event.getRoomId() !== room.roomId) return;
@@ -297,6 +306,7 @@ export const ReactionsProvider = ({
}
};
room.on(MatrixRoomEvent.TimelineReset, handleTimelineReset);
room.on(MatrixRoomEvent.Timeline, handleReactionEvent);
room.on(MatrixRoomEvent.Redaction, handleReactionEvent);
room.client.on(MatrixEventEvent.Decrypted, handleReactionEvent);
@@ -306,6 +316,7 @@ export const ReactionsProvider = ({
room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
return (): void => {
room.off(MatrixRoomEvent.TimelineReset, handleTimelineReset);
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
room.client.off(MatrixEventEvent.Decrypted, handleReactionEvent);
@@ -322,22 +333,67 @@ export const ReactionsProvider = ({
latestRaisedHands,
]);
const lowerHand = useCallback(async () => {
if (!myUserId || !raisedHands[myUserId]) {
const toggleRaisedHand = useCallback(async () => {
if (!myUserId) {
return;
}
const myReactionId = raisedHands[myUserId].reactionEventId;
const myReactionId = raisedHands[myUserId]?.reactionEventId;
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
try {
if (!myMembership) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembership,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
}
}, [myUserId, raisedHands, rtcSession, room]);
}, [myMembership, myUserId, raisedHands, rtcSession, room]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myUserId || reactions[myUserId]) {
// We're still reacting
return;
}
if (!myMembership) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembership,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
},
[myMembership, reactions, room, myUserId, rtcSession],
);
return (
<ReactionsContext.Provider
@@ -345,7 +401,8 @@ export const ReactionsProvider = ({
raisedHands: resultRaisedHands,
supportsReactions,
reactions,
lowerHand,
toggleRaisedHand,
sendReaction,
}}
>
{children}

View File

@@ -9,6 +9,7 @@ import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import {
createClient,
Filter,
ICreateClientOpts,
Preset,
Visibility,
@@ -123,7 +124,6 @@ export async function initClient(
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
store: new MemoryStore(),
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
@@ -165,7 +165,20 @@ export async function initClient(
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await client.startClient({
clientWellKnownPollPeriod: 60 * 10,
// ask for a high limit to try and avoid gappy syncs
filter: Filter.fromJson(undefined, "element-call", {
room: {
timeline: {
limit: 1000,
},
},
}),
// we ask for 20 past message to try and get some recent context
// n.b. past reactions are not guaranteed to be visible
initialSyncLimit: 20,
});
await syncPromise;
return client;

View File

@@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { map, Observable, of } from "rxjs";
import { map, Observable, of, SchedulerLike } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest";
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
@@ -14,13 +14,17 @@ import {
RemoteParticipant,
RemoteTrackPublication,
Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import { EventEmitter } from "stream";
import {
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
} from "../state/MediaViewModel";
import { E2eeType } from "../e2ee/e2eeType";
import { DEFAULT_CONFIG, ResolvedConfigOptions } from "../config/ConfigOptions";
import { Config } from "../config/Config";
export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers();
@@ -39,15 +43,23 @@ export interface OurRunHelpers extends RunHelpers {
schedule: (marbles: string, actions: Record<string, () => void>) => void;
}
interface TestRunnerGlobal {
rxjsTestScheduler?: SchedulerLike;
}
/**
* Run Observables with a scheduler that virtualizes time, for testing purposes.
*/
export function withTestScheduler(
continuation: (helpers: OurRunHelpers) => void,
): void {
new TestScheduler((actual, expected) => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equals(expected);
}).run((helpers) =>
});
// we set the test scheduler as a global so that you can watch it in a debugger
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
scheduler.run((helpers) =>
continuation({
...helpers,
schedule(marbles, actions) {
@@ -91,7 +103,7 @@ function mockEmitter<T>(): EmitterMock<T> {
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general.
export function mockMember(member: Partial<RoomMember>): RoomMember {
export function mockMatrixRoomMember(member: Partial<RoomMember>): RoomMember {
return { ...mockEmitter(), ...member } as RoomMember;
}
@@ -99,6 +111,33 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
}
/**
* A mock of a Livekit Room that can emit events.
*/
export class EmittableMockLivekitRoom extends EventEmitter {
public localParticipant?: LocalParticipant;
public remoteParticipants: Map<string, RemoteParticipant>;
public constructor(room: {
localParticipant?: LocalParticipant;
remoteParticipants: Map<string, RemoteParticipant>;
}) {
super();
this.localParticipant = room.localParticipant;
this.remoteParticipants = room.remoteParticipants ?? new Map();
}
public addParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.set(remoteParticipant.identity, remoteParticipant);
this.emit(RoomEvent.ParticipantConnected, remoteParticipant);
}
public removeParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.delete(remoteParticipant.identity);
this.emit(RoomEvent.ParticipantDisconnected, remoteParticipant);
}
}
export function mockLivekitRoom(
room: Partial<LivekitRoom>,
{
@@ -141,7 +180,7 @@ export async function withLocalMedia(
const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel(
"local",
mockMember(member),
mockMatrixRoomMember(member),
localParticipant,
{
kind: E2eeType.PER_PARTICIPANT,
@@ -176,7 +215,7 @@ export async function withRemoteMedia(
const remoteParticipant = mockRemoteParticipant(participant);
const vm = new RemoteUserMediaViewModel(
"remote",
mockMember(member),
mockMatrixRoomMember(member),
remoteParticipant,
{
kind: E2eeType.PER_PARTICIPANT,
@@ -189,3 +228,19 @@ export async function withRemoteMedia(
vm.destroy();
}
}
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG,
...config,
});
}
export function mockMediaPlay(): string[] {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
return audioIsPlaying;
}

View File

@@ -15,6 +15,7 @@ import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "vitest-axe/extend-expect";
import { logger } from "matrix-js-sdk/src/logger";
import "@testing-library/jest-dom/vitest";
import EN_GB from "../locales/en-GB/app.json";
import { Config } from "./config/Config";

View File

@@ -82,6 +82,10 @@ export default defineConfig(({ mode }) => {
// Default naming fallback
return "assets/[name]-[hash][extname]";
},
manualChunks: {
// we should be able to remove this one https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/167 lands
"matrix-sdk-crypto-wasm": ["@matrix-org/matrix-sdk-crypto-wasm"],
},
},
},
},

936
yarn.lock

File diff suppressed because it is too large Load Diff