mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +00:00
Merge remote-tracking branch 'origin/livekit' into hs/call-joined-left-sound
This commit is contained in:
@@ -37,6 +37,7 @@ module.exports = {
|
|||||||
"@typescript-eslint/promise-function-async": "error",
|
"@typescript-eslint/promise-function-async": "error",
|
||||||
"@typescript-eslint/require-await": "error",
|
"@typescript-eslint/require-await": "error",
|
||||||
"@typescript-eslint/await-thenable": "error",
|
"@typescript-eslint/await-thenable": "error",
|
||||||
|
"rxjs/no-exposed-subjects": "error",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<Location "/">
|
<Location "/">
|
||||||
# disable cache entriely by default (apart from Etag which is accurate enough)
|
# 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
|
CacheDisable on
|
||||||
ExpiresActive off
|
ExpiresActive off
|
||||||
|
|
||||||
|
|||||||
@@ -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>`.
|
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
|
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.
|
possible to support encryption.
|
||||||
The url parameters are spit into two categories: **general** and **widget related**.
|
|
||||||
|
|
||||||
## Widget related params
|
## Parameters
|
||||||
|
|
||||||
**widgetId**
|
| Name | Values | Required for widget | Required for SPA | Description |
|
||||||
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
|
| `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 doesn’t provide any. |
|
||||||
postMessage communication via the `parentUrl`.
|
| `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. |
|
||||||
```ts
|
| `baseUrl` | | Yes | Not applicable | The base URL of the homeserver to use for media lookups. |
|
||||||
widgetId: string | null;
|
| `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. |
|
||||||
**parentUrl**
|
| `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. |
|
||||||
The url used to send widget action postMessages. This should be the domain of
|
| `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. |
|
||||||
the client or the webview the widget is hosted in. (in case the widget is not
|
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||||
in an Iframe but in a dedicated webview we send the postMessages same webview
|
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
|
||||||
the widget lives in. Filtering is done in the widget so it ignores the messages
|
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||||
it receives from itself)
|
| `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. |
|
||||||
```ts
|
| `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) |
|
||||||
parentUrl: string | null;
|
| `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. |
|
||||||
**userId**
|
| `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. |
|
||||||
The user's ID (only used in matryoshka mode).
|
| `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. |
|
||||||
```ts
|
| `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.) |
|
||||||
userId: string | null;
|
| `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 user’s default homeserver. |
|
||||||
**deviceId**
|
| `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`. |
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"pick_reaction": "Reaktion auswählen",
|
"pick_reaction": "Reaktion auswählen",
|
||||||
"raise_hand": "Handmeldung",
|
"raise_hand": "Handmeldung",
|
||||||
"raise_hand_or_send_reaction": "Handmeldung oder Reaktion senden",
|
|
||||||
"register": "Registrieren",
|
"register": "Registrieren",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
@@ -62,6 +61,7 @@
|
|||||||
"preferences": "Einstellungen",
|
"preferences": "Einstellungen",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"reaction": "Reaktion",
|
"reaction": "Reaktion",
|
||||||
|
"reactions": "Reaktionen",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"something_went_wrong": "Etwas ist schief gelaufen",
|
"something_went_wrong": "Etwas ist schief gelaufen",
|
||||||
"unencrypted": "Nicht verschlüsselt",
|
"unencrypted": "Nicht verschlüsselt",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"pick_reaction": "Pick reaction",
|
"pick_reaction": "Pick reaction",
|
||||||
"raise_hand": "Raise hand",
|
"raise_hand": "Raise hand",
|
||||||
"raise_hand_or_send_reaction": "Raise hand or send reaction",
|
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
@@ -62,6 +61,7 @@
|
|||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"reaction": "Reaction",
|
"reaction": "Reaction",
|
||||||
|
"reactions": "Reactions",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
"unencrypted": "Not encrypted",
|
"unencrypted": "Not encrypted",
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
"preferences_tab_show_hand_raised_timer_label": "Show hand raise duration",
|
"preferences_tab_show_hand_raised_timer_label": "Show hand raise duration",
|
||||||
"speaker_device_selection_label": "Speaker"
|
"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",
|
"star_rating_input_label_other": "{{count}} stars",
|
||||||
"start_new_call": "Start new call",
|
"start_new_call": "Start new call",
|
||||||
"start_video_button_label": "Start video",
|
"start_video_button_label": "Start video",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@livekit/components-react": "^2.0.0",
|
"@livekit/components-react": "^2.0.0",
|
||||||
"@opentelemetry/api": "^1.4.0",
|
"@opentelemetry/api": "^1.4.0",
|
||||||
"@opentelemetry/core": "^1.25.1",
|
"@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/resources": "^1.25.1",
|
||||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"@sentry/react": "^8.0.0",
|
"@sentry/react": "^8.0.0",
|
||||||
"@sentry/vite-plugin": "^2.0.0",
|
"@sentry/vite-plugin": "^2.0.0",
|
||||||
"@testing-library/dom": "^10.1.0",
|
"@testing-library/dom": "^10.1.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
"livekit-client": "^2.5.7",
|
"livekit-client": "^2.5.7",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"loglevel": "^1.9.1",
|
"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",
|
"matrix-widget-api": "^1.10.0",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"observable-hooks": "^4.2.3",
|
"observable-hooks": "^4.2.3",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:base"],
|
"extends": ["config:recommended"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"groupName": "all non-major dependencies",
|
"groupName": "all non-major dependencies",
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "Compound",
|
"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",
|
"groupName": "LiveKit client",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "LiveKit components",
|
"groupName": "LiveKit components",
|
||||||
"matchPackagePrefixes": ["@livekit/components-"]
|
"matchPackageNames": ["@livekit/components-{/,}**"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "Vaul",
|
"groupName": "Vaul",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
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 { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { getAvatarUrl } from "./utils/matrix";
|
import { getAvatarUrl } from "./utils/matrix";
|
||||||
@@ -33,6 +33,7 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
size?: Size | number;
|
size?: Size | number;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Avatar: FC<Props> = ({
|
export const Avatar: FC<Props> = ({
|
||||||
@@ -41,6 +42,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
size = Size.MD,
|
size = Size.MD,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
|
||||||
@@ -64,6 +67,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
name={name}
|
name={name}
|
||||||
size={`${sizePx}px`}
|
size={`${sizePx}px`}
|
||||||
src={resolvedSrc}
|
src={resolvedSrc}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getRoomIdentifierFromUrl } from "../src/UrlParams";
|
import { getRoomIdentifierFromUrl, getUrlParams } from "../src/UrlParams";
|
||||||
|
|
||||||
const ROOM_NAME = "roomNameHere";
|
const ROOM_NAME = "roomNameHere";
|
||||||
const ROOM_ID = "!d45f138fsd";
|
const ROOM_ID = "!d45f138fsd";
|
||||||
@@ -86,4 +86,18 @@ describe("UrlParams", () => {
|
|||||||
.roomAlias,
|
.roomAlias,
|
||||||
).toBeFalsy();
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -211,8 +211,11 @@ export const getUrlParams = (
|
|||||||
|
|
||||||
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
|
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
|
||||||
|
|
||||||
|
const widgetId = parser.getParam("widgetId");
|
||||||
|
const isWidget = !!widgetId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
widgetId: parser.getParam("widgetId"),
|
widgetId,
|
||||||
parentUrl: parser.getParam("parentUrl"),
|
parentUrl: parser.getParam("parentUrl"),
|
||||||
|
|
||||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||||
@@ -224,7 +227,7 @@ export const getUrlParams = (
|
|||||||
confineToRoom:
|
confineToRoom:
|
||||||
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
|
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
|
||||||
appPrompt: parser.getFlagParam("appPrompt", true),
|
appPrompt: parser.getFlagParam("appPrompt", true),
|
||||||
preload: parser.getFlagParam("preload"),
|
preload: isWidget ? parser.getFlagParam("preload") : false,
|
||||||
hideHeader: parser.getFlagParam("hideHeader"),
|
hideHeader: parser.getFlagParam("hideHeader"),
|
||||||
showControls: parser.getFlagParam("showControls", true),
|
showControls: parser.getFlagParam("showControls", true),
|
||||||
hideScreensharing: parser.getFlagParam("hideScreensharing"),
|
hideScreensharing: parser.getFlagParam("hideScreensharing"),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
@@ -29,18 +28,13 @@ const membership: Record<string, string> = {
|
|||||||
|
|
||||||
function TestComponent({
|
function TestComponent({
|
||||||
rtcSession,
|
rtcSession,
|
||||||
room,
|
|
||||||
}: {
|
}: {
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
room: MockRoom;
|
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||||
<ReactionToggleButton
|
<ReactionToggleButton userId={memberUserIdAlice} />
|
||||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
|
||||||
client={room.client}
|
|
||||||
/>
|
|
||||||
</TestReactionsWrapper>
|
</TestReactionsWrapper>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
@@ -51,9 +45,9 @@ test("Can open menu", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
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();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,9 +56,9 @@ test("Can raise hand", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
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"));
|
await user.click(getByLabelText("action.raise_hand"));
|
||||||
expect(room.testSentEvents).toEqual([
|
expect(room.testSentEvents).toEqual([
|
||||||
[
|
[
|
||||||
@@ -87,10 +81,10 @@ test("Can lower hand", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
const { getByLabelText, container } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
|
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"));
|
await user.click(getByLabelText("action.lower_hand"));
|
||||||
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
|
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -101,9 +95,9 @@ test("Can react with emoji", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, getByText } = render(
|
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("🐶"));
|
await user.click(getByText("🐶"));
|
||||||
expect(room.testSentEvents).toEqual([
|
expect(room.testSentEvents).toEqual([
|
||||||
[
|
[
|
||||||
@@ -126,9 +120,9 @@ test("Can fully expand emoji picker", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByText, container, getByLabelText } = render(
|
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("common.reactions"));
|
||||||
await user.click(getByLabelText("action.show_more"));
|
await user.click(getByLabelText("action.show_more"));
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
await user.click(getByText("🦗"));
|
await user.click(getByText("🦗"));
|
||||||
@@ -149,14 +143,14 @@ test("Can fully expand emoji picker", async () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Can close search", async () => {
|
test("Can close reaction dialog", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
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.show_more"));
|
await user.click(getByLabelText("action.show_more"));
|
||||||
await user.click(getByLabelText("action.show_less"));
|
await user.click(getByLabelText("action.show_less"));
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ 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, Alert } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
RaisedHandSolidIcon,
|
RaisedHandSolidIcon,
|
||||||
ReactionIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
ReactionSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import {
|
import {
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
@@ -23,19 +23,11 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 classNames from "classnames";
|
||||||
|
|
||||||
import { useReactions } from "../useReactions";
|
import { useReactions } from "../useReactions";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
|
||||||
import styles from "./ReactionToggleButton.module.css";
|
import styles from "./ReactionToggleButton.module.css";
|
||||||
import {
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
|
||||||
ReactionOption,
|
|
||||||
ReactionSet,
|
|
||||||
ElementCallReactionEventType,
|
|
||||||
} from "../reactions";
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
|
|
||||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
@@ -47,15 +39,14 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
|
<Tooltip label={t("common.reactions")}>
|
||||||
<CpdButton
|
<CpdButton
|
||||||
className={classNames(raised && styles.raisedButton)}
|
className={classNames(raised && styles.raisedButton)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup
|
aria-haspopup
|
||||||
aria-label={t("action.raise_hand_or_send_reaction")}
|
|
||||||
kind={raised || open ? "primary" : "secondary"}
|
kind={raised || open ? "primary" : "secondary"}
|
||||||
iconOnly
|
iconOnly
|
||||||
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
|
Icon={raised ? RaisedHandSolidIcon : ReactionSolidIcon}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -96,9 +87,10 @@ export function ReactionPopupMenu({
|
|||||||
)}
|
)}
|
||||||
<div className={styles.reactionPopupMenu}>
|
<div className={styles.reactionPopupMenu}>
|
||||||
<section className={styles.handRaiseSection}>
|
<section className={styles.handRaiseSection}>
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label} caption="H">
|
||||||
<CpdButton
|
<CpdButton
|
||||||
kind={isHandRaised ? "primary" : "secondary"}
|
kind={isHandRaised ? "primary" : "secondary"}
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-pressed={isHandRaised}
|
aria-pressed={isHandRaised}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => toggleRaisedHand()}
|
onClick={() => toggleRaisedHand()}
|
||||||
@@ -115,14 +107,26 @@ export function ReactionPopupMenu({
|
|||||||
styles.reactionsMenu,
|
styles.reactionsMenu,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filteredReactionSet.map((reaction) => (
|
{filteredReactionSet.map((reaction, index) => (
|
||||||
<li key={reaction.name}>
|
<li key={reaction.name}>
|
||||||
<Tooltip label={reaction.name}>
|
<Tooltip
|
||||||
|
label={reaction.name}
|
||||||
|
caption={
|
||||||
|
index < ReactionsRowSize
|
||||||
|
? (index + 1).toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<CpdButton
|
<CpdButton
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
className={styles.reactionButton}
|
className={styles.reactionButton}
|
||||||
disabled={!canReact}
|
disabled={!canReact}
|
||||||
onClick={() => sendReaction(reaction)}
|
onClick={() => sendReaction(reaction)}
|
||||||
|
aria-keyshortcuts={
|
||||||
|
index < ReactionsRowSize
|
||||||
|
? (index + 1).toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{reaction.emoji}
|
{reaction.emoji}
|
||||||
</CpdButton>
|
</CpdButton>
|
||||||
@@ -154,52 +158,33 @@ export function ReactionPopupMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
rtcSession: MatrixRTCSession;
|
userId: string;
|
||||||
client: MatrixClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReactionToggleButton({
|
export function ReactionToggleButton({
|
||||||
client,
|
userId,
|
||||||
rtcSession,
|
|
||||||
...props
|
...props
|
||||||
}: ReactionToggleButtonProps): ReactNode {
|
}: ReactionToggleButtonProps): ReactNode {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
|
||||||
|
useReactions();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const userId = client.getUserId()!;
|
|
||||||
const isHandRaised = !!raisedHands[userId];
|
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
|
||||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||||
const [errorText, setErrorText] = useState<string>();
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
|
||||||
|
const isHandRaised = !!raisedHands[userId];
|
||||||
|
const canReact = !reactions[userId];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear whenever the reactions menu state changes.
|
// Clear whenever the reactions menu state changes.
|
||||||
setErrorText(undefined);
|
setErrorText(undefined);
|
||||||
}, [showReactionsMenu]);
|
}, [showReactionsMenu]);
|
||||||
|
|
||||||
const canReact = !reactions[userId];
|
|
||||||
|
|
||||||
const sendRelation = useCallback(
|
const sendRelation = useCallback(
|
||||||
async (reaction: ReactionOption) => {
|
async (reaction: ReactionOption) => {
|
||||||
try {
|
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);
|
setBusy(true);
|
||||||
await client.sendEvent(
|
await sendReaction(reaction);
|
||||||
rtcSession.room.roomId,
|
|
||||||
ElementCallReactionEventType,
|
|
||||||
{
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: RelationType.Reference,
|
|
||||||
event_id: parentEventId,
|
|
||||||
},
|
|
||||||
emoji: reaction.emoji,
|
|
||||||
name: reaction.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setErrorText(undefined);
|
setErrorText(undefined);
|
||||||
setShowReactionsMenu(false);
|
setShowReactionsMenu(false);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -209,59 +194,25 @@ export function ReactionToggleButton({
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[memberships, client, userId, rtcSession],
|
[sendReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleRaisedHand = useCallback(() => {
|
const wrappedToggleRaisedHand = useCallback(() => {
|
||||||
const raiseHand = async (): Promise<void> => {
|
const toggleHand = async (): Promise<void> => {
|
||||||
if (isHandRaised) {
|
try {
|
||||||
try {
|
setBusy(true);
|
||||||
setBusy(true);
|
await toggleRaisedHand();
|
||||||
await lowerHand();
|
setShowReactionsMenu(false);
|
||||||
setShowReactionsMenu(false);
|
} catch (ex) {
|
||||||
} finally {
|
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
|
||||||
setBusy(false);
|
logger.error("Failed to raise/lower hand", ex);
|
||||||
}
|
} finally {
|
||||||
} else {
|
setBusy(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void raiseHand();
|
void toggleHand();
|
||||||
}, [
|
}, [toggleRaisedHand]);
|
||||||
client,
|
|
||||||
isHandRaised,
|
|
||||||
memberships,
|
|
||||||
lowerHand,
|
|
||||||
rtcSession.room.roomId,
|
|
||||||
userId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -285,7 +236,7 @@ export function ReactionToggleButton({
|
|||||||
isHandRaised={isHandRaised}
|
isHandRaised={isHandRaised}
|
||||||
canReact={!busy && canReact}
|
canReact={!busy && canReact}
|
||||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
sendReaction={(reaction) => void sendRelation(reaction)}
|
||||||
toggleRaisedHand={toggleRaisedHand}
|
toggleRaisedHand={wrappedToggleRaisedHand}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`Can close search 1`] = `
|
exports[`Can close reaction dialog 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-aria-hidden="true"
|
data-aria-hidden="true"
|
||||||
@@ -9,7 +9,6 @@ exports[`Can close search 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
|
||||||
aria-labelledby=":r9l:"
|
aria-labelledby=":r9l:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
@@ -26,10 +25,9 @@ exports[`Can close search 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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"
|
||||||
<path
|
fill-rule="evenodd"
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -45,7 +43,6 @@ exports[`Can fully expand emoji picker 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
|
||||||
aria-labelledby=":r6c:"
|
aria-labelledby=":r6c:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
@@ -62,10 +59,9 @@ exports[`Can fully expand emoji picker 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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"
|
||||||
<path
|
fill-rule="evenodd"
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -78,7 +74,6 @@ exports[`Can lower hand 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
|
||||||
aria-labelledby=":r36:"
|
aria-labelledby=":r36:"
|
||||||
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
@@ -111,7 +106,6 @@ exports[`Can open menu 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
|
||||||
aria-labelledby=":r0:"
|
aria-labelledby=":r0:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
@@ -128,10 +122,9 @@ exports[`Can open menu 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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"
|
||||||
<path
|
fill-rule="evenodd"
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -144,7 +137,6 @@ exports[`Can raise hand 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
|
||||||
aria-labelledby=":r1j:"
|
aria-labelledby=":r1j:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
@@ -161,10 +153,9 @@ exports[`Can raise hand 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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"
|
||||||
<path
|
fill-rule="evenodd"
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
audioInput as audioInputSetting,
|
audioInput as audioInputSetting,
|
||||||
audioOutput as audioOutputSetting,
|
audioOutput as audioOutputSetting,
|
||||||
videoInput as videoInputSetting,
|
videoInput as videoInputSetting,
|
||||||
|
Setting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { isFirefox } from "../Platform";
|
import { isFirefox } from "../Platform";
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ function useObservableState<T>(
|
|||||||
|
|
||||||
function useMediaDevice(
|
function useMediaDevice(
|
||||||
kind: MediaDeviceKind,
|
kind: MediaDeviceKind,
|
||||||
fallbackDevice: string | undefined,
|
setting: Setting<string | undefined>,
|
||||||
usingNames: boolean,
|
usingNames: boolean,
|
||||||
alwaysDefault: boolean = false,
|
alwaysDefault: boolean = false,
|
||||||
): MediaDevice {
|
): MediaDevice {
|
||||||
@@ -84,15 +85,21 @@ function useMediaDevice(
|
|||||||
[kind, requestPermissions],
|
[kind, requestPermissions],
|
||||||
);
|
);
|
||||||
const available = useObservableState(deviceObserver, []);
|
const available = useObservableState(deviceObserver, []);
|
||||||
const [selectedId, select] = useState(fallbackDevice);
|
const [preferredId, select] = useSetting(setting);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
let devId;
|
let selectedId: string | undefined = undefined;
|
||||||
if (available) {
|
if (!alwaysDefault && available) {
|
||||||
devId = available.some((d) => d.deviceId === selectedId)
|
// If the preferred device is available, use it. Or if every available
|
||||||
? selectedId
|
// device ID is falsy, the browser is probably just being paranoid about
|
||||||
: available.some((d) => d.deviceId === fallbackDevice)
|
// fingerprinting and we should still try using the preferred device.
|
||||||
? fallbackDevice
|
// 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;
|
: available.at(0)?.deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +109,10 @@ function useMediaDevice(
|
|||||||
// device entries for the exact same device ID; deduplicate them
|
// device entries for the exact same device ID; deduplicate them
|
||||||
[...new Map(available.map((d) => [d.deviceId, d])).values()]
|
[...new Map(available.map((d) => [d.deviceId, d])).values()]
|
||||||
: [],
|
: [],
|
||||||
selectedId: alwaysDefault ? undefined : devId,
|
selectedId,
|
||||||
select,
|
select,
|
||||||
};
|
};
|
||||||
}, [available, selectedId, fallbackDevice, select, alwaysDefault]);
|
}, [available, preferredId, select, alwaysDefault]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceStub: MediaDevice = {
|
const deviceStub: MediaDevice = {
|
||||||
@@ -121,7 +128,7 @@ const devicesStub: MediaDevices = {
|
|||||||
stopUsingDeviceNames: () => {},
|
stopUsingDeviceNames: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
|
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
@@ -141,36 +148,22 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
|||||||
// for ouput devices because the selector wont be shown on FF.
|
// for ouput devices because the selector wont be shown on FF.
|
||||||
const useOutputNames = usingNames && !isFirefox();
|
const useOutputNames = usingNames && !isFirefox();
|
||||||
|
|
||||||
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
|
const audioInput = useMediaDevice(
|
||||||
const [storedAudioOutput, setStoredAudioOutput] =
|
"audioinput",
|
||||||
useSetting(audioOutputSetting);
|
audioInputSetting,
|
||||||
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
|
usingNames,
|
||||||
|
);
|
||||||
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
|
|
||||||
const audioOutput = useMediaDevice(
|
const audioOutput = useMediaDevice(
|
||||||
"audiooutput",
|
"audiooutput",
|
||||||
storedAudioOutput,
|
audioOutputSetting,
|
||||||
useOutputNames,
|
useOutputNames,
|
||||||
alwaysUseDefaultAudio,
|
alwaysUseDefaultAudio,
|
||||||
);
|
);
|
||||||
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
|
const videoInput = useMediaDevice(
|
||||||
|
"videoinput",
|
||||||
useEffect(() => {
|
videoInputSetting,
|
||||||
if (audioInput.selectedId !== undefined)
|
usingNames,
|
||||||
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 startUsingDeviceNames = useCallback(
|
const startUsingDeviceNames = useCallback(
|
||||||
() => setNumCallersUsingNames((n) => n + 1),
|
() => setNumCallersUsingNames((n) => n + 1),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.reactionIndicatorWidget {
|
.reactionIndicatorWidget {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* background-color: var(--cpd-color-bg-subtle-primary); */
|
background-color: #00000030;
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
|
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
|
||||||
background: "ffffff40";
|
background: "ffffff40";
|
||||||
@@ -14,12 +14,15 @@
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
width: 3em;
|
width: 3em;
|
||||||
|
padding-right: var(--cpd-space-2x);
|
||||||
|
margin-left: calc(var(--cpd-space-2x) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactionIndicatorWidgetLarge > p {
|
.reactionIndicatorWidgetLarge > p {
|
||||||
padding: var(--cpd-space-2x);
|
padding: var(--cpd-space-2x);
|
||||||
padding-right: var(--cpd-space-4x);
|
padding-right: var(--cpd-space-4x);
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactionLarge {
|
.reactionLarge {
|
||||||
@@ -30,14 +33,12 @@
|
|||||||
|
|
||||||
.reaction {
|
.reaction {
|
||||||
margin: var(--cpd-space-1x);
|
margin: var(--cpd-space-1x);
|
||||||
color: var(--cpd-color-icon-secondary);
|
color: white;
|
||||||
/* background-color: var(--cpd-color-icon-secondary); */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* box-shadow: var(--small-drop-shadow); */
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
max-width: fit-content;
|
max-width: fit-content;
|
||||||
|
|||||||
@@ -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[] = [
|
export const ReactionSet: ReactionOption[] = [
|
||||||
{
|
{
|
||||||
emoji: "👍",
|
emoji: "👍",
|
||||||
|
|||||||
@@ -131,48 +131,46 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const latestDevices = useRef<MediaDevices>();
|
const latestDevices = useRef<MediaDevices>();
|
||||||
latestDevices.current = deviceContext;
|
latestDevices.current = deviceContext;
|
||||||
|
|
||||||
|
// TODO: why do we use a ref here instead of using muteStates directly?
|
||||||
const latestMuteStates = useRef<MuteStates>();
|
const latestMuteStates = useRef<MuteStates>();
|
||||||
latestMuteStates.current = muteStates;
|
latestMuteStates.current = muteStates;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultDeviceSetup = async (
|
const defaultDeviceSetup = async ({
|
||||||
requestedDeviceData: JoinCallData,
|
audioInput,
|
||||||
): Promise<void> => {
|
videoInput,
|
||||||
|
}: JoinCallData): Promise<void> => {
|
||||||
// XXX: I think this is broken currently - LiveKit *won't* request
|
// XXX: I think this is broken currently - LiveKit *won't* request
|
||||||
// permissions and give you device names unless you specify a kind, but
|
// 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
|
// here we want all kinds of devices. This needs a fix in livekit-client
|
||||||
// for the following name-matching logic to do anything useful.
|
// for the following name-matching logic to do anything useful.
|
||||||
const devices = await Room.getLocalDevices(undefined, true);
|
const devices = await Room.getLocalDevices(undefined, true);
|
||||||
const { audioInput, videoInput } = requestedDeviceData;
|
|
||||||
if (audioInput === null) {
|
if (audioInput) {
|
||||||
latestMuteStates.current!.audio.setEnabled?.(false);
|
|
||||||
} else {
|
|
||||||
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
|
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
logger.warn("Unknown audio input: " + audioInput);
|
logger.warn("Unknown audio input: " + audioInput);
|
||||||
|
// override the default mute state
|
||||||
latestMuteStates.current!.audio.setEnabled?.(false);
|
latestMuteStates.current!.audio.setEnabled?.(false);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
||||||
);
|
);
|
||||||
latestDevices.current!.audioInput.select(deviceId);
|
latestDevices.current!.audioInput.select(deviceId);
|
||||||
latestMuteStates.current!.audio.setEnabled?.(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoInput === null) {
|
if (videoInput) {
|
||||||
latestMuteStates.current!.video.setEnabled?.(false);
|
|
||||||
} else {
|
|
||||||
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
|
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
logger.warn("Unknown video input: " + videoInput);
|
logger.warn("Unknown video input: " + videoInput);
|
||||||
|
// override the default mute state
|
||||||
latestMuteStates.current!.video.setEnabled?.(false);
|
latestMuteStates.current!.video.setEnabled?.(false);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found video input ID ${deviceId} for name ${videoInput}`,
|
`Found video input ID ${deviceId} for name ${videoInput}`,
|
||||||
);
|
);
|
||||||
latestDevices.current!.videoInput.select(deviceId);
|
latestDevices.current!.videoInput.select(deviceId);
|
||||||
latestMuteStates.current!.video.setEnabled?.(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -199,7 +197,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
} else {
|
} else {
|
||||||
// No lobby and no preload: we enter the rtc session right away
|
// No lobby and no preload: we enter the rtc session right away
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
|
||||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||||
})().catch((e) => {
|
})().catch((e) => {
|
||||||
logger.error("Error joining RTC session", e);
|
logger.error("Error joining RTC session", e);
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
onShareClick,
|
onShareClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||||
const { supportsReactions, raisedHands } = useReactions();
|
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
|
||||||
|
useReactions();
|
||||||
const raisedHandCount = useMemo(
|
const raisedHandCount = useMemo(
|
||||||
() => Object.keys(raisedHands).length,
|
() => Object.keys(raisedHands).length,
|
||||||
[raisedHands],
|
[raisedHands],
|
||||||
@@ -228,6 +229,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
toggleMicrophone,
|
toggleMicrophone,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||||
|
(reaction) => void sendReaction(reaction),
|
||||||
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const windowMode = useObservableEagerState(vm.windowMode);
|
const windowMode = useObservableEagerState(vm.windowMode);
|
||||||
@@ -573,8 +576,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<ReactionToggleButton
|
<ReactionToggleButton
|
||||||
key="raise_hand"
|
key="raise_hand"
|
||||||
className={styles.raiseHand}
|
className={styles.raiseHand}
|
||||||
client={client}
|
userId={client.getUserId()!}
|
||||||
rtcSession={rtcSession}
|
|
||||||
onTouchEnd={onControlsTouchEnd}
|
onTouchEnd={onControlsTouchEnd}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
172
src/room/MuteStates.test.tsx
Normal file
172
src/room/MuteStates.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
|
|||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { ElementWidgetActions, widget } from "../widget";
|
import { ElementWidgetActions, widget } from "../widget";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If there already are this many participants in the call, we automatically mute
|
* If there already are this many participants in the call, we automatically mute
|
||||||
@@ -72,13 +73,14 @@ function useMuteState(
|
|||||||
export function useMuteStates(): MuteStates {
|
export function useMuteStates(): MuteStates {
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
|
|
||||||
const audio = useMuteState(
|
const { skipLobby } = useUrlParams();
|
||||||
devices.audioInput,
|
|
||||||
() => Config.get().media_devices.enable_audio,
|
const audio = useMuteState(devices.audioInput, () => {
|
||||||
);
|
return Config.get().media_devices.enable_audio && !skipLobby;
|
||||||
|
});
|
||||||
const video = useMuteState(
|
const video = useMuteState(
|
||||||
devices.videoInput,
|
devices.videoInput,
|
||||||
() => Config.get().media_devices.enable_video,
|
() => Config.get().media_devices.enable_video && !skipLobby,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
TrackEvent,
|
TrackEvent,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
@@ -35,6 +34,7 @@ export function useSwitchCamera(
|
|||||||
video: Observable<LocalVideoTrack | null>,
|
video: Observable<LocalVideoTrack | null>,
|
||||||
): (() => void) | null {
|
): (() => void) | null {
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
|
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
||||||
|
|
||||||
// Produce an observable like the input 'video' observable, except make it
|
// Produce an observable like the input 'video' observable, except make it
|
||||||
// emit whenever the track is muted or the device changes
|
// emit whenever the track is muted or the device changes
|
||||||
@@ -75,6 +75,12 @@ export function useSwitchCamera(
|
|||||||
.restartTrack({
|
.restartTrack({
|
||||||
facingMode: facingMode === "user" ? "environment" : "user",
|
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) =>
|
.catch((e) =>
|
||||||
logger.error("Failed to switch camera", facingMode, e),
|
logger.error("Failed to switch camera", facingMode, e),
|
||||||
);
|
);
|
||||||
@@ -83,16 +89,5 @@ export function useSwitchCamera(
|
|||||||
[videoTrack],
|
[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);
|
return useObservableEagerState(switchCamera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
||||||
import { Config } from "../src/config/Config";
|
import { mockConfig } from "./utils/test";
|
||||||
import { DEFAULT_CONFIG } from "./config/ConfigOptions";
|
|
||||||
|
|
||||||
test("It joins the correct Session", async () => {
|
test("It joins the correct Session", async () => {
|
||||||
const focusFromOlderMembership = {
|
const focusFromOlderMembership = {
|
||||||
@@ -34,8 +33,7 @@ test("It joins the correct Session", async () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.spyOn(Config, "get").mockReturnValue({
|
mockConfig({
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||||
});
|
});
|
||||||
const mockedSession = vi.mocked({
|
const mockedSession = vi.mocked({
|
||||||
|
|||||||
18
src/settings/DeviceSelection.module.css
Normal file
18
src/settings/DeviceSelection.module.css
Normal 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);
|
||||||
|
}
|
||||||
71
src/settings/DeviceSelection.tsx
Normal file
71
src/settings/DeviceSelection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChangeEvent, FC, ReactNode, useCallback } from "react";
|
import { ChangeEvent, FC, useCallback } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { Dropdown, Separator, Text } from "@vector-im/compound-web";
|
import { Root as Form, Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import styles from "./SettingsModal.module.css";
|
import styles from "./SettingsModal.module.css";
|
||||||
@@ -19,7 +19,6 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
|||||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||||
import {
|
import {
|
||||||
useMediaDevices,
|
useMediaDevices,
|
||||||
MediaDevice,
|
|
||||||
useMediaDeviceNames,
|
useMediaDeviceNames,
|
||||||
} from "../livekit/MediaDevicesContext";
|
} from "../livekit/MediaDevicesContext";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
import { isFirefox } from "../Platform";
|
import { isFirefox } from "../Platform";
|
||||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
|
import { DeviceSelection } from "./DeviceSelection";
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| "audio"
|
| "audio"
|
||||||
@@ -70,40 +70,6 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
|
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
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=""
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const optInDescription = (
|
const optInDescription = (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<Trans i18nKey="settings.opt_in_description">
|
<Trans i18nKey="settings.opt_in_description">
|
||||||
@@ -125,25 +91,30 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
name: t("common.audio"),
|
name: t("common.audio"),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{generateDeviceSelection(devices.audioInput, t("common.microphone"))}
|
<Form>
|
||||||
{!isFirefox() &&
|
<DeviceSelection
|
||||||
generateDeviceSelection(
|
devices={devices.audioInput}
|
||||||
devices.audioOutput,
|
caption={t("common.microphone")}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</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 +122,14 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
const videoTab: Tab<SettingsTab> = {
|
const videoTab: Tab<SettingsTab> = {
|
||||||
key: "video",
|
key: "video",
|
||||||
name: t("common.video"),
|
name: t("common.video"),
|
||||||
content: generateDeviceSelection(devices.videoInput, t("common.camera")),
|
content: (
|
||||||
|
<Form>
|
||||||
|
<DeviceSelection
|
||||||
|
devices={devices.videoInput}
|
||||||
|
caption={t("common.camera")}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const preferencesTab: Tab<SettingsTab> = {
|
const preferencesTab: Tab<SettingsTab> = {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ConnectionState,
|
ConnectionState,
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
Participant,
|
Participant,
|
||||||
|
ParticipantEvent,
|
||||||
RemoteParticipant,
|
RemoteParticipant,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import * as ComponentsCore from "@livekit/components-core";
|
import * as ComponentsCore from "@livekit/components-core";
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
mockLivekitRoom,
|
mockLivekitRoom,
|
||||||
mockLocalParticipant,
|
mockLocalParticipant,
|
||||||
mockMatrixRoom,
|
mockMatrixRoom,
|
||||||
mockMember,
|
mockMatrixRoomMember,
|
||||||
mockRemoteParticipant,
|
mockRemoteParticipant,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
@@ -42,10 +43,10 @@ import { E2eeType } from "../e2ee/e2eeType";
|
|||||||
|
|
||||||
vi.mock("@livekit/components-core");
|
vi.mock("@livekit/components-core");
|
||||||
|
|
||||||
const alice = mockMember({ userId: "@alice:example.org" });
|
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
|
||||||
const bob = mockMember({ userId: "@bob:example.org" });
|
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
|
||||||
const carol = mockMember({ userId: "@carol:example.org" });
|
const carol = mockMatrixRoomMember({ userId: "@carol:example.org" });
|
||||||
const dave = mockMember({ userId: "@dave:example.org" });
|
const dave = mockMatrixRoomMember({ userId: "@dave:example.org" });
|
||||||
|
|
||||||
const aliceId = `${alice.userId}:AAAA`;
|
const aliceId = `${alice.userId}:AAAA`;
|
||||||
const bobId = `${bob.userId}:BBBB`;
|
const bobId = `${bob.userId}:BBBB`;
|
||||||
@@ -188,11 +189,15 @@ function withCallViewModel(
|
|||||||
);
|
);
|
||||||
const eventsSpy = vi
|
const eventsSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||||
.mockImplementation((p) =>
|
.mockImplementation((p, ...eventTypes) => {
|
||||||
(speaking.get(p) ?? of(false)).pipe(
|
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
||||||
map((s) => ({ ...p, isSpeaking: s }) as Participant),
|
return (speaking.get(p) ?? of(false)).pipe(
|
||||||
),
|
map((s) => ({ ...p, isSpeaking: s }) as Participant),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return of(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const roomEventSelectorSpy = vi
|
const roomEventSelectorSpy = vi
|
||||||
.spyOn(ComponentsCore, "roomEventSelector")
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
@@ -229,52 +234,55 @@ function withCallViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("participants are retained during a focus switch", () => {
|
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
|
// 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
|
// 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
|
// The visible participants should remain the same throughout the switch
|
||||||
const layoutMarbles = " a";
|
const expectedLayoutMarbles = " a";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
cold(participantMarbles, {
|
hot(participantInputMarbles, {
|
||||||
a: [aliceParticipant, bobParticipant],
|
a: [aliceParticipant, bobParticipant],
|
||||||
b: [],
|
b: [],
|
||||||
}),
|
}),
|
||||||
cold(connectionMarbles, {
|
hot(connectionInputMarbles, {
|
||||||
c: ConnectionState.Connected,
|
c: ConnectionState.Connected,
|
||||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||||
}),
|
}),
|
||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "grid",
|
{
|
||||||
spotlight: undefined,
|
a: {
|
||||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
type: "grid",
|
||||||
|
spotlight: undefined,
|
||||||
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("screen sharing activates spotlight layout", () => {
|
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,
|
// 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
|
// 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
|
// While there are no screen shares, switch to spotlight manually, and then
|
||||||
// switch back to grid at the end
|
// 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
|
// We should automatically enter spotlight for the first round of screen
|
||||||
// sharing, then return to grid, then manually go into spotlight, and
|
// sharing, then return to grid, then manually go into spotlight, and
|
||||||
// remain in spotlight until we manually go back to grid
|
// remain in spotlight until we manually go back to grid
|
||||||
const layoutMarbles = " abcdaefeg";
|
const expectedLayoutMarbles = " abcdaefeg";
|
||||||
const showSpeakingMarbles = "y----nyny";
|
const expectedShowSpeakingMarbles = "y----nyny";
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
cold(participantMarbles, {
|
hot(participantInputMarbles, {
|
||||||
a: [aliceParticipant, bobParticipant],
|
a: [aliceParticipant, bobParticipant],
|
||||||
b: [aliceSharingScreen, bobParticipant],
|
b: [aliceSharingScreen, bobParticipant],
|
||||||
c: [aliceSharingScreen, bobSharingScreen],
|
c: [aliceSharingScreen, bobSharingScreen],
|
||||||
@@ -283,80 +291,89 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeMarbles, {
|
schedule(modeInputMarbles, {
|
||||||
s: () => vm.setGridMode("spotlight"),
|
s: () => vm.setGridMode("spotlight"),
|
||||||
g: () => vm.setGridMode("grid"),
|
g: () => vm.setGridMode("grid"),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "grid",
|
{
|
||||||
spotlight: undefined,
|
a: {
|
||||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
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",
|
expectObservable(vm.showSpeakingIndicators).toBe(
|
||||||
spotlight: [`${aliceId}:0:screen-share`],
|
expectedShowSpeakingMarbles,
|
||||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
{
|
||||||
|
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", () => {
|
test("participants stay in the same order unless to appear/disappear", () => {
|
||||||
withTestScheduler(({ cold, schedule, expectObservable }) => {
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||||
const modeMarbles = "a";
|
const modeInputMarbles = " a";
|
||||||
// First Bob speaks, then Dave, then Alice
|
// First Bob speaks, then Dave, then Alice
|
||||||
const aSpeakingMarbles = "n- 1998ms - 1999ms y";
|
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
|
||||||
const bSpeakingMarbles = "ny 1998ms n 1999ms ";
|
const bSpeakingInputMarbles = "ny 1998ms n 1999ms ";
|
||||||
const dSpeakingMarbles = "n- 1998ms y 1999ms n";
|
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
|
||||||
// Nothing should change when Bob speaks, because Bob is already on screen.
|
// 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
|
// 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
|
// hasn't spoken at all. Then when Alice speaks, she should return to her
|
||||||
// place at the top.
|
// 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(
|
withCallViewModel(
|
||||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map([
|
new Map([
|
||||||
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
|
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||||
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
|
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||||
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
|
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||||
]),
|
]),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeMarbles, {
|
schedule(modeInputMarbles, {
|
||||||
a: () => {
|
a: () => {
|
||||||
// We imagine that only three tiles (the first three) will be visible
|
// We imagine that only three tiles (the first three) will be visible
|
||||||
// on screen at a time
|
// 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, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "grid",
|
{
|
||||||
spotlight: undefined,
|
a: {
|
||||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
|
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", () => {
|
test("spotlight speakers swap places", () => {
|
||||||
withTestScheduler(({ cold, schedule, expectObservable }) => {
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||||
// Go immediately into spotlight mode for the test
|
// Go immediately into spotlight mode for the test
|
||||||
const modeMarbles = " s";
|
const modeInputMarbles = " s";
|
||||||
// First Bob speaks, then Dave, then Alice
|
// First Bob speaks, then Dave, then Alice
|
||||||
const aSpeakingMarbles = "n--y";
|
const aSpeakingInputMarbles = "n--y";
|
||||||
const bSpeakingMarbles = "nyn";
|
const bSpeakingInputMarbles = "nyn";
|
||||||
const dSpeakingMarbles = "n-yn";
|
const dSpeakingInputMarbles = "n-yn";
|
||||||
// Alice should start in the spotlight, then Bob, then Dave, then Alice
|
// 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
|
// 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
|
// reversed by the end because they've been swapped in and out of the
|
||||||
// spotlight.
|
// spotlight.
|
||||||
const layoutMarbles = " abcd";
|
const expectedLayoutMarbles = "abcd";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map([
|
new Map([
|
||||||
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
|
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||||
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
|
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||||
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
|
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||||
]),
|
]),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeMarbles, { s: () => vm.setGridMode("spotlight") });
|
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "spotlight-landscape",
|
{
|
||||||
spotlight: [`${aliceId}:0`],
|
a: {
|
||||||
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
|
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", () => {
|
test("layout enters picture-in-picture mode when requested", () => {
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
// Enable then disable picture-in-picture
|
// Enable then disable picture-in-picture
|
||||||
const pipControlMarbles = "-ed";
|
const pipControlInputMarbles = "-ed";
|
||||||
// Should go into picture-in-picture layout then back to grid
|
// Should go into picture-in-picture layout then back to grid
|
||||||
const layoutMarbles = " aba";
|
const expectedLayoutMarbles = " aba";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
of([aliceParticipant, bobParticipant]),
|
of([aliceParticipant, bobParticipant]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(pipControlMarbles, {
|
schedule(pipControlInputMarbles, {
|
||||||
e: () => window.controls.enablePip(),
|
e: () => window.controls.enablePip(),
|
||||||
d: () => window.controls.disablePip(),
|
d: () => window.controls.disablePip(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "grid",
|
{
|
||||||
spotlight: undefined,
|
a: {
|
||||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
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 }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
// Start in spotlight mode, then switch to grid and back to spotlight a
|
// Start in spotlight mode, then switch to grid and back to spotlight a
|
||||||
// couple times
|
// couple times
|
||||||
const modeMarbles = " s-gs-gs";
|
const modeInputMarbles = " s-gs-gs";
|
||||||
// Expand and collapse the spotlight
|
// Expand and collapse the spotlight
|
||||||
const expandMarbles = "-a--a";
|
const expandInputMarbles = " -a--a";
|
||||||
// Spotlight should stay expanded during the first mode switch, and stay
|
// Spotlight should stay expanded during the first mode switch, and stay
|
||||||
// collapsed during the second mode switch
|
// collapsed during the second mode switch
|
||||||
const layoutMarbles = "abcbada";
|
const expectedLayoutMarbles = "abcbada";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
of([aliceParticipant, bobParticipant]),
|
of([aliceParticipant, bobParticipant]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeMarbles, {
|
schedule(modeInputMarbles, {
|
||||||
s: () => vm.setGridMode("spotlight"),
|
s: () => vm.setGridMode("spotlight"),
|
||||||
g: () => vm.setGridMode("grid"),
|
g: () => vm.setGridMode("grid"),
|
||||||
});
|
});
|
||||||
schedule(expandMarbles, {
|
schedule(expandInputMarbles, {
|
||||||
a: () => {
|
a: () => {
|
||||||
let toggle: () => void;
|
let toggle: () => void;
|
||||||
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
|
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
|
||||||
@@ -504,28 +530,31 @@ test("spotlight remembers whether it's expanded", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||||
a: {
|
expectedLayoutMarbles,
|
||||||
type: "spotlight-landscape",
|
{
|
||||||
spotlight: [`${aliceId}:0`],
|
a: {
|
||||||
grid: ["local:0", `${bobId}:0`],
|
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`],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
EMPTY,
|
EMPTY,
|
||||||
Observable,
|
Observable,
|
||||||
Subject,
|
Subject,
|
||||||
audit,
|
|
||||||
combineLatest,
|
combineLatest,
|
||||||
concat,
|
concat,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
@@ -76,6 +75,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
|||||||
import { oneOnOneLayout } from "./OneOnOneLayout";
|
import { oneOnOneLayout } from "./OneOnOneLayout";
|
||||||
import { pipLayout } from "./PipLayout";
|
import { pipLayout } from "./PipLayout";
|
||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
import { observeSpeaker } from "./observeSpeaker";
|
||||||
|
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
@@ -248,23 +248,7 @@ class UserMedia {
|
|||||||
livekitRoom,
|
livekitRoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipe(
|
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
|
||||||
// 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.presenter = observeParticipantEvents(
|
this.presenter = observeParticipantEvents(
|
||||||
participant,
|
participant,
|
||||||
@@ -307,7 +291,7 @@ class ScreenShare {
|
|||||||
|
|
||||||
type MediaItem = UserMedia | ScreenShare;
|
type MediaItem = UserMedia | ScreenShare;
|
||||||
|
|
||||||
function findMatrixMember(
|
function findMatrixRoomMember(
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
id: string,
|
id: string,
|
||||||
): RoomMember | undefined {
|
): RoomMember | undefined {
|
||||||
@@ -342,12 +326,16 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
/**
|
||||||
this.livekitRoom,
|
* The raw list of RemoteParticipants as reported by LiveKit
|
||||||
).pipe(this.scope.state());
|
*/
|
||||||
|
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[][]> =
|
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||||
this.connectionState.pipe(
|
this.connectionState.pipe(
|
||||||
withLatestFrom(this.rawRemoteParticipants),
|
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[]> =
|
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
[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([
|
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
|
||||||
this.remoteParticipants,
|
this.remoteParticipants,
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
@@ -419,7 +413,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||||
const id = p === localParticipant ? "local" : p.identity;
|
const id = p === localParticipant ? "local" : p.identity;
|
||||||
const member = findMatrixMember(this.matrixRoom, id);
|
const member = findMatrixRoomMember(this.matrixRoom, id);
|
||||||
if (member === undefined)
|
if (member === undefined)
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||||
@@ -472,6 +466,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of MediaItems that we want to display, that are of type UserMedia
|
||||||
|
*/
|
||||||
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||||
map((mediaItems) =>
|
map((mediaItems) =>
|
||||||
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
||||||
@@ -487,6 +484,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
|
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[]> =
|
private readonly screenShares: Observable<ScreenShare[]> =
|
||||||
this.mediaItems.pipe(
|
this.mediaItems.pipe(
|
||||||
map((mediaItems) =>
|
map((mediaItems) =>
|
||||||
@@ -945,7 +945,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly showFooter = this.windowMode.pipe(
|
public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
|
||||||
switchMap((mode) => {
|
switchMap((mode) => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "pip":
|
case "pip":
|
||||||
|
|||||||
119
src/state/observeSpeaker.test.ts
Normal file
119
src/state/observeSpeaker.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/state/observeSpeaker.ts
Normal file
36
src/state/observeSpeaker.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
},
|
},
|
||||||
[vm],
|
[vm],
|
||||||
);
|
);
|
||||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
const { raisedHands, toggleRaisedHand, reactions } = useReactions();
|
||||||
|
|
||||||
const AudioIcon = locallyMuted
|
const AudioIcon = locallyMuted
|
||||||
? VolumeOffSolidIcon
|
? VolumeOffSolidIcon
|
||||||
@@ -127,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
||||||
const currentReaction: ReactionOption | undefined =
|
const currentReaction: ReactionOption | undefined =
|
||||||
reactions[vm.member?.userId ?? ""];
|
reactions[vm.member?.userId ?? ""];
|
||||||
const raisedHandOnClick =
|
const raisedHandOnClick = vm.local
|
||||||
vm.local && handRaised ? (): void => void lowerHand() : undefined;
|
? (): void => void toggleRaisedHand()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const showSpeaking = showSpeakingIndicators && speaking;
|
const showSpeaking = showSpeakingIndicators && speaking;
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media.videoMuted video {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg {
|
.bg {
|
||||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
@@ -47,7 +43,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
display: none;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -55,10 +50,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media.videoMuted .avatar {
|
|
||||||
display: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS makes us put a condition here, even though all we want to do is
|
/* 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 */
|
unconditionally select the container so we can use cqmin units */
|
||||||
@container mediaView (width > 0) {
|
@container mediaView (width > 0) {
|
||||||
|
|||||||
117
src/tile/MediaView.test.tsx
Normal file
117
src/tile/MediaView.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,7 +76,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
<animated.div
|
<animated.div
|
||||||
className={classNames(styles.media, className, {
|
className={classNames(styles.media, className, {
|
||||||
[styles.mirror]: mirror,
|
[styles.mirror]: mirror,
|
||||||
[styles.videoMuted]: !videoEnabled,
|
|
||||||
})}
|
})}
|
||||||
style={style}
|
style={style}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -91,6 +90,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
size={avatarSize}
|
size={avatarSize}
|
||||||
src={member?.getMxcAvatarUrl()}
|
src={member?.getMxcAvatarUrl()}
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
|
style={{ display: videoEnabled ? "none" : "initial" }}
|
||||||
/>
|
/>
|
||||||
{video.publication !== undefined && (
|
{video.publication !== undefined && (
|
||||||
<VideoTrack
|
<VideoTrack
|
||||||
@@ -98,6 +98,8 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
// There's no reason for this to be focusable
|
// There's no reason for this to be focusable
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
|
style={{ display: videoEnabled ? "block" : "none" }}
|
||||||
|
data-testid="video"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +135,13 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
)*/}
|
)*/}
|
||||||
<div className={styles.nameTag}>
|
<div className={styles.nameTag}>
|
||||||
{nameTagLeadingIcon}
|
{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}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
{unencryptedWarning && (
|
{unencryptedWarning && (
|
||||||
@@ -146,6 +154,8 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className={styles.errorIcon}
|
className={styles.errorIcon}
|
||||||
|
role="img"
|
||||||
|
aria-label={t("common.unencrypted")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
||||||
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||||
|
|
||||||
// Test Explanation:
|
// Test Explanation:
|
||||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||||
// The TestComponent just wraps a button around that hook.
|
// The TestComponent just wraps a button around that hook.
|
||||||
|
|
||||||
interface TestComponentProps {
|
interface TestComponentProps {
|
||||||
setMicrophoneMuted: (muted: boolean) => void;
|
setMicrophoneMuted?: (muted: boolean) => void;
|
||||||
onButtonClick?: () => void;
|
onButtonClick?: () => void;
|
||||||
|
sendReaction?: () => void;
|
||||||
|
toggleHandRaised?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TestComponent: FC<TestComponentProps> = ({
|
const TestComponent: FC<TestComponentProps> = ({
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted = (): void => {},
|
||||||
onButtonClick = (): void => {},
|
onButtonClick = (): void => {},
|
||||||
|
sendReaction = (reaction: ReactionOption): void => {},
|
||||||
|
toggleHandRaised = (): void => {},
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useCallViewKeyboardShortcuts(
|
useCallViewKeyboardShortcuts(
|
||||||
@@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
() => {},
|
() => {},
|
||||||
() => {},
|
() => {},
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted,
|
||||||
|
sendReaction,
|
||||||
|
toggleHandRaised,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
@@ -74,6 +81,38 @@ test("spacebar prioritizes pressing a button", async () => {
|
|||||||
expect(onClick).toBeCalled();
|
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 () => {
|
test("unmuting happens in place of the default action", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const defaultPrevented = vi.fn();
|
const defaultPrevented = vi.fn();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { RefObject, useCallback, useMemo, useRef } from "react";
|
import { RefObject, useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { useEventTarget } from "./useEvents";
|
import { useEventTarget } from "./useEvents";
|
||||||
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether focus is in the same part of the tree as the given
|
* 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);
|
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(
|
export function useCallViewKeyboardShortcuts(
|
||||||
focusElement: RefObject<HTMLElement | null>,
|
focusElement: RefObject<HTMLElement | null>,
|
||||||
toggleMicrophoneMuted: () => void,
|
toggleMicrophoneMuted: () => void,
|
||||||
toggleLocalVideoMuted: () => void,
|
toggleLocalVideoMuted: () => void,
|
||||||
setMicrophoneMuted: (muted: boolean) => void,
|
setMicrophoneMuted: (muted: boolean) => void,
|
||||||
|
sendReaction: (reaction: ReactionOption) => void,
|
||||||
|
toggleHandRaised: () => void,
|
||||||
): void {
|
): void {
|
||||||
const spacebarHeld = useRef(false);
|
const spacebarHeld = useRef(false);
|
||||||
|
|
||||||
@@ -36,6 +43,8 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (focusElement.current === null) return;
|
if (focusElement.current === null) return;
|
||||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||||
|
return;
|
||||||
|
|
||||||
if (event.key === "m") {
|
if (event.key === "m") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -49,6 +58,12 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
spacebarHeld.current = true;
|
spacebarHeld.current = true;
|
||||||
setMicrophoneMuted(false);
|
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,
|
toggleLocalVideoMuted,
|
||||||
toggleMicrophoneMuted,
|
toggleMicrophoneMuted,
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted,
|
||||||
|
sendReaction,
|
||||||
|
toggleHandRaised,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Because this is set on the window, to prevent shortcuts from activating
|
// Because this is set on the window, to prevent shortcuts from activating
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ interface ReactionsContextType {
|
|||||||
raisedHands: Record<string, Date>;
|
raisedHands: Record<string, Date>;
|
||||||
supportsReactions: boolean;
|
supportsReactions: boolean;
|
||||||
reactions: Record<string, ReactionOption>;
|
reactions: Record<string, ReactionOption>;
|
||||||
lowerHand: () => Promise<void>;
|
toggleRaisedHand: () => Promise<void>;
|
||||||
|
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
||||||
@@ -104,7 +105,6 @@ export const ReactionsProvider = ({
|
|||||||
),
|
),
|
||||||
[raisedHands],
|
[raisedHands],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
||||||
setRaisedHands((prevRaisedHands) => ({
|
setRaisedHands((prevRaisedHands) => ({
|
||||||
...prevRaisedHands,
|
...prevRaisedHands,
|
||||||
@@ -181,6 +181,11 @@ export const ReactionsProvider = ({
|
|||||||
const latestMemberships = useLatest(memberships);
|
const latestMemberships = useLatest(memberships);
|
||||||
const latestRaisedHands = useLatest(raisedHands);
|
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.
|
// This effect handles any *live* reaction/redactions in the room.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reactionTimeouts = new Set<number>();
|
const reactionTimeouts = new Set<number>();
|
||||||
@@ -322,22 +327,67 @@ export const ReactionsProvider = ({
|
|||||||
latestRaisedHands,
|
latestRaisedHands,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lowerHand = useCallback(async () => {
|
const toggleRaisedHand = useCallback(async () => {
|
||||||
if (!myUserId || !raisedHands[myUserId]) {
|
if (!myUserId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const myReactionId = raisedHands[myUserId].reactionEventId;
|
const myReactionId = raisedHands[myUserId]?.reactionEventId;
|
||||||
|
|
||||||
if (!myReactionId) {
|
if (!myReactionId) {
|
||||||
logger.warn(`Hand raised but no reaction event to redact!`);
|
try {
|
||||||
return;
|
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 {
|
}, [myMembership, myUserId, raisedHands, rtcSession, room]);
|
||||||
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
|
||||||
logger.debug("Redacted raise hand event");
|
const sendReaction = useCallback(
|
||||||
} catch (ex) {
|
async (reaction: ReactionOption) => {
|
||||||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
if (!myUserId || reactions[myUserId]) {
|
||||||
}
|
// We're still reacting
|
||||||
}, [myUserId, raisedHands, rtcSession, room]);
|
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 (
|
return (
|
||||||
<ReactionsContext.Provider
|
<ReactionsContext.Provider
|
||||||
@@ -345,7 +395,8 @@ export const ReactionsProvider = ({
|
|||||||
raisedHands: resultRaisedHands,
|
raisedHands: resultRaisedHands,
|
||||||
supportsReactions,
|
supportsReactions,
|
||||||
reactions,
|
reactions,
|
||||||
lowerHand,
|
toggleRaisedHand,
|
||||||
|
sendReaction,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export async function initClient(
|
|||||||
localTimeoutMs: 5000,
|
localTimeoutMs: 5000,
|
||||||
useE2eForGroupCall: e2eEnabled,
|
useE2eForGroupCall: e2eEnabled,
|
||||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
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:
|
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
|
|||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
Please see LICENSE in the repository root for full details.
|
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 { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
} from "../state/MediaViewModel";
|
} from "../state/MediaViewModel";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { DEFAULT_CONFIG, ResolvedConfigOptions } from "../config/ConfigOptions";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
|
||||||
export function withFakeTimers(continuation: () => void): void {
|
export function withFakeTimers(continuation: () => void): void {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -39,15 +41,23 @@ export interface OurRunHelpers extends RunHelpers {
|
|||||||
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TestRunnerGlobal {
|
||||||
|
rxjsTestScheduler?: SchedulerLike;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run Observables with a scheduler that virtualizes time, for testing purposes.
|
* Run Observables with a scheduler that virtualizes time, for testing purposes.
|
||||||
*/
|
*/
|
||||||
export function withTestScheduler(
|
export function withTestScheduler(
|
||||||
continuation: (helpers: OurRunHelpers) => void,
|
continuation: (helpers: OurRunHelpers) => void,
|
||||||
): void {
|
): void {
|
||||||
new TestScheduler((actual, expected) => {
|
const scheduler = new TestScheduler((actual, expected) => {
|
||||||
expect(actual).deep.equals(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({
|
continuation({
|
||||||
...helpers,
|
...helpers,
|
||||||
schedule(marbles, actions) {
|
schedule(marbles, actions) {
|
||||||
@@ -91,7 +101,7 @@ function mockEmitter<T>(): EmitterMock<T> {
|
|||||||
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
|
// 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
|
// 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.
|
// 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;
|
return { ...mockEmitter(), ...member } as RoomMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +151,7 @@ export async function withLocalMedia(
|
|||||||
const localParticipant = mockLocalParticipant({});
|
const localParticipant = mockLocalParticipant({});
|
||||||
const vm = new LocalUserMediaViewModel(
|
const vm = new LocalUserMediaViewModel(
|
||||||
"local",
|
"local",
|
||||||
mockMember(member),
|
mockMatrixRoomMember(member),
|
||||||
localParticipant,
|
localParticipant,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
@@ -176,7 +186,7 @@ export async function withRemoteMedia(
|
|||||||
const remoteParticipant = mockRemoteParticipant(participant);
|
const remoteParticipant = mockRemoteParticipant(participant);
|
||||||
const vm = new RemoteUserMediaViewModel(
|
const vm = new RemoteUserMediaViewModel(
|
||||||
"remote",
|
"remote",
|
||||||
mockMember(member),
|
mockMatrixRoomMember(member),
|
||||||
remoteParticipant,
|
remoteParticipant,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
@@ -189,3 +199,10 @@ export async function withRemoteMedia(
|
|||||||
vm.destroy();
|
vm.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||||
|
vi.spyOn(Config, "get").mockReturnValue({
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { afterEach } from "vitest";
|
|||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import "vitest-axe/extend-expect";
|
import "vitest-axe/extend-expect";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import EN_GB from "../locales/en-GB/app.json";
|
import EN_GB from "../locales/en-GB/app.json";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
// Default naming fallback
|
// Default naming fallback
|
||||||
return "assets/[name]-[hash][extname]";
|
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"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user