This commit is contained in:
Half-Shot
2024-12-09 11:52:22 +00:00
49 changed files with 1317 additions and 556 deletions

View File

@@ -42,7 +42,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}} tags: ${{ inputs.docker_tags}}
@@ -51,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -28,7 +28,7 @@ jobs:
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version-file: ".node-version"
- name: Install dependencies - name: Install dependencies
run: "yarn install" run: "yarn install"
- name: Build - name: Build

View File

@@ -12,7 +12,7 @@ jobs:
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version-file: ".node-version"
- name: Install dependencies - name: Install dependencies
run: "yarn install" run: "yarn install"
- name: Prettier - name: Prettier

View File

@@ -14,7 +14,7 @@ jobs:
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version-file: ".node-version"
- name: Install dependencies - name: Install dependencies
run: "yarn install" run: "yarn install"
- name: Vitest - name: Vitest

View File

@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version-file: ".node-version"
- name: Install Deps - name: Install Deps
run: "yarn install --frozen-lockfile" run: "yarn install --frozen-lockfile"

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -92,6 +92,9 @@ work:
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. # per MSC4140.
@@ -109,6 +112,10 @@ summary contains the room join rules. We need that to decide if the user gets
prompted with the option to knock ("Request to join call"), a cannot join error or the prompted with the option to knock ("Request to join call"), a cannot join error or the
join view. join view.
MSC4222 allow clients to opt-in to a change of the sync v2 API that allows them
to correctly track the state of the room. This is required by Element Call to
track room state reliably.
Element Call requires a Livekit SFU alongside a [Livekit JWT Element Call requires a Livekit SFU alongside a [Livekit JWT
service](https://github.com/element-hq/lk-jwt-service) to work. The url to the service](https://github.com/element-hq/lk-jwt-service) to work. The url to the
Livekit JWT service can either be configured in the config of Element Call Livekit JWT service can either be configured in the config of Element Call
@@ -213,7 +220,7 @@ To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used: `t("some_new_key")` 1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This 1. Run `yarn i18n` to extract the new key and update the translation files. This
will add a skeleton entry to the `locales/en-GB/app.json` file: will add a skeleton entry to the `locales/en/app.json` file:
```jsonc ```jsonc
{ {
... ...
@@ -221,7 +228,7 @@ To add a new translation key you can do these steps:
... ...
} }
``` ```
1. Update the skeleton entry in the `locales/en-GB/app.json` file with 1. Update the skeleton entry in the `locales/en/app.json` file with
the English translation: the English translation:
```jsonc ```jsonc

View File

@@ -25,6 +25,9 @@ trusted_key_servers:
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -21,7 +21,7 @@ export default {
}, },
], ],
}, },
locales: ["en-GB"], locales: ["en"],
output: "locales/$LOCALE/$NAMESPACE.json", output: "locales/$LOCALE/$NAMESPACE.json",
input: ["src/**/*.{ts,tsx}"], input: ["src/**/*.{ts,tsx}"],
sort: true, sort: true,

View File

@@ -7,13 +7,13 @@
"features": ["plural_postfix_us", "filter_untranslated"], "features": ["plural_postfix_us", "filter_untranslated"],
"files": [ "files": [
{ {
"pattern": "locales/en-GB/*.json", "pattern": "locales/en/*.json",
"lang": "inherited" "lang": "inherited"
}, },
{ {
"group": "existing", "group": "existing",
"pattern": "locales/*/*.json", "pattern": "locales/*/*.json",
"excludes": ["locales/en-GB/*.json"], "excludes": ["locales/en/*.json"],
"lang": "${autodetectLang}" "lang": "${autodetectLang}"
} }
] ]
@@ -25,9 +25,6 @@
"output": "locales/${langLsrDash}/${file}" "output": "locales/${langLsrDash}/${file}"
} }
], ],
"includeSourceLang": "${includeSourceLang|false}", "includeSourceLang": "${includeSourceLang|false}"
"langAliases": {
"en": "en_GB"
}
} }
} }

View File

@@ -69,9 +69,10 @@
"video": "Video" "video": "Video"
}, },
"developer_mode": { "developer_mode": {
"crypto_version": "Krypto-Version:{{version}}", "crypto_version": "Krypto-Version: {{version}}",
"device_id": "Geräte-ID: {{id}}", "device_id": "Geräte-ID: {{id}}",
"duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer", "duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer",
"hostname": "Hostname: {{hostname}}",
"matrix_id": "Matrix-ID: {{id}}" "matrix_id": "Matrix-ID: {{id}}"
}, },
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.", "disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
@@ -146,7 +147,6 @@
"screenshare_button_label": "Bildschirm teilen", "screenshare_button_label": "Bildschirm teilen",
"settings": { "settings": {
"audio_tab": { "audio_tab": {
"effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden",
"effect_volume_label": "Lautstärke der Soundeffekte" "effect_volume_label": "Lautstärke der Soundeffekte"
}, },
"developer_settings_label": "Entwicklereinstellungen", "developer_settings_label": "Entwicklereinstellungen",
@@ -193,6 +193,7 @@
"expand": "Erweitern", "expand": "Erweitern",
"mute_for_me": "Für mich stumm schalten", "mute_for_me": "Für mich stumm schalten",
"muted_for_me": "Für mich stumm geschaltet", "muted_for_me": "Für mich stumm geschaltet",
"volume": "Lautstärke" "volume": "Lautstärke",
"waiting_for_media": "Warten auf Medien..."
} }
} }

View File

@@ -147,7 +147,7 @@
"screenshare_button_label": "Share screen", "screenshare_button_label": "Share screen",
"settings": { "settings": {
"audio_tab": { "audio_tab": {
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play", "effect_volume_description": "Volume for sound effects such as: joining or leaving a call, and reactions.",
"effect_volume_label": "Sound effect volume" "effect_volume_label": "Sound effect volume"
}, },
"developer_settings_label": "Developer Settings", "developer_settings_label": "Developer Settings",
@@ -194,6 +194,7 @@
"expand": "Expand", "expand": "Expand",
"mute_for_me": "Mute for me", "mute_for_me": "Mute for me",
"muted_for_me": "Muted for me", "muted_for_me": "Muted for me",
"volume": "Volume" "volume": "Volume",
"waiting_for_media": "Waiting for media..."
} }
} }

195
locales/ro/app.json Normal file
View File

