diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index c85cddf5..8d4233a1 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -42,7 +42,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: ${{ inputs.docker_tags}} @@ -51,7 +51,7 @@ jobs: uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 - 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: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/element-call.yaml b/.github/workflows/element-call.yaml index 7924140d..a424fb74 100644 --- a/.github/workflows/element-call.yaml +++ b/.github/workflows/element-call.yaml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" - node-version: "lts/*" + node-version-file: ".node-version" - name: Install dependencies run: "yarn install" - name: Build diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 69493ff6..d9367626 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -12,7 +12,7 @@ jobs: uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" - node-version: "lts/*" + node-version-file: ".node-version" - name: Install dependencies run: "yarn install" - name: Prettier diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b63eb283..a1c7f232 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,7 +14,7 @@ jobs: uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" - node-version: "lts/*" + node-version-file: ".node-version" - name: Install dependencies run: "yarn install" - name: Vitest diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 7359f781..30ce6ff9 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" - node-version: "lts/*" + node-version-file: ".node-version" - name: Install Deps run: "yarn install --frozen-lockfile" diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index a0af77fc..ffd73d5e 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ work: experimental_features: # MSC3266: Room summary API. Used for knocking over federation 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 # 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 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 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 @@ -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. 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 { ... @@ -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: ```jsonc diff --git a/backend/dev_homeserver.yaml b/backend/dev_homeserver.yaml index b41de45b..5697c32e 100644 --- a/backend/dev_homeserver.yaml +++ b/backend/dev_homeserver.yaml @@ -25,6 +25,9 @@ trusted_key_servers: experimental_features: # MSC3266: Room summary API. Used for knocking over federation 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 # per MSC4140. Must be a positive value if set. Defaults to no diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index 7d71d727..3acf2b5e 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -21,7 +21,7 @@ export default { }, ], }, - locales: ["en-GB"], + locales: ["en"], output: "locales/$LOCALE/$NAMESPACE.json", input: ["src/**/*.{ts,tsx}"], sort: true, diff --git a/localazy.json b/localazy.json index 2b9f713c..823e4a3e 100644 --- a/localazy.json +++ b/localazy.json @@ -7,13 +7,13 @@ "features": ["plural_postfix_us", "filter_untranslated"], "files": [ { - "pattern": "locales/en-GB/*.json", + "pattern": "locales/en/*.json", "lang": "inherited" }, { "group": "existing", "pattern": "locales/*/*.json", - "excludes": ["locales/en-GB/*.json"], + "excludes": ["locales/en/*.json"], "lang": "${autodetectLang}" } ] @@ -25,9 +25,6 @@ "output": "locales/${langLsrDash}/${file}" } ], - "includeSourceLang": "${includeSourceLang|false}", - "langAliases": { - "en": "en_GB" - } + "includeSourceLang": "${includeSourceLang|false}" } } diff --git a/locales/de/app.json b/locales/de/app.json index 7c465406..828fe3e0 100644 --- a/locales/de/app.json +++ b/locales/de/app.json @@ -68,8 +68,13 @@ "username": "Benutzername", "video": "Video" }, - "crypto_version": "Krypto-Version:{{version}}", - "device_id": "Geräte-ID: {{id}}", + "developer_mode": { + "crypto_version": "Krypto-Version: {{version}}", + "device_id": "Geräte-ID: {{id}}", + "duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer", + "hostname": "Hostname: {{hostname}}", + "matrix_id": "Matrix-ID: {{id}}" + }, "disconnected_banner": "Die Verbindung zum Server wurde getrennt.", "full_screen_view_description": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.", "full_screen_view_h1": "<0>Hoppla, etwas ist schiefgelaufen.", @@ -111,7 +116,6 @@ "login_auth_links_prompt": "Noch nicht registriert?", "login_subheading": "Weiter zu Element", "login_title": "Anmelden", - "matrix_id": "Matrix-ID: {{id}}", "microphone_off": "Mikrofon aus", "microphone_on": "Mikrofon an", "mute_microphone_button_label": "Mikrofon stumm schalten", @@ -149,7 +153,6 @@ "developer_settings_label": "Entwicklereinstellungen", "developer_settings_label_description": "Zeige die Entwicklereinstellungen im Einstellungsfenster.", "developer_tab_title": "Entwickler", - "duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer", "feedback_tab_body": "Falls du auf Probleme stößt oder einfach nur eine Rückmeldung geben möchtest, sende uns bitte eine kurze Beschreibung.", "feedback_tab_description_label": "Deine Rückmeldung", "feedback_tab_h4": "Rückmeldung geben", @@ -191,6 +194,7 @@ "expand": "Erweitern", "mute_for_me": "Für mich stumm schalten", "muted_for_me": "Für mich stumm geschaltet", - "volume": "Lautstärke" + "volume": "Lautstärke", + "waiting_for_media": "Warten auf Medien..." } } diff --git a/locales/en-GB/app.json b/locales/en/app.json similarity index 96% rename from locales/en-GB/app.json rename to locales/en/app.json index 6340d160..e500f66c 100644 --- a/locales/en-GB/app.json +++ b/locales/en/app.json @@ -68,8 +68,13 @@ "username": "Username", "video": "Video" }, - "crypto_version": "Crypto version: {{version}}", - "device_id": "Device ID: {{id}}", + "developer_mode": { + "crypto_version": "Crypto version: {{version}}", + "device_id": "Device ID: {{id}}", + "duplicate_tiles_label": "Number of additional tile copies per participant", + "hostname": "Hostname: {{hostname}}", + "matrix_id": "Matrix ID: {{id}}" + }, "disconnected_banner": "Connectivity to the server has been lost.", "full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.", "full_screen_view_h1": "<0>Oops, something's gone wrong.", @@ -111,7 +116,6 @@ "login_auth_links_prompt": "Not registered yet?", "login_subheading": "To continue to Element", "login_title": "Login", - "matrix_id": "Matrix ID: {{id}}", "microphone_off": "Microphone off", "microphone_on": "Microphone on", "mute_microphone_button_label": "Mute microphone", @@ -149,7 +153,6 @@ "developer_settings_label": "Developer Settings", "developer_settings_label_description": "Expose developer settings in the settings window.", "developer_tab_title": "Developer", - "duplicate_tiles_label": "Number of additional tile copies per participant", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_description_label": "Your feedback", "feedback_tab_h4": "Submit feedback", @@ -191,6 +194,7 @@ "expand": "Expand", "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", - "volume": "Volume" + "volume": "Volume", + "waiting_for_media": "Waiting for media..." } } diff --git a/locales/ro/app.json b/locales/ro/app.json new file mode 100644 index 00000000..ab55c511 --- /dev/null +++ b/locales/ro/app.json @@ -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 și Politica noastră <6> privind cookie-urile.", + "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? <1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare ", + "feedback_done": "<0>Vă mulțumim pentru feedback! ", + "feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. ", + "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. ", + "full_screen_view_h1": "<0>Hopa, ceva nu a mers bine. ", + "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 sau <2> accesați ca invitat ", + "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 și <6> Termenii și condițiile. <9>Făcând clic pe „Înregistrare”, sunteți de acord cu Acordul nostru de licențiere pentru utilizatorul <12> final (EULA) ", + "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? <1><0>Conectați-vă sau <2> accesați ca invitat ", + "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) ", + "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><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 ", + "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) ", + "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" + } +} diff --git a/package.json b/package.json index ebaa59ff..71e3f9c9 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jsdom": "^21.1.7", "@types/lodash-es": "^4.17.12", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", "@types/react-dom": "^18.3.0", @@ -63,7 +63,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@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", "@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-react": "^4.0.1", diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts index 4a8830da..3c65e620 100644 --- a/src/@types/i18next.d.ts +++ b/src/@types/i18next.d.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import "i18next"; // 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" { interface CustomTypeOptions { diff --git a/src/TranslatedError.ts b/src/TranslatedError.ts index 0dbe675a..420556be 100644 --- a/src/TranslatedError.ts +++ b/src/TranslatedError.ts @@ -22,7 +22,7 @@ export abstract class TranslatedError extends Error { messageKey: ParseKeys, translationFn: TFunction, ) { - super(translationFn(messageKey, { lng: "en-GB" } as TOptions)); + super(translationFn(messageKey, { lng: "en" } as TOptions)); this.translatedMessage = translationFn(messageKey); } } diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index ed4d5bce..3947ba66 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -51,7 +51,7 @@ export interface ConfigOptions { // a livekit service url in the client well-known. // The well known needs to be formatted like so: // {"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; }; diff --git a/src/index.css b/src/index.css index bf6d1605..aeeccaf4 100644 --- a/src/index.css +++ b/src/index.css @@ -47,6 +47,11 @@ layer(compound); --background-gradient: url("graphics/backgroundGradient.svg"); } +:root, +[class*="cpd-theme-"] { + --video-tile-background: var(--cpd-color-bg-subtle-secondary); +} + .cpd-theme-dark { --cpd-color-border-accent: var(--cpd-color-green-1100); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); diff --git a/src/initializer.tsx b/src/initializer.tsx index 47634078..e9290504 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -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: // { -// "../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("../locales/*/*.json", { @@ -41,7 +41,7 @@ const getLocaleUrl = ( const supportedLngs = [ ...new Set( 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 const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; if (!lang) { @@ -133,7 +133,7 @@ export class Initializer { .use(languageDetector) .use(initReactI18next) .init({ - fallbackLng: "en-GB", + fallbackLng: "en", defaultNS: "app", keySeparator: ".", nsSeparator: false, diff --git a/src/input/Input.tsx b/src/input/Input.tsx index cf2902cd..77003e5a 100644 --- a/src/input/Input.tsx +++ b/src/input/Input.tsx @@ -73,6 +73,7 @@ interface InputFieldProps { defaultValue?: string; placeholder?: string; defaultChecked?: boolean; + min?: number; onChange?: (event: ChangeEvent) => void; } @@ -91,6 +92,7 @@ export const InputField = forwardRef< suffix, description, disabled, + min, ...rest }, ref, @@ -127,6 +129,7 @@ export const InputField = forwardRef< checked={checked} disabled={disabled} aria-describedby={descriptionId} + min={min} {...rest} /> )} diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx new file mode 100644 index 00000000..78d08f60 --- /dev/null +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -0,0 +1,295 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { render } from "@testing-library/react"; +import { beforeEach, expect, test } from "vitest"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ConnectionState, Room } from "livekit-client"; +import { BehaviorSubject, of } from "rxjs"; +import { afterEach } from "node:test"; +import { act } from "react"; +import { + CallMembership, + type MatrixRTCSession, +} from "matrix-js-sdk/src/matrixrtc"; + +import { soundEffectVolumeSetting } from "../settings/settings"; +import { + EmittableMockLivekitRoom, + mockLivekitRoom, + mockLocalParticipant, + mockMatrixRoom, + mockMatrixRoomMember, + mockMediaPlay, + mockRemoteParticipant, + mockRtcMembership, + MockRTCSession, +} from "../utils/test"; +import { E2eeType } from "../e2ee/e2eeType"; +import { CallViewModel } from "../state/CallViewModel"; +import { + CallEventAudioRenderer, + MAX_PARTICIPANT_COUNT_FOR_SOUND, +} from "./CallEventAudioRenderer"; + +const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); +const local = mockMatrixRoomMember(localRtcMember); +const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); +const alice = mockMatrixRoomMember(aliceRtcMember); +const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); +const bob = mockMatrixRoomMember(bobRtcMember); +const localParticipant = mockLocalParticipant({ identity: "" }); +const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`; +const bobId = `${bob.userId}:${bobRtcMember.deviceId}`; +const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); +const bobParticipant = mockRemoteParticipant({ identity: bobId }); + +const originalPlayFn = window.HTMLMediaElement.prototype.play; + +const enterSound = "http://localhost:3000/src/sound/join_call.ogg"; +const leaveSound = "http://localhost:3000/src/sound/left_call.ogg"; + +beforeEach(() => { + soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue); +}); + +afterEach(() => { + window.HTMLMediaElement.prototype.play = originalPlayFn; +}); + +test("plays a sound when entering a call", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const matrixRoomMembers = new Map( + [local, alice, bob].map((p) => [p.userId, p]), + ); + const remoteParticipants = of([aliceParticipant]); + const liveKitRoom = mockLivekitRoom( + { localParticipant }, + { remoteParticipants }, + ); + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const session = new MockRTCSession(matrixRoom, localRtcMember, [ + aliceRtcMember, + ]) as unknown as MatrixRTCSession; + + const vm = new CallViewModel( + session, + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + + render(); + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + ]); +}); + +test("plays no sound when muted", () => { + soundEffectVolumeSetting.setValue(0); + const audioIsPlaying: string[] = mockMediaPlay(); + const matrixRoomMembers = new Map( + [local, alice, bob].map((p) => [p.userId, p]), + ); + const remoteParticipants = of([aliceParticipant, bobParticipant]); + const liveKitRoom = mockLivekitRoom( + { localParticipant }, + { remoteParticipants }, + ); + + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const session = new MockRTCSession(matrixRoom, localRtcMember, [ + aliceRtcMember, + ]) as unknown as MatrixRTCSession; + + const vm = new CallViewModel( + session, + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + + render(); + // Play a sound when joining a call. + expect(audioIsPlaying).toHaveLength(0); +}); + +test("plays a sound when a user joins", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const matrixRoomMembers = new Map([local, alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map( + [aliceParticipant].map((p) => [p.identity, p]), + ); + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const remoteRtcMemberships = new BehaviorSubject([ + aliceRtcMember, + ]); + // we give Bob an RTC session now, but no participant yet + const session = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships( + remoteRtcMemberships.asObservable(), + ) as unknown as MatrixRTCSession; + + const vm = new CallViewModel( + session, + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + + act(() => { + remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); + }); + // Play a sound when joining a call. + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + // Bob joins + enterSound, + ]); +}); + +test("plays a sound when a user leaves", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const matrixRoomMembers = new Map([local, alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map( + [aliceParticipant].map((p) => [p.identity, p]), + ); + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const remoteRtcMemberships = new BehaviorSubject([ + aliceRtcMember, + ]); + + const session = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships(remoteRtcMemberships) as unknown as MatrixRTCSession; + + const vm = new CallViewModel( + session, + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + + act(() => { + remoteRtcMemberships.next([]); + }); + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + // Alice leaves + leaveSound, + ]); +}); + +test("plays no sound when the session member count is larger than the max, until decreased", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const matrixRoomMembers = new Map([local, alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map( + [aliceParticipant].map((p) => [p.identity, p]), + ); + + const mockRtcMemberships: CallMembership[] = []; + + for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) { + mockRtcMemberships.push( + mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`), + ); + } + + const remoteRtcMemberships = new BehaviorSubject( + mockRtcMemberships, + ); + + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const session = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships(remoteRtcMemberships) as unknown as MatrixRTCSession; + + const vm = new CallViewModel( + session, + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + expect(audioIsPlaying).toEqual([]); + // When the count drops to the max we should play the leave sound + act(() => { + remoteRtcMemberships.next( + mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1), + ); + }); + expect(audioIsPlaying).toEqual([leaveSound]); +}); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx new file mode 100644 index 00000000..4f8213cf --- /dev/null +++ b/src/room/CallEventAudioRenderer.tsx @@ -0,0 +1,117 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ReactNode, useEffect, useRef } from "react"; +import { filter } from "rxjs"; + +import { + soundEffectVolumeSetting as effectSoundVolumeSetting, + useSetting, +} from "../settings/settings"; +import { CallViewModel } from "../state/CallViewModel"; +import enterCallSoundMp3 from "../sound/join_call.mp3"; +import enterCallSoundOgg from "../sound/join_call.ogg"; +import leftCallSoundMp3 from "../sound/left_call.mp3"; +import leftCallSoundOgg from "../sound/left_call.ogg"; + +// Do not play any sounds if the participant count has exceeded this +// number. +export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; +export const CONCURRENT_AUDIO_CHANNELS = 2; + +export function CallEventAudioRenderer({ + vm, +}: { + vm: CallViewModel; +}): ReactNode { + const [effectSoundVolume] = useSetting(effectSoundVolumeSetting); + const callEntered = useRef<(HTMLAudioElement | null)[]>([]); + const callLeft = useRef<(HTMLAudioElement | null)[]>([]); + + useEffect(() => { + if (effectSoundVolume === 0) { + return; + } + const joinSub = vm.memberChanges + .pipe( + filter( + ({ joined, ids }) => + ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, + ), + ) + .subscribe(({ joined }) => { + const availablePlayer = callEntered.current.find((v) => v?.paused); + void availablePlayer?.play(); + }); + + const leftSub = vm.memberChanges + .pipe( + filter( + ({ ids, left }) => + ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0, + ), + ) + .subscribe(() => { + const availablePlayer = callLeft.current.find((v) => v?.paused); + void availablePlayer?.play(); + }); + + return (): void => { + joinSub.unsubscribe(); + leftSub.unsubscribe(); + }; + }, [effectSoundVolume, callEntered, callLeft, vm]); + + // Set volume. + useEffect(() => { + callEntered.current.forEach((a) => { + if (a) { + a.volume = effectSoundVolume; + } + }); + callLeft.current.forEach((a) => { + if (a) { + a.volume = effectSoundVolume; + } + }); + }, [callEntered, callLeft, effectSoundVolume]); + + // Do not render any audio elements if playback is disabled. Will save + // audio file fetches. + if (effectSoundVolume === 0) { + return null; + } + + return ( + // Will play as soon as it's mounted, which is what we want as this will + // play when the call is entered. + <> + {Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => ( + + ))} + {Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => ( + + ))} + + ); +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f4340f47..ab35f164 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -87,6 +87,7 @@ import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { useSwitchCamera } from "./useSwitchCamera"; import { soundEffectVolumeSetting, useSetting } from "../settings/settings"; import { ReactionsOverlay } from "./ReactionsOverlay"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -123,7 +124,7 @@ export const ActiveCall: FC = (props) => { useEffect(() => { if (livekitRoom !== undefined) { const vm = new CallViewModel( - props.rtcSession.room, + props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable, @@ -131,12 +132,7 @@ export const ActiveCall: FC = (props) => { setVm(vm); return (): void => vm.destroy(); } - }, [ - props.rtcSession.room, - livekitRoom, - props.e2eeSystem, - connStateObservable, - ]); + }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]); if (livekitRoom === undefined || vm === null) return null; @@ -670,6 +666,7 @@ export const InCallView: FC = ({ ))} {renderContent()} +