Merge branch 'livekit' into fkwp/change_video_codec
11
README.md
@@ -143,7 +143,8 @@ via the `org.matrix.msc4143.rtc_foci` key, e.g.:
|
|||||||
where the format for MatrixRTC using LiveKit backend is defined in
|
where the format for MatrixRTC using LiveKit backend is defined in
|
||||||
[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
|
[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
|
||||||
In the example above Matrix clients do discover a focus of type `livekit` which
|
In the example above Matrix clients do discover a focus of type `livekit` which
|
||||||
points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
|
points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||||
|
via `livekit_service_url`.
|
||||||
|
|
||||||
### Backend Selection
|
### Backend Selection
|
||||||
|
|
||||||
@@ -154,9 +155,9 @@ points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
|
|||||||
the **first participant who joined the call** defines via the `foci_preferred`
|
the **first participant who joined the call** defines via the `foci_preferred`
|
||||||
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
|
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
|
||||||
will be used for this call.
|
will be used for this call.
|
||||||
- During the actual call join flow, the **LiveKit JWT Auth Service** provides
|
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
|
||||||
the client with the **LiveKit SFU WebSocket URL** and an **access JWT token**
|
provides the client with the **LiveKit SFU WebSocket URL** and an
|
||||||
in order to exchange media via WebRTC.
|
**access JWT token** in order to exchange media via WebRTC.
|
||||||
|
|
||||||
The example below illustrates how backend selection works across **Matrix
|
The example below illustrates how backend selection works across **Matrix
|
||||||
federation**, using the setup from sites A, B, and C. It demonstrates backend
|
federation**, using the setup from sites A, B, and C. It demonstrates backend
|
||||||
@@ -208,7 +209,7 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the
|
|||||||
whole stack of components which is required for a local development environment:
|
whole stack of components which is required for a local development environment:
|
||||||
|
|
||||||
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
|
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
|
||||||
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
|
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
|
||||||
- Minimum LiveKit SFU Setup using dev defaults for config
|
- Minimum LiveKit SFU Setup using dev defaults for config
|
||||||
- Redis db for completeness
|
- Redis db for completeness
|
||||||
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
|
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 929 KiB After Width: | Height: | Size: 941 KiB |
|
Before Width: | Height: | Size: 886 KiB After Width: | Height: | Size: 908 KiB |
@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
|
|||||||
```
|
```
|
||||||
|
|
||||||
As a prerequisite for the
|
As a prerequisite for the
|
||||||
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
|
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||||
make sure that your Synapse server has either a `federation` or `openid`
|
make sure that your Synapse server has either a `federation` or `openid`
|
||||||
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
|
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ required for each site deployment.
|
|||||||
|
|
||||||
As depicted above in the `example.com` site deployment, Element Call requires a
|
As depicted above in the `example.com` site deployment, Element Call requires a
|
||||||
[Livekit SFU](https://github.com/livekit/livekit) alongside a
|
[Livekit SFU](https://github.com/livekit/livekit) alongside a
|
||||||
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
|
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||||
to implement
|
to implement
|
||||||
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
|
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ the example above, this results in:
|
|||||||
| Service | Endpoint | Example |
|
| Service | Endpoint | Example |
|
||||||
| -------- | ------- | ------- |
|
| -------- | ------- | ------- |
|
||||||
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
|
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
|
||||||
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
|
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
|
||||||
|
|
||||||
Using Nginx, you can achieve this by:
|
Using Nginx, you can achieve this by:
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# JWT Service running at port 8080
|
# MatrixRTC Authorization Service running at port 8080
|
||||||
proxy_pass http://localhost:8080/;
|
proxy_pass http://localhost:8080/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"call_is_not_supported": "Volání není podporováno",
|
"call_is_not_supported": "Volání není podporováno",
|
||||||
"call_not_found": "Volání nebylo nalezeno",
|
"call_not_found": "Volání nebylo nalezeno",
|
||||||
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový</1>.</0>",
|
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu hovoru. Zkontrolujte, zda máte správný odkaz, nebo<2> vytvořte nový</2>.</0>",
|
||||||
"connection_lost": "Spojení ztraceno",
|
"connection_lost": "Spojení ztraceno",
|
||||||
"connection_lost_description": "Hovor byl přerušen.",
|
"connection_lost_description": "Hovor byl přerušen.",
|
||||||
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
||||||
@@ -105,6 +105,11 @@
|
|||||||
"knock_reject_heading": "Přístup odepřen",
|
"knock_reject_heading": "Přístup odepřen",
|
||||||
"reason": "Důvod"
|
"reason": "Důvod"
|
||||||
},
|
},
|
||||||
|
"handset": {
|
||||||
|
"overlay_back_button": "Zpět do režimu reproduktoru",
|
||||||
|
"overlay_description": "Funguje pouze při používání aplikace",
|
||||||
|
"overlay_title": "Režim sluchátka"
|
||||||
|
},
|
||||||
"hangup_button_label": "Ukončit hovor",
|
"hangup_button_label": "Ukončit hovor",
|
||||||
"header_label": "Domov Element Call",
|
"header_label": "Domov Element Call",
|
||||||
"header_participants_label": "Účastníci",
|
"header_participants_label": "Účastníci",
|
||||||
@@ -177,6 +182,8 @@
|
|||||||
"change_device_button": "Změnit zvukové zařízení",
|
"change_device_button": "Změnit zvukové zařízení",
|
||||||
"default": "Výchozí",
|
"default": "Výchozí",
|
||||||
"default_named": "Výchozí <2> ({{name}}) </2>",
|
"default_named": "Výchozí <2> ({{name}}) </2>",
|
||||||
|
"handset": "Sluchátko",
|
||||||
|
"loudspeaker": "Reproduktor",
|
||||||
"microphone": "Mikrofon",
|
"microphone": "Mikrofon",
|
||||||
"microphone_numbered": "Mikrofon {{n}}",
|
"microphone_numbered": "Mikrofon {{n}}",
|
||||||
"speaker": "Reproduktor",
|
"speaker": "Reproduktor",
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
|
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
|
||||||
"open_elsewhere": "Åbnet i en anden fane",
|
"open_elsewhere": "Åbnet i en anden fane",
|
||||||
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
|
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
|
||||||
|
"room_creation_restricted": "Kunne ikke oprette opkald",
|
||||||
|
"room_creation_restricted_description": "Oprettelse af opkald er muligvis begrænset til autoriserede brugere. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
|
||||||
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
|
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
|
||||||
},
|
},
|
||||||
"group_call_loader": {
|
"group_call_loader": {
|
||||||
@@ -105,6 +107,11 @@
|
|||||||
"knock_reject_heading": "Adgang nægtet",
|
"knock_reject_heading": "Adgang nægtet",
|
||||||
"reason": "Årsag: {{reason}}"
|
"reason": "Årsag: {{reason}}"
|
||||||
},
|
},
|
||||||
|
"handset": {
|
||||||
|
"overlay_back_button": "Tilbage til højttalertilstand",
|
||||||
|
"overlay_description": "Virker kun, når du bruger appen",
|
||||||
|
"overlay_title": "Telefon-højtaler"
|
||||||
|
},
|
||||||
"hangup_button_label": "Afslut opkald",
|
"hangup_button_label": "Afslut opkald",
|
||||||
"header_label": "Element Ring hjem",
|
"header_label": "Element Ring hjem",
|
||||||
"header_participants_label": "Deltagere",
|
"header_participants_label": "Deltagere",
|
||||||
@@ -176,6 +183,7 @@
|
|||||||
"change_device_button": "Skift lydenhed",
|
"change_device_button": "Skift lydenhed",
|
||||||
"default": "Standard",
|
"default": "Standard",
|
||||||
"default_named": "Standard <2>({{name}})</2>",
|
"default_named": "Standard <2>({{name}})</2>",
|
||||||
|
"handset": "Telefon",
|
||||||
"loudspeaker": "Højttaler",
|
"loudspeaker": "Højttaler",
|
||||||
"microphone": "Mikrofon",
|
"microphone": "Mikrofon",
|
||||||
"microphone_numbered": "Mikrofon {{n}}",
|
"microphone_numbered": "Mikrofon {{n}}",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"reaction": "Reaktion",
|
"reaction": "Reaktion",
|
||||||
"reactions": "Reaktionen",
|
"reactions": "Reaktionen",
|
||||||
|
"reconnecting": "Verbindung wird wiederhergestellt...",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"unencrypted": "Nicht verschlüsselt",
|
"unencrypted": "Nicht verschlüsselt",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"reaction": "Reaction",
|
"reaction": "Reaction",
|
||||||
"reactions": "Reactions",
|
"reactions": "Reactions",
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"unencrypted": "Not encrypted",
|
"unencrypted": "Not encrypted",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"call_is_not_supported": "Call stöds inte",
|
"call_is_not_supported": "Call stöds inte",
|
||||||
"call_not_found": "Samtal hittades inte",
|
"call_not_found": "Samtal hittades inte",
|
||||||
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <1>skapa en ny</1>.</0>",
|
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <2>skapa en ny</2>.</0>",
|
||||||
"connection_lost": "Anslutning förlorad",
|
"connection_lost": "Anslutning förlorad",
|
||||||
"connection_lost_description": "Du kopplades bort från samtalet.",
|
"connection_lost_description": "Du kopplades bort från samtalet.",
|
||||||
"e2ee_unsupported": "Inkompatibel webbläsare",
|
"e2ee_unsupported": "Inkompatibel webbläsare",
|
||||||
@@ -94,6 +94,8 @@
|
|||||||
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
|
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
|
||||||
"open_elsewhere": "Öppnades i en annan flik",
|
"open_elsewhere": "Öppnades i en annan flik",
|
||||||
"open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.",
|
"open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.",
|
||||||
|
"room_creation_restricted": "Misslyckades att skapa samtal",
|
||||||
|
"room_creation_restricted_description": "Samtalsskapande kan vara begränsat till endast behöriga användare. Försök igen senare eller kontakta serveradministratören om problemet kvarstår.",
|
||||||
"unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod:</0> <1>{{ errorCode }}</1>). Kontakta din serveradministratör."
|
"unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod:</0> <1>{{ errorCode }}</1>). Kontakta din serveradministratör."
|
||||||
},
|
},
|
||||||
"group_call_loader": {
|
"group_call_loader": {
|
||||||
@@ -105,6 +107,11 @@
|
|||||||
"knock_reject_heading": "Inte tillåten att gå med",
|
"knock_reject_heading": "Inte tillåten att gå med",
|
||||||
"reason": "Anledning"
|
"reason": "Anledning"
|
||||||
},
|
},
|
||||||
|
"handset": {
|
||||||
|
"overlay_back_button": "Tillbaka till högtalarläge",
|
||||||
|
"overlay_description": "Fungerar bara när appen används",
|
||||||
|
"overlay_title": "Telefonläge"
|
||||||
|
},
|
||||||
"hangup_button_label": "Avsluta samtal",
|
"hangup_button_label": "Avsluta samtal",
|
||||||
"header_label": "Element Call Hem",
|
"header_label": "Element Call Hem",
|
||||||
"header_participants_label": "Deltagare",
|
"header_participants_label": "Deltagare",
|
||||||
@@ -176,6 +183,8 @@
|
|||||||
"change_device_button": "Byt ljudenhet",
|
"change_device_button": "Byt ljudenhet",
|
||||||
"default": "Förval",
|
"default": "Förval",
|
||||||
"default_named": "Förval <2>({{name}})</2>",
|
"default_named": "Förval <2>({{name}})</2>",
|
||||||
|
"handset": "Telefonlur",
|
||||||
|
"loudspeaker": "Högtalare",
|
||||||
"microphone": "Mikrofon",
|
"microphone": "Mikrofon",
|
||||||
"microphone_numbered": "Mikrofon {{n}}",
|
"microphone_numbered": "Mikrofon {{n}}",
|
||||||
"speaker": "Högtalare",
|
"speaker": "Högtalare",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage";
|
|||||||
import { RoomPage } from "./room/RoomPage";
|
import { RoomPage } from "./room/RoomPage";
|
||||||
import { ClientProvider } from "./ClientContext";
|
import { ClientProvider } from "./ClientContext";
|
||||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
|
||||||
import { Initializer } from "./initializer";
|
import { Initializer } from "./initializer";
|
||||||
import { widget } from "./widget";
|
import { widget } from "./widget";
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
@@ -86,7 +85,6 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
<Sentry.ErrorBoundary
|
<Sentry.ErrorBoundary
|
||||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||||
>
|
>
|
||||||
<DisconnectedBanner />
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<SentryRoute path="/" element={<HomePage />} />
|
<SentryRoute path="/" element={<HomePage />} />
|
||||||
<SentryRoute path="/login" element={<LoginPage />} />
|
<SentryRoute path="/login" element={<LoginPage />} />
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export const AppBar: FC<Props> = ({ children }) => {
|
|||||||
style={{ display: hidden ? "none" : "block" }}
|
style={{ display: hidden ? "none" : "block" }}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
>
|
>
|
||||||
<Header>
|
<Header
|
||||||
|
// App bar is mainly seen in the call view, which has its own
|
||||||
|
// 'reconnecting' toast
|
||||||
|
disconnectedBanner={false}
|
||||||
|
>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<Tooltip label={t("common.back")}>
|
<Tooltip label={t("common.back")}>
|
||||||
<IconButton onClick={onBackClick}>
|
<IconButton onClick={onBackClick}>
|
||||||
|
|||||||
@@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react";
|
|||||||
import { Avatar, Size } from "./Avatar";
|
import { Avatar, Size } from "./Avatar";
|
||||||
import { EncryptionLock } from "./room/EncryptionLock";
|
import { EncryptionLock } from "./room/EncryptionLock";
|
||||||
import { useMediaQuery } from "./useMediaQuery";
|
import { useMediaQuery } from "./useMediaQuery";
|
||||||
|
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||||
|
|
||||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||||
ref?: Ref<HTMLElement>;
|
ref?: Ref<HTMLElement>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Whether the header should display an informational banner whenever the
|
||||||
|
* client is disconnected from the homeserver.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
disconnectedBanner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: FC<HeaderProps> = ({
|
export const Header: FC<HeaderProps> = ({
|
||||||
ref,
|
ref,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
disconnectedBanner = true,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header
|
<>
|
||||||
ref={ref}
|
<header
|
||||||
className={classNames(styles.header, className)}
|
ref={ref}
|
||||||
{...rest}
|
className={classNames(styles.header, className)}
|
||||||
>
|
{...rest}
|
||||||
{children}
|
>
|
||||||
</header>
|
{children}
|
||||||
|
</header>
|
||||||
|
{disconnectedBanner && <DisconnectedBanner />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ interface Props {
|
|||||||
* A supporting icon to display within the toast.
|
* A supporting icon to display within the toast.
|
||||||
*/
|
*/
|
||||||
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||||
|
/**
|
||||||
|
* Whether the toast should be portaled into the root of the document (rather
|
||||||
|
* than rendered in-place within the component tree).
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
|
|||||||
autoDismiss,
|
autoDismiss,
|
||||||
children,
|
children,
|
||||||
Icon,
|
Icon,
|
||||||
|
portal = true,
|
||||||
}) => {
|
}) => {
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [open, autoDismiss, onDismiss]);
|
}, [open, autoDismiss, onDismiss]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<DialogOverlay
|
||||||
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||||
|
/>
|
||||||
|
<DialogContent aria-describedby={undefined} asChild>
|
||||||
|
<DialogClose
|
||||||
|
className={classNames(
|
||||||
|
overlayStyles.overlay,
|
||||||
|
overlayStyles.animate,
|
||||||
|
styles.toast,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogTitle asChild>
|
||||||
|
<Text as="h3" size="sm" weight="semibold">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</DialogTitle>
|
||||||
|
{Icon && <Icon width={20} height={20} aria-hidden />}
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||||
<DialogOverlay
|
|
||||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
|
||||||
/>
|
|
||||||
<DialogContent aria-describedby={undefined} asChild>
|
|
||||||
<DialogClose
|
|
||||||
className={classNames(
|
|
||||||
overlayStyles.overlay,
|
|
||||||
overlayStyles.animate,
|
|
||||||
styles.toast,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogTitle asChild>
|
|
||||||
<Text as="h3" size="sm" weight="semibold">
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</DialogTitle>
|
|
||||||
{Icon && <Icon width={20} height={20} aria-hidden />}
|
|
||||||
</DialogClose>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
ShareScreenSolidIcon,
|
ShareScreenSolidIcon,
|
||||||
SettingsSolidIcon,
|
SettingsSolidIcon,
|
||||||
SwitchCameraSolidIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
@@ -67,23 +66,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
|
|
||||||
props,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("switch_camera")}>
|
|
||||||
<CpdButton
|
|
||||||
iconOnly
|
|
||||||
Icon={SwitchCameraSolidIcon}
|
|
||||||
kind="secondary"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ const TileWrapper_ = memo(
|
|||||||
useDrag((state) => onDrag?.current!(id, state), {
|
useDrag((state) => onDrag?.current!(id, state), {
|
||||||
target: ref,
|
target: ref,
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
preventScroll: true,
|
// Previous designs, which allowed tiles to be dragged and dropped around
|
||||||
|
// the scrolling grid, required us to set preventScroll to true here. But
|
||||||
|
// our designs no longer call for this, and meanwhile there's a bug in
|
||||||
|
// use-gesture that causes filterTaps + preventScroll to break buttons
|
||||||
|
// within tiles (like the 'switch camera' button) on mobile.
|
||||||
|
// https://github.com/pmndrs/use-gesture/issues/593
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
aliceRtcMember,
|
aliceRtcMember,
|
||||||
bobRtcMember,
|
bobRtcMember,
|
||||||
local,
|
local,
|
||||||
|
localRtcMember,
|
||||||
} from "../utils/test-fixtures";
|
} from "../utils/test-fixtures";
|
||||||
|
|
||||||
vitest.mock("../useAudioContext");
|
vitest.mock("../useAudioContext");
|
||||||
@@ -66,7 +67,7 @@ beforeEach(() => {
|
|||||||
* a noise every time.
|
* a noise every time.
|
||||||
*/
|
*/
|
||||||
test("plays one sound when entering a call", () => {
|
test("plays one sound when entering a call", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
@@ -74,56 +75,58 @@ test("plays one sound when entering a call", () => {
|
|||||||
|
|
||||||
// Joining a call usually means remote participants are added later.
|
// Joining a call usually means remote participants are added later.
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
expect(playSound).toHaveBeenCalledOnce();
|
expect(playSound).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user joins", () => {
|
test("plays a sound when a user joins", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
// Play a sound when joining a call.
|
// Play a sound when joining a call.
|
||||||
expect(playSound).toBeCalledWith("join");
|
expect(playSound).toBeCalledWith("join");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user leaves", () => {
|
test("plays a sound when a user leaves", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([]);
|
rtcMemberships$.next([localRtcMember]);
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("left");
|
expect(playSound).toBeCalledWith("left");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||||
const mockRtcMemberships: CallMembership[] = [];
|
const mockRtcMemberships: CallMembership[] = [localRtcMember];
|
||||||
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
||||||
mockRtcMemberships.push(
|
mockRtcMemberships.push(
|
||||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||||
[local, alice],
|
[local, alice],
|
||||||
mockRtcMemberships,
|
mockRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
expect(playSound).not.toBeCalled();
|
expect(playSound).not.toBeCalled();
|
||||||
|
// Remove the last membership in the array to test the leaving sound
|
||||||
|
// (The array has length MAX_PARTICIPANT_COUNT_FOR_SOUND + 1)
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next(
|
rtcMemberships$.next(
|
||||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("left");
|
expect(playSound).toBeCalledWith("left");
|
||||||
|
|||||||
@@ -137,11 +137,9 @@ function createGroupCallView(
|
|||||||
getJoinRule: () => JoinRule.Invite,
|
getJoinRule: () => JoinRule.Invite,
|
||||||
} as Partial<RoomState> as RoomState,
|
} as Partial<RoomState> as RoomState,
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||||
room,
|
constant([localRtcMember]),
|
||||||
localRtcMember,
|
);
|
||||||
[],
|
|
||||||
).withMemberships(constant([]));
|
|
||||||
rtcSession.joined = joined;
|
rtcSession.joined = joined;
|
||||||
const muteState = {
|
const muteState = {
|
||||||
audio: { enabled: false },
|
audio: { enabled: false },
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
client={client}
|
client={client}
|
||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
rtcSession={rtcSession as MatrixRTCSession}
|
rtcSession={rtcSession as MatrixRTCSession}
|
||||||
|
matrixRoom={room}
|
||||||
participantCount={participantCount}
|
participantCount={participantCount}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
header={header}
|
header={header}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
@media (max-width: 340px) {
|
@media (max-width: 340px) {
|
||||||
.invite,
|
.invite,
|
||||||
.switchCamera,
|
|
||||||
.shareScreen {
|
.shareScreen {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function createInCallView(): RenderResult & {
|
|||||||
kind: E2eeType.NONE,
|
kind: E2eeType.NONE,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
matrixRoom={room}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
participantCount={0}
|
participantCount={0}
|
||||||
onLeave={function (): void {
|
onLeave={function (): void {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||||
import { ConnectionState, type Room } from "livekit-client";
|
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
ShareScreenButton,
|
ShareScreenButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
ReactionToggleButton,
|
ReactionToggleButton,
|
||||||
SwitchCameraButton,
|
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
@@ -94,7 +93,6 @@ import {
|
|||||||
useReactionsSender,
|
useReactionsSender,
|
||||||
} from "../reactions/useReactionsSender";
|
} from "../reactions/useReactionsSender";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
import { useSwitchCamera } from "./useSwitchCamera";
|
|
||||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||||
import {
|
import {
|
||||||
@@ -113,6 +111,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
|
|||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
import { Toast } from "../Toast.tsx";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -166,6 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
|
props.matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
{
|
{
|
||||||
@@ -184,6 +184,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
|
props.matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
@@ -212,7 +213,8 @@ export interface InCallViewProps {
|
|||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
livekitRoom: Room;
|
matrixRoom: MatrixRoom;
|
||||||
|
livekitRoom: LivekitRoom;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
participantCount: number;
|
participantCount: number;
|
||||||
/** Function to call when the user explicitly ends the call */
|
/** Function to call when the user explicitly ends the call */
|
||||||
@@ -228,6 +230,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
matrixInfo,
|
matrixInfo,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
|
matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
muteStates,
|
muteStates,
|
||||||
participantCount,
|
participantCount,
|
||||||
@@ -272,7 +275,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const [useExperimentalToDeviceTransport] = useSetting(
|
const [useExperimentalToDeviceTransport] = useSetting(
|
||||||
useExperimentalToDeviceTransportSetting,
|
useExperimentalToDeviceTransportSetting,
|
||||||
);
|
);
|
||||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
|
|
||||||
const showToDeviceEncryption = useMemo(
|
const showToDeviceEncryption = useMemo(
|
||||||
@@ -309,6 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
const windowMode = useBehavior(vm.windowMode$);
|
const windowMode = useBehavior(vm.windowMode$);
|
||||||
const layout = useBehavior(vm.layout$);
|
const layout = useBehavior(vm.layout$);
|
||||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||||
@@ -318,7 +322,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const showFooter = useBehavior(vm.showFooter$);
|
const showFooter = useBehavior(vm.showFooter$);
|
||||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
|
||||||
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
|
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
|
||||||
|
|
||||||
// Ideally we could detect taps by listening for click events and checking
|
// Ideally we could detect taps by listening for click events and checking
|
||||||
@@ -502,7 +505,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
break;
|
break;
|
||||||
case "standard":
|
case "standard":
|
||||||
header = (
|
header = (
|
||||||
<Header className={styles.header} ref={headerRef}>
|
<Header
|
||||||
|
className={styles.header}
|
||||||
|
ref={headerRef}
|
||||||
|
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||||
|
>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<RoomHeaderInfo
|
<RoomHeaderInfo
|
||||||
id={matrixInfo.roomId}
|
id={matrixInfo.roomId}
|
||||||
@@ -642,7 +649,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||||
rtcSession.room.roomId,
|
matrixRoom.roomId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleScreensharing = useCallback(() => {
|
const toggleScreensharing = useCallback(() => {
|
||||||
@@ -676,15 +683,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (switchCamera !== null)
|
|
||||||
buttons.push(
|
|
||||||
<SwitchCameraButton
|
|
||||||
key="switch_camera"
|
|
||||||
className={styles.switchCamera}
|
|
||||||
onClick={switchCamera}
|
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
if (canScreenshare && !hideScreensharing) {
|
if (canScreenshare && !hideScreensharing) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<ShareScreenButton
|
<ShareScreenButton
|
||||||
@@ -762,6 +760,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The reconnecting toast cannot be dismissed
|
||||||
|
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.inRoom}
|
className={styles.inRoom}
|
||||||
@@ -789,8 +790,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
|
<Toast
|
||||||
|
onDismiss={onDismissReconnectingToast}
|
||||||
|
open={reconnecting}
|
||||||
|
portal={false}
|
||||||
|
>
|
||||||
|
{t("common.reconnecting")}
|
||||||
|
</Toast>
|
||||||
<EarpieceOverlay
|
<EarpieceOverlay
|
||||||
show={earpieceMode}
|
show={earpieceMode && !reconnecting}
|
||||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||||
/>
|
/>
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
@@ -800,7 +808,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
client={client}
|
client={client}
|
||||||
roomId={rtcSession.room.roomId}
|
roomId={matrixRoom.roomId}
|
||||||
open={settingsModalOpen}
|
open={settingsModalOpen}
|
||||||
onDismiss={closeSettings}
|
onDismiss={closeSettings}
|
||||||
tab={settingsTab}
|
tab={settingsTab}
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
type LocalVideoTrack,
|
type LocalVideoTrack,
|
||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { map } from "rxjs";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
EndCallButton,
|
EndCallButton,
|
||||||
MicButton,
|
MicButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
SwitchCameraButton,
|
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from "../button/Button";
|
} from "../button/Button";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -47,7 +45,6 @@ import { E2eeType } from "../e2ee/e2eeType";
|
|||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
|
|
||||||
import {
|
import {
|
||||||
useTrackProcessor,
|
useTrackProcessor,
|
||||||
useTrackProcessorSync,
|
useTrackProcessorSync,
|
||||||
@@ -195,12 +192,6 @@ export const LobbyView: FC<Props> = ({
|
|||||||
}, [devices, videoInputId, videoTrack]);
|
}, [devices, videoInputId, videoTrack]);
|
||||||
|
|
||||||
useTrackProcessorSync(videoTrack);
|
useTrackProcessorSync(videoTrack);
|
||||||
const showSwitchCamera = useShowSwitchCamera(
|
|
||||||
useObservable(
|
|
||||||
(inputs$) => inputs$.pipe(map(([video]) => video)),
|
|
||||||
[videoTrack],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||||
// animations and don't have to feel bad about reusing its CSS
|
// animations and don't have to feel bad about reusing its CSS
|
||||||
@@ -257,9 +248,6 @@ export const LobbyView: FC<Props> = ({
|
|||||||
onClick={onVideoPress}
|
onClick={onVideoPress}
|
||||||
disabled={muteStates.video.setEnabled === null}
|
disabled={muteStates.video.setEnabled === null}
|
||||||
/>
|
/>
|
||||||
{showSwitchCamera && (
|
|
||||||
<SwitchCameraButton onClick={showSwitchCamera} />
|
|
||||||
)}
|
|
||||||
<SettingsButton onClick={openSettings} />
|
<SettingsButton onClick={openSettings} />
|
||||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«r5»"
|
aria-labelledby="«r8»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -279,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«ra»"
|
aria-labelledby="«rd»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -301,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rf»"
|
aria-labelledby="«ri»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -322,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rk»"
|
aria-labelledby="«rn»"
|
||||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -348,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
class="toggle layout"
|
class="toggle layout"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«rp»"
|
aria-labelledby="«rs»"
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
@@ -366,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«ru»"
|
aria-labelledby="«r11»"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
fromEvent,
|
|
||||||
map,
|
|
||||||
merge,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
startWith,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
|
||||||
import {
|
|
||||||
facingModeFromLocalTrack,
|
|
||||||
type LocalVideoTrack,
|
|
||||||
TrackEvent,
|
|
||||||
} from "livekit-client";
|
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
|
||||||
import { platform } from "../Platform";
|
|
||||||
import { useLatest } from "../useLatest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the user should be shown a button to switch their camera,
|
|
||||||
* producing a callback if so.
|
|
||||||
*/
|
|
||||||
export function useSwitchCamera(
|
|
||||||
video$: Observable<LocalVideoTrack | null>,
|
|
||||||
): (() => void) | null {
|
|
||||||
const mediaDevices = useMediaDevices();
|
|
||||||
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
|
||||||
|
|
||||||
// Produce an observable like the input 'video' observable, except make it
|
|
||||||
// emit whenever the track is muted or the device changes
|
|
||||||
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
|
|
||||||
(inputs$) =>
|
|
||||||
inputs$.pipe(
|
|
||||||
switchMap(([video$]) => video$),
|
|
||||||
switchMap((video) => {
|
|
||||||
if (video === null) return of(null);
|
|
||||||
return merge(
|
|
||||||
fromEvent(video, TrackEvent.Restarted).pipe(
|
|
||||||
startWith(null),
|
|
||||||
map(() => video),
|
|
||||||
),
|
|
||||||
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[video$],
|
|
||||||
);
|
|
||||||
|
|
||||||
const switchCamera$: Observable<(() => void) | null> = useObservable(
|
|
||||||
(inputs$) =>
|
|
||||||
platform === "desktop"
|
|
||||||
? of(null)
|
|
||||||
: inputs$.pipe(
|
|
||||||
switchMap(([track$]) => track$),
|
|
||||||
map((track) => {
|
|
||||||
if (track === null) return null;
|
|
||||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
|
||||||
// If the camera isn't front or back-facing, don't provide a switch
|
|
||||||
// camera shortcut at all
|
|
||||||
if (facingMode !== "user" && facingMode !== "environment")
|
|
||||||
return null;
|
|
||||||
// Restart the track with a camera facing the opposite direction
|
|
||||||
return (): void =>
|
|
||||||
void track
|
|
||||||
.restartTrack({
|
|
||||||
facingMode: facingMode === "user" ? "environment" : "user",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Inform the MediaDeviceContext which camera was chosen
|
|
||||||
const deviceId =
|
|
||||||
track.mediaStreamTrack.getSettings().deviceId;
|
|
||||||
if (deviceId !== undefined) setVideoInput.current(deviceId);
|
|
||||||
})
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error("Failed to switch camera", facingMode, e),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[videoTrack$],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useObservableEagerState(switchCamera$);
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, vi, onTestFinished, it } from "vitest";
|
import { test, vi, onTestFinished, it } from "vitest";
|
||||||
|
import EventEmitter from "events";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
@@ -17,16 +18,18 @@ import {
|
|||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
|
type LocalTrackPublication,
|
||||||
type Participant,
|
type Participant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import * as ComponentsCore from "@livekit/components-core";
|
import * as ComponentsCore from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
|
Status,
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -94,6 +97,11 @@ vi.mock("rxjs", async (importOriginal) => ({
|
|||||||
|
|
||||||
vi.mock("@livekit/components-core");
|
vi.mock("@livekit/components-core");
|
||||||
|
|
||||||
|
const yesNo = {
|
||||||
|
y: true,
|
||||||
|
n: false,
|
||||||
|
};
|
||||||
|
|
||||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||||
|
|
||||||
const carol = local;
|
const carol = local;
|
||||||
@@ -226,33 +234,56 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CallViewModelInputs {
|
||||||
|
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
|
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
||||||
|
connectionState$: Observable<ECConnectionState>;
|
||||||
|
speaking: Map<Participant, Observable<boolean>>;
|
||||||
|
mediaDevices: MediaDevices;
|
||||||
|
initialSyncState: SyncState;
|
||||||
|
}
|
||||||
|
|
||||||
function withCallViewModel(
|
function withCallViewModel(
|
||||||
remoteParticipants$: Behavior<RemoteParticipant[]>,
|
{
|
||||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
remoteParticipants$ = constant([]),
|
||||||
connectionState$: Observable<ECConnectionState>,
|
rtcMembers$ = constant([localRtcMember]),
|
||||||
speaking: Map<Participant, Observable<boolean>>,
|
connectionState$ = of(ConnectionState.Connected),
|
||||||
mediaDevices: MediaDevices,
|
speaking = new Map(),
|
||||||
|
mediaDevices = mockMediaDevices({}),
|
||||||
|
initialSyncState = SyncState.Syncing,
|
||||||
|
}: Partial<CallViewModelInputs>,
|
||||||
continuation: (
|
continuation: (
|
||||||
vm: CallViewModel,
|
vm: CallViewModel,
|
||||||
|
rtcSession: MockRTCSession,
|
||||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||||
|
setSyncState: (value: SyncState) => void,
|
||||||
) => void,
|
) => void,
|
||||||
options: CallViewModelOptions = {
|
options: CallViewModelOptions = {
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
autoLeaveWhenOthersLeft: false,
|
autoLeaveWhenOthersLeft: false,
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
|
let syncState = initialSyncState;
|
||||||
|
const setSyncState = (value: SyncState): void => {
|
||||||
|
const prev = syncState;
|
||||||
|
syncState = value;
|
||||||
|
room.client.emit(ClientEvent.Sync, value, prev);
|
||||||
|
};
|
||||||
const room = mockMatrixRoom({
|
const room = mockMatrixRoom({
|
||||||
client: {
|
client: new (class extends EventEmitter {
|
||||||
getUserId: () => localRtcMember.sender,
|
public getUserId(): string | undefined {
|
||||||
getDeviceId: () => localRtcMember.deviceId,
|
return localRtcMember.sender;
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
}
|
||||||
|
public getDeviceId(): string {
|
||||||
|
return localRtcMember.deviceId;
|
||||||
|
}
|
||||||
|
public getSyncState(): SyncState {
|
||||||
|
return syncState;
|
||||||
|
}
|
||||||
|
})() as Partial<MatrixClient> as MatrixClient,
|
||||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
|
||||||
room,
|
|
||||||
localRtcMember,
|
|
||||||
[],
|
|
||||||
).withMemberships(rtcMembers$);
|
|
||||||
const participantsSpy = vi
|
const participantsSpy = vi
|
||||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
.mockReturnValue(remoteParticipants$);
|
.mockReturnValue(remoteParticipants$);
|
||||||
@@ -279,7 +310,7 @@ function withCallViewModel(
|
|||||||
.spyOn(ComponentsCore, "roomEventSelector")
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
.mockImplementation((room, eventType) => of());
|
.mockImplementation((room, eventType) => of());
|
||||||
|
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const livekitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants$ },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
@@ -288,7 +319,8 @@ function withCallViewModel(
|
|||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
liveKitRoom,
|
room,
|
||||||
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
options,
|
options,
|
||||||
connectionState$,
|
connectionState$,
|
||||||
@@ -304,7 +336,7 @@ function withCallViewModel(
|
|||||||
roomEventSelectorSpy!.mockRestore();
|
roomEventSelectorSpy!.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
continuation(vm, { raisedHands$: raisedHands$ });
|
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("participants are retained during a focus switch", () => {
|
test("participants are retained during a focus switch", () => {
|
||||||
@@ -317,17 +349,17 @@ test("participants are retained during a focus switch", () => {
|
|||||||
const expectedLayoutMarbles = " a";
|
const expectedLayoutMarbles = " a";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
behavior(participantInputMarbles, {
|
{
|
||||||
a: [aliceParticipant, bobParticipant],
|
remoteParticipants$: behavior(participantInputMarbles, {
|
||||||
b: [],
|
a: [aliceParticipant, bobParticipant],
|
||||||
}),
|
b: [],
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
}),
|
||||||
behavior(connectionInputMarbles, {
|
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
c: ConnectionState.Connected,
|
connectionState$: behavior(connectionInputMarbles, {
|
||||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
c: ConnectionState.Connected,
|
||||||
}),
|
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||||
new Map(),
|
}),
|
||||||
mockMediaDevices({}),
|
},
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
@@ -358,16 +390,15 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
const expectedLayoutMarbles = " abcdaefeg";
|
const expectedLayoutMarbles = " abcdaefeg";
|
||||||
const expectedShowSpeakingMarbles = "y----nyny";
|
const expectedShowSpeakingMarbles = "y----nyny";
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
behavior(participantInputMarbles, {
|
{
|
||||||
a: [aliceParticipant, bobParticipant],
|
remoteParticipants$: behavior(participantInputMarbles, {
|
||||||
b: [aliceSharingScreen, bobParticipant],
|
a: [aliceParticipant, bobParticipant],
|
||||||
c: [aliceSharingScreen, bobSharingScreen],
|
b: [aliceSharingScreen, bobParticipant],
|
||||||
d: [aliceParticipant, bobSharingScreen],
|
c: [aliceSharingScreen, bobSharingScreen],
|
||||||
}),
|
d: [aliceParticipant, bobSharingScreen],
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
}),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeInputMarbles, {
|
schedule(modeInputMarbles, {
|
||||||
s: () => vm.setGridMode("spotlight"),
|
s: () => vm.setGridMode("spotlight"),
|
||||||
@@ -419,10 +450,7 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
);
|
);
|
||||||
expectObservable(vm.showSpeakingIndicators$).toBe(
|
expectObservable(vm.showSpeakingIndicators$).toBe(
|
||||||
expectedShowSpeakingMarbles,
|
expectedShowSpeakingMarbles,
|
||||||
{
|
yesNo,
|
||||||
y: true,
|
|
||||||
n: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -443,24 +471,24 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
|||||||
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
remoteParticipants$: constant([
|
||||||
of(ConnectionState.Connected),
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
aliceParticipant,
|
aliceParticipant,
|
||||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
bobParticipant,
|
bobParticipant,
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
daveParticipant,
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
]),
|
||||||
],
|
rtcMembers$: constant([
|
||||||
]),
|
localRtcMember,
|
||||||
mockMediaDevices({}),
|
aliceRtcMember,
|
||||||
|
bobRtcMember,
|
||||||
|
daveRtcMember,
|
||||||
|
]),
|
||||||
|
speaking: new Map([
|
||||||
|
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||||
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
|
]),
|
||||||
|
},
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(visibilityInputMarbles, {
|
schedule(visibilityInputMarbles, {
|
||||||
a: () => {
|
a: () => {
|
||||||
@@ -510,20 +538,23 @@ test("participants adjust order when space becomes constrained", () => {
|
|||||||
const expectedLayoutMarbles = " a-b";
|
const expectedLayoutMarbles = " a-b";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
remoteParticipants$: constant([
|
||||||
of(ConnectionState.Connected),
|
aliceParticipant,
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
bobParticipant,
|
bobParticipant,
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
daveParticipant,
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
]),
|
||||||
],
|
rtcMembers$: constant([
|
||||||
]),
|
localRtcMember,
|
||||||
mockMediaDevices({}),
|
aliceRtcMember,
|
||||||
|
bobRtcMember,
|
||||||
|
daveRtcMember,
|
||||||
|
]),
|
||||||
|
speaking: new Map([
|
||||||
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
|
]),
|
||||||
|
},
|
||||||
(vm) => {
|
(vm) => {
|
||||||
let setVisibleTiles: ((value: number) => void) | null = null;
|
let setVisibleTiles: ((value: number) => void) | null = null;
|
||||||
vm.layout$.subscribe((layout) => {
|
vm.layout$.subscribe((layout) => {
|
||||||
@@ -569,24 +600,24 @@ test("spotlight speakers swap places", () => {
|
|||||||
const expectedLayoutMarbles = "abcd";
|
const expectedLayoutMarbles = "abcd";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
remoteParticipants$: constant([
|
||||||
of(ConnectionState.Connected),
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
aliceParticipant,
|
aliceParticipant,
|
||||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
bobParticipant,
|
bobParticipant,
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
daveParticipant,
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
]),
|
||||||
],
|
rtcMembers$: constant([
|
||||||
]),
|
localRtcMember,
|
||||||
mockMediaDevices({}),
|
aliceRtcMember,
|
||||||
|
bobRtcMember,
|
||||||
|
daveRtcMember,
|
||||||
|
]),
|
||||||
|
speaking: new Map([
|
||||||
|
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||||
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
|
]),
|
||||||
|
},
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||||
|
|
||||||
@@ -628,11 +659,10 @@ test("layout enters picture-in-picture mode when requested", () => {
|
|||||||
const expectedLayoutMarbles = " aba";
|
const expectedLayoutMarbles = " aba";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(pipControlInputMarbles, {
|
schedule(pipControlInputMarbles, {
|
||||||
e: () => window.controls.enablePip(),
|
e: () => window.controls.enablePip(),
|
||||||
@@ -670,11 +700,10 @@ test("spotlight remembers whether it's expanded", () => {
|
|||||||
const expectedLayoutMarbles = "abcbada";
|
const expectedLayoutMarbles = "abcbada";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
schedule(modeInputMarbles, {
|
schedule(modeInputMarbles, {
|
||||||
s: () => vm.setGridMode("spotlight"),
|
s: () => vm.setGridMode("spotlight"),
|
||||||
@@ -727,23 +756,22 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
|||||||
const expectedLayoutMarbles = "a-bc-b";
|
const expectedLayoutMarbles = "a-bc-b";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
behavior(scenarioInputMarbles, {
|
{
|
||||||
a: [],
|
remoteParticipants$: behavior(scenarioInputMarbles, {
|
||||||
b: [bobParticipant],
|
a: [],
|
||||||
c: [aliceParticipant, bobParticipant],
|
b: [bobParticipant],
|
||||||
d: [aliceParticipant, daveParticipant, bobParticipant],
|
c: [aliceParticipant, bobParticipant],
|
||||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
d: [aliceParticipant, daveParticipant, bobParticipant],
|
||||||
}),
|
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||||
behavior(scenarioInputMarbles, {
|
}),
|
||||||
a: [],
|
rtcMembers$: behavior(scenarioInputMarbles, {
|
||||||
b: [],
|
a: [localRtcMember],
|
||||||
c: [aliceRtcMember],
|
b: [localRtcMember],
|
||||||
d: [aliceRtcMember, daveRtcMember],
|
c: [localRtcMember, aliceRtcMember],
|
||||||
e: [aliceRtcMember, daveRtcMember],
|
d: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
}),
|
e: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
of(ConnectionState.Connected),
|
}),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
@@ -780,15 +808,14 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
|||||||
const expectedLayoutMarbles = "abc";
|
const expectedLayoutMarbles = "abc";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
behavior(scenarioInputMarbles, {
|
{
|
||||||
a: [],
|
remoteParticipants$: behavior(scenarioInputMarbles, {
|
||||||
b: [aliceParticipant],
|
a: [],
|
||||||
c: [aliceParticipant, bobParticipant],
|
b: [aliceParticipant],
|
||||||
}),
|
c: [aliceParticipant, bobParticipant],
|
||||||
constant([]), // No one joins the MatrixRTC session
|
}),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: constant([localRtcMember]), // No one else joins the MatrixRTC session
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
@@ -827,16 +854,14 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
|||||||
const expectedLayoutMarbles = "abcd";
|
const expectedLayoutMarbles = "abcd";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
{
|
||||||
behavior(scenarioInputMarbles, {
|
rtcMembers$: behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [aliceRtcMember],
|
b: [localRtcMember, aliceRtcMember],
|
||||||
c: [aliceRtcMember, daveRtcMember],
|
c: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
d: [daveRtcMember],
|
d: [localRtcMember, daveRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
},
|
||||||
new Map(),
|
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
vm.setGridMode("grid");
|
vm.setGridMode("grid");
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
@@ -875,17 +900,20 @@ test("should disambiguate users with the same displayname", () => {
|
|||||||
const expectedLayoutMarbles = "abcde";
|
const expectedLayoutMarbles = "abcde";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
{
|
||||||
behavior(scenarioInputMarbles, {
|
rtcMembers$: behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [aliceRtcMember],
|
b: [localRtcMember, aliceRtcMember],
|
||||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||||
d: [aliceRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
d: [
|
||||||
e: [aliceDoppelgangerRtcMember, bobRtcMember],
|
localRtcMember,
|
||||||
}),
|
aliceRtcMember,
|
||||||
of(ConnectionState.Connected),
|
aliceDoppelgangerRtcMember,
|
||||||
new Map(),
|
bobRtcMember,
|
||||||
mockMediaDevices({}),
|
],
|
||||||
|
e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
||||||
|
}),
|
||||||
|
},
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||||
// Carol has no displayname - So userId is used.
|
// Carol has no displayname - So userId is used.
|
||||||
@@ -925,14 +953,12 @@ test("should disambiguate users with invisible characters", () => {
|
|||||||
const expectedLayoutMarbles = "ab";
|
const expectedLayoutMarbles = "ab";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
{
|
||||||
behavior(scenarioInputMarbles, {
|
rtcMembers$: behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
},
|
||||||
new Map(),
|
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||||
// Carol has no displayname - So userId is used.
|
// Carol has no displayname - So userId is used.
|
||||||
@@ -958,14 +984,12 @@ test("should strip RTL characters from displayname", () => {
|
|||||||
const expectedLayoutMarbles = "ab";
|
const expectedLayoutMarbles = "ab";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
{
|
||||||
behavior(scenarioInputMarbles, {
|
rtcMembers$: behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [daveRtcMember, daveRTLRtcMember],
|
b: [localRtcMember, daveRtcMember, daveRTLRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
},
|
||||||
new Map(),
|
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||||
// Carol has no displayname - So userId is used.
|
// Carol has no displayname - So userId is used.
|
||||||
@@ -985,17 +1009,16 @@ test("should strip RTL characters from displayname", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
||||||
withTestScheduler(({ schedule, expectObservable, behavior }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
// There should always be one tile for each MatrixRTCSession
|
// There should always be one tile for each MatrixRTCSession
|
||||||
const expectedLayoutMarbles = "ab";
|
const expectedLayoutMarbles = "ab";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
{
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
(vm, _rtcSession, { raisedHands$ }) => {
|
||||||
(vm, { raisedHands$ }) => {
|
|
||||||
schedule("ab", {
|
schedule("ab", {
|
||||||
a: () => {
|
a: () => {
|
||||||
// We imagine that only two tiles (the first two) will be visible on screen at a time
|
// We imagine that only two tiles (the first two) will be visible on screen at a time
|
||||||
@@ -1076,10 +1099,10 @@ function rtcMemberJoinLeave$(
|
|||||||
) => Observable<CallMembership[]>,
|
) => Observable<CallMembership[]>,
|
||||||
): Observable<CallMembership[]> {
|
): Observable<CallMembership[]> {
|
||||||
return hot("a-b-c-d", {
|
return hot("a-b-c-d", {
|
||||||
a: [], // Start empty
|
a: [localRtcMember], // Start empty
|
||||||
b: [aliceRtcMember], // Alice joins
|
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||||
c: [aliceRtcMember], // Alice still there
|
c: [localRtcMember, aliceRtcMember], // Alice still there
|
||||||
d: [], // Alice leaves
|
d: [localRtcMember], // Alice leaves
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,11 +1110,7 @@ test("allOthersLeft$ emits only when someone joined and then all others left", (
|
|||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
{ remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) },
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
|
||||||
of(ConnectionState.Connected),
|
|
||||||
new Map(),
|
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
|
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
|
||||||
},
|
},
|
||||||
@@ -1102,11 +1121,10 @@ test("allOthersLeft$ emits only when someone joined and then all others left", (
|
|||||||
test("allOthersLeft$ emits true when someone joined and then all others left", () => {
|
test("allOthersLeft$ emits true when someone joined and then all others left", () => {
|
||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(participantJoinLeave$(hot), []),
|
{
|
||||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.allOthersLeft$).toBe(
|
expectObservable(vm.allOthersLeft$).toBe(
|
||||||
"n-----u", // false initially, then at frame 6: true then false emissions in same frame
|
"n-----u", // false initially, then at frame 6: true then false emissions in same frame
|
||||||
@@ -1120,11 +1138,10 @@ test("allOthersLeft$ emits true when someone joined and then all others left", (
|
|||||||
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
|
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
|
||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(participantJoinLeave$(hot), []),
|
{
|
||||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
|
||||||
"------e", // false initially, then at frame 6: true then false emissions in same frame
|
"------e", // false initially, then at frame 6: true then false emissions in same frame
|
||||||
@@ -1142,11 +1159,10 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is
|
|||||||
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
{
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
remoteParticipants$: scope.behavior(nooneEverThere$(hot), []),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: scope.behavior(nooneEverThere$(hot), []),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||||
},
|
},
|
||||||
@@ -1161,11 +1177,10 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena
|
|||||||
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(participantJoinLeave$(hot), []),
|
{
|
||||||
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||||
of(ConnectionState.Connected),
|
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||||
},
|
},
|
||||||
@@ -1180,27 +1195,26 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option
|
|||||||
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
||||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(
|
{
|
||||||
hot("a-b-c-d", {
|
remoteParticipants$: scope.behavior(
|
||||||
a: [], // Alone
|
hot("a-b-c-d", {
|
||||||
b: [aliceParticipant], // Alice joins
|
a: [], // Alone
|
||||||
c: [aliceParticipant],
|
b: [aliceParticipant], // Alice joins
|
||||||
d: [], // Local joins with a second device
|
c: [aliceParticipant],
|
||||||
}),
|
d: [], // Local joins with a second device
|
||||||
[], //Alice leaves
|
}),
|
||||||
),
|
[], //Alice leaves
|
||||||
scope.behavior(
|
),
|
||||||
hot("a-b-c-d", {
|
rtcMembers$: scope.behavior(
|
||||||
a: [localRtcMember], // Start empty
|
hot("a-b-c-d", {
|
||||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
a: [localRtcMember], // Start empty
|
||||||
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
|
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||||
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
|
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
|
||||||
}),
|
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
|
||||||
[],
|
}),
|
||||||
),
|
[],
|
||||||
of(ConnectionState.Connected),
|
),
|
||||||
new Map(),
|
},
|
||||||
mockMediaDevices({}),
|
|
||||||
(vm) => {
|
(vm) => {
|
||||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
|
||||||
e: undefined,
|
e: undefined,
|
||||||
@@ -1234,25 +1248,91 @@ test("audio output changes when toggling earpiece mode", () => {
|
|||||||
const expectedEarpieceModeMarbles = "n-yn";
|
const expectedEarpieceModeMarbles = "n-yn";
|
||||||
const expectedTargetStateMarbles = " sese";
|
const expectedTargetStateMarbles = " sese";
|
||||||
|
|
||||||
|
withCallViewModel({ mediaDevices: devices }, (vm) => {
|
||||||
|
schedule(toggleInputMarbles, {
|
||||||
|
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
||||||
|
});
|
||||||
|
expectObservable(vm.earpieceMode$).toBe(
|
||||||
|
expectedEarpieceModeMarbles,
|
||||||
|
yesNo,
|
||||||
|
);
|
||||||
|
expectObservable(
|
||||||
|
vm.audioOutputSwitcher$.pipe(map((switcher) => switcher?.targetOutput)),
|
||||||
|
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("media tracks are paused while reconnecting to MatrixRTC", () => {
|
||||||
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
|
const trackRunning$ = new BehaviorSubject(true);
|
||||||
|
const originalPublications = localParticipant.trackPublications;
|
||||||
|
localParticipant.trackPublications = new Map([
|
||||||
|
[
|
||||||
|
"video",
|
||||||
|
{
|
||||||
|
track: new (class {
|
||||||
|
public get isUpstreamPaused(): boolean {
|
||||||
|
return !trackRunning$.value;
|
||||||
|
}
|
||||||
|
public async pauseUpstream(): Promise<void> {
|
||||||
|
trackRunning$.next(false);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
public async resumeUpstream(): Promise<void> {
|
||||||
|
trackRunning$.next(true);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
} as unknown as LocalTrackPublication,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
onTestFinished(() => {
|
||||||
|
localParticipant.trackPublications = originalPublications;
|
||||||
|
});
|
||||||
|
|
||||||
|
// There are three indicators that the client might be disconnected from
|
||||||
|
// MatrixRTC: whether the sync loop is connected, whether the membership is
|
||||||
|
// present in local room state, and whether the membership manager thinks
|
||||||
|
// we've hit the timeout for the delayed leave event. Let's test all
|
||||||
|
// combinations of these conditions.
|
||||||
|
const syncingMarbles = " nyny----n--y";
|
||||||
|
const membershipStatusMarbles = " y---ny-n-yn-y";
|
||||||
|
const probablyLeftMarbles = " n-----y-ny---n";
|
||||||
|
const expectedReconnectingMarbles = "n-ynyny------n";
|
||||||
|
const expectedTrackRunningMarbles = "nynynyn------y";
|
||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
{ initialSyncState: SyncState.Reconnecting },
|
||||||
constant([]),
|
(vm, rtcSession, _subjects, setSyncState) => {
|
||||||
of(ConnectionState.Connected),
|
schedule(syncingMarbles, {
|
||||||
new Map(),
|
y: () => setSyncState(SyncState.Syncing),
|
||||||
devices,
|
n: () => setSyncState(SyncState.Reconnecting),
|
||||||
(vm) => {
|
|
||||||
schedule(toggleInputMarbles, {
|
|
||||||
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
|
||||||
});
|
});
|
||||||
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
|
schedule(membershipStatusMarbles, {
|
||||||
n: false,
|
y: () => {
|
||||||
y: true,
|
rtcSession.membershipStatus = Status.Connected;
|
||||||
|
},
|
||||||
|
n: () => {
|
||||||
|
rtcSession.membershipStatus = Status.Reconnecting;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expectObservable(
|
schedule(probablyLeftMarbles, {
|
||||||
vm.audioOutputSwitcher$.pipe(
|
y: () => {
|
||||||
map((switcher) => switcher?.targetOutput),
|
rtcSession.probablyLeft = true;
|
||||||
),
|
},
|
||||||
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
|
n: () => {
|
||||||
|
rtcSession.probablyLeft = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectObservable(vm.reconnecting$).toBe(
|
||||||
|
expectedReconnectingMarbles,
|
||||||
|
yesNo,
|
||||||
|
);
|
||||||
|
expectObservable(trackRunning$).toBe(
|
||||||
|
expectedTrackRunningMarbles,
|
||||||
|
yesNo,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ import {
|
|||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
|
ConnectionState,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
LocalVideoTrack,
|
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
SyncState,
|
||||||
|
type Room as MatrixRoom,
|
||||||
|
type RoomMember,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
@@ -50,6 +55,8 @@ import {
|
|||||||
type CallMembership,
|
type CallMembership,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
|
MembershipManagerEvent,
|
||||||
|
Status,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
@@ -60,12 +67,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
observeTrackReference$,
|
|
||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "./MediaViewModel";
|
} from "./MediaViewModel";
|
||||||
import { accumulate, finalizeValue } from "../utils/observable";
|
import {
|
||||||
|
accumulate,
|
||||||
|
and$,
|
||||||
|
finalizeValue,
|
||||||
|
pauseWhen,
|
||||||
|
} from "../utils/observable";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import {
|
import {
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
@@ -263,6 +274,8 @@ class UserMedia {
|
|||||||
participant: LocalParticipant | RemoteParticipant | undefined,
|
participant: LocalParticipant | RemoteParticipant | undefined,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
mediaDevices: MediaDevices,
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Observable<string>,
|
displayname$: Observable<string>,
|
||||||
handRaised$: Observable<Date | null>,
|
handRaised$: Observable<Date | null>,
|
||||||
reaction$: Observable<ReactionOption | null>,
|
reaction$: Observable<ReactionOption | null>,
|
||||||
@@ -276,6 +289,7 @@ class UserMedia {
|
|||||||
this.participant$ as Behavior<LocalParticipant>,
|
this.participant$ as Behavior<LocalParticipant>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
|
mediaDevices,
|
||||||
this.scope.behavior(displayname$),
|
this.scope.behavior(displayname$),
|
||||||
this.scope.behavior(handRaised$),
|
this.scope.behavior(handRaised$),
|
||||||
this.scope.behavior(reaction$),
|
this.scope.behavior(reaction$),
|
||||||
@@ -289,6 +303,7 @@ class UserMedia {
|
|||||||
>,
|
>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
|
pretendToBeDisconnected$,
|
||||||
this.scope.behavior(displayname$),
|
this.scope.behavior(displayname$),
|
||||||
this.scope.behavior(handRaised$),
|
this.scope.behavior(handRaised$),
|
||||||
this.scope.behavior(reaction$),
|
this.scope.behavior(reaction$),
|
||||||
@@ -342,7 +357,8 @@ class ScreenShare {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
liveKitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayName$: Observable<string>,
|
displayName$: Observable<string>,
|
||||||
) {
|
) {
|
||||||
this.participant$ = new BehaviorSubject(participant);
|
this.participant$ = new BehaviorSubject(participant);
|
||||||
@@ -352,7 +368,8 @@ class ScreenShare {
|
|||||||
member,
|
member,
|
||||||
this.participant$.asObservable(),
|
this.participant$.asObservable(),
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
liveKitRoom,
|
livekitRoom,
|
||||||
|
pretendToBeDisconnected$,
|
||||||
this.scope.behavior(displayName$),
|
this.scope.behavior(displayName$),
|
||||||
participant.isLocal,
|
participant.isLocal,
|
||||||
);
|
);
|
||||||
@@ -368,7 +385,7 @@ type MediaItem = UserMedia | ScreenShare;
|
|||||||
|
|
||||||
function getRoomMemberFromRtcMember(
|
function getRoomMemberFromRtcMember(
|
||||||
rtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
room: Room,
|
room: MatrixRoom,
|
||||||
): { id: string; member: RoomMember | undefined } {
|
): { id: string; member: RoomMember | undefined } {
|
||||||
// WARN! This is not exactly the sender but the user defined in the state key.
|
// WARN! This is not exactly the sender but the user defined in the state key.
|
||||||
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
|
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
|
||||||
@@ -390,18 +407,79 @@ function getRoomMemberFromRtcMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
|
private readonly userId = this.matrixRoom.client.getUserId();
|
||||||
observeTrackReference$(
|
|
||||||
this.livekitRoom.localParticipant,
|
private readonly matrixConnected$ = this.scope.behavior(
|
||||||
Track.Source.Camera,
|
// To consider ourselves connected to MatrixRTC, we check the following:
|
||||||
).pipe(
|
and$(
|
||||||
map((trackRef) => {
|
// The client is connected to the sync loop
|
||||||
const track = trackRef?.publication?.track;
|
(
|
||||||
return track instanceof LocalVideoTrack ? track : null;
|
fromEvent(this.matrixRoom.client, ClientEvent.Sync) as Observable<
|
||||||
}),
|
[SyncState]
|
||||||
|
>
|
||||||
|
).pipe(
|
||||||
|
startWith([this.matrixRoom.client.getSyncState()]),
|
||||||
|
map(([state]) => state === SyncState.Syncing),
|
||||||
|
),
|
||||||
|
// Room state observed by session says we're connected
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MembershipManagerEvent.StatusChanged,
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => this.matrixRTCSession.membershipStatus === Status.Connected),
|
||||||
|
),
|
||||||
|
// Also watch out for warnings that we've likely hit a timeout and our
|
||||||
|
// delayed leave event is being sent (this condition is here because it
|
||||||
|
// provides an earlier warning than the sync loop timeout, and we wouldn't
|
||||||
|
// see the actual leave event until we reconnect to the sync loop)
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MembershipManagerEvent.ProbablyLeft,
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => this.matrixRTCSession.probablyLeft !== true),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly connected$ = this.scope.behavior(
|
||||||
|
and$(
|
||||||
|
this.matrixConnected$,
|
||||||
|
this.livekitConnectionState$.pipe(
|
||||||
|
map((state) => state === ConnectionState.Connected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we should tell the user that we're reconnecting to the call.
|
||||||
|
*/
|
||||||
|
public readonly reconnecting$ = this.scope.behavior(
|
||||||
|
this.connected$.pipe(
|
||||||
|
// We are reconnecting if we previously had some successful initial
|
||||||
|
// connection but are now disconnected
|
||||||
|
scan(
|
||||||
|
({ connectedPreviously, reconnecting }, connectedNow) => ({
|
||||||
|
connectedPreviously: connectedPreviously || connectedNow,
|
||||||
|
reconnecting: connectedPreviously && !connectedNow,
|
||||||
|
}),
|
||||||
|
{ connectedPreviously: false, reconnecting: false },
|
||||||
|
),
|
||||||
|
map(({ reconnecting }) => reconnecting),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether various media/event sources should pretend to be disconnected from
|
||||||
|
* all network input, even if their connection still technically works.
|
||||||
|
*/
|
||||||
|
// We do this when the app is in the 'reconnecting' state, because it might be
|
||||||
|
// that the LiveKit connection is still functional while the homeserver is
|
||||||
|
// down, for example, and we want to avoid making people worry that the app is
|
||||||
|
// in a split-brained state.
|
||||||
|
private readonly pretendToBeDisconnected$ = this.reconnecting$;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raw list of RemoteParticipants as reported by LiveKit
|
* The raw list of RemoteParticipants as reported by LiveKit
|
||||||
*/
|
*/
|
||||||
@@ -416,7 +494,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
||||||
RemoteParticipant[][]
|
RemoteParticipant[][]
|
||||||
>(
|
>(
|
||||||
this.connectionState$.pipe(
|
this.livekitConnectionState$.pipe(
|
||||||
withLatestFrom(this.rawRemoteParticipants$),
|
withLatestFrom(this.rawRemoteParticipants$),
|
||||||
mergeMap(([s, ps]) => {
|
mergeMap(([s, ps]) => {
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
// Whenever we switch focuses, we should retain all the previous
|
||||||
@@ -429,7 +507,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Wait for time to pass and the connection state to have changed
|
// Wait for time to pass and the connection state to have changed
|
||||||
forkJoin([
|
forkJoin([
|
||||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||||
this.connectionState$.pipe(
|
this.livekitConnectionState$.pipe(
|
||||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||||
take(1),
|
take(1),
|
||||||
),
|
),
|
||||||
@@ -453,74 +531,80 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The RemoteParticipants including those that are being "held" on the screen
|
* The RemoteParticipants including those that are being "held" on the screen
|
||||||
*/
|
*/
|
||||||
private readonly remoteParticipants$ = this.scope.behavior<
|
private readonly remoteParticipants$ = this.scope
|
||||||
RemoteParticipant[]
|
.behavior<RemoteParticipant[]>(
|
||||||
>(
|
combineLatest(
|
||||||
combineLatest(
|
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
(raw, holds) => {
|
||||||
(raw, holds) => {
|
const result = [...raw];
|
||||||
const result = [...raw];
|
const resultIds = new Set(result.map((p) => p.identity));
|
||||||
const resultIds = new Set(result.map((p) => p.identity));
|
|
||||||
|
|
||||||
// Incorporate the held participants into the list
|
// Incorporate the held participants into the list
|
||||||
for (const hold of holds) {
|
for (const hold of holds) {
|
||||||
for (const p of hold) {
|
for (const p of hold) {
|
||||||
if (!resultIds.has(p.identity)) {
|
if (!resultIds.has(p.identity)) {
|
||||||
result.push(p);
|
result.push(p);
|
||||||
resultIds.add(p.identity);
|
resultIds.add(p.identity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||||
private readonly memberships$: Observable<CallMembership[]> = merge(
|
|
||||||
// Handle call membership changes.
|
|
||||||
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged),
|
|
||||||
// Handle room membership changes (and displayname updates)
|
|
||||||
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
|
|
||||||
).pipe(
|
|
||||||
startWith(this.matrixRTCSession.memberships),
|
|
||||||
map(() => {
|
|
||||||
return this.matrixRTCSession.memberships;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displaynames for each member of the call. This will disambiguate
|
* Displaynames for each member of the call. This will disambiguate
|
||||||
* any displaynames that clashes with another member. Only members
|
* any displaynames that clashes with another member. Only members
|
||||||
* joined to the call are considered here.
|
* joined to the call are considered here.
|
||||||
*/
|
*/
|
||||||
public readonly memberDisplaynames$ = this.memberships$.pipe(
|
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
||||||
map((memberships) => {
|
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
||||||
const displaynameMap = new Map<string, string>();
|
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||||
const { room } = this.matrixRTCSession;
|
public readonly memberDisplaynames$ = this.scope.behavior(
|
||||||
|
merge(
|
||||||
|
// Handle call membership changes.
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
),
|
||||||
|
// Handle room membership changes (and displayname updates)
|
||||||
|
fromEvent(this.matrixRoom, RoomStateEvent.Members),
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => {
|
||||||
|
const memberships = this.matrixRTCSession.memberships;
|
||||||
|
const displaynameMap = new Map<string, string>();
|
||||||
|
const room = this.matrixRoom;
|
||||||
|
|
||||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
for (const rtcMember of memberships) {
|
for (const rtcMember of memberships) {
|
||||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
logger.error("Could not find member for media id:", matrixIdentifier);
|
logger.error(
|
||||||
continue;
|
"Could not find member for media id:",
|
||||||
|
matrixIdentifier,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const disambiguate = shouldDisambiguate(member, memberships, room);
|
||||||
|
displaynameMap.set(
|
||||||
|
matrixIdentifier,
|
||||||
|
calculateDisplayName(member, disambiguate),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const disambiguate = shouldDisambiguate(member, memberships, room);
|
return displaynameMap;
|
||||||
displaynameMap.set(
|
}),
|
||||||
matrixIdentifier,
|
pauseWhen(this.pretendToBeDisconnected$),
|
||||||
calculateDisplayName(member, disambiguate),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
return displaynameMap;
|
|
||||||
}),
|
|
||||||
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
|
||||||
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
|
||||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
|
public readonly handsRaised$ = this.scope.behavior(
|
||||||
|
this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)),
|
||||||
|
);
|
||||||
|
|
||||||
public readonly reactions$ = this.scope.behavior(
|
public readonly reactions$ = this.scope.behavior(
|
||||||
this.reactionsSubject$.pipe(
|
this.reactionsSubject$.pipe(
|
||||||
@@ -532,6 +616,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
pauseWhen(this.pretendToBeDisconnected$),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -549,7 +634,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
fromEvent(
|
fromEvent(
|
||||||
this.matrixRTCSession,
|
this.matrixRTCSession,
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
).pipe(startWith(null)),
|
).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)),
|
||||||
showNonMemberTiles.value$,
|
showNonMemberTiles.value$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
scan(
|
scan(
|
||||||
@@ -565,7 +650,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
) => {
|
) => {
|
||||||
const newItems = new Map(
|
const newItems = new Map(
|
||||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||||
const room = this.matrixRTCSession.room;
|
const room = this.matrixRoom;
|
||||||
// m.rtc.members are the basis for calculating what is visible in the call
|
// m.rtc.members are the basis for calculating what is visible in the call
|
||||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||||
const { member, id: livekitParticipantId } =
|
const { member, id: livekitParticipantId } =
|
||||||
@@ -616,6 +701,8 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.mediaDevices,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
),
|
),
|
||||||
@@ -639,6 +726,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
),
|
),
|
||||||
@@ -680,6 +768,8 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.mediaDevices,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map(
|
map(
|
||||||
(m) => m.get(participant.identity) ?? "[👻]",
|
(m) => m.get(participant.identity) ?? "[👻]",
|
||||||
@@ -783,12 +873,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
|
|
||||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||||
map(({ userIds, leftUserIds }) => {
|
map(({ userIds, leftUserIds }) => {
|
||||||
const userId = this.matrixRTCSession.room.client.getUserId();
|
if (!this.userId) {
|
||||||
if (!userId) {
|
logger.warn("Could not access user ID to compute allOthersLeft");
|
||||||
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
|
return (
|
||||||
|
userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
startWith(false),
|
startWith(false),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
@@ -900,7 +991,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
map((speaker) => (speaker ? [speaker] : [])),
|
map((speaker) => (speaker ? [speaker] : [])),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(shallowEquals),
|
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1485,10 +1576,11 @@ export class CallViewModel extends ViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
private readonly matrixRTCSession: MatrixRTCSession,
|
private readonly matrixRTCSession: MatrixRTCSession,
|
||||||
|
private readonly matrixRoom: MatrixRoom,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
private readonly options: CallViewModelOptions,
|
private readonly options: CallViewModelOptions,
|
||||||
private readonly connectionState$: Observable<ECConnectionState>,
|
private readonly livekitConnectionState$: Observable<ECConnectionState>,
|
||||||
private readonly handsRaisedSubject$: Observable<
|
private readonly handsRaisedSubject$: Observable<
|
||||||
Record<string, RaisedHandInfo>
|
Record<string, RaisedHandInfo>
|
||||||
>,
|
>,
|
||||||
@@ -1497,5 +1589,51 @@ export class CallViewModel extends ViewModel {
|
|||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
// Pause upstream of all local media tracks when we're disconnected from
|
||||||
|
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||||
|
// 'reconnecting' and yet still be transmitting your media to others.
|
||||||
|
// We use matrixConnected$ rather than reconnecting$ because we want to
|
||||||
|
// pause tracks during the initial joining sequence too until we're sure
|
||||||
|
// that our own media is displayed on screen.
|
||||||
|
this.matrixConnected$.pipe(this.scope.bind()).subscribe((connected) => {
|
||||||
|
const publications =
|
||||||
|
this.livekitRoom.localParticipant.trackPublications.values();
|
||||||
|
if (connected) {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === true) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(
|
||||||
|
`Resumming ${kind} track (MatrixRTC connection present)`,
|
||||||
|
);
|
||||||
|
p.track
|
||||||
|
.resumeUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === false) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(
|
||||||
|
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
|
||||||
|
);
|
||||||
|
p.track
|
||||||
|
.pauseUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, onTestFinished, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type LocalTrackPublication,
|
||||||
|
LocalVideoTrack,
|
||||||
|
TrackEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { waitFor } from "@testing-library/dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
mockLocalParticipant,
|
||||||
|
mockMediaDevices,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
withLocalMedia,
|
withLocalMedia,
|
||||||
withRemoteMedia,
|
withRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
|
import { getValue } from "../utils/observable";
|
||||||
|
import { constant } from "./Behavior";
|
||||||
|
|
||||||
|
global.MediaStreamTrack = class {} as unknown as {
|
||||||
|
new (): MediaStreamTrack;
|
||||||
|
prototype: MediaStreamTrack;
|
||||||
|
};
|
||||||
|
global.MediaStream = class {} as unknown as {
|
||||||
|
new (): MediaStream;
|
||||||
|
prototype: MediaStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
|
vi.mock("../Platform", () => ({
|
||||||
|
get platform(): string {
|
||||||
|
return platformMock();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||||
|
|
||||||
@@ -79,17 +105,23 @@ test("toggle fit/contain for a participant's video", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("local media remembers whether it should always be shown", async () => {
|
test("local media remembers whether it should always be shown", async () => {
|
||||||
await withLocalMedia(rtcMembership, {}, (vm) =>
|
await withLocalMedia(
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
rtcMembership,
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
{},
|
||||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
mockLocalParticipant({}),
|
||||||
}),
|
mockMediaDevices({}),
|
||||||
|
(vm) =>
|
||||||
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
|
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||||
|
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// Next local media should start out *not* always shown
|
// Next local media should start out *not* always shown
|
||||||
await withLocalMedia(
|
await withLocalMedia(
|
||||||
rtcMembership,
|
rtcMembership,
|
||||||
|
|
||||||
{},
|
{},
|
||||||
|
mockLocalParticipant({}),
|
||||||
|
mockMediaDevices({}),
|
||||||
(vm) =>
|
(vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||||
@@ -97,3 +129,77 @@ test("local media remembers whether it should always be shown", async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("switch cameras", async () => {
|
||||||
|
// Camera switching is only available on mobile
|
||||||
|
platformMock.mockReturnValue("android");
|
||||||
|
onTestFinished(() => void platformMock.mockReset());
|
||||||
|
|
||||||
|
// Construct a mock video track which knows how to be restarted
|
||||||
|
const track = new LocalVideoTrack({
|
||||||
|
getConstraints() {},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
} as unknown as MediaStreamTrack);
|
||||||
|
|
||||||
|
let deviceId = "front camera";
|
||||||
|
const restartTrack = vi.fn(async ({ facingMode }) => {
|
||||||
|
deviceId = facingMode === "user" ? "front camera" : "back camera";
|
||||||
|
track.emit(TrackEvent.Restarted);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
track.restartTrack = restartTrack;
|
||||||
|
|
||||||
|
Object.defineProperty(track, "mediaStreamTrack", {
|
||||||
|
get() {
|
||||||
|
return {
|
||||||
|
label: "Video",
|
||||||
|
getSettings: (): object => ({
|
||||||
|
deviceId,
|
||||||
|
facingMode: deviceId === "front camera" ? "user" : "environment",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectVideoInput = vi.fn();
|
||||||
|
|
||||||
|
await withLocalMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockLocalParticipant({
|
||||||
|
getTrackPublication() {
|
||||||
|
return { track } as unknown as LocalTrackPublication;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mockMediaDevices({
|
||||||
|
videoInput: {
|
||||||
|
available$: constant(new Map()),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: selectVideoInput,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (vm) => {
|
||||||
|
// Switch to back camera
|
||||||
|
getValue(vm.switchCamera$)!();
|
||||||
|
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
facingMode: "environment",
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||||
|
});
|
||||||
|
expect(deviceId).toBe("back camera");
|
||||||
|
|
||||||
|
// Switch to front camera
|
||||||
|
getValue(vm.switchCamera$)!();
|
||||||
|
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||||
|
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||||
|
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||||
|
});
|
||||||
|
expect(deviceId).toBe("front camera");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
LocalTrack,
|
LocalTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
type Participant,
|
type Participant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
RemoteTrack,
|
RemoteTrack,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type RoomMember } from "matrix-js-sdk";
|
import { type RoomMember } from "matrix-js-sdk";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
type Observable,
|
type Observable,
|
||||||
@@ -51,6 +53,8 @@ import { accumulate } from "../utils/observable";
|
|||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { type ReactionOption } from "../reactions";
|
import { type ReactionOption } from "../reactions";
|
||||||
|
import { platform } from "../Platform";
|
||||||
|
import { type MediaDevices } from "./MediaDevices";
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
export function observeTrackReference$(
|
export function observeTrackReference$(
|
||||||
@@ -357,10 +361,7 @@ export type UserMediaViewModel =
|
|||||||
* Some participant's user media.
|
* Some participant's user media.
|
||||||
*/
|
*/
|
||||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||||
/**
|
private readonly _speaking$ = this.scope.behavior(
|
||||||
* Whether the participant is speaking.
|
|
||||||
*/
|
|
||||||
public readonly speaking$ = this.scope.behavior(
|
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) =>
|
switchMap((p) =>
|
||||||
p
|
p
|
||||||
@@ -372,15 +373,27 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* Whether the participant is speaking.
|
||||||
|
*/
|
||||||
|
// Getter backed by a private field so that subclasses can override it
|
||||||
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
return this._speaking$;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||||
*/
|
*/
|
||||||
public readonly audioEnabled$: Behavior<boolean>;
|
public readonly audioEnabled$: Behavior<boolean>;
|
||||||
|
|
||||||
|
private readonly _videoEnabled$: Behavior<boolean>;
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending video.
|
* Whether this participant is sending video.
|
||||||
*/
|
*/
|
||||||
public readonly videoEnabled$: Behavior<boolean>;
|
// Getter backed by a private field so that subclasses can override it
|
||||||
|
public get videoEnabled$(): Behavior<boolean> {
|
||||||
|
return this._videoEnabled$;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||||
/**
|
/**
|
||||||
@@ -417,7 +430,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
this.audioEnabled$ = this.scope.behavior(
|
this.audioEnabled$ = this.scope.behavior(
|
||||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||||
);
|
);
|
||||||
this.videoEnabled$ = this.scope.behavior(
|
this._videoEnabled$ = this.scope.behavior(
|
||||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -443,20 +456,38 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
*/
|
*/
|
||||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
/**
|
/**
|
||||||
* Whether the video should be mirrored.
|
* The local video track as an observable that emits whenever the track
|
||||||
|
* changes, the camera is switched, or the track is muted.
|
||||||
*/
|
*/
|
||||||
public readonly mirror$ = this.scope.behavior(
|
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
this.video$.pipe(
|
this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication?.track;
|
||||||
if (!(track instanceof LocalTrack)) return of(false);
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
// Watch for track restarts, because they indicate a camera switch
|
return merge(
|
||||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
startWith(null),
|
// This event is also emitted when unmuting the track object.
|
||||||
// Mirror only front-facing cameras (those that face the user)
|
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
startWith(null),
|
||||||
|
map(() => track),
|
||||||
|
),
|
||||||
|
// When the track object is muted, reset it to null.
|
||||||
|
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the video should be mirrored.
|
||||||
|
*/
|
||||||
|
public readonly mirror$ = this.scope.behavior(
|
||||||
|
this.videoTrack$.pipe(
|
||||||
|
// Mirror only front-facing cameras (those that face the user)
|
||||||
|
map(
|
||||||
|
(track) =>
|
||||||
|
track !== null &&
|
||||||
|
facingModeFromLocalTrack(track).facingMode === "user",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,12 +498,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for switching between the front and back cameras.
|
||||||
|
*/
|
||||||
|
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||||
|
this.scope.behavior(
|
||||||
|
platform === "desktop"
|
||||||
|
? of(null)
|
||||||
|
: this.videoTrack$.pipe(
|
||||||
|
map((track) => {
|
||||||
|
if (track === null) return null;
|
||||||
|
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||||
|
// If the camera isn't front or back-facing, don't provide a switch
|
||||||
|
// camera shortcut at all
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment")
|
||||||
|
return null;
|
||||||
|
// Restart the track with a camera facing the opposite direction
|
||||||
|
return (): void =>
|
||||||
|
void track
|
||||||
|
.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Inform the MediaDevices which camera was chosen
|
||||||
|
const deviceId =
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
if (deviceId !== undefined)
|
||||||
|
this.mediaDevices.videoInput.select(deviceId);
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Failed to switch camera", facingMode, e),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant$: Behavior<LocalParticipant | undefined>,
|
participant$: Behavior<LocalParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly mediaDevices: MediaDevices,
|
||||||
displayName$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
@@ -514,6 +581,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* A remote participant's user media.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
// This private field is used to override the value from the superclass
|
||||||
|
private __speaking$: Behavior<boolean>;
|
||||||
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
return this.__speaking$;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly locallyMutedToggle$ = new Subject<void>();
|
private readonly locallyMutedToggle$ = new Subject<void>();
|
||||||
private readonly localVolumeAdjustment$ = new Subject<number>();
|
private readonly localVolumeAdjustment$ = new Subject<number>();
|
||||||
private readonly localVolumeCommit$ = new Subject<void>();
|
private readonly localVolumeCommit$ = new Subject<void>();
|
||||||
@@ -553,6 +626,12 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This private field is used to override the value from the superclass
|
||||||
|
private __videoEnabled$: Behavior<boolean>;
|
||||||
|
public get videoEnabled$(): Behavior<boolean> {
|
||||||
|
return this.__videoEnabled$;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant's audio is disabled.
|
* Whether this participant's audio is disabled.
|
||||||
*/
|
*/
|
||||||
@@ -566,6 +645,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
participant$: Observable<RemoteParticipant | undefined>,
|
participant$: Observable<RemoteParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayname$: Behavior<string>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
@@ -581,11 +661,33 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
reaction$,
|
reaction$,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.__speaking$ = this.scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : super.speaking$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.__videoEnabled$ = this.scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : super.videoEnabled$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the local volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
combineLatest([
|
combineLatest([
|
||||||
participant$,
|
participant$,
|
||||||
this.localVolume$.pipe(this.scope.bind()),
|
// The local volume, taking into account whether we're supposed to pretend
|
||||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
// that the audio stream is disconnected (since we don't necessarily want
|
||||||
|
// that to modify the UI state).
|
||||||
|
this.pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)),
|
||||||
|
this.scope.bind(),
|
||||||
|
),
|
||||||
|
]).subscribe(([p, volume]) => p?.setVolume(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleLocallyMuted(): void {
|
public toggleLocallyMuted(): void {
|
||||||
@@ -625,12 +727,20 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* Some participant's screen share media.
|
* Some participant's screen share media.
|
||||||
*/
|
*/
|
||||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||||
|
/**
|
||||||
|
* Whether this screen share's video should be displayed.
|
||||||
|
*/
|
||||||
|
public readonly videoEnabled$ = this.scope.behavior(
|
||||||
|
this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayname$: Behavior<string>,
|
||||||
public readonly local: boolean,
|
public readonly local: boolean,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -83,3 +83,25 @@ borders don't support gradients */
|
|||||||
.volumeSlider {
|
.volumeSlider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--cpd-color-bg-action-secondary-rest);
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera > svg {
|
||||||
|
color: var(--cpd-color-icon-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover) {
|
||||||
|
.tile .switchCamera:hover {
|
||||||
|
background: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
border-color: var(--cpd-color-border-interactive-hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera:active {
|
||||||
|
background: var(--cpd-color-bg-subtle-primary);
|
||||||
|
border-color: var(--cpd-color-border-interactive-hovered);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
UserProfileIcon,
|
UserProfileIcon,
|
||||||
ExpandIcon,
|
ExpandIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
|
SwitchCameraSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -65,6 +66,7 @@ interface UserMediaTileProps extends TileProps {
|
|||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
locallyMuted: boolean;
|
locallyMuted: boolean;
|
||||||
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
locallyMuted,
|
locallyMuted,
|
||||||
|
primaryButton,
|
||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
className,
|
className,
|
||||||
@@ -160,20 +163,22 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
}
|
}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
<Menu
|
primaryButton ?? (
|
||||||
open={menuOpen}
|
<Menu
|
||||||
onOpenChange={setMenuOpen}
|
open={menuOpen}
|
||||||
title={displayName}
|
onOpenChange={setMenuOpen}
|
||||||
trigger={
|
title={displayName}
|
||||||
<button aria-label={t("common.options")}>
|
trigger={
|
||||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
<button aria-label={t("common.options")}>
|
||||||
</button>
|
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||||
}
|
</button>
|
||||||
side="left"
|
}
|
||||||
align="start"
|
side="left"
|
||||||
>
|
align="start"
|
||||||
{menu}
|
>
|
||||||
</Menu>
|
{menu}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
raisedHandTime={handRaised ?? undefined}
|
raisedHandTime={handRaised ?? undefined}
|
||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
@@ -208,6 +213,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mirror = useBehavior(vm.mirror$);
|
const mirror = useBehavior(vm.mirror$);
|
||||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||||
|
const switchCamera = useBehavior(vm.switchCamera$);
|
||||||
|
|
||||||
const latestAlwaysShow = useLatest(alwaysShow);
|
const latestAlwaysShow = useLatest(alwaysShow);
|
||||||
const onSelectAlwaysShow = useCallback(
|
const onSelectAlwaysShow = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
@@ -223,6 +230,17 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
locallyMuted={false}
|
locallyMuted={false}
|
||||||
mirror={mirror}
|
mirror={mirror}
|
||||||
|
primaryButton={
|
||||||
|
switchCamera === null ? undefined : (
|
||||||
|
<button
|
||||||
|
className={styles.switchCamera}
|
||||||
|
aria-label={t("switch_camera")}
|
||||||
|
onClick={switchCamera}
|
||||||
|
>
|
||||||
|
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
menuStart={
|
menuStart={
|
||||||
<ToggleMenuItem
|
<ToggleMenuItem
|
||||||
Icon={VisibilityOnIcon}
|
Icon={VisibilityOnIcon}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
|
|
||||||
.nameTag {
|
.nameTag {
|
||||||
grid-area: nameTag;
|
grid-area: nameTag;
|
||||||
|
place-self: end start;
|
||||||
padding: var(--cpd-space-1x);
|
padding: var(--cpd-space-1x);
|
||||||
padding-block: var(--cpd-space-1x);
|
padding-block: var(--cpd-space-1x);
|
||||||
color: var(--cpd-color-text-primary);
|
color: var(--cpd-color-text-primary);
|
||||||
@@ -173,7 +174,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fg > button:active {
|
.fg > button:active {
|
||||||
background: var(--cpd-color-bg-action-primary-pressed) !important;
|
background: var(--cpd-color-bg-action-primary-pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg > button[data-state="open"] {
|
.fg > button[data-state="open"] {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import userEvent from "@testing-library/user-event";
|
|||||||
|
|
||||||
import { SpotlightTile } from "./SpotlightTile";
|
import { SpotlightTile } from "./SpotlightTile";
|
||||||
import {
|
import {
|
||||||
|
mockLocalParticipant,
|
||||||
|
mockMediaDevices,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
withLocalMedia,
|
withLocalMedia,
|
||||||
withRemoteMedia,
|
withRemoteMedia,
|
||||||
@@ -39,6 +41,8 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
rawDisplayName: "Bob",
|
rawDisplayName: "Bob",
|
||||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||||
},
|
},
|
||||||
|
mockLocalParticipant({}),
|
||||||
|
mockMediaDevices({}),
|
||||||
async (vm2) => {
|
async (vm2) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const toggleExpanded = vi.fn();
|
const toggleExpanded = vi.fn();
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface SpotlightItemBaseProps {
|
|||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
video: TrackReferenceOrPlaceholder | undefined;
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
|
videoEnabled: boolean;
|
||||||
member: RoomMember | undefined;
|
member: RoomMember | undefined;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
encryptionStatus: EncryptionStatus;
|
encryptionStatus: EncryptionStatus;
|
||||||
@@ -63,7 +64,6 @@ interface SpotlightItemBaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
videoEnabled: boolean;
|
|
||||||
videoFit: "contain" | "cover";
|
videoFit: "contain" | "cover";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +90,10 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
|
||||||
const cropVideo = useBehavior(vm.cropVideo$);
|
const cropVideo = useBehavior(vm.cropVideo$);
|
||||||
|
|
||||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||||
RefAttributes<HTMLDivElement> = {
|
RefAttributes<HTMLDivElement> = {
|
||||||
videoEnabled,
|
|
||||||
videoFit: cropVideo ? "cover" : "contain",
|
videoFit: cropVideo ? "cover" : "contain",
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
@@ -135,6 +133,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const displayName = useBehavior(vm.displayName$);
|
const displayName = useBehavior(vm.displayName$);
|
||||||
const video = useBehavior(vm.video$);
|
const video = useBehavior(vm.video$);
|
||||||
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||||
|
|
||||||
@@ -160,6 +159,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
video,
|
video,
|
||||||
|
videoEnabled,
|
||||||
member: vm.member,
|
member: vm.member,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -169,7 +169,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return vm instanceof ScreenShareViewModel ? (
|
return vm instanceof ScreenShareViewModel ? (
|
||||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
<MediaView videoFit="contain" mirror={false} {...baseProps} />
|
||||||
) : (
|
) : (
|
||||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
|
|||||||
24
src/utils/observable.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import { withTestScheduler } from "./test";
|
||||||
|
import { pauseWhen } from "./observable";
|
||||||
|
|
||||||
|
test("pauseWhen", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const inputMarbles = " abcdefgh-i-jk-";
|
||||||
|
const pauseMarbles = " n-y--n-yn-y--n";
|
||||||
|
const outputMarbles = "abc--fgh-i---k";
|
||||||
|
expectObservable(
|
||||||
|
behavior(inputMarbles).pipe(
|
||||||
|
pauseWhen(behavior(pauseMarbles, { y: true, n: false })),
|
||||||
|
),
|
||||||
|
).toBe(outputMarbles);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,16 +7,23 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type Observable,
|
type Observable,
|
||||||
|
audit,
|
||||||
|
combineLatest,
|
||||||
concat,
|
concat,
|
||||||
defer,
|
defer,
|
||||||
|
filter,
|
||||||
finalize,
|
finalize,
|
||||||
map,
|
map,
|
||||||
|
of,
|
||||||
scan,
|
scan,
|
||||||
startWith,
|
startWith,
|
||||||
takeWhile,
|
takeWhile,
|
||||||
tap,
|
tap,
|
||||||
|
withLatestFrom,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,3 +93,27 @@ export function getValue<T>(state$: Observable<T>): T {
|
|||||||
if (value === nothing) throw new Error("Not a state Observable");
|
if (value === nothing) throw new Error("Not a state Observable");
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Observable that has a value of true whenever all its inputs are
|
||||||
|
* true.
|
||||||
|
*/
|
||||||
|
export function and$(...inputs: Observable<boolean>[]): Observable<boolean> {
|
||||||
|
return combineLatest(inputs, (...flags) => flags.every((flag) => flag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that pauses all changes in the input value whenever a Behavior
|
||||||
|
* is true. When the Behavior returns to being false, the most recently
|
||||||
|
* suppressed change is emitted as the most recent value.
|
||||||
|
*/
|
||||||
|
export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
||||||
|
return (value$: Observable<T>): Observable<T> =>
|
||||||
|
value$.pipe(
|
||||||
|
withLatestFrom(pause$),
|
||||||
|
audit(([, pause]) =>
|
||||||
|
pause ? pause$.pipe(filter((pause) => !pause)) : of(null),
|
||||||
|
),
|
||||||
|
map(([value]) => value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ import { BehaviorSubject, of } from "rxjs";
|
|||||||
import { vitest } from "vitest";
|
import { vitest } from "vitest";
|
||||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
|
import {
|
||||||
|
type RoomMember,
|
||||||
|
type MatrixClient,
|
||||||
|
type Room,
|
||||||
|
SyncState,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
|
||||||
import type { RoomMember, MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { CallViewModel } from "../state/CallViewModel";
|
import { CallViewModel } from "../state/CallViewModel";
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +39,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
|||||||
|
|
||||||
export function getBasicRTCSession(
|
export function getBasicRTCSession(
|
||||||
members: RoomMember[],
|
members: RoomMember[],
|
||||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||||
): {
|
): {
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
matrixRoom: Room;
|
||||||
|
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||||
} {
|
} {
|
||||||
const matrixRoomId = "!myRoomId:example.com";
|
const matrixRoomId = "!myRoomId:example.com";
|
||||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||||
@@ -51,6 +57,7 @@ export function getBasicRTCSession(
|
|||||||
client: {
|
client: {
|
||||||
getUserId: () => localRtcMember.sender,
|
getUserId: () => localRtcMember.sender,
|
||||||
getDeviceId: () => localRtcMember.deviceId,
|
getDeviceId: () => localRtcMember.deviceId,
|
||||||
|
getSyncState: () => SyncState.Syncing,
|
||||||
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||||
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||||
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
||||||
@@ -91,52 +98,53 @@ export function getBasicRTCSession(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||||
initialRemoteRtcMemberships,
|
initialRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
|
||||||
matrixRoom,
|
rtcMemberships$,
|
||||||
localRtcMember,
|
);
|
||||||
).withMemberships(remoteRtcMemberships$);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rtcSession,
|
rtcSession,
|
||||||
remoteRtcMemberships$,
|
matrixRoom,
|
||||||
|
rtcMemberships$,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a basic CallViewModel to test components that make use of it.
|
* Construct a basic CallViewModel to test components that make use of it.
|
||||||
* @param members
|
* @param members
|
||||||
* @param initialRemoteRtcMemberships
|
* @param initialRtcMemberships
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function getBasicCallViewModelEnvironment(
|
export function getBasicCallViewModelEnvironment(
|
||||||
members: RoomMember[],
|
members: RoomMember[],
|
||||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||||
): {
|
): {
|
||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||||
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||||
} {
|
} {
|
||||||
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
|
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
|
||||||
members,
|
members,
|
||||||
initialRemoteRtcMemberships,
|
initialRtcMemberships,
|
||||||
);
|
);
|
||||||
const handRaisedSubject$ = new BehaviorSubject({});
|
const handRaisedSubject$ = new BehaviorSubject({});
|
||||||
const reactionsSubject$ = new BehaviorSubject({});
|
const reactionsSubject$ = new BehaviorSubject({});
|
||||||
|
|
||||||
const remoteParticipants$ = of([aliceParticipant]);
|
const remoteParticipants$ = of([aliceParticipant]);
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const livekitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants$ },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
liveKitRoom,
|
matrixRoom,
|
||||||
|
livekitRoom,
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
@@ -147,7 +155,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
vm,
|
vm,
|
||||||
remoteRtcMemberships$,
|
rtcMemberships$,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
handRaisedSubject$: handRaisedSubject$,
|
handRaisedSubject$: handRaisedSubject$,
|
||||||
reactionsSubject$: reactionsSubject$,
|
reactionsSubject$: reactionsSubject$,
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import {
|
|||||||
type Focus,
|
type Focus,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
type MatrixRTCSessionEventHandlerMap,
|
type MatrixRTCSessionEventHandlerMap,
|
||||||
|
MembershipManagerEvent,
|
||||||
type SessionMembershipData,
|
type SessionMembershipData,
|
||||||
|
Status,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type LocalTrackPublication,
|
type LocalTrackPublication,
|
||||||
@@ -233,6 +236,7 @@ export function mockLocalParticipant(
|
|||||||
): LocalParticipant {
|
): LocalParticipant {
|
||||||
return {
|
return {
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
|
trackPublications: new Map(),
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
@@ -243,9 +247,10 @@ export function mockLocalParticipant(
|
|||||||
export async function withLocalMedia(
|
export async function withLocalMedia(
|
||||||
localRtcMember: CallMembership,
|
localRtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
|
localParticipant: LocalParticipant,
|
||||||
|
mediaDevices: MediaDevices,
|
||||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const localParticipant = mockLocalParticipant({});
|
|
||||||
const vm = new LocalUserMediaViewModel(
|
const vm = new LocalUserMediaViewModel(
|
||||||
"local",
|
"local",
|
||||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||||
@@ -254,6 +259,7 @@ export async function withLocalMedia(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({ localParticipant }),
|
mockLivekitRoom({ localParticipant }),
|
||||||
|
mediaDevices,
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
@@ -293,6 +299,7 @@ export async function withRemoteMedia(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||||
|
constant(false),
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
@@ -314,8 +321,10 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MockRTCSession extends TypedEventEmitter<
|
export class MockRTCSession extends TypedEventEmitter<
|
||||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
MatrixRTCSessionEventHandlerMap &
|
||||||
|
RoomAndToDeviceEventsHandlerMap &
|
||||||
|
MembershipManagerEventHandlerMap
|
||||||
> {
|
> {
|
||||||
public readonly statistics = {
|
public readonly statistics = {
|
||||||
counters: {},
|
counters: {},
|
||||||
@@ -325,7 +334,6 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly room: Room,
|
public readonly room: Room,
|
||||||
private localMembership: CallMembership,
|
|
||||||
public memberships: CallMembership[] = [],
|
public memberships: CallMembership[] = [],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -341,14 +349,37 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
): MockRTCSession {
|
): MockRTCSession {
|
||||||
rtcMembers$.subscribe((m) => {
|
rtcMembers$.subscribe((m) => {
|
||||||
const old = this.memberships;
|
const old = this.memberships;
|
||||||
// always prepend the local participant
|
this.memberships = m as CallMembership[];
|
||||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
this.emit(
|
||||||
this.memberships = updated;
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
|
old,
|
||||||
|
this.memberships,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _membershipStatus = Status.Connected;
|
||||||
|
public get membershipStatus(): Status {
|
||||||
|
return this._membershipStatus;
|
||||||
|
}
|
||||||
|
public set membershipStatus(value: Status) {
|
||||||
|
const prev = this._membershipStatus;
|
||||||
|
this._membershipStatus = value;
|
||||||
|
if (value !== prev)
|
||||||
|
this.emit(MembershipManagerEvent.StatusChanged, prev, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _probablyLeft = false;
|
||||||
|
public get probablyLeft(): boolean {
|
||||||
|
return this._probablyLeft;
|
||||||
|
}
|
||||||
|
public set probablyLeft(value: boolean) {
|
||||||
|
const prev = this._probablyLeft;
|
||||||
|
this._probablyLeft = value;
|
||||||
|
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockTrack = (identity: string): TrackReference =>
|
export const mockTrack = (identity: string): TrackReference =>
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ export const widget = ((): WidgetHelpers | null => {
|
|||||||
|
|
||||||
const sendState = [
|
const sendState = [
|
||||||
userId, // Legacy call membership events
|
userId, // Legacy call membership events
|
||||||
`_${userId}_${deviceId}`, // Session membership events
|
`_${userId}_${deviceId}_m.call`, // Session membership events
|
||||||
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||||
].map((stateKey) => ({
|
].map((stateKey) => ({
|
||||||
eventType: EventType.GroupCallMemberPrefix,
|
eventType: EventType.GroupCallMemberPrefix,
|
||||||
stateKey,
|
stateKey,
|
||||||
|
|||||||
12
yarn.lock
@@ -10046,8 +10046,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"livekit-client@npm:^2.13.0":
|
"livekit-client@npm:^2.13.0":
|
||||||
version: 2.15.5
|
version: 2.15.6
|
||||||
resolution: "livekit-client@npm:2.15.5"
|
resolution: "livekit-client@npm:2.15.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@livekit/mutex": "npm:1.1.1"
|
"@livekit/mutex": "npm:1.1.1"
|
||||||
"@livekit/protocol": "npm:1.39.3"
|
"@livekit/protocol": "npm:1.39.3"
|
||||||
@@ -10060,7 +10060,7 @@ __metadata:
|
|||||||
webrtc-adapter: "npm:^9.0.1"
|
webrtc-adapter: "npm:^9.0.1"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/dom-mediacapture-record": ^1
|
"@types/dom-mediacapture-record": ^1
|
||||||
checksum: 10c0/52a70bdd39d802737ed7c25ae5d06daf9921156c4fc74f918009e86204430b2d200b66c55cefab949be4e5411cbc4d25eac92976f62f96b7226057a5b0706baa
|
checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -10279,8 +10279,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
||||||
version: 37.12.0
|
version: 37.13.0
|
||||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4e1e0723e3674725f8ed9cda5be7a7efe304e29"
|
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=3a33c658bbcb8ce8791ec066db899f2571f5c52f"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.5"
|
"@babel/runtime": "npm:^7.12.5"
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
||||||
@@ -10296,7 +10296,7 @@ __metadata:
|
|||||||
sdp-transform: "npm:^2.14.1"
|
sdp-transform: "npm:^2.14.1"
|
||||||
unhomoglyph: "npm:^1.0.6"
|
unhomoglyph: "npm:^1.0.6"
|
||||||
uuid: "npm:11"
|
uuid: "npm:11"
|
||||||
checksum: 10c0/e8f97b90ded9e46bdd803dde9861e178c76741999c224cbc869ad38695f300702405e5c365e270663b90e2a317b646bbf2b9e9bd7d51ffce36e6d2ca3ab29ed9
|
checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||