@@ -0,0 +1,195 @@
{
"a11y": {
"user_menu": "Meniul utilizatorului"
},
"action": {
"close": "Închide",
"copy_link": "Copiază linkul",
"edit": "Editare",
"go": "Du-te",
"invite": "Invită",
"lower_hand": "Mâna inferioară",
"no": "No",
"pick_reaction": "Alegeți reacția",
"raise_hand": "Ridicați mâna",
"register": "Inregistrare",
"remove": "elimina",
"show_less": "Arată mai puțin",
"show_more": "Arată mai mult",
"sign_in": "Autentificare",
"sign_out": "Sign out",
"submit": "Trimiteți",
"upload_file": "Încărcați fișierul"
},
"analytics_notice": "Prin participarea la această versiune beta, sunteți de acord cu colectarea de date anonime, pe care le folosim pentru a îmbunătăți produsul. Puteți găsi mai multe informații despre datele pe care le urmărim în Politica noastră de <2> confidențialitate </2> și Politica noastră <6> privind cookie-urile</6>.",
"app_selection_modal": {
"continue_in_browser": "Continuați în browser",
"open_in_app": "Deschideți în aplicație",
"text": "Sunteți gata să vă alăturați?",
"title": "Selectați aplicația"
},
"application_opened_another_tab": "Această aplicație a fost deschisă într-o altă filă.",
"browser_media_e2ee_unsupported": "Browserul dvs. web nu acceptă criptarea media end-to-end. Browserele acceptate sunt Chrome, Safari, Firefox > = 117",
"browser_media_e2ee_unsupported_heading": "Browser incompatibil",
"call_ended_view": {
"body": "Ai fost deconectat de la apel",
"create_account_button": "Creează cont",
"create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? </0><1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare </1>",
"feedback_done": "<0>Vă mulțumim pentru feedback! </0>",
"feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. </0>",
"headline": "{{displayName}}, apelul tău s-a încheiat.",
"not_now_button": "Nu acum, reveniți la ecranul de pornire",
"reconnect_button": "Reconecta",
"survey_prompt": "Cum a mers?"
},
"call_name": "Numele apelului",
"common": {
"analytics": "Analiză",
"audio": "Audio",
"avatar": "avatar",
"back": "Înapoi",
"camera": "Aparat foto",
"display_name": "Nume afișat",
"encrypted": "Criptat",
"error": "Eroare",
"home": "Acasa",
"loading": "Se încarcă...",
"microphone": "Microfon",
"next": "Urmator\n",
"options": "Opțiuni",
"password": "Parolă",
"preferences": "preferinte",
"profile": "Profil",
"reaction": "Reacție",
"reactions": "Reacții",
"settings": "Settings",
"something_went_wrong": "Ceva nu a mers bine",
"unencrypted": "Nu este criptat",
"username": "Nume utilizator",
"video": "Videoclip"
},
"developer_mode": {
"crypto_version": "Versiunea Crypto: {{version}}",
"device_id": "ID-ul dispozitivului: {{id}}",
"duplicate_tiles_label": "Numărul de exemplare suplimentare de cartonașe per participant",
"hostname": "Numele gazdei: {{hostname}}",
"matrix_id": "ID-ul matricei: {{id}}"
},
"disconnected_banner": "Conectivitatea la server a fost pierdută.",
"full_screen_view_description": "<0>Trimiterea jurnalelor de depanare ne va ajuta să urmărim problema. </0>",
"full_screen_view_h1": "<0>Hopa, ceva nu a mers bine. </0>",
"group_call_loader": {
"banned_body": "Ai fost interzis să ieși din cameră.",
"banned_heading": "Interzis",
"call_ended_body": "Ați fost eliminat din apel.",
"call_ended_heading": "Apel încheiat",
"failed_heading": "Nu s-a putut alătura",
"failed_text": "Apelul nu a fost găsit sau nu este accesibil.",
"knock_reject_body": "Cererea dvs. de a vă alătura a fost respinsă.",
"knock_reject_heading": "Acces refuzat",
"reason": "Motivul"
},
"hangup_button_label": "Încheiați apelul",
"header_label": "Element Call Home",
"header_participants_label": "Participanți",
"invite_modal": {
"link_copied_toast": "Link copiat în clipboard",
"title": "Invitați la acest apel"
},
"join_existing_call_modal": {
"join_button": "Da, alăturați-vă apelului",
"text": "Acest apel există deja, doriți să vă alăturați?",
"title": "Alăturați-vă apelului existent?"
},
"layout_grid_label": "GRILĂ",
"layout_spotlight_label": "Spotlight",
"lobby": {
"ask_to_join": "Solicitare de participare la apel",
"join_as_guest": "Alăturați-vă ca invitat",
"join_button": "Alăturați-vă apelului",
"leave_button": "Înapoi la cele mai recente",
"waiting_for_invite": "Solicitare trimisă! În așteptarea permisiunii de a participa..."
},
"log_in": "Autentificare",
"logging_in": "Autentificare...",
"login_auth_links": "<0>Creați un cont </0> sau <2> accesați ca invitat </2>",
"login_auth_links_prompt": "Nu sunteți încă înregistrat?",
"login_subheading": "Pentru a continua la Element",
"login_title": "Logare",
"microphone_off": "Microfon oprit",
"microphone_on": "Microfon pornit",
"mute_microphone_button_label": "Dezactivați microfonul",
"qr_code": "COD QR",
"rageshake_button_error_caption": "Încearcă din nou trimiterea jurnalelor",
"rageshake_request_modal": {
"body": "Un alt utilizator al acestui apel are o problemă. Pentru a diagnostica mai bine aceste probleme, am dori să colectăm un jurnal de depanare.",
"title": "Solicitare jurnal de depanare"
},
"rageshake_send_logs": "Trimiteți jurnale de depanare",
"rageshake_sending": "Trimiterea...",
"rageshake_sending_logs": "Trimiterea jurnalelor de depanare...",
"rageshake_sent": "Multumesc!",
"recaptcha_caption": "Acest site este protejat de reCAPTCHA și se aplică Politica de <2> confidențialitate Google </2> și <6> Termenii și condițiile. </6> <9></9>Făcând clic pe „Înregistrare”, sunteți de acord cu Acordul nostru de licențiere pentru utilizatorul <12> final (EULA) </12>",
"recaptcha_dismissed": "Recaptcha a fost respins",
"recaptcha_not_loaded": "Recaptcha nu a fost încărcat",
"register": {
"passwords_must_match": "Parolele trebuie să se potrivească",
"registering": "Înregistrare..."
},
"register_auth_links": "<0>Ai deja un cont? </0><1><0>Conectați-vă </0> sau <2> accesați ca invitat </2> </1>",
"register_confirm_password_label": "Confirmă Parola",
"register_heading": "Creează-ți contul",
"return_home_button": "Reveniți la ecranul de pornire",
"room_auth_view_continue_button": "Continuă",
"room_auth_view_eula_caption": "Făcând clic pe „Continuați”, sunteți de acord cu Acordul nostru de licențiere pentru utilizatorul <2> final (EULA) </2>",
"screenshare_button_label": "Partajare ecran",
"settings": {
"audio_tab": {
"effect_volume_description": "Reglați volumul la care reacționează reacțiile și efectele ridicate de mână",
"effect_volume_label": "Volumul efectului sonor"
},
"developer_settings_label": "Setări pentru dezvoltatori",
"developer_settings_label_description": "Expuneți setările dezvoltatorului în fereastra de setări.",
"developer_tab_title": "dezvoltator",
"feedback_tab_body": "Dacă întâmpinați probleme sau pur și simplu doriți să oferiți feedback, vă rugăm să ne trimiteți o scurtă descriere mai jos.",
"feedback_tab_description_label": "Feedback-ul tău",
"feedback_tab_h4": "Trimiteți Feedback",
"feedback_tab_send_logs_label": "Includeți jurnale de depanare",
"feedback_tab_thank_you": "Vă mulțumim, am primit feedback-ul dvs.!",
"feedback_tab_title": "Feedback",
"more_tab_title": "Mai mult",
"opt_in_description": "<0></0><1></1>Puteți retrage consimțământul debifând această casetă. Dacă sunteți în prezent la un apel, această setare va intra în vigoare la sfârșitul apelului.",
"preferences_tab": {
"reactions_play_sound_description": "Redați un efect sonor atunci când cineva trimite o reacție la un apel.",
"reactions_play_sound_label": "Redați sunete de reacție",
"reactions_show_description": "Afișați o animație atunci când cineva trimite o reacție.",
"reactions_show_label": "Afișați reacțiile",
"reactions_title": "Reacții"
},
"preferences_tab_body": "Aici puteți configura opțiuni suplimentare pentru o experiență îmbunătățită",
"preferences_tab_h4": "preferinte",
"preferences_tab_show_hand_raised_timer_description": "Afișați un cronometru atunci când un participant ridică mâna",
"preferences_tab_show_hand_raised_timer_label": "Afișați durata ridicării mâinii",
"speaker_device_selection_label": "vorbitor"
},
"start_new_call": "Începe un nou apel",
"start_video_button_label": "Începeți videoclipul",
"stop_screenshare_button_label": "Partajarea ecranului",
"stop_video_button_label": "Opriți videoclipul",
"submitting": "Trimiterea...",
"switch_camera": "Comutați camera",
"unauthenticated_view_body": "Nu sunteți încă înregistrat? <2>Creați un cont </2>",
"unauthenticated_view_eula_caption": "Făcând clic pe „Go”, sunteți de acord cu Acordul nostru de licențiere pentru utilizatorul <2> final (EULA) </2>",
"unauthenticated_view_login_button": "Conectați-vă la contul dvs.",
"unmute_microphone_button_label": "Anulează microfonul",
"version": "{{productName}}Versiune: {{version}}",
"video_tile": {
"always_show": "Arată întotdeauna",
"change_fit_contain": "Se potrivește cadrului",
"collapse": "colaps",
"expand": "Extindeți",
"mute_for_me": "Mute pentru mine",
"muted_for_me": "Dezactivat pentru mine",
"volume": "VOLUM"
}
}

View File

@@ -53,7 +53,7 @@
"@types/grecaptcha": "^3.0.9", "@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.0.0", "@types/node": "^22.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -63,7 +63,7 @@
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.9.1", "@vector-im/compound-design-tokens": "^2.0.0",
"@vector-im/compound-web": "^7.2.0", "@vector-im/compound-web": "^7.2.0",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.1",

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import "i18next"; import "i18next";
// import all namespaces (for the default language, only) // import all namespaces (for the default language, only)
import app from "../../locales/en-GB/app.json"; import app from "../../locales/en/app.json";
declare module "i18next" { declare module "i18next" {
interface CustomTypeOptions { interface CustomTypeOptions {

156
src/Avatar.test.tsx Normal file
View File

@@ -0,0 +1,156 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { FC, PropsWithChildren } from "react";
import { ClientContextProvider } from "./ClientContext";
import { Avatar } from "./Avatar";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
const TestComponent: FC<
PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }>
> = ({ client, children, supportsThumbnails }) => {
return (
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
thumbnails: supportsThumbnails ?? true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
{children}
</ClientContextProvider>
);
};
afterEach(() => {
vi.unstubAllGlobals();
});
test("should just render a placeholder when the user has no avatar", () => {
const client = vi.mocked<MatrixClient>({
getAccessToken: () => "my-access-token",
mxcUrlToHttp: () => vi.fn(),
} as unknown as MatrixClient);
vi.spyOn(client, "mxcUrlToHttp");
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
getMxcAvatarUrl: () => undefined,
},
);
const displayName = "Alice";
render(
<TestComponent client={client}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
const element = screen.getByRole("img", { name: "@alice:example.org" });
expect(element.tagName).toEqual("SPAN");
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
});
test("should just render a placeholder when thumbnails are not supported", () => {
const client = vi.mocked<MatrixClient>({
getAccessToken: () => "my-access-token",
mxcUrlToHttp: () => vi.fn(),
} as unknown as MatrixClient);
vi.spyOn(client, "mxcUrlToHttp");
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
getMxcAvatarUrl: () => "mxc://example.org/alice-avatar",
},
);
const displayName = "Alice";
render(
<TestComponent client={client} supportsThumbnails={false}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
const element = screen.getByRole("img", { name: "@alice:example.org" });
expect(element.tagName).toEqual("SPAN");
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
});
test("should attempt to fetch authenticated media", async () => {
const expectedAuthUrl = "http://example.org/media/alice-avatar";
const expectedObjectURL = "my-object-url";
const accessToken = "my-access-token";
const theBlob = new Blob([]);
// vitest doesn't have a implementation of create/revokeObjectURL, so we need
// to delete the property. It's a bit odd, but it works.
Reflect.deleteProperty(global.window.URL, "createObjectURL");
globalThis.URL.createObjectURL = vi.fn().mockReturnValue(expectedObjectURL);
Reflect.deleteProperty(global.window.URL, "revokeObjectURL");
globalThis.URL.revokeObjectURL = vi.fn();
const fetchFn = vi.fn().mockResolvedValue({
blob: async () => Promise.resolve(theBlob),
});
vi.stubGlobal("fetch", fetchFn);
const client = vi.mocked<MatrixClient>({
getAccessToken: () => accessToken,
mxcUrlToHttp: () => vi.fn(),
} as unknown as MatrixClient);
vi.spyOn(client, "mxcUrlToHttp").mockReturnValue(expectedAuthUrl);
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
getMxcAvatarUrl: () => "mxc://example.org/alice-avatar",
},
);
const displayName = "Alice";
render(
<TestComponent client={client}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
// Fetch is asynchronous, so wait for this to resolve.
await vi.waitUntil(() =>
document.querySelector(`img[src='${expectedObjectURL}']`),
);
expect(client.mxcUrlToHttp).toBeCalledTimes(1);
expect(globalThis.fetch).toBeCalledWith(expectedAuthUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
});

View File

@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useMemo, FC, CSSProperties } from "react"; import { useMemo, FC, CSSProperties, useState, useEffect } from "react";
import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { getAvatarUrl } from "./utils/matrix"; import { useClientState } from "./ClientContext";
import { useClient } from "./ClientContext";
export enum Size { export enum Size {
XS = "xs", XS = "xs",
@@ -36,6 +36,28 @@ interface Props {
style?: CSSProperties; style?: CSSProperties;
} }
export function getAvatarUrl(
client: MatrixClient,
mxcUrl: string | null,
avatarSize = 96,
): string | null {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
// scale is more suitable for larger sizes
const resizeMethod = avatarSize <= 96 ? "crop" : "scale";
return mxcUrl
? client.mxcUrlToHttp(
mxcUrl,
width,
height,
resizeMethod,
false,
true,
true,
)
: null;
}
export const Avatar: FC<Props> = ({ export const Avatar: FC<Props> = ({
className, className,
id, id,
@@ -45,7 +67,7 @@ export const Avatar: FC<Props> = ({
style, style,
...props ...props
}) => { }) => {
const { client } = useClient(); const clientState = useClientState();
const sizePx = useMemo( const sizePx = useMemo(
() => () =>
@@ -55,10 +77,50 @@ export const Avatar: FC<Props> = ({
[size], [size],
); );
const resolvedSrc = useMemo(() => { const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
if (!client || !src || !sizePx) return undefined;
return src.startsWith("mxc://") ? getAvatarUrl(client, src, sizePx) : src; useEffect(() => {
}, [client, src, sizePx]); if (clientState?.state !== "valid") {
return;
}
const { authenticated, supportedFeatures } = clientState;
const client = authenticated?.client;
if (!client || !src || !sizePx || !supportedFeatures.thumbnails) {
return;
}
const token = client.getAccessToken();
if (!token) {
return;
}
const resolveSrc = getAvatarUrl(client, src, sizePx);
if (!resolveSrc) {
setAvatarUrl(undefined);
return;
}
let objectUrl: string | undefined;
fetch(resolveSrc, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then(async (req) => req.blob())
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl);
})
.catch((ex) => {
setAvatarUrl(undefined);
});
return (): void => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, src, sizePx]);
return ( return (
<CompoundAvatar <CompoundAvatar
@@ -66,7 +128,7 @@ export const Avatar: FC<Props> = ({
id={id} id={id}
name={name} name={name}
size={`${sizePx}px`} size={`${sizePx}px`}
src={resolvedSrc} src={avatarUrl}
style={style} style={style}
{...props} {...props}
/> />

View File

@@ -48,6 +48,7 @@ export type ValidClientState = {
disconnected: boolean; disconnected: boolean;
supportedFeatures: { supportedFeatures: {
reactions: boolean; reactions: boolean;
thumbnails: boolean;
}; };
setClient: (params?: SetClientParams) => void; setClient: (params?: SetClientParams) => void;
}; };
@@ -71,6 +72,8 @@ export type SetClientParams = {
const ClientContext = createContext<ClientState | undefined>(undefined); const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider;
export const useClientState = (): ClientState | undefined => export const useClientState = (): ClientState | undefined =>
useContext(ClientContext); useContext(ClientContext);
@@ -253,6 +256,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const [isDisconnected, setIsDisconnected] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false);
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
const state: ClientState | undefined = useMemo(() => { const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) { if (alreadyOpenedErr) {
@@ -278,6 +282,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
disconnected: isDisconnected, disconnected: isDisconnected,
supportedFeatures: { supportedFeatures: {
reactions: supportsReactions, reactions: supportsReactions,
thumbnails: supportsThumbnails,
}, },
}; };
}, [ }, [
@@ -288,6 +293,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
setClient, setClient,
isDisconnected, isDisconnected,
supportsReactions, supportsReactions,
supportsThumbnails,
]); ]);
const onSync = useCallback( const onSync = useCallback(
@@ -313,6 +319,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
} }
if (initClientState.widgetApi) { if (initClientState.widgetApi) {
// There is currently no widget API for authenticated media thumbnails.
setSupportsThumbnails(false);
const reactSend = initClientState.widgetApi.hasCapability( const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction", "org.matrix.msc2762.send.event:m.reaction",
); );
@@ -334,6 +342,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
} }
} else { } else {
setSupportsReactions(true); setSupportsReactions(true);
setSupportsThumbnails(true);
} }
return (): void => { return (): void => {

View File

@@ -22,7 +22,7 @@ export abstract class TranslatedError extends Error {
messageKey: ParseKeys<DefaultNamespace, TOptions>, messageKey: ParseKeys<DefaultNamespace, TOptions>,
translationFn: TFunction<DefaultNamespace>, translationFn: TFunction<DefaultNamespace>,
) { ) {
super(translationFn(messageKey, { lng: "en-GB" } as TOptions)); super(translationFn(messageKey, { lng: "en" } as TOptions));
this.translatedMessage = translationFn(messageKey); this.translatedMessage = translationFn(messageKey);
} }
} }

View File

@@ -51,7 +51,7 @@ export interface ConfigOptions {
// a livekit service url in the client well-known. // a livekit service url in the client well-known.
// The well known needs to be formatted like so: // The well known needs to be formatted like so:
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"} // {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
// and stored under the key: "livekit_focus" // and stored under the key: "org.matrix.msc4143.rtc_foci"
livekit_service_url: string; livekit_service_url: string;
}; };

