Compare commits
48 Commits
robin/swit
...
toger5/wai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c599d6eae9 | ||
|
|
1e8b87408c | ||
|
|
1428df8567 | ||
|
|
b8acdc3cec | ||
|
|
880e07c07f | ||
|
|
07522d6704 | ||
|
|
58ed7ef93e | ||
|
|
c1694fe706 | ||
|
|
a775bcd62a | ||
|
|
c15551c9f5 | ||
|
|
f410a11934 | ||
|
|
3122ccf4e4 | ||
|
|
03527e9d7a | ||
|
|
36be15436a | ||
|
|
04c03b1e72 | ||
|
|
38e60208ef | ||
|
|
fb8de061f3 | ||
|
|
acd26c61ef | ||
|
|
e475f56af5 | ||
|
|
9486ed5d77 | ||
|
|
7ba4df7781 | ||
|
|
db65a5308a | ||
|
|
482e2f8f6f | ||
|
|
cd5b7cdc47 | ||
|
|
e097e58212 | ||
|
|
31892f46cd | ||
|
|
c0dccf89cb | ||
|
|
44ddb40eea | ||
|
|
5b8839b577 | ||
|
|
33bf63d8d2 | ||
|
|
a7cc11ac81 | ||
|
|
25f04030fc | ||
|
|
5d0289f3c9 | ||
|
|
3da9013426 | ||
|
|
1a1e5a9db8 | ||
|
|
87574451a9 | ||
|
|
a0d778083d | ||
|
|
8de6ddceb0 | ||
|
|
8b3b72dfb4 | ||
|
|
db59679ad4 | ||
|
|
f08ae36f9e | ||
|
|
dc789e63f2 | ||
|
|
94085bff00 | ||
|
|
770346676c | ||
|
|
8c1fa3fd70 | ||
|
|
217fa40ddb | ||
|
|
02f9d0822f | ||
|
|
e133289a7f |
@@ -28,8 +28,6 @@ module.exports = {
|
||||
rules: {
|
||||
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
// We should use the js-sdk logger, never console directly.
|
||||
"no-console": ["error"],
|
||||
"react/display-name": "error",
|
||||
// Encourage proper usage of Promises:
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
@@ -46,6 +44,15 @@ module.exports = {
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
"rxjs/finnish": ["error", { names: { "^this$": false } }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["src/*/**"],
|
||||
rules: {
|
||||
// In application code we should use the js-sdk logger, never console directly.
|
||||
"no-console": ["error"],
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
|
||||
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
|
||||
[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
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
|
||||
will be used for this call.
|
||||
- During the actual call join flow, the **LiveKit JWT Auth Service** provides
|
||||
the client with the **LiveKit SFU WebSocket URL** and an **access JWT token**
|
||||
in order to exchange media via WebRTC.
|
||||
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
|
||||
provides the client with the **LiveKit SFU WebSocket URL** and an
|
||||
**access JWT token** in order to exchange media via WebRTC.
|
||||
|
||||
The example below illustrates how backend selection works across **Matrix
|
||||
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:
|
||||
|
||||
- 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
|
||||
- Redis db for completeness
|
||||
- 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
|
||||
[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`
|
||||
[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
|
||||
[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
|
||||
[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 |
|
||||
| -------- | ------- | ------- |
|
||||
| [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:
|
||||
|
||||
@@ -102,7 +102,7 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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/;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default {
|
||||
import type { UserConfig } from "i18next-parser";
|
||||
|
||||
const config: UserConfig = {
|
||||
keySeparator: ".",
|
||||
namespaceSeparator: false,
|
||||
contextSeparator: "|",
|
||||
@@ -26,3 +28,5 @@ export default {
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
sort: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
11
knip.ts
@@ -1,8 +1,15 @@
|
||||
import { KnipConfig } from "knip";
|
||||
/*
|
||||
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 { type KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
vite: {
|
||||
config: ["vite.config.js", "vite-embedded.config.js"],
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts"],
|
||||
},
|
||||
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
||||
ignoreBinaries: [
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Volání není podporováno",
|
||||
"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_description": "Hovor byl přerušen.",
|
||||
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
||||
@@ -105,6 +105,11 @@
|
||||
"knock_reject_heading": "Přístup odepřen",
|
||||
"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",
|
||||
"header_label": "Domov Element Call",
|
||||
"header_participants_label": "Účastníci",
|
||||
@@ -177,6 +182,8 @@
|
||||
"change_device_button": "Změnit zvukové zařízení",
|
||||
"default": "Výchozí",
|
||||
"default_named": "Výchozí <2> ({{name}}) </2>",
|
||||
"handset": "Sluchátko",
|
||||
"loudspeaker": "Reproduktor",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"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 }}).",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -105,6 +107,11 @@
|
||||
"knock_reject_heading": "Adgang nægtet",
|
||||
"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",
|
||||
"header_label": "Element Ring hjem",
|
||||
"header_participants_label": "Deltagere",
|
||||
@@ -176,6 +183,7 @@
|
||||
"change_device_button": "Skift lydenhed",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard <2>({{name}})</2>",
|
||||
"handset": "Telefon",
|
||||
"loudspeaker": "Højttaler",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"profile": "Profil",
|
||||
"reaction": "Reaktion",
|
||||
"reactions": "Reaktionen",
|
||||
"reconnecting": "Verbindung wird wiederhergestellt...",
|
||||
"settings": "Einstellungen",
|
||||
"unencrypted": "Nicht verschlüsselt",
|
||||
"username": "Benutzername",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"profile": "Profile",
|
||||
"reaction": "Reaction",
|
||||
"reactions": "Reactions",
|
||||
"reconnecting": "Reconnecting…",
|
||||
"settings": "Settings",
|
||||
"unencrypted": "Not encrypted",
|
||||
"username": "Username",
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Call stöds 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_description": "Du kopplades bort från samtalet.",
|
||||
"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 }}).",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -105,6 +107,11 @@
|
||||
"knock_reject_heading": "Inte tillåten att gå med",
|
||||
"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",
|
||||
"header_label": "Element Call Hem",
|
||||
"header_participants_label": "Deltagare",
|
||||
@@ -176,6 +183,8 @@
|
||||
"change_device_button": "Byt ljudenhet",
|
||||
"default": "Förval",
|
||||
"default_named": "Förval <2>({{name}})</2>",
|
||||
"handset": "Telefonlur",
|
||||
"loudspeaker": "Högtalare",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Högtalare",
|
||||
|
||||
@@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { Initializer } from "./initializer";
|
||||
import { widget } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
@@ -86,7 +85,6 @@ export const App: FC<Props> = ({ vm }) => {
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<DisconnectedBanner />
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -61,7 +61,11 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
className={styles.bar}
|
||||
>
|
||||
<Header>
|
||||
<Header
|
||||
// App bar is mainly seen in the call view, which has its own
|
||||
// 'reconnecting' toast
|
||||
disconnectedBanner={false}
|
||||
>
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
|
||||
@@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
ref?: Ref<HTMLElement>;
|
||||
children: ReactNode;
|
||||
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> = ({
|
||||
ref,
|
||||
children,
|
||||
className,
|
||||
disconnectedBanner = true,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
{disconnectedBanner && <DisconnectedBanner />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ interface Props {
|
||||
* A supporting icon to display within the toast.
|
||||
*/
|
||||
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,
|
||||
children,
|
||||
Icon,
|
||||
portal = true,
|
||||
}) => {
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
|
||||
}
|
||||
}, [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 (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<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>
|
||||
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -216,6 +216,17 @@ export interface UrlConfiguration {
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
@@ -347,6 +358,7 @@ export const getUrlParams = (
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification" as RTCNotificationType,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
@@ -366,6 +378,7 @@ export const getUrlParams = (
|
||||
...inAppDefault,
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
};
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
@@ -391,6 +404,7 @@ export const getUrlParams = (
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -442,6 +456,7 @@ export const getUrlParams = (
|
||||
"ring",
|
||||
"notification",
|
||||
]),
|
||||
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
||||
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { defer, sleep } from "matrix-js-sdk/lib/utils";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { useECConnectionState } from "./useECConnectionState";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
@@ -93,8 +93,8 @@ describe("Leaking connection prevention", () => {
|
||||
|
||||
test("Should cancel pending connections when the component is unmounted", async () => {
|
||||
const connectCall = vi.fn();
|
||||
const pendingConnection = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const pendingConnection = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
@@ -141,8 +141,8 @@ describe("Leaking connection prevention", () => {
|
||||
|
||||
test("Should cancel about to open but not yet opened connection", async () => {
|
||||
const createTracksCall = vi.fn();
|
||||
const pendingCreateTrack = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const pendingCreateTrack = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ import { act } from "react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { mockRtcMembership } from "../utils/test";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
@@ -31,7 +28,9 @@ import {
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
@@ -66,7 +65,7 @@ beforeEach(() => {
|
||||
* a noise every time.
|
||||
*/
|
||||
test("plays one sound when entering a call", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
@@ -74,56 +73,58 @@ test("plays one sound when entering a call", () => {
|
||||
|
||||
// Joining a call usually means remote participants are added later.
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("plays a sound when a user joins", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
// Play a sound when joining a call.
|
||||
expect(playSound).toBeCalledWith("join");
|
||||
});
|
||||
|
||||
test("plays a sound when a user leaves", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([]);
|
||||
rtcMemberships$.next([localRtcMember]);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
|
||||
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++) {
|
||||
mockRtcMemberships.push(
|
||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
mockRtcMemberships,
|
||||
);
|
||||
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
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(() => {
|
||||
remoteRtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
||||
rtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
|
||||
);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
@@ -155,6 +156,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
playSound.mockClear();
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -169,7 +171,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -179,5 +181,5 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { filter, interval, throttle } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
@@ -21,11 +20,6 @@ import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
export const callEventAudioSounds = prefetchSounds({
|
||||
join: {
|
||||
mp3: joinCallSoundMp3,
|
||||
@@ -60,37 +54,18 @@ export function CallEventAudioRenderer({
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
useEffect(() => {
|
||||
const joinSub = vm.participantChanges$
|
||||
.pipe(
|
||||
filter(
|
||||
({ joined, ids }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("join");
|
||||
});
|
||||
|
||||
const leftSub = vm.participantChanges$
|
||||
.pipe(
|
||||
filter(
|
||||
({ ids, left }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("left");
|
||||
});
|
||||
|
||||
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("raiseHand");
|
||||
});
|
||||
|
||||
const screenshareSub = vm.newScreenShare$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("screenshareStarted");
|
||||
});
|
||||
const joinSub = vm.joinSoundEffect$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("join"),
|
||||
);
|
||||
const leftSub = vm.leaveSoundEffect$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("left"),
|
||||
);
|
||||
const handRaisedSub = vm.newHandRaised$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("raiseHand"),
|
||||
);
|
||||
const screenshareSub = vm.newScreenShare$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("screenshareStarted"),
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
joinSub.unsubscribe();
|
||||
|
||||
@@ -137,11 +137,9 @@ function createGroupCallView(
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(constant([]));
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||
constant([localRtcMember]),
|
||||
);
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
|
||||
@@ -452,7 +452,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
participantCount={participantCount}
|
||||
matrixRoom={room}
|
||||
onLeave={onLeave}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
|
||||
@@ -175,8 +175,8 @@ function createInCallView(): RenderResult & {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
|
||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { ConnectionState, type Room } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
@@ -111,6 +111,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -164,6 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
{
|
||||
@@ -182,6 +184,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
}
|
||||
}, [
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
props.e2eeSystem,
|
||||
@@ -210,9 +213,9 @@ export interface InCallViewProps {
|
||||
vm: CallViewModel;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
livekitRoom: Room;
|
||||
matrixRoom: MatrixRoom;
|
||||
livekitRoom: LivekitRoom;
|
||||
muteStates: MuteStates;
|
||||
participantCount: number;
|
||||
/** Function to call when the user explicitly ends the call */
|
||||
onLeave: () => void;
|
||||
header: HeaderStyle;
|
||||
@@ -226,9 +229,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
vm,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
header: headerStyle,
|
||||
connState,
|
||||
@@ -270,7 +273,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
@@ -307,6 +310,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
@@ -316,7 +321,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
|
||||
useSubscription(vm.autoLeave$, onLeave);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
// that the pointerType of the event is "touch", but this isn't yet supported
|
||||
@@ -499,7 +504,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
break;
|
||||
case "standard":
|
||||
header = (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<Header
|
||||
className={styles.header}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
@@ -639,7 +648,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(() => {
|
||||
@@ -750,6 +759,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The reconnecting toast cannot be dismissed
|
||||
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
@@ -777,8 +789,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<Toast
|
||||
onDismiss={onDismissReconnectingToast}
|
||||
open={reconnecting}
|
||||
portal={false}
|
||||
>
|
||||
{t("common.reconnecting")}
|
||||
</Toast>
|
||||
<EarpieceOverlay
|
||||
show={earpieceMode}
|
||||
show={earpieceMode && !reconnecting}
|
||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||
/>
|
||||
<ReactionsOverlay vm={vm} />
|
||||
@@ -788,7 +807,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
roomId={matrixRoom.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
|
||||
@@ -49,6 +49,34 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="participantsLine"
|
||||
>
|
||||
<svg
|
||||
aria-label="Participants"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
|
||||
/>
|
||||
<path
|
||||
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
|
||||
data-testid="roomHeader_participants_count"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -256,7 +284,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r5»"
|
||||
aria-labelledby="«r8»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -279,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«ra»"
|
||||
aria-labelledby="«rd»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -301,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rf»"
|
||||
aria-labelledby="«ri»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -322,7 +350,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rk»"
|
||||
aria-labelledby="«rn»"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -348,7 +376,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="«rp»"
|
||||
aria-labelledby="«rs»"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -366,7 +394,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="«ru»"
|
||||
aria-labelledby="«r11»"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
|
||||
@@ -11,15 +11,23 @@ import {
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Room as LivekitRoom,
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
} 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 {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
NEVER,
|
||||
type Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
@@ -28,10 +36,12 @@ import {
|
||||
filter,
|
||||
forkJoin,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
of,
|
||||
pairwise,
|
||||
race,
|
||||
scan,
|
||||
skip,
|
||||
@@ -40,14 +50,19 @@ import {
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
takeUntil,
|
||||
throttleTime,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -62,7 +77,12 @@ import {
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { accumulate, finalizeValue } from "../utils/observable";
|
||||
import {
|
||||
accumulate,
|
||||
and$,
|
||||
finalizeValue,
|
||||
pauseWhen,
|
||||
} from "../utils/observable";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
duplicateTiles,
|
||||
@@ -91,16 +111,27 @@ import { observeSpeaker$ } from "./observeSpeaker";
|
||||
import { shallowEquals } from "../utils/array";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { constant, type Behavior } from "./Behavior";
|
||||
|
||||
export interface CallViewModelOptions {
|
||||
encryptionSystem: EncryptionSystem;
|
||||
autoLeaveWhenOthersLeft?: boolean;
|
||||
/**
|
||||
* If the call is started in a way where we want it to behave like a telephone usecase
|
||||
* If we sent a notification event, we want the ui to show a ringing state
|
||||
*/
|
||||
waitForCallPickup?: boolean;
|
||||
}
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
// This is the number of participants that we think constitutes a "small" call
|
||||
// on mobile. No spotlight tile should be shown below this threshold.
|
||||
const smallMobileCallThreshold = 3;
|
||||
@@ -261,6 +292,7 @@ class UserMedia {
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
mediaDevices: MediaDevices,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
@@ -288,6 +320,7 @@ class UserMedia {
|
||||
>,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayname$),
|
||||
this.scope.behavior(handRaised$),
|
||||
this.scope.behavior(reaction$),
|
||||
@@ -341,7 +374,8 @@ class ScreenShare {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
liveKitRoom: LivekitRoom,
|
||||
livekitRoom: LivekitRoom,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Observable<string>,
|
||||
) {
|
||||
this.participant$ = new BehaviorSubject(participant);
|
||||
@@ -351,7 +385,8 @@ class ScreenShare {
|
||||
member,
|
||||
this.participant$.asObservable(),
|
||||
encryptionSystem,
|
||||
liveKitRoom,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayName$),
|
||||
participant.isLocal,
|
||||
);
|
||||
@@ -367,7 +402,7 @@ type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
function getRoomMemberFromRtcMember(
|
||||
rtcMember: CallMembership,
|
||||
room: Room,
|
||||
room: MatrixRoom,
|
||||
): { id: string; member: RoomMember | undefined } {
|
||||
// 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.
|
||||
@@ -389,6 +424,79 @@ function getRoomMemberFromRtcMember(
|
||||
|
||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||
export class CallViewModel extends ViewModel {
|
||||
private readonly userId = this.matrixRoom.client.getUserId();
|
||||
|
||||
private readonly matrixConnected$ = this.scope.behavior(
|
||||
// To consider ourselves connected to MatrixRTC, we check the following:
|
||||
and$(
|
||||
// The client is connected to the sync loop
|
||||
(
|
||||
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
|
||||
*/
|
||||
@@ -403,7 +511,7 @@ export class CallViewModel extends ViewModel {
|
||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
||||
RemoteParticipant[][]
|
||||
>(
|
||||
this.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
withLatestFrom(this.rawRemoteParticipants$),
|
||||
mergeMap(([s, ps]) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
@@ -416,7 +524,7 @@ export class CallViewModel extends ViewModel {
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
forkJoin([
|
||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
this.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||
take(1),
|
||||
),
|
||||
@@ -440,74 +548,89 @@ export class CallViewModel extends ViewModel {
|
||||
/**
|
||||
* The RemoteParticipants including those that are being "held" on the screen
|
||||
*/
|
||||
private readonly remoteParticipants$ = this.scope.behavior<
|
||||
RemoteParticipant[]
|
||||
>(
|
||||
combineLatest(
|
||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
private readonly remoteParticipants$ = this.scope
|
||||
.behavior<RemoteParticipant[]>(
|
||||
combineLatest(
|
||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
return result;
|
||||
},
|
||||
),
|
||||
)
|
||||
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||
|
||||
private readonly memberships$ = this.scope.behavior(
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
).pipe(
|
||||
startWith(null),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
map(() => this.matrixRTCSession.memberships),
|
||||
),
|
||||
);
|
||||
|
||||
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
|
||||
* any displaynames that clashes with another member. Only members
|
||||
* joined to the call are considered here.
|
||||
*/
|
||||
public readonly memberDisplaynames$ = this.memberships$.pipe(
|
||||
map((memberships) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
const { room } = this.matrixRTCSession;
|
||||
// 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 memberDisplaynames$ = this.scope.behavior(
|
||||
// React to call memberships and also display name updates
|
||||
// (calculateDisplayName implicitly depends on the room member data)
|
||||
combineLatest(
|
||||
[
|
||||
this.memberships$,
|
||||
fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe(
|
||||
startWith(null),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
),
|
||||
],
|
||||
(memberships, _members) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
const room = this.matrixRoom;
|
||||
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||
if (!member) {
|
||||
logger.error("Could not find member for media id:", matrixIdentifier);
|
||||
continue;
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||
if (!member) {
|
||||
logger.error(
|
||||
"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);
|
||||
displaynameMap.set(
|
||||
matrixIdentifier,
|
||||
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:
|
||||
return displaynameMap;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
this.reactionsSubject$.pipe(
|
||||
@@ -519,6 +642,7 @@ export class CallViewModel extends ViewModel {
|
||||
]),
|
||||
),
|
||||
),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -530,13 +654,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.remoteParticipants$,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
duplicateTiles.value$,
|
||||
// Also react to changes in the MatrixRTC session list.
|
||||
// The session list will also be update if a room membership changes.
|
||||
// No additional RoomState event listener needs to be set up.
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
).pipe(startWith(null)),
|
||||
this.memberships$,
|
||||
showNonMemberTiles.value$,
|
||||
]).pipe(
|
||||
scan(
|
||||
@@ -546,15 +664,15 @@ export class CallViewModel extends ViewModel {
|
||||
remoteParticipants,
|
||||
{ participant: localParticipant },
|
||||
duplicateTiles,
|
||||
_membershipsChanged,
|
||||
memberships,
|
||||
showNonMemberTiles,
|
||||
],
|
||||
) => {
|
||||
const newItems = new Map(
|
||||
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
|
||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||
for (const rtcMember of memberships) {
|
||||
const { member, id: livekitParticipantId } =
|
||||
getRoomMemberFromRtcMember(rtcMember, room);
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
@@ -604,6 +722,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -627,6 +746,7 @@ export class CallViewModel extends ViewModel {
|
||||
participant,
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -669,6 +789,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map(
|
||||
(m) => m.get(participant.identity) ?? "[👻]",
|
||||
@@ -717,78 +838,118 @@ export class CallViewModel extends ViewModel {
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* This observable tracks the currently connected participants.
|
||||
*
|
||||
* - Each participant has one livekit connection
|
||||
* - Each participant has a corresponding MatrixRTC membership state event
|
||||
* - There can be multiple participants for one matrix user.
|
||||
*/
|
||||
public readonly participantChanges$ = this.userMedia$.pipe(
|
||||
map((mediaItems) => mediaItems.map((m) => m.id)),
|
||||
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
||||
(prev, ids) => {
|
||||
const left = prev.ids.filter((id) => !ids.includes(id));
|
||||
const joined = ids.filter((id) => !prev.ids.includes(id));
|
||||
return { ids, joined, left };
|
||||
},
|
||||
{ ids: [], joined: [], left: [] },
|
||||
public readonly joinSoundEffect$ = this.userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
|
||||
current.length > prev.length,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* This observable tracks the matrix users that are currently in the call.
|
||||
* There can be just one matrix user with multiple participants (see also participantChanges$)
|
||||
*/
|
||||
public readonly matrixUserChanges$ = this.userMedia$.pipe(
|
||||
map(
|
||||
(mediaItems) =>
|
||||
new Set(
|
||||
mediaItems
|
||||
.map((m) => m.vm.member?.userId)
|
||||
.filter((id) => id !== undefined),
|
||||
),
|
||||
),
|
||||
scan<
|
||||
Set<string>,
|
||||
{
|
||||
userIds: Set<string>;
|
||||
joinedUserIds: Set<string>;
|
||||
leftUserIds: Set<string>;
|
||||
}
|
||||
>(
|
||||
(prevState, userIds) => {
|
||||
const left = new Set(
|
||||
[...prevState.userIds].filter((id) => !userIds.has(id)),
|
||||
);
|
||||
const joined = new Set(
|
||||
[...userIds].filter((id) => !prevState.userIds.has(id)),
|
||||
);
|
||||
return { userIds: userIds, joinedUserIds: joined, leftUserIds: left };
|
||||
},
|
||||
{ userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() },
|
||||
),
|
||||
);
|
||||
|
||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||
map(({ userIds, leftUserIds }) => {
|
||||
const userId = this.matrixRTCSession.room.client.getUserId();
|
||||
if (!userId) {
|
||||
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
||||
return false;
|
||||
}
|
||||
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
|
||||
}),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
);
|
||||
|
||||
public readonly leaveSoundEffect$ = this.userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
|
||||
current.length < prev.length,
|
||||
),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* The number of participants currently in the call.
|
||||
*
|
||||
* - Each participant has a corresponding MatrixRTC membership state event
|
||||
* - There can be multiple participants for one Matrix user if they join from
|
||||
* multiple devices.
|
||||
*/
|
||||
public readonly participantCount$ = this.scope.behavior(
|
||||
this.memberships$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
private readonly allOthersLeft$ = this.memberships$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.every((m) => m.sender === this.userId) &&
|
||||
prev.some((m) => m.sender !== this.userId),
|
||||
),
|
||||
map(() => {}),
|
||||
take(1),
|
||||
);
|
||||
|
||||
public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft
|
||||
? this.allOthersLeft$
|
||||
: NEVER;
|
||||
|
||||
/**
|
||||
* Emits whenever the RTC session tells us that it intends to ring for a given
|
||||
* duration.
|
||||
*/
|
||||
private readonly beginRingingForMs$ = (
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
) as Observable<[IRTCNotificationContent]>
|
||||
)
|
||||
// event.lifetime is expected to be in ms
|
||||
.pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0));
|
||||
|
||||
/**
|
||||
* Whether some Matrix user other than ourself is joined to the call.
|
||||
*/
|
||||
private readonly someoneElseJoined$ = this.memberships$.pipe(
|
||||
map((ms) => ms.some((m) => m.sender !== this.userId)),
|
||||
);
|
||||
|
||||
/**
|
||||
* The current call pickup state of the call.
|
||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||
* Then we can conclude if we were the first one to join or not.
|
||||
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
||||
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
||||
* The call failed. If desired this can be used as a trigger to exit the call.
|
||||
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
|
||||
* - null: EC is configured to never show any waiting for answer state.
|
||||
*/
|
||||
public readonly callPickupState$ = this.options.waitForCallPickup
|
||||
? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">(
|
||||
concat(
|
||||
concat(
|
||||
// We don't know if the RTC session decides to send a notify event
|
||||
// yet. It will only be known once we sent our own membership and
|
||||
// know we were the first one to join.
|
||||
of("unknown" as const),
|
||||
// Once we get the signal to begin ringing:
|
||||
this.beginRingingForMs$.pipe(
|
||||
take(1),
|
||||
switchMap((lifetime) =>
|
||||
lifetime === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
EMPTY
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetime).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has
|
||||
// likely stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))),
|
||||
),
|
||||
of("success" as const),
|
||||
),
|
||||
)
|
||||
: constant(null);
|
||||
|
||||
/**
|
||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||
*/
|
||||
@@ -889,7 +1050,7 @@ export class CallViewModel extends ViewModel {
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1474,10 +1635,11 @@ export class CallViewModel extends ViewModel {
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRTCSession: MatrixRTCSession,
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
private readonly livekitRoom: LivekitRoom,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
private readonly options: CallViewModelOptions,
|
||||
private readonly connectionState$: Observable<ECConnectionState>,
|
||||
private readonly livekitConnectionState$: Observable<ECConnectionState>,
|
||||
private readonly handsRaisedSubject$: Observable<
|
||||
Record<string, RaisedHandInfo>
|
||||
>,
|
||||
@@ -1486,5 +1648,51 @@ export class CallViewModel extends ViewModel {
|
||||
>,
|
||||
) {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,10 +361,7 @@ export type UserMediaViewModel =
|
||||
* Some participant's user media.
|
||||
*/
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
private readonly _speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
@@ -376,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).
|
||||
*/
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
|
||||
private readonly _videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
@@ -421,7 +430,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -572,6 +581,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
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 localVolumeAdjustment$ = new Subject<number>();
|
||||
private readonly localVolumeCommit$ = new Subject<void>();
|
||||
@@ -611,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.
|
||||
*/
|
||||
@@ -624,6 +645,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
@@ -639,11 +661,33 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
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
|
||||
combineLatest([
|
||||
participant$,
|
||||
this.localVolume$.pipe(this.scope.bind()),
|
||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
||||
// The local volume, taking into account whether we're supposed to pretend
|
||||
// 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 {
|
||||
@@ -683,12 +727,20 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
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(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Behavior<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
|
||||
@@ -54,6 +54,7 @@ interface SpotlightItemBaseProps {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoEnabled: boolean;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
@@ -63,7 +64,6 @@ interface SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
videoEnabled: boolean;
|
||||
videoFit: "contain" | "cover";
|
||||
}
|
||||
|
||||
@@ -90,12 +90,10 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
};
|
||||
@@ -135,6 +133,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
|
||||
@@ -160,6 +159,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoEnabled,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
@@ -169,7 +169,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
||||
<MediaView videoFit="contain" mirror={false} {...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 {
|
||||
type Observable,
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
defer,
|
||||
filter,
|
||||
finalize,
|
||||
map,
|
||||
of,
|
||||
scan,
|
||||
startWith,
|
||||
takeWhile,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
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");
|
||||
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 { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
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 { CallViewModel } from "../state/CallViewModel";
|
||||
import {
|
||||
@@ -34,10 +39,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||
|
||||
export function getBasicRTCSession(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
): {
|
||||
rtcSession: MockRTCSession;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
matrixRoom: Room;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
} {
|
||||
const matrixRoomId = "!myRoomId:example.com";
|
||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||
@@ -51,6 +57,7 @@ export function getBasicRTCSession(
|
||||
client: {
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
||||
@@ -91,52 +98,53 @@ export function getBasicRTCSession(
|
||||
),
|
||||
});
|
||||
|
||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRemoteRtcMemberships,
|
||||
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRtcMemberships,
|
||||
);
|
||||
|
||||
const rtcSession = new MockRTCSession(
|
||||
matrixRoom,
|
||||
localRtcMember,
|
||||
).withMemberships(remoteRtcMemberships$);
|
||||
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
|
||||
rtcMemberships$,
|
||||
);
|
||||
|
||||
return {
|
||||
rtcSession,
|
||||
remoteRtcMemberships$,
|
||||
matrixRoom,
|
||||
rtcMemberships$,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a basic CallViewModel to test components that make use of it.
|
||||
* @param members
|
||||
* @param initialRemoteRtcMemberships
|
||||
* @param initialRtcMemberships
|
||||
* @returns
|
||||
*/
|
||||
export function getBasicCallViewModelEnvironment(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcSession: MockRTCSession;
|
||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||
} {
|
||||
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
|
||||
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
|
||||
members,
|
||||
initialRemoteRtcMemberships,
|
||||
initialRtcMemberships,
|
||||
);
|
||||
const handRaisedSubject$ = new BehaviorSubject({});
|
||||
const reactionsSubject$ = new BehaviorSubject({});
|
||||
|
||||
const remoteParticipants$ = of([aliceParticipant]);
|
||||
const liveKitRoom = mockLivekitRoom(
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
{ remoteParticipants$ },
|
||||
);
|
||||
const vm = new CallViewModel(
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
mockMediaDevices({}),
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
@@ -147,7 +155,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
);
|
||||
return {
|
||||
vm,
|
||||
remoteRtcMemberships$,
|
||||
rtcMemberships$,
|
||||
rtcSession,
|
||||
handRaisedSubject$: handRaisedSubject$,
|
||||
reactionsSubject$: reactionsSubject$,
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
type Focus,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
type SessionMembershipData,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
@@ -233,6 +236,7 @@ export function mockLocalParticipant(
|
||||
): LocalParticipant {
|
||||
return {
|
||||
isLocal: true,
|
||||
trackPublications: new Map(),
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
...mockEmitter(),
|
||||
@@ -295,6 +299,7 @@ export async function withRemoteMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
constant(false),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
@@ -316,8 +321,10 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||
}
|
||||
|
||||
export class MockRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap &
|
||||
RoomAndToDeviceEventsHandlerMap &
|
||||
MembershipManagerEventHandlerMap
|
||||
> {
|
||||
public readonly statistics = {
|
||||
counters: {},
|
||||
@@ -327,7 +334,6 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
|
||||
public constructor(
|
||||
public readonly room: Room,
|
||||
private localMembership: CallMembership,
|
||||
public memberships: CallMembership[] = [],
|
||||
) {
|
||||
super();
|
||||
@@ -343,14 +349,37 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
): MockRTCSession {
|
||||
rtcMembers$.subscribe((m) => {
|
||||
const old = this.memberships;
|
||||
// always prepend the local participant
|
||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
||||
this.memberships = updated;
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
|
||||
this.memberships = m as CallMembership[];
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
old,
|
||||
this.memberships,
|
||||
);
|
||||
});
|
||||
|
||||
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 =>
|
||||
|
||||
@@ -120,8 +120,8 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
|
||||
const sendState = [
|
||||
userId, // Legacy call membership events
|
||||
`_${userId}_${deviceId}`, // Session membership events
|
||||
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
`_${userId}_${deviceId}_m.call`, // Session membership events
|
||||
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/*
|
||||
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 { defineConfig, mergeConfig } from "vite";
|
||||
import fullConfig from "./vite.config";
|
||||
import generateFile from "vite-plugin-generate-file";
|
||||
|
||||
import fullConfig from "./vite.config";
|
||||
|
||||
const base = "./";
|
||||
|
||||
// Config for embedded deployments (possibly hosted under a non-root path)
|
||||
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, loadEnv, searchForWorkspaceRoot } from "vite";
|
||||
import {
|
||||
loadEnv,
|
||||
searchForWorkspaceRoot,
|
||||
type ConfigEnv,
|
||||
type UserConfig,
|
||||
} from "vite";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
||||
@@ -15,10 +20,14 @@ import { realpathSync } from "fs";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode, packageType }) => {
|
||||
// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite)
|
||||
export default ({
|
||||
mode,
|
||||
packageType,
|
||||
}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
// Environment variables with the VITE_ prefix are accessible at runtime.
|
||||
// So, we set this to allow for build/package specific behaviour.
|
||||
// So, we set this to allow for build/package specific behavior.
|
||||
// In future we might be able to do what is needed via code splitting at
|
||||
// build time.
|
||||
process.env.VITE_PACKAGE = packageType ?? "full";
|
||||
@@ -93,7 +102,7 @@ export default defineConfig(({ mode, packageType }) => {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: ({ originalFileNames }) => {
|
||||
assetFileNames: ({ originalFileNames }): string => {
|
||||
if (originalFileNames) {
|
||||
for (const name of originalFileNames) {
|
||||
// Custom asset name for locales to include the locale code in the filename
|
||||
@@ -143,4 +152,4 @@ export default defineConfig(({ mode, packageType }) => {
|
||||
exclude: ["@matrix-org/matrix-sdk-crypto-wasm"],
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config.js";
|
||||
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default defineConfig((configEnv) =>
|
||||
mergeConfig(
|
||||
12
yarn.lock
@@ -10046,8 +10046,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"livekit-client@npm:^2.13.0":
|
||||
version: 2.15.4
|
||||
resolution: "livekit-client@npm:2.15.4"
|
||||
version: 2.15.6
|
||||
resolution: "livekit-client@npm:2.15.6"
|
||||
dependencies:
|
||||
"@livekit/mutex": "npm:1.1.1"
|
||||
"@livekit/protocol": "npm:1.39.3"
|
||||
@@ -10060,7 +10060,7 @@ __metadata:
|
||||
webrtc-adapter: "npm:^9.0.1"
|
||||
peerDependencies:
|
||||
"@types/dom-mediacapture-record": ^1
|
||||
checksum: 10c0/f12a3b604aed8e075791c60a75c9eea5467e5a0bab48c2eb23216ec357e89a50199274121e0330b61f946d0be9556058824ce1b832003e722786e54ba33017a6
|
||||
checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10279,8 +10279,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
||||
version: 37.12.0
|
||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4e1e0723e3674725f8ed9cda5be7a7efe304e29"
|
||||
version: 37.13.0
|
||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
||||
@@ -10296,7 +10296,7 @@ __metadata:
|
||||
sdp-transform: "npm:^2.14.1"
|
||||
unhomoglyph: "npm:^1.0.6"
|
||||
uuid: "npm:11"
|
||||
checksum: 10c0/e8f97b90ded9e46bdd803dde9861e178c76741999c224cbc869ad38695f300702405e5c365e270663b90e2a317b646bbf2b9e9bd7d51ffce36e6d2ca3ab29ed9
|
||||
checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||