View File

@@ -47,6 +47,11 @@ layer(compound);
--background-gradient: url("graphics/backgroundGradient.svg"); --background-gradient: url("graphics/backgroundGradient.svg");
} }
:root,
[class*="cpd-theme-"] {
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
}
.cpd-theme-dark { .cpd-theme-dark {
--cpd-color-border-accent: var(--cpd-color-green-1100); --cpd-color-border-accent: var(--cpd-color-green-1100);
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary);

View File

@@ -24,7 +24,7 @@ import { platform } from "./Platform";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// { // {
// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", // "../locales/en/app.json": "/whatever/assets/root/locales/en-aabbcc.json",
// ... // ...
// } // }
const locales = import.meta.glob<string>("../locales/*/*.json", { const locales = import.meta.glob<string>("../locales/*/*.json", {
@@ -41,7 +41,7 @@ const getLocaleUrl = (
const supportedLngs = [ const supportedLngs = [
...new Set( ...new Set(
Object.keys(locales).map((url) => { Object.keys(locales).map((url) => {
// The URLs are of the form ../locales/en-GB/app.json // The URLs are of the form ../locales/en/app.json
// This extracts the language code from the URL // This extracts the language code from the URL
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
if (!lang) { if (!lang) {
@@ -133,7 +133,7 @@ export class Initializer {
.use(languageDetector) .use(languageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: "en-GB", fallbackLng: "en",
defaultNS: "app", defaultNS: "app",
keySeparator: ".", keySeparator: ".",
nsSeparator: false, nsSeparator: false,

View File

@@ -8,23 +8,31 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { import {
afterAll, afterAll,
afterEach,
beforeEach, beforeEach,
expect, expect,
Mock,
MockedFunction, MockedFunction,
test, test,
vitest, vitest,
} from "vitest"; } from "vitest";
import { ConnectionState, RemoteParticipant, Room } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { of } from "rxjs"; import { ConnectionState } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { afterEach } from "node:test";
import { act, ReactNode } from "react"; import { act, ReactNode } from "react";
import {
CallMembership,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { import {
EmittableMockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockRemoteParticipant, mockRemoteParticipant,
mockRtcMembership,
MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel"; import { CallViewModel } from "../state/CallViewModel";
@@ -32,22 +40,21 @@ import {
CallEventAudioRenderer, CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND, MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer"; } from "./CallEventAudioRenderer";
import { prefetchSounds, useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { import { TestReactionsWrapper } from "../utils/testReactions";
MockRoom, import { prefetchSounds } from "../soundUtils";
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" }); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" }); const local = mockMatrixRoomMember(localRtcMember);
const aliceId = `${alice.userId}:AAAA`; const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
const bobId = `${bob.userId}:BBBB`; const alice = mockMatrixRoomMember(aliceRtcMember);
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
const bobParticipant = mockRemoteParticipant({ identity: bobId });
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils");
afterEach(() => { afterEach(() => {
vitest.resetAllMocks(); vitest.resetAllMocks();
@@ -57,7 +64,7 @@ afterAll(() => {
vitest.restoreAllMocks(); vitest.restoreAllMocks();
}); });
let playSound: Mock< let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
>; >;
@@ -72,19 +79,65 @@ beforeEach(() => {
}); });
function TestComponent({ function TestComponent({
room, rtcSession,
vm, vm,
}: { }: {
room: MockRoom; rtcSession: MockRTCSession;
vm: CallViewModel; vm: CallViewModel;
}): ReactNode { }): ReactNode {
return ( return (
<TestReactionsWrapper rtcSession={new MockRTCSession(room, {})}> <TestReactionsWrapper
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<CallEventAudioRenderer vm={vm} /> <CallEventAudioRenderer vm={vm} />
</TestReactionsWrapper> </TestReactionsWrapper>
); );
} }
function getMockEnv(
members: RoomMember[],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
vm: CallViewModel;
session: MockRTCSession;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const matrixRoom = mockMatrixRoom({
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
on: vitest.fn(),
off: vitest.fn(),
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
});
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const session = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships);
const vm = new CallViewModel(
session as unknown as MatrixRTCSession,
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
return { vm, session, remoteRtcMemberships };
}
/** /**
* We don't want to play a sound when loading the call state * We don't want to play a sound when loading the call state
* because typically this occurs in two stages. We first join * because typically this occurs in two stages. We first join
@@ -93,118 +146,56 @@ function TestComponent({
* 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 liveKitRoom = new EmittableMockLivekitRoom({ const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
localParticipant, render(<TestComponent rtcSession={session} vm={vm} />);
remoteParticipants: new Map(),
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
// Joining a call usually means remote participants are added later. // Joining a call usually means remote participants are added later.
act(() => { act(() => {
liveKitRoom.addParticipant(bobParticipant); remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
}); });
expect(playSound).toHaveBeenCalledOnce();
render(<TestComponent room={room} vm={vm} />);
expect(playSound).toBeCalled();
}); });
// TODO: Same test?
test("plays a sound when a user joins", () => { test("plays a sound when a user joins", () => {
const remoteParticipants = new Map( const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
[aliceParticipant].map((p) => [p.identity, p]), render(<TestComponent rtcSession={session} vm={vm} />);
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom as unknown as Room,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<TestComponent room={room} vm={vm} />);
act(() => { act(() => {
liveKitRoom.addParticipant(bobParticipant); remoteRtcMemberships.next([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 remoteParticipants = new Map( const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
[aliceParticipant].map((p) => [p.identity, p]), render(<TestComponent rtcSession={session} vm={vm} />);
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<TestComponent room={room} vm={vm} />);
act(() => { act(() => {
liveKitRoom.removeParticipant(aliceParticipant); remoteRtcMemberships.next([]);
}); });
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", () => {
expect(playSound).not.toBeCalled(); const mockRtcMemberships: CallMembership[] = [];
const remoteParticipants = new Map<string, RemoteParticipant>([ for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
[aliceParticipant.identity, aliceParticipant], mockRtcMemberships.push(
// You + other participants to hit the max. mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map< );
[string, RemoteParticipant] }
>((_, index) => {
const p = mockRemoteParticipant({
identity: `@user${index}:example.com:DEV${index}`,
});
return [p.identity, p];
}),
]);
// Preload the call with the maximum members, assume that const { session, vm, remoteRtcMemberships } = getMockEnv(
// we're already in the call by this point rather than [local, alice],
// joining. mockRtcMemberships,
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
); );
render(<TestComponent room={room} vm={vm} />);
// When the count drops, play a leave sound. render(<TestComponent rtcSession={session} vm={vm} />);
expect(playSound).not.toBeCalled();
act(() => { act(() => {
liveKitRoom.removeParticipant(aliceParticipant); remoteRtcMemberships.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
);
}); });
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });

View File

@@ -15,7 +15,8 @@ import leftCallSoundMp3 from "../sound/left_call.mp3";
import leftCallSoundOgg from "../sound/left_call.ogg"; import leftCallSoundOgg from "../sound/left_call.ogg";
import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundOgg from "../sound/raise_hand.ogg?url";
import handSoundMp3 from "../sound/raise_hand.mp3?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url";
import { prefetchSounds, useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useReactions } from "../useReactions"; import { useReactions } from "../useReactions";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
@@ -24,7 +25,7 @@ import { useLatest } from "../useLatest";
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500; export const THROTTLE_SOUND_EFFECT_MS = 500;
export const CallEventAudioSounds = prefetchSounds({ const sounds = prefetchSounds({
join: { join: {
mp3: joinCallSoundMp3, mp3: joinCallSoundMp3,
ogg: joinCallSoundOgg, ogg: joinCallSoundOgg,
@@ -45,7 +46,7 @@ export function CallEventAudioRenderer({
vm: CallViewModel; vm: CallViewModel;
}): ReactNode { }): ReactNode {
const audioEngineCtx = useAudioContext({ const audioEngineCtx = useAudioContext({
sounds: CallEventAudioSounds, sounds,
latencyHint: "interactive", latencyHint: "interactive",
}); });
const audioEngineRef = useLatest(audioEngineCtx); const audioEngineRef = useLatest(audioEngineCtx);

View File

@@ -343,7 +343,7 @@ export const GroupCallView: FC<Props> = ({
<ActiveCall <ActiveCall
client={client} client={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession} rtcSession={rtcSession as unknown as MatrixRTCSession}
participantCount={participantCount} participantCount={participantCount}
onLeave={onLeave} onLeave={onLeave}
hideHeader={hideHeader} hideHeader={hideHeader}

View File

@@ -120,7 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => { useEffect(() => {
if (livekitRoom !== undefined) { if (livekitRoom !== undefined) {
const vm = new CallViewModel( const vm = new CallViewModel(
props.rtcSession.room, props.rtcSession,
livekitRoom, livekitRoom,
props.e2eeSystem, props.e2eeSystem,
connStateObservable, connStateObservable,
@@ -128,12 +128,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
setVm(vm); setVm(vm);
return (): void => vm.destroy(); return (): void => vm.destroy();
} }
}, [ }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
props.rtcSession.room,
livekitRoom,
props.e2eeSystem,
connStateObservable,
]);
if (livekitRoom === undefined || vm === null) return null; if (livekitRoom === undefined || vm === null) return null;

View File

@@ -29,8 +29,9 @@ import {
playReactionsSound, playReactionsSound,
soundEffectVolumeSetting, soundEffectVolumeSetting,
} from "../settings/settings"; } from "../settings/settings";
import { prefetchSounds, useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { GenericReaction, ReactionSet } from "../reactions"; import { GenericReaction, ReactionSet } from "../reactions";
import { prefetchSounds } from "../soundUtils";
const memberUserIdAlice = "@alice:example.org"; const memberUserIdAlice = "@alice:example.org";
const memberUserIdBob = "@bob:example.org"; const memberUserIdBob = "@bob:example.org";
@@ -60,6 +61,7 @@ function TestComponent({
} }
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils");
afterEach(() => { afterEach(() => {
vitest.resetAllMocks(); vitest.resetAllMocks();

View File

@@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { ReactNode, useDeferredValue, useEffect } from "react"; import { ReactNode, useDeferredValue, useEffect, useState } from "react";
import { useReactions } from "../useReactions"; import { useReactions } from "../useReactions";
import { playReactionsSound, useSetting } from "../settings/settings"; import { playReactionsSound, useSetting } from "../settings/settings";
import { GenericReaction, ReactionSet } from "../reactions"; import { GenericReaction, ReactionSet } from "../reactions";
import { prefetchSounds, useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
const SoundMap = Object.fromEntries([ const soundMap = Object.fromEntries([
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [ ...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
v.name, v.name,
v.sound!, v.sound!,
@@ -21,18 +22,29 @@ const SoundMap = Object.fromEntries([
[GenericReaction.name, GenericReaction.sound], [GenericReaction.name, GenericReaction.sound],
]); ]);
const Sounds = prefetchSounds(SoundMap);
export function ReactionsAudioRenderer(): ReactNode { export function ReactionsAudioRenderer(): ReactNode {
const { reactions } = useReactions(); const { reactions } = useReactions();
const [shouldPlay] = useSetting(playReactionsSound); const [shouldPlay] = useSetting(playReactionsSound);
const [soundCache, setSoundCache] = useState<ReturnType<
typeof prefetchSounds
> | null>(null);
const audioEngineCtx = useAudioContext({ const audioEngineCtx = useAudioContext({
sounds: Sounds, sounds: soundCache,
latencyHint: "interactive", latencyHint: "interactive",
}); });
const audioEngineRef = useLatest(audioEngineCtx); const audioEngineRef = useLatest(audioEngineCtx);
const oldReactions = useDeferredValue(reactions); const oldReactions = useDeferredValue(reactions);
useEffect(() => {
if (!shouldPlay || soundCache) {
return;
}
// This is fine even if we load the component multiple times,
// as the browser's cache should ensure once the media is loaded
// once that future fetches come via the cache.
setSoundCache(prefetchSounds(soundMap));
}, [soundCache, shouldPlay]);
useEffect(() => { useEffect(() => {
if (!shouldPlay || !audioEngineRef.current) { if (!shouldPlay || !audioEngineRef.current) {
return; return;
@@ -47,7 +59,7 @@ export function ReactionsAudioRenderer(): ReactNode {
// Don't replay old reactions // Don't replay old reactions
return; return;
} }
if (SoundMap[reactionName]) { if (soundMap[reactionName]) {
void audioEngineRef.current.playSound(reactionName); void audioEngineRef.current.playSound(reactionName);
} else { } else {
// Fallback sounds. // Fallback sounds.
@@ -55,5 +67,5 @@ export function ReactionsAudioRenderer(): ReactNode {
} }
} }
}, [audioEngineRef, shouldPlay, oldReactions, reactions]); }, [audioEngineRef, shouldPlay, oldReactions, reactions]);
return <></>; return null;
} }

View File

@@ -18,8 +18,7 @@ Please see LICENSE in the repository root for full details.
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
background-color: black; background-color: var(--video-tile-background);
background-color: var(--cpd-color-bg-subtle-primary);
} }
video.mirror { video.mirror {
@@ -35,7 +34,7 @@ video.mirror {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: var(--cpd-color-bg-subtle-secondary); background-color: var(--video-tile-background);
} }
.buttonBar { .buttonBar {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; import { ChangeEvent, FC, useCallback, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Root as Form, Text } from "@vector-im/compound-web"; import { Root as Form, Text } from "@vector-im/compound-web";
@@ -86,9 +86,6 @@ export const SettingsModal: FC<Props> = ({
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume); const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
// Debounce saving the sound volume as it triggers certain components to reload.
useEffect(() => {});
const audioTab: Tab<SettingsTab> = { const audioTab: Tab<SettingsTab> = {
key: "audio", key: "audio",
name: t("common.audio"), name: t("common.audio"),

63
src/soundUtils.ts Normal file
View File

@@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
type SoundDefinition = { mp3?: string; ogg: string };
export type PrefetchedSounds<S extends string> = Promise<
Record<S, ArrayBuffer>
>;
/**
* Determine the best format we can use to play our sounds
* through. We prefer ogg support if possible, but will fall
* back to MP3.
* @returns "ogg" if the browser is likely to support it, or "mp3" otherwise.
*/
function getPreferredAudioFormat(): "ogg" | "mp3" {
const a = document.createElement("audio");
if (a.canPlayType("audio/ogg") === "maybe") {
return "ogg";
}
// Otherwise just assume MP3, as that has a chance of being more widely supported.
return "mp3";
}
const preferredFormat = getPreferredAudioFormat();
/**
* Prefetch sounds to be used by the AudioContext. This can
* be called outside the scope of a component to ensure the
* sounds load ahead of time.
* @param sounds A set of sound files that may be played.
* @returns A map of sound files to buffers.
*/
export async function prefetchSounds<S extends string>(
sounds: Record<S, SoundDefinition>,
): PrefetchedSounds<S> {
const buffers: Record<string, ArrayBuffer> = {};
await Promise.all(
Object.entries(sounds).map(async ([name, file]) => {
const { mp3, ogg } = file as SoundDefinition;
// Use preferred format, fallback to ogg if no mp3 is provided.
// Load an audio file
const response = await fetch(
preferredFormat === "ogg" ? ogg : (mp3 ?? ogg),
);
if (!response.ok) {
// If the sound doesn't load, it's not the end of the world. We won't play
// the sound when requested, but it's better than failing the whole application.
logger.warn(`Could not load sound ${name}, response was not okay`);
return;
}
// Decode it
buffers[name] = await response.arrayBuffer();
}),
);
return buffers as Record<S, ArrayBuffer>;
}

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, onTestFinished } from "vitest"; import { test, vi, onTestFinished, it } from "vitest";
import { import {
combineLatest, combineLatest,
debounceTime, debounceTime,
@@ -25,6 +25,7 @@ import {
} from "livekit-client"; } from "livekit-client";
import * as ComponentsCore from "@livekit/components-core"; import * as ComponentsCore from "@livekit/components-core";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { CallViewModel, Layout } from "./CallViewModel"; import { CallViewModel, Layout } from "./CallViewModel";
import { import {
@@ -34,6 +35,8 @@ import {
mockMatrixRoomMember, mockMatrixRoomMember,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler, withTestScheduler,
mockRtcMembership,
MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { import {
ECAddonConnectionState, ECAddonConnectionState,
@@ -43,14 +46,19 @@ import { E2eeType } from "../e2ee/e2eeType";
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" }); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" }); const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
const carol = mockMatrixRoomMember({ userId: "@carol:example.org" }); const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const dave = mockMatrixRoomMember({ userId: "@dave:example.org" }); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
const aliceId = `${alice.userId}:AAAA`; const alice = mockMatrixRoomMember(aliceRtcMember);
const bobId = `${bob.userId}:BBBB`; const bob = mockMatrixRoomMember(bobRtcMember);
const daveId = `${dave.userId}:DDDD`; const carol = mockMatrixRoomMember(localRtcMember);
const dave = mockMatrixRoomMember(daveRtcMember);
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
const bobId = `${bob.userId}:${bobRtcMember.deviceId}`;
const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
@@ -65,7 +73,9 @@ const bobSharingScreen = mockRemoteParticipant({
}); });
const daveParticipant = mockRemoteParticipant({ identity: daveId }); const daveParticipant = mockRemoteParticipant({ identity: daveId });
const members = new Map([alice, bob, carol, dave].map((p) => [p.userId, p])); const roomMembers = new Map(
[alice, bob, carol, dave].map((p) => [p.userId, p]),
);
export interface GridLayoutSummary { export interface GridLayoutSummary {
type: "grid"; type: "grid";
@@ -173,10 +183,23 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
function withCallViewModel( function withCallViewModel(
remoteParticipants: Observable<RemoteParticipant[]>, remoteParticipants: Observable<RemoteParticipant[]>,
rtcMembers: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>, connectionState: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void, continuation: (vm: CallViewModel) => void,
): void { ): void {
const room = mockMatrixRoom({
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => roomMembers.get(userId) ?? null,
});
const rtcSession = new MockRTCSession(
room,
localRtcMember,
[],
).withMemberships(rtcMembers);
const participantsSpy = vi const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver") .spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants); .mockReturnValue(remoteParticipants);
@@ -209,12 +232,7 @@ function withCallViewModel(
); );
const vm = new CallViewModel( const vm = new CallViewModel(
mockMatrixRoom({ rtcSession as unknown as MatrixRTCSession,
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom, liveKitRoom,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
@@ -247,6 +265,7 @@ test("participants are retained during a focus switch", () => {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
of([aliceRtcMember, bobRtcMember]),
hot(connectionInputMarbles, { hot(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
@@ -288,6 +307,7 @@ test("screen sharing activates spotlight layout", () => {
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen], d: [aliceParticipant, bobSharingScreen],
}), }),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -356,7 +376,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
const modeInputMarbles = " a"; const modeInputMarbles = " a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = "ny 1998ms n 1999ms "; const bSpeakingInputMarbles = "ny 1998ms n 1999ms -";
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n"; const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen. // Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who // When Dave speaks he should switch with Alice because she's the one who
@@ -366,6 +386,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
@@ -427,6 +448,7 @@ test("spotlight speakers swap places", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
@@ -475,6 +497,7 @@ test("layout enters picture-in-picture mode when requested", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -515,6 +538,7 @@ test("spotlight remembers whether it's expanded", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -559,3 +583,104 @@ test("spotlight remembers whether it's expanded", () => {
); );
}); });
}); });
test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ hot, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec";
// Bob should never be visible
const expectedLayoutMarbles = "a-bc-b";
withCallViewModel(
hot(scenarioInputMarbles, {
a: [],
b: [bobParticipant],
c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen],
}),
hot(scenarioInputMarbles, {
a: [],
b: [],
c: [aliceRtcMember],
d: [aliceRtcMember, daveRtcMember],
e: [aliceRtcMember, daveRtcMember],
}),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0"],
},
b: {
type: "one-on-one",
local: "local:0",
remote: `${aliceId}:0`,
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
},
);
},
);
});
});
it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships
const scenarioInputMarbles = " abcd";
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "abcd";
withCallViewModel(
of([]),
hot(scenarioInputMarbles, {
a: [],
b: [aliceRtcMember],
c: [aliceRtcMember, daveRtcMember],
d: [daveRtcMember],
}),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0"],
},
b: {
type: "one-on-one",
local: "local:0",
remote: `${aliceId}:0`,
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
d: {
type: "one-on-one",
local: "local:0",
remote: `${daveId}:0`,
},
},
);
},
);
});
});

View File

@@ -18,12 +18,9 @@ import {
RemoteParticipant, RemoteParticipant,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { import {
Room as MatrixRoom, BehaviorSubject,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import {
EMPTY, EMPTY,
Observable, Observable,
Subject, Subject,
@@ -49,6 +46,10 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { import {
@@ -222,41 +223,67 @@ interface LayoutScanState {
class UserMedia { class UserMedia {
private readonly scope = new ObservableScope(); private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel; public readonly vm: UserMediaViewModel;
private readonly participant: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined
>;
public readonly speaker: Observable<boolean>; public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>; public readonly presenter: Observable<boolean>;
public constructor( public constructor(
public readonly id: string, public readonly id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
this.vm = participant.isLocal this.participant = new BehaviorSubject(participant);
? new LocalUserMediaViewModel(
id, if (participant?.isLocal) {
member, this.vm = new LocalUserMediaViewModel(
participant as LocalParticipant, this.id,
encryptionSystem, member,
livekitRoom, this.participant.asObservable() as Observable<LocalParticipant>,
) encryptionSystem,
: new RemoteUserMediaViewModel( livekitRoom,
id, );
member, } else {
participant as RemoteParticipant, this.vm = new RemoteUserMediaViewModel(
encryptionSystem, id,
livekitRoom, member,
); this.participant.asObservable() as Observable<
RemoteParticipant | undefined
>,
encryptionSystem,
livekitRoom,
);
}
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state()); this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
this.presenter = observeParticipantEvents( this.presenter = this.participant.pipe(
participant, switchMap(
ParticipantEvent.TrackPublished, (p) =>
ParticipantEvent.TrackUnpublished, (p &&
ParticipantEvent.LocalTrackPublished, observeParticipantEvents(
ParticipantEvent.LocalTrackUnpublished, p,
).pipe(map((p) => p.isScreenShareEnabled)); ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false),
),
this.scope.state(),
);
}
public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void {
if (this.participant.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia.
this.participant.next(newParticipant);
}
} }
public destroy(): void { public destroy(): void {
@@ -267,6 +294,9 @@ class UserMedia {
class ScreenShare { class ScreenShare {
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private readonly participant: BehaviorSubject<
LocalParticipant | RemoteParticipant
>;
public constructor( public constructor(
id: string, id: string,
@@ -275,12 +305,15 @@ class ScreenShare {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom, liveKitRoom: LivekitRoom,
) { ) {
this.participant = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
id, id,
member, member,
participant, this.participant.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
participant.isLocal,
); );
} }
@@ -317,11 +350,11 @@ function findMatrixRoomMember(
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> = public readonly localVideo: Observable<LocalVideoTrack | null> =
observeTrackReference( observeTrackReference(
this.livekitRoom.localParticipant, of(this.livekitRoom.localParticipant),
Track.Source.Camera, Track.Source.Camera,
).pipe( ).pipe(
map((trackRef) => { map((trackRef) => {
const track = trackRef.publication?.track; const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
); );
@@ -401,49 +434,87 @@ export class CallViewModel extends ViewModel {
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value, duplicateTiles.value,
// Also react to changes in the list of members // Also react to changes in the MatrixRTC session list.
fromEvent(this.matrixRoom, RoomStateEvent.Update).pipe(startWith(null)), // 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)),
]).pipe( ]).pipe(
scan( scan(
( (
prevItems, prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles], [
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
_membershipsChanged,
],
) => { ) => {
const newItems = new Map( const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { // m.rtc.members are the basis for calculating what is visible in the call
const id = p === localParticipant ? "local" : p.identity; for (const rtcMember of this.matrixRTCSession.memberships) {
const member = findMatrixRoomMember(this.matrixRoom, id); const room = this.matrixRTCSession.room;
if (member === undefined) // WARN! This is not exactly the sender but the user defined in the state key.
logger.warn( // This will be available once we change to the new "member as object" format in the MatrixRTC object.
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, let livekitParticipantId =
); rtcMember.sender + ":" + rtcMember.deviceId;
// Create as many tiles for this participant as called for by let participant:
// the duplicateTiles option | LocalParticipant
| RemoteParticipant
| undefined = undefined;
if (
rtcMember.sender === room.client.getUserId()! &&
rtcMember.deviceId === room.client.getDeviceId()
) {
livekitParticipantId = "local";
participant = localParticipant;
} else {
participant = remoteParticipants.find(
(p) => p.identity === livekitParticipantId,
);
}
const member = findMatrixRoomMember(room, livekitParticipantId);
if (!member) {
logger.error(
"Could not find member for media id: ",
livekitParticipantId,
);
}
for (let i = 0; i < 1 + duplicateTiles; i++) { for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${id}:${i}`; const indexedMediaId = `${livekitParticipantId}:${i}`;
const prevMedia = prevItems.get(indexedMediaId);
if (prevMedia && prevMedia instanceof UserMedia) {
prevMedia.updateParticipant(participant);
}
yield [ yield [
userMediaId, indexedMediaId,
prevItems.get(userMediaId) ?? // We create UserMedia with or without a participant.
// This will be the initial value of a BehaviourSubject.
// Once a participant appears we will update the BehaviourSubject. (see above)
prevMedia ??
new UserMedia( new UserMedia(
userMediaId, indexedMediaId,
member, member,
p, participant,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
), ),
]; ];
if (p.isScreenShareEnabled) { if (participant?.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`; const screenShareId = `${indexedMediaId}:screen-share`;
yield [ yield [
screenShareId, screenShareId,
prevItems.get(screenShareId) ?? prevItems.get(screenShareId) ??
new ScreenShare( new ScreenShare(
screenShareId, screenShareId,
member, member,
p, participant,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
), ),
@@ -454,7 +525,6 @@ export class CallViewModel extends ViewModel {
}.bind(this)(), }.bind(this)(),
); );
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
return newItems; return newItems;
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
@@ -488,11 +558,6 @@ export class CallViewModel extends ViewModel {
), ),
); );
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
this.mediaItems.pipe(
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
);
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */
@@ -504,7 +569,7 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly spotlightSpeaker: Observable<UserMediaViewModel> = private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> =
this.userMedia.pipe( this.userMedia.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
@@ -515,7 +580,7 @@ export class CallViewModel extends ViewModel {
), ),
), ),
), ),
scan<(readonly [UserMedia, boolean])[], UserMedia, null>( scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
(prev, mediaItems) => { (prev, mediaItems) => {
// Only remote users that are still in the call should be sticky // Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] = const [stickyMedia, stickySpeaking] =
@@ -532,11 +597,11 @@ export class CallViewModel extends ViewModel {
// Otherwise, spotlight an arbitrary remote user // Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.vm.local)?.[0] ?? mediaItems.find(([m]) => !m.vm.local)?.[0] ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)![0]); mediaItems.find(([m]) => m.vm.local)?.[0]);
}, },
null, null,
), ),
map((speaker) => speaker.vm), map((speaker) => speaker?.vm ?? null),
this.scope.state(), this.scope.state(),
); );
@@ -576,37 +641,57 @@ export class CallViewModel extends ViewModel {
}), }),
); );
private readonly spotlightAndPip: Observable<
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
> = this.screenShares.pipe(
map((screenShares) =>
screenShares.length > 0
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
this.spotlightSpeaker.pipe(
switchMap((speaker) =>
speaker.local
? of(null)
: this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
),
),
] as const),
),
);
private readonly spotlight: Observable<MediaViewModel[]> = private readonly spotlight: Observable<MediaViewModel[]> =
this.spotlightAndPip.pipe( this.screenShares.pipe(
switchMap(([spotlight]) => spotlight), switchMap((screenShares) => {
if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm));
}
return this.spotlightSpeaker.pipe(
map((speaker) => (speaker ? [speaker] : [])),
);
}),
this.scope.state(), this.scope.state(),
); );
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares,
this.spotlightSpeaker,
this.mediaItems,
]).pipe(
switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) {
return this.spotlightSpeaker;
}
if (!spotlight || spotlight.local) {
return of(null);
}
const localUserMedia = mediaItems.find(
(m) => m.vm instanceof LocalUserMediaViewModel,
) as UserMedia | undefined;
const localUserMediaViewModel = localUserMedia?.vm as
| LocalUserMediaViewModel
| undefined;
if (!localUserMediaViewModel) {
return of(null);
}
return localUserMediaViewModel.alwaysShow.pipe(
map((alwaysShow) => {
if (alwaysShow) {
return localUserMediaViewModel;
}
return null;
}),
);
}),
this.scope.state(),
);
private readonly hasRemoteScreenShares: Observable<boolean> = private readonly hasRemoteScreenShares: Observable<boolean> =
this.spotlight.pipe( this.spotlight.pipe(
map((spotlight) => map((spotlight) =>
@@ -615,9 +700,6 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly pip: Observable<UserMediaViewModel | null> =
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe( private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe(
startWith(false), startWith(false),
); );
@@ -721,15 +803,16 @@ export class CallViewModel extends ViewModel {
this.mediaItems.pipe( this.mediaItems.pipe(
map((mediaItems) => { map((mediaItems) => {
if (mediaItems.length !== 2) return null; if (mediaItems.length !== 2) return null;
const local = mediaItems.find((vm) => vm.vm.local)! const local = mediaItems.find((vm) => vm.vm.local)?.vm as
.vm as LocalUserMediaViewModel; | LocalUserMediaViewModel
| undefined;
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
| RemoteUserMediaViewModel | RemoteUserMediaViewModel
| undefined; | undefined;
// There might not be a remote tile if there are screen shares, or if // There might not be a remote tile if there are screen shares, or if
// only the local user is in the call and they're using the duplicate // only the local user is in the call and they're using the duplicate
// tiles option // tiles option
if (remote === undefined) return null; if (!remote || !local) return null;
return { type: "one-on-one", local, remote }; return { type: "one-on-one", local, remote };
}), }),
@@ -1010,7 +1093,7 @@ 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 matrixRoom: MatrixRoom, private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState: Observable<ECConnectionState>,

View File

@@ -8,14 +8,17 @@ Please see LICENSE in the repository root for full details.
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { import {
mockRtcMembership,
withLocalMedia, withLocalMedia,
withRemoteMedia, withRemoteMedia,
withTestScheduler, withTestScheduler,
} from "../utils/test"; } from "../utils/test";
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
test("control a participant's volume", async () => { test("control a participant's volume", async () => {
const setVolumeSpy = vi.fn(); const setVolumeSpy = vi.fn();
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) => await withRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy }, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -60,7 +63,7 @@ test("control a participant's volume", async () => {
}); });
test("toggle fit/contain for a participant's video", async () => { test("toggle fit/contain for a participant's video", async () => {
await withRemoteMedia({}, {}, (vm) => await withRemoteMedia(rtcMembership, {}, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab|", { schedule("-ab|", {
a: () => vm.toggleFitContain(), a: () => vm.toggleFitContain(),
@@ -76,17 +79,21 @@ 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({}, (vm) => await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) }); schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: 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({}, (vm) => await withLocalMedia(
withTestScheduler(({ expectObservable, schedule }) => { rtcMembership,
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); {},
}), (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
}),
); );
}); });

View File

@@ -32,7 +32,6 @@ import {
Observable, Observable,
Subject, Subject,
combineLatest, combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
filter, filter,
fromEvent, fromEvent,
@@ -40,7 +39,6 @@ import {
map, map,
merge, merge,
of, of,
shareReplay,
startWith, startWith,
switchMap, switchMap,
throttleTime, throttleTime,
@@ -77,16 +75,24 @@ export function useDisplayName(vm: MediaViewModel): string {
} }
export function observeTrackReference( export function observeTrackReference(
participant: Participant, participant: Observable<Participant | undefined>,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> { ): Observable<TrackReferenceOrPlaceholder | undefined> {
return observeParticipantMedia(participant).pipe( return participant.pipe(
map(() => ({ switchMap((p) => {
participant, if (p) {
publication: participant.getTrackPublication(source), return observeParticipantMedia(p).pipe(
source, map(() => ({
})), participant: p,
distinctUntilKeyChanged("publication"), publication: p.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
);
} else {
return of(undefined);
}
}),
); );
} }
@@ -105,11 +111,11 @@ function observeRemoteTrackReceivingOkay(
}; };
return combineLatest([ return combineLatest([
observeTrackReference(participant, source), observeTrackReference(of(participant), source),
interval(1000).pipe(startWith(0)), interval(1000).pipe(startWith(0)),
]).pipe( ]).pipe(
switchMap(async ([trackReference]) => { switchMap(async ([trackReference]) => {
const track = trackReference.publication?.track; const track = trackReference?.publication?.track;
if (!track || !(track instanceof RemoteTrack)) { if (!track || !(track instanceof RemoteTrack)) {
return undefined; return undefined;
} }
@@ -200,14 +206,10 @@ export enum EncryptionStatus {
} }
abstract class BaseMediaViewModel extends ViewModel { abstract class BaseMediaViewModel extends ViewModel {
/**
* Whether the media belongs to the local user.
*/
public readonly local = this.participant.isLocal;
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: Observable<TrackReferenceOrPlaceholder>; public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -215,6 +217,11 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly encryptionStatus: Observable<EncryptionStatus>; public readonly encryptionStatus: Observable<EncryptionStatus>;
/**
* Whether this media corresponds to the local participant.
*/
public abstract readonly local: boolean;
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -226,7 +233,12 @@ abstract class BaseMediaViewModel extends ViewModel {
// TODO: Fully separate the data layer from the UI layer by keeping the // TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal // member object internal
public readonly member: RoomMember | undefined, public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit.
protected readonly participant: Observable<
LocalParticipant | RemoteParticipant | undefined
>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
@@ -243,69 +255,72 @@ abstract class BaseMediaViewModel extends ViewModel {
[audio, this.video], [audio, this.video],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe( ).pipe(this.scope.state());
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);
if (participant.isLocal || encryptionSystem.kind === E2eeType.NONE) { this.encryptionStatus = this.participant.pipe(
this.encryptionStatus = of(EncryptionStatus.Okay).pipe( switchMap((participant): Observable<EncryptionStatus> => {
this.scope.state(), if (!participant) {
); return of(EncryptionStatus.Connecting);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { } else if (
this.encryptionStatus = combineLatest([ participant.isLocal ||
encryptionErrorObservable( encryptionSystem.kind === E2eeType.NONE
livekitRoom, ) {
participant, return of(EncryptionStatus.Okay);
encryptionSystem, } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
"MissingKey", return combineLatest([
), encryptionErrorObservable(
encryptionErrorObservable( livekitRoom,
livekitRoom, participant,
participant, encryptionSystem,
encryptionSystem, "MissingKey",
"InvalidKey", ),
), encryptionErrorObservable(
observeRemoteTrackReceivingOkay(participant, audioSource), livekitRoom,
observeRemoteTrackReceivingOkay(participant, videoSource), participant,
]).pipe( encryptionSystem,
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { "InvalidKey",
if (keyMissing) return EncryptionStatus.KeyMissing; ),
if (keyInvalid) return EncryptionStatus.KeyInvalid; observeRemoteTrackReceivingOkay(participant, audioSource),
if (audioOkay || videoOkay) return EncryptionStatus.Okay; observeRemoteTrackReceivingOkay(participant, videoSource),
return undefined; // no change ]).pipe(
}), map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
filter((x) => !!x), if (keyMissing) return EncryptionStatus.KeyMissing;
startWith(EncryptionStatus.Connecting), if (keyInvalid) return EncryptionStatus.KeyInvalid;
this.scope.state(), if (audioOkay || videoOkay) return EncryptionStatus.Okay;
); return undefined; // no change
} else { }),
this.encryptionStatus = combineLatest([ filter((x) => !!x),
encryptionErrorObservable( startWith(EncryptionStatus.Connecting),
livekitRoom, );
participant, } else {
encryptionSystem, return combineLatest([
"InvalidKey", encryptionErrorObservable(
), livekitRoom,
observeRemoteTrackReceivingOkay(participant, audioSource), participant,
observeRemoteTrackReceivingOkay(participant, videoSource), encryptionSystem,
]).pipe( "InvalidKey",
map( ),
([keyInvalid, audioOkay, videoOkay]): observeRemoteTrackReceivingOkay(participant, audioSource),
| EncryptionStatus observeRemoteTrackReceivingOkay(participant, videoSource),
| undefined => { ]).pipe(
if (keyInvalid) return EncryptionStatus.PasswordInvalid; map(
if (audioOkay || videoOkay) return EncryptionStatus.Okay; ([keyInvalid, audioOkay, videoOkay]):
return undefined; // no change | EncryptionStatus
}, | undefined => {
), if (keyInvalid) return EncryptionStatus.PasswordInvalid;
filter((x) => !!x), if (audioOkay || videoOkay) return EncryptionStatus.Okay;
startWith(EncryptionStatus.Connecting), return undefined; // no change
this.scope.state(), },
); ),
} filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
);
}
}),
this.scope.state(),
);
} }
} }
@@ -324,11 +339,14 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = observeParticipantEvents( public readonly speaking = this.participant.pipe(
this.participant, switchMap((p) =>
ParticipantEvent.IsSpeakingChanged, p
).pipe( ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
map((p) => p.isSpeaking), map((p) => p.isSpeaking),
)
: of(false),
),
this.scope.state(), this.scope.state(),
); );
@@ -350,7 +368,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
@@ -364,18 +382,25 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
livekitRoom, livekitRoom,
); );
const media = observeParticipantMedia(participant).pipe(this.scope.state()); const media = participant.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(),
);
this.audioEnabled = media.pipe( this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false), map((m) => m?.microphoneTrack?.isMuted === false),
); );
this.videoEnabled = media.pipe( this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false), map((m) => m?.cameraTrack?.isMuted === false),
); );
} }
public toggleFitContain(): void { public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value); this._cropVideo.next(!this._cropVideo.value);
} }
public get local(): boolean {
return this instanceof LocalUserMediaViewModel;
}
} }
/** /**
@@ -387,7 +412,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
*/ */
public readonly mirror = this.video.pipe( public readonly mirror = 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 LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch // Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe( return fromEvent(track, TrackEvent.Restarted).pipe(
@@ -409,7 +434,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant, participant: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
@@ -470,18 +495,17 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: RemoteParticipant, participant: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(id, member, participant, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
this.localVolume combineLatest([
.pipe(this.scope.bind()) participant,
.subscribe((volume) => this.localVolume.pipe(this.scope.bind()),
(this.participant as RemoteParticipant).setVolume(volume), ]).subscribe(([p, volume]) => p && p.setVolume(volume));
);
} }
public toggleLocallyMuted(): void { public toggleLocallyMuted(): void {
@@ -504,9 +528,10 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly local: boolean,
) { ) {
super( super(
id, id,

View File

@@ -13,7 +13,7 @@ import { of } from "rxjs";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
import { withRemoteMedia } from "../utils/test"; import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsProvider } from "../useReactions"; import { ReactionsProvider } from "../useReactions";
@@ -25,6 +25,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
test("GridTile is accessible", async () => { test("GridTile is accessible", async () => {
await withRemoteMedia( await withRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{ {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",

View File

@@ -175,6 +175,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
raisedHandTime={handRaised} raisedHandTime={handRaised}
currentReaction={currentReaction} currentReaction={currentReaction}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
{...props} {...props}
/> />
); );

View File

@@ -15,7 +15,7 @@ Please see LICENSE in the repository root for full details.
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
object-fit: contain; object-fit: contain;
background-color: var(--cpd-color-bg-subtle-primary); background-color: var(--video-tile-background);
/* This transform is a no-op, but it forces Firefox to use a different /* This transform is a no-op, but it forces Firefox to use a different
rendering path, one that actually clips the corners of <video> elements into rendering path, one that actually clips the corners of <video> elements into
the intended rounded shape. We can remove this if Firefox stops being broken. */ the intended rounded shape. We can remove this if Firefox stops being broken. */
@@ -35,7 +35,7 @@ Please see LICENSE in the repository root for full details.
} }
.bg { .bg {
background-color: var(--cpd-color-bg-subtle-secondary); background-color: var(--video-tile-background);
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
border-radius: inherit; border-radius: inherit;
@@ -74,9 +74,9 @@ unconditionally select the container so we can use cqmin units */
calc(var(--media-view-border-radius) - var(--cpd-space-3x)) calc(var(--media-view-border-radius) - var(--cpd-space-3x))
); );
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 30px 1fr 30px;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: "status status" "nameTag button"; grid-template-areas: "reactions status ." "nameTag nameTag button";
gap: var(--cpd-space-1x); gap: var(--cpd-space-1x);
place-items: start; place-items: start;
} }
@@ -101,8 +101,8 @@ unconditionally select the container so we can use cqmin units */
grid-area: status; grid-area: status;
justify-self: center; justify-self: center;
align-self: start; align-self: start;
padding: var(--cpd-space-1x); padding: var(--cpd-space-2x);
padding-block: var(--cpd-space-1x); padding-block: var(--cpd-space-2x);
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
background-color: var(--cpd-color-bg-canvas-default); background-color: var(--cpd-color-bg-canvas-default);
display: flex; display: flex;
@@ -116,6 +116,12 @@ unconditionally select the container so we can use cqmin units */
text-align: center; text-align: center;
} }
.reactions {
grid-area: reactions;
display: flex;
gap: var(--cpd-space-1x);
}
.nameTag > svg, .nameTag > svg,
.nameTag > span { .nameTag > span {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, test } from "vitest"; import { describe, expect, it, test } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -42,6 +42,7 @@ describe("MediaView", () => {
unencryptedWarning: false, unencryptedWarning: false,
video: trackReference, video: trackReference,
member: undefined, member: undefined,
localParticipant: false,
}; };
test("is accessible", async () => { test("is accessible", async () => {
@@ -59,6 +60,25 @@ describe("MediaView", () => {
}); });
}); });
describe("with no participant", () => {
it("shows avatar for local user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
);
expect(screen.getByRole("img", { name: "some name" })).toBeVisible();
expect(screen.queryAllByText("video_tile.waiting_for_media").length).toBe(
0,
);
});
it("shows avatar and label for remote user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
);
expect(screen.getByRole("img", { name: "some name" })).toBeVisible();
expect(screen.getByText("video_tile.waiting_for_media")).toBeVisible();
});
});
describe("name tag", () => { describe("name tag", () => {
test("is shown with name", () => { test("is shown with name", () => {
render(<MediaView {...baseProps} displayName="Bob" />); render(<MediaView {...baseProps} displayName="Bob" />);

View File

@@ -28,7 +28,7 @@ interface Props extends ComponentProps<typeof animated.div> {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder | undefined;
videoFit: "cover" | "contain"; videoFit: "cover" | "contain";
mirror: boolean; mirror: boolean;
member: RoomMember | undefined; member: RoomMember | undefined;
@@ -41,6 +41,7 @@ interface Props extends ComponentProps<typeof animated.div> {
raisedHandTime?: Date; raisedHandTime?: Date;
currentReaction?: ReactionOption; currentReaction?: ReactionOption;
raisedHandOnClick?: () => void; raisedHandOnClick?: () => void;
localParticipant: boolean;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -63,6 +64,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
raisedHandTime, raisedHandTime,
currentReaction, currentReaction,
raisedHandOnClick, raisedHandOnClick,
localParticipant,
...props ...props
}, },
ref, ref,
@@ -90,21 +92,21 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
size={avatarSize} size={avatarSize}
src={member?.getMxcAvatarUrl()} src={member?.getMxcAvatarUrl()}
className={styles.avatar} className={styles.avatar}
style={{ display: videoEnabled ? "none" : "initial" }} style={{ display: video && videoEnabled ? "none" : "initial" }}
/> />
{video.publication !== undefined && ( {video?.publication !== undefined && (
<VideoTrack <VideoTrack
trackRef={video} trackRef={video}
// There's no reason for this to be focusable // There's no reason for this to be focusable
tabIndex={-1} tabIndex={-1}
disablePictureInPicture disablePictureInPicture
style={{ display: videoEnabled ? "block" : "none" }} style={{ display: video && videoEnabled ? "block" : "none" }}
data-testid="video" data-testid="video"
/> />
)} )}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
<div style={{ display: "flex", gap: "var(--cpd-space-1x)" }}> <div className={styles.reactions}>
<RaisedHandIndicator <RaisedHandIndicator
raisedHandTime={raisedHandTime} raisedHandTime={raisedHandTime}
miniature={avatarSize < 96} miniature={avatarSize < 96}
@@ -118,6 +120,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
/> />
)} )}
</div> </div>
{!video && !localParticipant && (
<div className={styles.status}>
{t("video_tile.waiting_for_media")}
</div>
)}
{/* TODO: Bring this back once encryption status is less broken */} {/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && ( {/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}> <div className={styles.status}>

View File

@@ -12,7 +12,11 @@ import userEvent from "@testing-library/user-event";
import { of } from "rxjs"; import { of } from "rxjs";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { withLocalMedia, withRemoteMedia } from "../utils/test"; import {
mockRtcMembership,
withLocalMedia,
withRemoteMedia,
} from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
@@ -22,6 +26,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
test("SpotlightTile is accessible", async () => { test("SpotlightTile is accessible", async () => {
await withRemoteMedia( await withRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{ {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
@@ -29,6 +34,7 @@ test("SpotlightTile is accessible", async () => {
{}, {},
async (vm1) => { async (vm1) => {
await withLocalMedia( await withLocalMedia(
mockRtcMembership("@bob:example.org", "BBBB"),
{ {
rawDisplayName: "Bob", rawDisplayName: "Bob",
getMxcAvatarUrl: () => "mxc://dlskf", getMxcAvatarUrl: () => "mxc://dlskf",

View File

@@ -49,12 +49,13 @@ interface SpotlightItemBaseProps {
"data-id": string; "data-id": string;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder | undefined;
member: RoomMember | undefined; member: RoomMember | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus; encryptionStatus: EncryptionStatus;
displayName: string; displayName: string;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
localParticipant: boolean;
} }
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -163,6 +164,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
displayName, displayName,
encryptionStatus, encryptionStatus,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
localParticipant: vm.local,
}; };
return vm instanceof ScreenShareViewModel ? ( return vm instanceof ScreenShareViewModel ? (
@@ -210,7 +212,9 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised); const maximised = useObservableEagerState(vm.maximised);
const media = useObservableEagerState(vm.media); const media = useObservableEagerState(vm.media);
const [visibleId, setVisibleId] = useState(media[0].id); const [visibleId, setVisibleId] = useState<string | undefined>(
media[0]?.id,
);
const latestMedia = useLatest(media); const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId);

View File

@@ -15,11 +15,13 @@ import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
import { useAudioContext } from "./useAudioContext"; import { useAudioContext } from "./useAudioContext";
import { soundEffectVolumeSetting } from "./settings/settings"; import { soundEffectVolumeSetting } from "./settings/settings";
const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0),
});
const TestComponent: FC = () => { const TestComponent: FC = () => {
const audioCtx = useAudioContext({ const audioCtx = useAudioContext({
sounds: Promise.resolve({ sounds: staticSounds,
aSound: new ArrayBuffer(0),
}),
latencyHint: "balanced", latencyHint: "balanced",
}); });
if (!audioCtx) { if (!audioCtx) {
@@ -27,11 +29,9 @@ const TestComponent: FC = () => {
} }
return ( return (
<> <>
<button onClick={() => void audioCtx.playSound("aSound")}> <button onClick={() => audioCtx.playSound("aSound")}>Valid sound</button>
Valid sound
</button>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any*/} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any*/}
<button onClick={() => void audioCtx.playSound("not-valid" as any)}> <button onClick={() => audioCtx.playSound("not-valid" as any)}>
Invalid sound Invalid sound
</button> </button>
</> </>
@@ -61,7 +61,7 @@ class MockAudioContext {
vitest.mocked({ vitest.mocked({
connect: (v: unknown) => v, connect: (v: unknown) => v,
start: () => {}, start: () => {},
addEventListener: (_eventType: string, cb: () => void) => cb(), addEventListener: (_name: string, cb: () => void) => cb(),
}), }),
); );
public createGain = vitest.fn().mockReturnValue(this.gain); public createGain = vitest.fn().mockReturnValue(this.gain);

View File

@@ -13,9 +13,7 @@ import {
useSetting, useSetting,
} from "./settings/settings"; } from "./settings/settings";
import { useMediaDevices } from "./livekit/MediaDevicesContext"; import { useMediaDevices } from "./livekit/MediaDevicesContext";
import { useInitial } from "./useInitial"; import { PrefetchedSounds } from "./soundUtils";
type SoundDefinition = { mp3?: string; ogg: string };
/** /**
* Play a sound though a given AudioContext. Will take * Play a sound though a given AudioContext. Will take
@@ -41,61 +39,13 @@ async function playSound(
return p; return p;
} }
/**
* Determine the best format we can use to play our sounds
* through. We prefer ogg support if possible, but will fall
* back to MP3.
* @returns "ogg" if the browser is likely to support it, or "mp3" otherwise.
*/
function getPreferredAudioFormat(): "ogg" | "mp3" {
const a = document.createElement("audio");
if (a.canPlayType("audio/ogg") === "maybe") {
return "ogg";
}
// Otherwise just assume MP3, as that has a chance of being more widely supported.
return "mp3";
}
type PrefetchedSounds<S extends string> = Promise<Record<S, ArrayBuffer>>;
// We prefer to load these sounds ahead of time, so there
// is no delay on call join.
const PreferredFormat = getPreferredAudioFormat();
/**
* Prefetch sounds to be used by the AudioContext. This should
* be called outside the scope of a component to ensure the
* sounds load ahead of time.
* @param sounds A set of sound files that may be played.
* @returns A map of sound files to buffers.
*/
export async function prefetchSounds<S extends string>(
sounds: Record<S, SoundDefinition>,
): PrefetchedSounds<S> {
const buffers: Record<string, ArrayBuffer> = {};
await Promise.all(
Object.entries(sounds).map(async ([name, file]) => {
const { mp3, ogg } = file as SoundDefinition;
// Use preferred format, fallback to ogg if no mp3 is provided.
// Load an audio file
const response = await fetch(
PreferredFormat === "ogg" ? ogg : (mp3 ?? ogg),
);
if (!response.ok) {
// If the sound doesn't load, it's not the end of the world. We won't play
// the sound when requested, but it's better than failing the whole application.
logger.warn(`Could not load sound ${name}, resposne was not okay`);
return;
}
// Decode it
buffers[name] = await response.arrayBuffer();
}),
);
return buffers as Record<S, ArrayBuffer>;
}
interface Props<S extends string> { interface Props<S extends string> {
sounds: PrefetchedSounds<S>; /**
* The sounds to play. If no sounds should be played then
* this can be set to null, which will prevent the audio
* context from being created.
*/
sounds: PrefetchedSounds<S> | null;
latencyHint: AudioContextLatencyCategory; latencyHint: AudioContextLatencyCategory;
} }
@@ -116,9 +66,12 @@ export function useAudioContext<S extends string>(
const devices = useMediaDevices(); const devices = useMediaDevices();
const [audioContext, setAudioContext] = useState<AudioContext>(); const [audioContext, setAudioContext] = useState<AudioContext>();
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>(); const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
const soundCache = useInitial(async () => props.sounds);
useEffect(() => { useEffect(() => {
const sounds = props.sounds;
if (!sounds) {
return;
}
const ctx = new AudioContext({ const ctx = new AudioContext({
// We want low latency for these effects. // We want low latency for these effects.
latencyHint: props.latencyHint, latencyHint: props.latencyHint,
@@ -129,11 +82,8 @@ export function useAudioContext<S extends string>(
// close during this process, so it's okay if it throws. // close during this process, so it's okay if it throws.
(async (): Promise<void> => { (async (): Promise<void> => {
const buffers: Record<string, AudioBuffer> = {}; const buffers: Record<string, AudioBuffer> = {};
for (const [name, buffer] of Object.entries(await soundCache)) { for (const [name, buffer] of Object.entries<ArrayBuffer>(await sounds)) {
const audioBuffer = await ctx.decodeAudioData( const audioBuffer = await ctx.decodeAudioData(buffer.slice(0));
// Type quirk, this is *definitely* a ArrayBuffer.
(buffer as ArrayBuffer).slice(0),
);
buffers[name] = audioBuffer; buffers[name] = audioBuffer;
} }
setAudioBuffers(buffers as Record<S, AudioBuffer>); setAudioBuffers(buffers as Record<S, AudioBuffer>);
@@ -148,7 +98,7 @@ export function useAudioContext<S extends string>(
}); });
setAudioContext(undefined); setAudioContext(undefined);
}; };
}, [soundCache, props.latencyHint]); }, [props.sounds, props.latencyHint]);
// Update the sink ID whenever we change devices. // Update the sink ID whenever we change devices.
useEffect(() => { useEffect(() => {

View File

@@ -333,15 +333,3 @@ export function getRelativeRoomUrl(
: ""; : "";
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`; return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
} }
export function getAvatarUrl(
client: MatrixClient,
mxcUrl: string,
avatarSize = 96,
): string {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
// scale is more suitable for larger sizes
const resizeMethod = avatarSize <= 96 ? "crop" : "scale";
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, resizeMethod)!;
}

View File

@@ -7,16 +7,27 @@ Please see LICENSE in the repository root for full details.
import { map, Observable, of, SchedulerLike } from "rxjs"; import { map, Observable, of, SchedulerLike } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix"; import {
RoomMember,
Room as MatrixRoom,
MatrixEvent,
Room,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import {
CallMembership,
Focus,
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap,
SessionMembershipData,
} from "matrix-js-sdk/src/matrixrtc";
import { import {
LocalParticipant, LocalParticipant,
LocalTrackPublication, LocalTrackPublication,
RemoteParticipant, RemoteParticipant,
RemoteTrackPublication, RemoteTrackPublication,
Room as LivekitRoom, Room as LivekitRoom,
RoomEvent,
} from "livekit-client"; } from "livekit-client";
import { EventEmitter } from "stream";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
@@ -100,48 +111,46 @@ function mockEmitter<T>(): EmitterMock<T> {
}; };
} }
export function mockRtcMembership(
user: string | RoomMember,
deviceId: string,
callId = "",
fociPreferred: Focus[] = [],
focusActive: Focus = { type: "oldest_membership" },
membership: Partial<SessionMembershipData> = {},
): CallMembership {
const data: SessionMembershipData = {
application: "m.call",
call_id: callId,
device_id: deviceId,
foci_preferred: fociPreferred,
focus_active: focusActive,
...membership,
};
const event = new MatrixEvent({
sender: typeof user === "string" ? user : user.userId,
});
return new CallMembership(event, data);
}
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are // Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe // rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general. // it's useful for matrix-js-sdk consumers in general.
export function mockMatrixRoomMember(member: Partial<RoomMember>): RoomMember { export function mockMatrixRoomMember(
return { ...mockEmitter(), ...member } as RoomMember; rtcMembership: CallMembership,
member: Partial<RoomMember> = {},
): RoomMember {
return {
...mockEmitter(),
userId: rtcMembership.sender,
...member,
} as RoomMember;
} }
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom { export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom; return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
} }
/**
* A mock of a Livekit Room that can emit events.
*/
export class EmittableMockLivekitRoom extends EventEmitter {
public localParticipant?: LocalParticipant;
public remoteParticipants: Map<string, RemoteParticipant>;
public constructor(room: {
localParticipant?: LocalParticipant;
remoteParticipants: Map<string, RemoteParticipant>;
}) {
super();
this.localParticipant = room.localParticipant;
this.remoteParticipants = room.remoteParticipants ?? new Map();
}
public addParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.set(remoteParticipant.identity, remoteParticipant);
this.emit(RoomEvent.ParticipantConnected, remoteParticipant);
}
public removeParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.delete(remoteParticipant.identity);
this.emit(RoomEvent.ParticipantDisconnected, remoteParticipant);
}
public getAsLivekitRoom(): LivekitRoom {
return this as unknown as LivekitRoom;
}
}
export function mockLivekitRoom( export function mockLivekitRoom(
room: Partial<LivekitRoom>, room: Partial<LivekitRoom>,
{ {
@@ -178,14 +187,15 @@ export function mockLocalParticipant(
} }
export async function withLocalMedia( export async function withLocalMedia(
member: Partial<RoomMember>, localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>, continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const localParticipant = mockLocalParticipant({}); const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMatrixRoomMember(member), mockMatrixRoomMember(localRtcMember, roomMember),
localParticipant, of(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@@ -212,15 +222,16 @@ export function mockRemoteParticipant(
} }
export async function withRemoteMedia( export async function withRemoteMedia(
member: Partial<RoomMember>, localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: Partial<RemoteParticipant>,
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>, continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const remoteParticipant = mockRemoteParticipant(participant); const remoteParticipant = mockRemoteParticipant(participant);
const vm = new RemoteUserMediaViewModel( const vm = new RemoteUserMediaViewModel(
"remote", "remote",
mockMatrixRoomMember(member), mockMatrixRoomMember(localRtcMember, roomMember),
remoteParticipant, of(remoteParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@@ -239,3 +250,30 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
...config, ...config,
}); });
} }
export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap
> {
public constructor(
public readonly room: Room,
private localMembership: CallMembership,
public memberships: CallMembership[] = [],
) {
super();
}
public withMemberships(
rtcMembers: Observable<Partial<CallMembership>[]>,
): 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);
});
return this;
}
}

View File

@@ -32,7 +32,7 @@ export const TestReactionsWrapper = ({
rtcSession, rtcSession,
children, children,
}: PropsWithChildren<{ }: PropsWithChildren<{
rtcSession: MockRTCSession; rtcSession: MockRTCSession | MatrixRTCSession;
}>): ReactNode => { }>): ReactNode => {
return ( return (
<ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}> <ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>

View File

@@ -17,20 +17,20 @@ import "vitest-axe/extend-expect";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import EN_GB from "../locales/en-GB/app.json"; import EN from "../locales/en/app.json";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
// Bare-minimum i18n config // Bare-minimum i18n config
i18n i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: "en-GB", lng: "en",
fallbackLng: "en-GB", fallbackLng: "en",
supportedLngs: ["en-GB"], supportedLngs: ["en"],
// We embed the translations, so that it never needs to fetch // We embed the translations, so that it never needs to fetch
resources: { resources: {
"en-GB": { en: {
app: EN_GB, app: EN,
}, },
}, },
interpolation: { interpolation: {

View File

@@ -3051,12 +3051,12 @@
dependencies: dependencies:
undici-types "~6.19.8" undici-types "~6.19.8"
"@types/node@^20.0.0": "@types/node@^22.0.0":
version "20.17.9" version "22.10.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.9.tgz#5f141d4b7ee125cdee5faefe28de095398865bab" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766"
integrity sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw== integrity sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==
dependencies: dependencies:
undici-types "~6.19.2" undici-types "~6.20.0"
"@types/normalize-package-data@^2.4.0": "@types/normalize-package-data@^2.4.0":
version "2.4.4" version "2.4.4"
@@ -3307,12 +3307,10 @@
dependencies: dependencies:
"@use-gesture/core" "10.3.1" "@use-gesture/core" "10.3.1"
"@vector-im/compound-design-tokens@^1.9.1": "@vector-im/compound-design-tokens@^2.0.0":
version "1.9.2" version "2.1.1"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.9.2.tgz#0b76e5475da3bc36443f7dc87951b937b5013d6f" resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-2.1.1.tgz#d6175a99fe4b97688464126f255386990f3048d6"
integrity sha512-gQmK4dHR2iws3ZskDv8Il6A4/rvQV7TPSmEOXLsahDhBTInWqexXeQnNRSt9Z5DsLPrkxL3/KoCt9lfYu/yiag== integrity sha512-QnUi2K14D9KTXxcLQKUU3V75cforZLMwhaaJDNftT8F5mG86950hAM+qhgDNEpEU+pkTffQj0/g/5859YmqWzQ==
dependencies:
prettier "^3.3.3"
"@vector-im/compound-web@^7.2.0": "@vector-im/compound-web@^7.2.0":
version "7.4.0" version "7.4.0"
@@ -7007,7 +7005,7 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.0.0, prettier@^3.3.3: prettier@^3.0.0:
version "3.4.1" version "3.4.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.1.tgz#e211d451d6452db0a291672ca9154bc8c2579f7b" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.1.tgz#e211d451d6452db0a291672ca9154bc8c2579f7b"
integrity sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg== integrity sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==
@@ -8262,11 +8260,16 @@ underscore.string@~3.3.4:
sprintf-js "^1.1.1" sprintf-js "^1.1.1"
util-deprecate "^1.0.2" util-deprecate "^1.0.2"
undici-types@~6.19.2, undici-types@~6.19.8: undici-types@~6.19.8:
version "6.19.8" version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
undici-types@~6.20.0:
version "6.20.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
undici@^5.25.4: undici@^5.25.4:
version "5.28.4" version "5.28.4"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"
@@ -8411,9 +8414,9 @@ value-or-function@^4.0.0:
integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg== integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==
vaul@^1.0.0: vaul@^1.0.0:
version "1.1.0" version "1.1.1"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.0.tgz#7da4bc965e0b184ada632f1208096b0f5575d920" resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.1.tgz#93aceaad16f7c53aacf28a2609b2dd43b5a91fa0"
integrity sha512-YhO/bikcauk48hzhMhvIvT+U87cuCbNbKk9fF4Ou5UkI9t2KkBMernmdP37pCzF15hrv55fcny1YhexK8h6GVQ== integrity sha512-+ejzF6ffQKPcfgS7uOrGn017g39F8SO4yLPXbBhpC7a0H+oPqPna8f1BUfXaz8eU4+pxbQcmjxW+jWBSbxjaFg==
dependencies: dependencies:
"@radix-ui/react-dialog" "^1.1.1" "@radix-ui/react-dialog" "^1.1.1"