diff --git a/.eslintrc.cjs b/.eslintrc.cjs index db5f3fd9..f338dac7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,6 +44,7 @@ module.exports = { ], // To encourage good usage of RxJS: "rxjs/no-exposed-subjects": "error", + "rxjs/finnish": "error", }, settings: { react: { diff --git a/locales/bg/app.json b/locales/bg/app.json index 4d51f573..eb9e5704 100644 --- a/locales/bg/app.json +++ b/locales/bg/app.json @@ -62,7 +62,6 @@ "developer_tab_title": "Разработчик", "feedback_tab_h4": "Изпрати обратна връзка", "feedback_tab_send_logs_label": "Включи debug логове", - "more_tab_title": "Още", "speaker_device_selection_label": "Говорител" }, "unauthenticated_view_body": "Все още не сте регистрирани? <2>Създайте акаунт", diff --git a/locales/cs/app.json b/locales/cs/app.json index 8089af9a..b9793e1f 100644 --- a/locales/cs/app.json +++ b/locales/cs/app.json @@ -60,12 +60,9 @@ "return_home_button": "Vrátit se na domácí obrazovku", "screenshare_button_label": "Sdílet obrazovku", "settings": { - "developer_settings_label": "Vývojářské nastavení", - "developer_settings_label_description": "Zobrazit vývojářské nastavení.", "developer_tab_title": "Vývojář", "feedback_tab_h4": "Dát feedback", "feedback_tab_send_logs_label": "Zahrnout ladící záznamy", - "more_tab_title": "Více", "speaker_device_selection_label": "Reproduktor" }, "unauthenticated_view_body": "Nejste registrovaní? <2>Vytvořit účet", diff --git a/locales/de/app.json b/locales/de/app.json index 67138ca5..2c35b341 100644 --- a/locales/de/app.json +++ b/locales/de/app.json @@ -147,10 +147,9 @@ "screenshare_button_label": "Bildschirm teilen", "settings": { "audio_tab": { + "effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden", "effect_volume_label": "Lautstärke der Soundeffekte" }, - "developer_settings_label": "Entwicklereinstellungen", - "developer_settings_label_description": "Zeige die Entwicklereinstellungen im Einstellungsfenster.", "developer_tab_title": "Entwickler", "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", @@ -158,19 +157,13 @@ "feedback_tab_send_logs_label": "Debug-Protokolle anhängen", "feedback_tab_thank_you": "Danke, wir haben deine Rückmeldung erhalten!", "feedback_tab_title": "Rückmeldung", - "more_tab_title": "Mehr", "opt_in_description": "<0><1>Du kannst deine Zustimmung durch Abwählen dieses Kästchens zurückziehen. Falls du dich aktuell in einem Anruf befindest, wird diese Einstellung nach dem Ende des Anrufs wirksam.", "preferences_tab": { "reactions_play_sound_description": "Einen Soundeffekt abspielen, wenn jemand eine Reaktion sendet", "reactions_play_sound_label": "Reaktionstöne abspielen", "reactions_show_description": "Zeige eine Animation, wenn jemand eine Reaktion sendet.", - "reactions_show_label": "Reaktionen anzeigen", - "reactions_title": "Reaktionen" + "reactions_show_label": "Reaktionen anzeigen" }, - "preferences_tab_body": "Hier können zusätzliche Optionen für individuelle Anforderungen eingestellt werden", - "preferences_tab_h4": "Einstellungen", - "preferences_tab_show_hand_raised_timer_description": "Einen Timer zur Handmeldung anzeigen", - "preferences_tab_show_hand_raised_timer_label": "Dauer der Handmeldung anzeigen", "speaker_device_selection_label": "Lautsprecher" }, "star_rating_input_label_one": "{{count}} Stern", diff --git a/locales/el/app.json b/locales/el/app.json index c4b7a7a9..10b9396d 100644 --- a/locales/el/app.json +++ b/locales/el/app.json @@ -67,8 +67,6 @@ "return_home_button": "Επιστροφή στην αρχική οθόνη", "screenshare_button_label": "Κοινή χρήση οθόνης", "settings": { - "developer_settings_label": "Ρυθμίσεις προγραμματιστή", - "developer_settings_label_description": "Εμφάνιση ρυθμίσεων προγραμματιστή στο παράθυρο ρυθμίσεων.", "developer_tab_title": "Προγραμματιστής", "feedback_tab_body": "Εάν αντιμετωπίζετε προβλήματα ή απλά θέλετε να μας δώσετε κάποια σχόλια, παρακαλούμε στείλτε μας μια σύντομη περιγραφή παρακάτω.", "feedback_tab_description_label": "Τα σχόλιά σας", @@ -76,7 +74,6 @@ "feedback_tab_send_logs_label": "Να συμπεριληφθούν αρχεία καταγραφής", "feedback_tab_thank_you": "Ευχαριστούμε, λάβαμε τα σχόλιά σας!", "feedback_tab_title": "Ανατροφοδότηση", - "more_tab_title": "Περισσότερα", "opt_in_description": "<0><1>Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας αποεπιλέγοντας αυτό το πλαίσιο. Εάν βρίσκεστε σε κλήση, η ρύθμιση αυτή θα τεθεί σε ισχύ στο τέλος της.", "speaker_device_selection_label": "Ηχείο" }, diff --git a/locales/en/app.json b/locales/en/app.json index ff222298..7af8040f 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -48,13 +48,11 @@ "audio": "Audio", "avatar": "Avatar", "back": "Back", - "camera": "Camera", "display_name": "Display name", "encrypted": "Encrypted", "error": "Error", "home": "Home", "loading": "Loading…", - "microphone": "Microphone", "next": "Next", "options": "Options", "password": "Password", @@ -74,7 +72,8 @@ "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", "hostname": "Hostname: {{hostname}}", - "matrix_id": "Matrix ID: {{id}}" + "matrix_id": "Matrix ID: {{id}}", + "show_non_member_tiles": "Show tiles for non-member media" }, "disconnected_banner": "Connectivity to the server has been lost.", "full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.", @@ -155,6 +154,16 @@ "background_blur_label": "Blur the background of the video", "blur_not_supported_by_browser": "(Background blur is not supported by this device)", "developer_tab_title": "Developer", + "devices": { + "camera": "Camera", + "camera_numbered": "Camera {{n}}", + "default": "Default", + "default_named": "Default <2>({{name}})", + "microphone": "Microphone", + "microphone_numbered": "Microphone {{n}}", + "speaker": "Speaker", + "speaker_numbered": "Speaker {{n}}" + }, "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", @@ -165,15 +174,14 @@ "preferences_tab": { "developer_mode_label": "Developer mode", "developer_mode_label_description": "Enable developer mode and show developer settings tab.", + "introduction": "Here you can configure extra options for an improved experience.", "reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.", "reactions_play_sound_label": "Play reaction sounds", "reactions_show_description": "Show an animation when anyone sends a reaction.", - "reactions_show_label": "Show reactions" - }, - "preferences_tab_body": "Here you can configure extra options for an improved experience.", - "preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand", - "preferences_tab_show_hand_raised_timer_label": "Show hand raise duration", - "speaker_device_selection_label": "Speaker" + "reactions_show_label": "Show reactions", + "show_hand_raised_timer_description": "Show a timer when a participant raises their hand", + "show_hand_raised_timer_label": "Show hand raise duration" + } }, "star_rating_input_label_one": "{{count}} star", "star_rating_input_label_other": "{{count}} stars", diff --git a/locales/es/app.json b/locales/es/app.json index c9e9ee1a..96f6710c 100644 --- a/locales/es/app.json +++ b/locales/es/app.json @@ -67,8 +67,6 @@ "room_auth_view_eula_caption": "Al hacer clic en \"Unirse a la llamada ahora\", aceptas nuestro <2>Contrato de Licencia de Usuario Final (CLUF)", "screenshare_button_label": "Compartir pantalla", "settings": { - "developer_settings_label": "Ajustes de desarrollador", - "developer_settings_label_description": "Muestra los ajustes de desarrollador en la ventana de ajustes.", "developer_tab_title": "Desarrollador", "feedback_tab_body": "Si tienes algún problema o simplemente quieres darnos tu opinión, por favor envíanos una breve descripción.", "feedback_tab_description_label": "Tus comentarios", @@ -76,7 +74,6 @@ "feedback_tab_send_logs_label": "Incluir registros de depuración", "feedback_tab_thank_you": "¡Gracias, hemos recibido tus comentarios!", "feedback_tab_title": "Danos tu opinión", - "more_tab_title": "Más", "opt_in_description": "<0><1>Puedes retirar tu consentimiento desmarcando esta casilla. Si estás en una llamada, este ajuste se aplicará al final de esta.", "speaker_device_selection_label": "Altavoz" }, diff --git a/locales/et/app.json b/locales/et/app.json index bdce05f6..ccd1c699 100644 --- a/locales/et/app.json +++ b/locales/et/app.json @@ -97,8 +97,6 @@ "room_auth_view_eula_caption": "Klõpsides „Liitu kõnega kohe“, nõustud sa meie <2>Lõppkasutaja litsentsilepinguga (EULA)", "screenshare_button_label": "Jaga ekraani", "settings": { - "developer_settings_label": "Arendaja seadistused", - "developer_settings_label_description": "Näita seadistuste aknas arendajale vajalikke seadeid.", "developer_tab_title": "Arendaja", "feedback_tab_body": "Kui selle rakenduse kasutamisel tekib sul probleeme või lihtsalt soovid oma arvamust avaldada, siis palun täida alljärgnev lühike kirjeldus.", "feedback_tab_description_label": "Sinu tagasiside", @@ -106,7 +104,6 @@ "feedback_tab_send_logs_label": "Lisa veatuvastuslogid", "feedback_tab_thank_you": "Tänud, me oleme sinu tagasiside kätte saanud!", "feedback_tab_title": "Tagasiside", - "more_tab_title": "Rohkem", "opt_in_description": "<0><1>Sa võid selle valiku eelmaldamisega alati oma nõusoleku tagasi võtta. Kui sul parasjagu on kõne pooleli, siis seadistuste muudatus jõustub pärast kõne lõppu.", "speaker_device_selection_label": "Kõlar" }, diff --git a/locales/fa/app.json b/locales/fa/app.json index 7fdf98a2..125ec785 100644 --- a/locales/fa/app.json +++ b/locales/fa/app.json @@ -64,7 +64,6 @@ "developer_tab_title": "توسعه دهنده", "feedback_tab_h4": "بازخورد ارائه دهید", "feedback_tab_send_logs_label": "شامل لاگ‌های عیب‌یابی", - "more_tab_title": "بیشتر", "speaker_device_selection_label": "بلندگو" }, "unauthenticated_view_body": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری", diff --git a/locales/fr/app.json b/locales/fr/app.json index 4cb2b9e4..f465244d 100644 --- a/locales/fr/app.json +++ b/locales/fr/app.json @@ -95,8 +95,6 @@ "room_auth_view_eula_caption": "En cliquant sur « Rejoindre l’appel maintenant », vous acceptez notre <2>Contrat de Licence Utilisateur Final (CLUF)", "screenshare_button_label": "Partage d’écran", "settings": { - "developer_settings_label": "Paramètres développeurs", - "developer_settings_label_description": "Affiche les paramètres développeurs dans la fenêtre des paramètres.", "developer_tab_title": "Développeur", "feedback_tab_body": "Si vous rencontrez des problèmes, ou vous voulez simplement faire un commentaire, faites-en une courte description ci-dessous.", "feedback_tab_description_label": "Votre commentaire", @@ -104,7 +102,6 @@ "feedback_tab_send_logs_label": "Inclure les journaux de débogage", "feedback_tab_thank_you": "Merci, nous avons reçu vos commentaires !", "feedback_tab_title": "Commentaires", - "more_tab_title": "Plus", "opt_in_description": "<0><1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de l’appel.", "speaker_device_selection_label": "Intervenant" }, diff --git a/locales/id/app.json b/locales/id/app.json index c1c7fae3..c479d604 100644 --- a/locales/id/app.json +++ b/locales/id/app.json @@ -96,8 +96,6 @@ "room_auth_view_eula_caption": "Dengan mengeklik \"Bergabung ke panggilan sekarang\", Anda menyetujui <2>Perjanjian Lisensi Pengguna Akhir (EULA) kami", "screenshare_button_label": "Bagikan layar", "settings": { - "developer_settings_label": "Pengaturan Pengembang", - "developer_settings_label_description": "Ekspos pengaturan pengembang dalam jendela pengaturan.", "developer_tab_title": "Pengembang", "feedback_tab_body": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.", "feedback_tab_description_label": "Masukan Anda", @@ -105,7 +103,6 @@ "feedback_tab_send_logs_label": "Termasuk catatan pengawakutuan", "feedback_tab_thank_you": "Terima kasih, kami telah menerima masukan Anda!", "feedback_tab_title": "Masukan", - "more_tab_title": "Lainnya", "opt_in_description": "<0><1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.", "speaker_device_selection_label": "Pembicara" }, diff --git a/locales/it/app.json b/locales/it/app.json index a3d1d797..6fe08427 100644 --- a/locales/it/app.json +++ b/locales/it/app.json @@ -94,15 +94,12 @@ "room_auth_view_eula_caption": "Cliccando \"Entra in chiamata ora\", accetti il nostro <2>accordo di licenza con l'utente finale (EULA)", "screenshare_button_label": "Condividi schermo", "settings": { - "developer_settings_label": "Impostazioni per sviluppatori", - "developer_settings_label_description": "Mostra le impostazioni per sviluppatori nella finestra delle impostazioni.", "developer_tab_title": "Sviluppatore", "feedback_tab_body": "Se stai riscontrando problemi o semplicemente vuoi dare un'opinione, inviaci una breve descrizione qua sotto.", "feedback_tab_description_label": "Il tuo commento", "feedback_tab_h4": "Invia commento", "feedback_tab_send_logs_label": "Includi registri di debug", "feedback_tab_thank_you": "Grazie, abbiamo ricevuto il tuo commento!", - "more_tab_title": "Altro", "opt_in_description": "<0><1>Puoi revocare il consenso deselezionando questa casella. Se attualmente sei in una chiamata, avrà effetto al termine di essa.", "speaker_device_selection_label": "Altoparlante" }, diff --git a/locales/lv/app.json b/locales/lv/app.json index a92d4bf2..ee48986f 100644 --- a/locales/lv/app.json +++ b/locales/lv/app.json @@ -75,8 +75,6 @@ "room_auth_view_eula_caption": "Klikšķināšana uz \"Pievienoties zvanam tagad\" apliecina piekrišanu mūsu <2>galalietotāja licencēšanas nolīgumam (GLLN)", "screenshare_button_label": "Kopīgot ekrānu", "settings": { - "developer_settings_label": "Izstrādātāja iestatījumi", - "developer_settings_label_description": "Izstādīt izstrādātāja iestatījumus iestatījumu logā.", "developer_tab_title": "Izstrādātājs", "feedback_tab_body": "Ja tiek piedzīvoti sarežģījumi vai vienkārši ir vēlme sniegt kādu atsauksmi, lūgums zemāk nosūtīt mums īsu aprakstu.", "feedback_tab_description_label": "Tava atsauksme", @@ -84,7 +82,6 @@ "feedback_tab_send_logs_label": "Iekļaut atkļūdošanas žurnāla ierakstus", "feedback_tab_thank_you": "Paldies, mēs saņēmām atsauksmi!", "feedback_tab_title": "Atsauksmes", - "more_tab_title": "Vairāk", "opt_in_description": "<0><1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās.", "speaker_device_selection_label": "Runātājs" }, diff --git a/locales/pl/app.json b/locales/pl/app.json index 7b27c488..db3986ef 100644 --- a/locales/pl/app.json +++ b/locales/pl/app.json @@ -99,8 +99,6 @@ "room_auth_view_eula_caption": "Klikając \"Dołącz teraz do rozmowy\", zgadzasz się na naszą <2>Umowę licencyjną (EULA)", "screenshare_button_label": "Udostępnij ekran", "settings": { - "developer_settings_label": "Opcje programisty", - "developer_settings_label_description": "Wyświetl opcje programisty w oknie ustawień.", "developer_tab_title": "Programista", "feedback_tab_body": "Jeśli posiadasz problemy lub chciałbyś zgłosić swoją opinię, wyślij nam krótki opis.", "feedback_tab_description_label": "Twoje opinie", @@ -108,7 +106,6 @@ "feedback_tab_send_logs_label": "Dołącz dzienniki debugowania", "feedback_tab_thank_you": "Dziękujemy, otrzymaliśmy Twoją opinię!", "feedback_tab_title": "Opinia użytkownika", - "more_tab_title": "Więcej", "opt_in_description": "<0><1>Możesz wycofać swoją zgodę poprzez odznaczenie tego pola. Jeśli już jesteś w trakcie rozmowy, opcja zostanie zastosowana po jej zakończeniu.", "speaker_device_selection_label": "Głośnik" }, diff --git a/locales/ro/app.json b/locales/ro/app.json index ab55c511..0b83b0f3 100644 --- a/locales/ro/app.json +++ b/locales/ro/app.json @@ -148,8 +148,6 @@ "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", @@ -157,19 +155,13 @@ "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" + "reactions_show_label": "Afișați reacțiile" }, - "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", diff --git a/locales/ru/app.json b/locales/ru/app.json index 0836b2bb..725fffdd 100644 --- a/locales/ru/app.json +++ b/locales/ru/app.json @@ -69,8 +69,6 @@ "return_home_button": "Вернуться в Начало", "screenshare_button_label": "Поделиться экраном", "settings": { - "developer_settings_label": "Настройки Разработчика", - "developer_settings_label_description": "Раскрыть настройки разработчика в окне настроек.", "developer_tab_title": "Разработчику", "feedback_tab_body": "Если у вас возникли проблемы или вы просто хотите оставить отзыв, отправьте нам краткое описание ниже.", "feedback_tab_description_label": "Ваш отзыв", @@ -78,7 +76,6 @@ "feedback_tab_send_logs_label": "Приложить журнал отладки", "feedback_tab_thank_you": "Спасибо. Мы получили ваш отзыв!", "feedback_tab_title": "Отзыв", - "more_tab_title": "Больше", "opt_in_description": "<0><1>Вы можете отозвать согласие, сняв этот флажок. Если вы в данный момент находитесь в разговоре, эта настройка вступит в силу по окончании разговора.", "speaker_device_selection_label": "Динамик" }, diff --git a/locales/sk/app.json b/locales/sk/app.json index 48d69430..fbf2dc37 100644 --- a/locales/sk/app.json +++ b/locales/sk/app.json @@ -97,8 +97,6 @@ "room_auth_view_eula_caption": "Kliknutím na \"Pripojiť sa k hovoru teraz\" súhlasíte s našou <2>Licenčnou zmluvou s koncovým používateľom (EULA)", "screenshare_button_label": "Zdieľať obrazovku", "settings": { - "developer_settings_label": "Nastavenia pre vývojárov", - "developer_settings_label_description": "Zobraziť nastavenia pre vývojárov v okne nastavení.", "developer_tab_title": "Vývojár", "feedback_tab_body": "Ak máte problémy alebo jednoducho chcete poskytnúť spätnú väzbu, pošlite nám krátky popis nižšie.", "feedback_tab_description_label": "Vaša spätná väzba", @@ -106,7 +104,6 @@ "feedback_tab_send_logs_label": "Zahrnúť záznamy o ladení", "feedback_tab_thank_you": "Ďakujeme, dostali sme vašu spätnú väzbu!", "feedback_tab_title": "Spätná väzba", - "more_tab_title": "Viac", "opt_in_description": "<0><1>Súhlas môžete odvolať zrušením označenia tohto políčka. Ak práve prebieha hovor, toto nastavenie nadobudne platnosť po skončení hovoru.", "speaker_device_selection_label": "Reproduktor" }, diff --git a/locales/tr/app.json b/locales/tr/app.json index 8be1769e..d14f6883 100644 --- a/locales/tr/app.json +++ b/locales/tr/app.json @@ -52,8 +52,7 @@ "settings": { "developer_tab_title": "Geliştirici", "feedback_tab_h4": "Geri bildirim ver", - "feedback_tab_send_logs_label": "Hata ayıklama kütüğünü dahil et", - "more_tab_title": "Daha" + "feedback_tab_send_logs_label": "Hata ayıklama kütüğünü dahil et" }, "unauthenticated_view_body": "Kaydolmadınız mı? <2>Hesap açın", "unauthenticated_view_login_button": "Hesabınıza girin" diff --git a/locales/uk/app.json b/locales/uk/app.json index f88d14a8..4faa19df 100644 --- a/locales/uk/app.json +++ b/locales/uk/app.json @@ -99,8 +99,6 @@ "room_auth_view_eula_caption": "Натискаючи \"Приєднатися до виклику зараз\", ви погоджуєтеся з нашою <2>Ліцензійною угодою з кінцевим користувачем (EULA)", "screenshare_button_label": "Поділитися екраном", "settings": { - "developer_settings_label": "Налаштування розробника", - "developer_settings_label_description": "Відкрийте налаштування розробника у вікні налаштувань.", "developer_tab_title": "Розробнику", "feedback_tab_body": "Якщо у вас виникли проблеми або ви просто хочете залишити відгук, надішліть нам короткий опис нижче.", "feedback_tab_description_label": "Ваш відгук", @@ -108,7 +106,6 @@ "feedback_tab_send_logs_label": "Долучити журнали налагодження", "feedback_tab_thank_you": "Дякуємо, ми отримали ваш відгук!", "feedback_tab_title": "Відгук", - "more_tab_title": "Докладніше", "opt_in_description": "<0><1>Ви можете відкликати згоду, прибравши цей прапорець. Якщо ви зараз розмовляєте, це налаштування застосується після завершення виклику.", "speaker_device_selection_label": "Динамік" }, diff --git a/locales/vi/app.json b/locales/vi/app.json index 2e59af69..3bd622ce 100644 --- a/locales/vi/app.json +++ b/locales/vi/app.json @@ -55,7 +55,6 @@ "register_confirm_password_label": "Xác nhận mật khẩu", "screenshare_button_label": "Chia sẻ màn hình", "settings": { - "developer_settings_label": "Cài đặt phát triển", "developer_tab_title": "Nhà phát triển", "feedback_tab_description_label": "Phản hồi của bạn", "feedback_tab_h4": "Gửi phản hồi", diff --git a/locales/zh-Hans/app.json b/locales/zh-Hans/app.json index da450f15..58d405d0 100644 --- a/locales/zh-Hans/app.json +++ b/locales/zh-Hans/app.json @@ -92,8 +92,6 @@ "room_auth_view_eula_caption": "点击 \"加入通话\",即表示您同意我们的<2>最终用户许可协议 (EULA)", "screenshare_button_label": "屏幕共享", "settings": { - "developer_settings_label": "开发者设置", - "developer_settings_label_description": "在设置中显示开发者设置。", "developer_tab_title": "开发者", "feedback_tab_body": "如果遇到问题或想提供一些反馈意见,请在下面向我们发送简短描述。", "feedback_tab_description_label": "您的反馈", @@ -101,7 +99,6 @@ "feedback_tab_send_logs_label": "包含调试日志", "feedback_tab_thank_you": "谢谢,我们收到了反馈!", "feedback_tab_title": "反馈", - "more_tab_title": "更多", "opt_in_description": "<0><1>您可以取消选中复选框来撤回同意。如果正在通话中,此设置将在通话结束时生效。", "speaker_device_selection_label": "发言人" }, diff --git a/locales/zh-Hant/app.json b/locales/zh-Hant/app.json index f6bb2aa0..b73e4658 100644 --- a/locales/zh-Hant/app.json +++ b/locales/zh-Hant/app.json @@ -99,8 +99,6 @@ "room_auth_view_eula_caption": "點擊「立刻加入通話」即表示您同意我們的<2>終端使用者授權協議 (EULA)", "screenshare_button_label": "分享畫面", "settings": { - "developer_settings_label": "開發者設定", - "developer_settings_label_description": "在設定視窗中顯示開發者設定。", "developer_tab_title": "開發者", "feedback_tab_body": "若您遇到問題或只是想提供一些回饋,請在下方傳送簡短說明給我們。", "feedback_tab_description_label": "您的回饋", @@ -108,7 +106,6 @@ "feedback_tab_send_logs_label": "包含除錯紀錄", "feedback_tab_thank_you": "感謝,我們已經收到您的回饋了!", "feedback_tab_title": "回饋", - "more_tab_title": "更多", "opt_in_description": "<0><1>您可以透過取消核取此方塊來撤回同意。若您目前正在通話中,此設定將在通話結束時生效。", "speaker_device_selection_label": "發言者" }, diff --git a/src/Modal.test.tsx b/src/Modal.test.tsx index bb6fb0f7..6368c7d9 100644 --- a/src/Modal.test.tsx +++ b/src/Modal.test.tsx @@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { expect, test } from "vitest"; +import { expect, test, afterEach } from "vitest"; import { render } from "@testing-library/react"; import { type ReactNode, useState } from "react"; -import { afterEach } from "node:test"; import userEvent from "@testing-library/user-event"; import { Modal } from "./Modal"; diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 5124e711..2817f78c 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -415,7 +415,7 @@ export class PosthogAnalytics { // * When the user changes their preferences on this device // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // won't be called (i.e. this.anonymity will be left as the default, until the setting changes) - optInAnalytics.value.subscribe((optIn) => { + optInAnalytics.value$.subscribe((optIn) => { this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled); this.maybeIdentifyUser().catch(() => logger.log("Could not identify user"), diff --git a/src/controls.ts b/src/controls.ts index 2e8ea7d3..fe8cc4de 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -13,18 +13,18 @@ export interface Controls { disablePip: () => void; } -export const setPipEnabled = new Subject(); +export const setPipEnabled$ = new Subject(); window.controls = { canEnterPip(): boolean { - return setPipEnabled.observed; + return setPipEnabled$.observed; }, enablePip(): void { - if (!setPipEnabled.observed) throw new Error("No call is running"); - setPipEnabled.next(true); + if (!setPipEnabled$.observed) throw new Error("No call is running"); + setPipEnabled$.next(true); }, disablePip(): void { - if (!setPipEnabled.observed) throw new Error("No call is running"); - setPipEnabled.next(false); + if (!setPipEnabled$.observed) throw new Error("No call is running"); + setPipEnabled$.next(false); }, }; diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index e05cd025..0e64481a 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -31,15 +31,15 @@ export interface CallLayoutInputs { /** * The minimum bounds of the layout area. */ - minBounds: Observable; + minBounds$: Observable; /** * The alignment of the floating spotlight tile, if present. */ - spotlightAlignment: BehaviorSubject; + spotlightAlignment$: BehaviorSubject; /** * The alignment of the small picture-in-picture tile, if present. */ - pipAlignment: BehaviorSubject; + pipAlignment$: BehaviorSubject; } export interface CallLayoutOutputs { diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index 411d9d08..031a73b5 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -24,7 +24,6 @@ import { createContext, forwardRef, memo, - useCallback, useContext, useEffect, useMemo, @@ -54,7 +53,6 @@ interface Tile { id: string; model: Model; onDrag: DragCallback | undefined; - setVisible: (visible: boolean) => void; } type PlacedTile = Tile & Rect; @@ -88,7 +86,6 @@ interface SlotProps extends Omit, "onDrag"> { id: string; model: Model; onDrag?: DragCallback; - onVisibilityChange?: (visible: boolean) => void; style?: CSSProperties; className?: string; } @@ -115,28 +112,51 @@ function offset(element: HTMLElement, relativeTo: Element): Offset { } } +export type VisibleTilesCallback = (visibleTiles: number) => void; + interface LayoutContext { setGeneration: Dispatch>; + setVisibleTilesCallback: Dispatch< + SetStateAction + >; } const LayoutContext = createContext(null); +function useLayoutContext(): LayoutContext { + const context = useContext(LayoutContext); + if (context === null) + throw new Error("useUpdateLayout called outside a Grid layout context"); + return context; +} + /** * Enables Grid to react to layout changes. You must call this in your Layout * component or else Grid will not be reactive. */ export function useUpdateLayout(): void { - const context = useContext(LayoutContext); - if (context === null) - throw new Error("useUpdateLayout called outside a Grid layout context"); - + const { setGeneration } = useLayoutContext(); // On every render, tell Grid that the layout may have changed - useEffect(() => - context.setGeneration((prev) => (prev === null ? 0 : prev + 1)), + useEffect(() => setGeneration((prev) => (prev === null ? 0 : prev + 1))); +} + +/** + * Asks Grid to call a callback whenever the number of visible tiles may have + * changed. + */ +export function useVisibleTiles(callback: VisibleTilesCallback): void { + const { setVisibleTilesCallback } = useLayoutContext(); + useEffect( + () => setVisibleTilesCallback(() => callback), + [callback, setVisibleTilesCallback], + ); + useEffect( + () => (): void => setVisibleTilesCallback(null), + [setVisibleTilesCallback], ); } -const windowHeightObservable = fromEvent(window, "resize").pipe( +const windowHeightObservable$ = fromEvent(window, "resize").pipe( startWith(null), map(() => window.innerHeight), ); @@ -242,42 +262,23 @@ export function Grid< const [gridRoot, gridRef2] = useState(null); const gridRef = useMergedRefs(gridRef1, gridRef2); - const windowHeight = useObservableEagerState(windowHeightObservable); + const windowHeight = useObservableEagerState(windowHeightObservable$); const [layoutRoot, setLayoutRoot] = useState(null); const [generation, setGeneration] = useState(null); + const [visibleTilesCallback, setVisibleTilesCallback] = + useState(null); const tiles = useInitial(() => new Map>()); const prefersReducedMotion = usePrefersReducedMotion(); const Slot: FC> = useMemo( () => - function Slot({ - id, - model, - onDrag, - onVisibilityChange, - style, - className, - ...props - }) { + function Slot({ id, model, onDrag, style, className, ...props }) { const ref = useRef(null); - const prevVisible = useRef(null); - const setVisible = useCallback( - (visible: boolean) => { - if ( - onVisibilityChange !== undefined && - visible !== prevVisible.current - ) { - onVisibilityChange(visible); - prevVisible.current = visible; - } - }, - [onVisibilityChange], - ); useEffect(() => { - tiles.set(id, { id, model, onDrag, setVisible }); + tiles.set(id, { id, model, onDrag }); return (): void => void tiles.delete(id); - }, [id, model, onDrag, setVisible]); + }, [id, model, onDrag]); return (
({ setGeneration }), []); + const context: LayoutContext = useMemo( + () => ({ setGeneration, setVisibleTilesCallback }), + [setVisibleTilesCallback], + ); // Combine the tile definitions and slots together to create placed tiles const placedTiles = useMemo(() => { @@ -342,9 +346,11 @@ export function Grid< ); useEffect(() => { - for (const tile of placedTiles) - tile.setVisible(tile.y + tile.height <= visibleHeight); - }, [placedTiles, visibleHeight]); + visibleTilesCallback?.( + placedTiles.filter((tile) => tile.y + tile.height <= visibleHeight) + .length, + ); + }, [placedTiles, visibleTilesCallback, visibleHeight]); // Drag state is stored in a ref rather than component state, because we use // react-spring's imperative API during gestures to improve responsiveness diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 45aecd85..fd26c6ee 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -13,7 +13,7 @@ import { type GridLayout as GridLayoutModel } from "../state/CallViewModel"; import styles from "./GridLayout.module.css"; import { useInitial } from "../useInitial"; import { type CallLayout, arrangeTiles } from "./CallLayout"; -import { type DragCallback, useUpdateLayout } from "./Grid"; +import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid"; interface GridCSSProperties extends CSSProperties { "--gap": string; @@ -26,8 +26,8 @@ interface GridCSSProperties extends CSSProperties { * together in a scrolling grid. */ export const makeGridLayout: CallLayout = ({ - minBounds, - spotlightAlignment, + minBounds$, + spotlightAlignment$, }) => ({ scrollingOnTop: false, @@ -37,7 +37,7 @@ export const makeGridLayout: CallLayout = ({ useUpdateLayout(); const alignment = useObservableEagerState( useInitial(() => - spotlightAlignment.pipe( + spotlightAlignment$.pipe( distinctUntilChanged( (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, ), @@ -47,7 +47,7 @@ export const makeGridLayout: CallLayout = ({ const onDragSpotlight: DragCallback = useCallback( ({ xRatio, yRatio }) => - spotlightAlignment.next({ + spotlightAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), @@ -73,7 +73,8 @@ export const makeGridLayout: CallLayout = ({ // The scrolling part of the layout is where all the grid tiles live scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { useUpdateLayout(); - const { width, height: minHeight } = useObservableEagerState(minBounds); + useVisibleTiles(model.setVisibleTiles); + const { width, height: minHeight } = useObservableEagerState(minBounds$); const { gap, tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, minHeight, model.grid.length), [width, minHeight, model.grid.length], @@ -93,13 +94,7 @@ export const makeGridLayout: CallLayout = ({ } > {model.grid.map((m) => ( - + ))}
); diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index e841a686..9d49ae90 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -19,8 +19,8 @@ import { type DragCallback, useUpdateLayout } from "./Grid"; * is shown at maximum size, overlaid by a small view of the local participant. */ export const makeOneOnOneLayout: CallLayout = ({ - minBounds, - pipAlignment, + minBounds$, + pipAlignment$, }) => ({ scrollingOnTop: false, @@ -31,8 +31,8 @@ export const makeOneOnOneLayout: CallLayout = ({ scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { useUpdateLayout(); - const { width, height } = useObservableEagerState(minBounds); - const pipAlignmentValue = useObservableEagerState(pipAlignment); + const { width, height } = useObservableEagerState(minBounds$); + const pipAlignmentValue = useObservableEagerState(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -40,7 +40,7 @@ export const makeOneOnOneLayout: CallLayout = ({ const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment.next({ + pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), @@ -52,7 +52,6 @@ export const makeOneOnOneLayout: CallLayout = ({ @@ -61,7 +60,6 @@ export const makeOneOnOneLayout: CallLayout = ({ id={model.local.id} model={model.local} onDrag={onDragLocalTile} - onVisibilityChange={model.local.setVisible} data-block-alignment={pipAlignmentValue.block} data-inline-alignment={pipAlignmentValue.inline} /> diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index 371930ce..a50cecb9 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -19,7 +19,7 @@ import styles from "./SpotlightExpandedLayout.module.css"; */ export const makeSpotlightExpandedLayout: CallLayout< SpotlightExpandedLayoutModel -> = ({ pipAlignment }) => ({ +> = ({ pipAlignment$ }) => ({ scrollingOnTop: true, fixed: forwardRef(function SpotlightExpandedLayoutFixed( @@ -44,11 +44,11 @@ export const makeSpotlightExpandedLayout: CallLayout< ref, ) { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment); + const pipAlignmentValue = useObservableEagerState(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment.next({ + pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), @@ -63,7 +63,6 @@ export const makeSpotlightExpandedLayout: CallLayout< id={model.pip.id} model={model.pip} onDrag={onDragPip} - onVisibilityChange={model.pip.setVisible} data-block-alignment={pipAlignmentValue.block} data-inline-alignment={pipAlignmentValue.inline} /> diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 8596aec8..a80cb6fa 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -12,7 +12,7 @@ import classNames from "classnames"; import { type CallLayout } from "./CallLayout"; import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightLandscapeLayout.module.css"; -import { useUpdateLayout } from "./Grid"; +import { useUpdateLayout, useVisibleTiles } from "./Grid"; /** * An implementation of the "spotlight landscape" layout, in which the spotlight @@ -21,7 +21,7 @@ import { useUpdateLayout } from "./Grid"; */ export const makeSpotlightLandscapeLayout: CallLayout< SpotlightLandscapeLayoutModel -> = ({ minBounds }) => ({ +> = ({ minBounds$ }) => ({ scrollingOnTop: false, fixed: forwardRef(function SpotlightLandscapeLayoutFixed( @@ -29,7 +29,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< ref, ) { useUpdateLayout(); - useObservableEagerState(minBounds); + useObservableEagerState(minBounds$); return (
@@ -50,9 +50,10 @@ export const makeSpotlightLandscapeLayout: CallLayout< ref, ) { useUpdateLayout(); - useObservableEagerState(minBounds); + useVisibleTiles(model.setVisibleTiles); + useObservableEagerState(minBounds$); const withIndicators = - useObservableEagerState(model.spotlight.media).length > 1; + useObservableEagerState(model.spotlight.media$).length > 1; return (
@@ -63,13 +64,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< />
{model.grid.map((m) => ( - + ))}
diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index beeca3e6..9f62a520 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -12,7 +12,7 @@ import classNames from "classnames"; import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightPortraitLayout.module.css"; -import { useUpdateLayout } from "./Grid"; +import { useUpdateLayout, useVisibleTiles } from "./Grid"; interface GridCSSProperties extends CSSProperties { "--grid-gap": string; @@ -27,7 +27,7 @@ interface GridCSSProperties extends CSSProperties { */ export const makeSpotlightPortraitLayout: CallLayout< SpotlightPortraitLayoutModel -> = ({ minBounds }) => ({ +> = ({ minBounds$ }) => ({ scrollingOnTop: false, fixed: forwardRef(function SpotlightPortraitLayoutFixed( @@ -54,7 +54,8 @@ export const makeSpotlightPortraitLayout: CallLayout< ref, ) { useUpdateLayout(); - const { width } = useObservableEagerState(minBounds); + useVisibleTiles(model.setVisibleTiles); + const { width } = useObservableEagerState(minBounds$); const { gap, tileWidth, tileHeight } = arrangeTiles( width, // TODO: We pretend that the minimum height is the width, because the @@ -63,7 +64,7 @@ export const makeSpotlightPortraitLayout: CallLayout< model.grid.length, ); const withIndicators = - useObservableEagerState(model.spotlight.media).length > 1; + useObservableEagerState(model.spotlight.media$).length > 1; return (
{model.grid.map((m) => ( - + ))}
diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index f25761f9..44e8a037 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -16,7 +16,8 @@ import { useState, } from "react"; import { createMediaDeviceObserver } from "@livekit/components-core"; -import { type Observable } from "rxjs"; +import { map, startWith } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; import { @@ -26,11 +27,25 @@ import { videoInput as videoInputSetting, type Setting, } from "../settings/settings"; -import { isFirefox } from "../Platform"; + +export type DeviceLabel = + | { type: "name"; name: string } + | { type: "number"; number: number } + | { type: "default"; name: string | null }; export interface MediaDevice { - available: MediaDeviceInfo[]; + /** + * A map from available device IDs to labels. + */ + available: Map; selectedId: string | undefined; + /** + * The group ID of the selected device. + */ + // This is exposed sort of ad-hoc because it's only needed for knowing when to + // restart the tracks of default input devices, and ideally this behavior + // would be encapsulated somehow… + selectedGroupId: string | undefined; select: (deviceId: string) => void; } @@ -42,26 +57,10 @@ export interface MediaDevices { stopUsingDeviceNames: () => void; } -// Cargo-culted from @livekit/components-react -function useObservableState( - observable: Observable | undefined, - startWith: T, -): T { - const [state, setState] = useState(startWith); - useEffect(() => { - // observable state doesn't run in SSR - if (typeof window === "undefined" || !observable) return; - const subscription = observable.subscribe(setState); - return (): void => subscription.unsubscribe(); - }, [observable]); - return state; -} - function useMediaDevice( kind: MediaDeviceKind, setting: Setting, usingNames: boolean, - alwaysDefault: boolean = false, ): MediaDevice { // Make sure we don't needlessly reset to a device observer without names, // once permissions are already given @@ -75,49 +74,97 @@ function useMediaDevice( // useMediaDevices provides no way to request device names. // Tragically, the only way to get device names out of LiveKit is to specify a // kind, which then results in multiple permissions requests. - const deviceObserver = useMemo( + const deviceObserver$ = useMemo( () => createMediaDeviceObserver( kind, () => logger.error("Error creating MediaDeviceObserver"), requestPermissions, - ), + ).pipe(startWith([])), [kind, requestPermissions], ); - const available = useObservableState(deviceObserver, []); - const [preferredId, select] = useSetting(setting); + const available = useObservableEagerState( + useMemo( + () => + deviceObserver$.pipe( + map((availableRaw) => { + // Sometimes browsers (particularly Firefox) can return multiple device + // entries for the exact same device ID; using a map deduplicates them + let available = new Map( + availableRaw.map((d, i) => [ + d.deviceId, + d.label + ? { type: "name", name: d.label } + : { type: "number", number: i + 1 }, + ]), + ); + // Create a virtual default audio output for browsers that don't have one. + // Its device ID must be the empty string because that's what setSinkId + // recognizes. + if ( + kind === "audiooutput" && + available.size && + !available.has("") && + !available.has("default") + ) + available = new Map([ + ["", { type: "default", name: availableRaw[0]?.label || null }], + ...available, + ]); + // Note: creating virtual default input devices would be another problem + // entirely, because requesting a media stream from deviceId "" won't + // automatically track the default device. + return available; + }), + ), + [kind, deviceObserver$], + ), + ); - return useMemo(() => { - let selectedId: string | undefined = undefined; - if (!alwaysDefault && available) { + const [preferredId, select] = useSetting(setting); + const selectedId = useMemo(() => { + if (available.size) { // If the preferred device is available, use it. Or if every available // device ID is falsy, the browser is probably just being paranoid about // fingerprinting and we should still try using the preferred device. // Worst case it is not available and the browser will gracefully fall // back to some other device for us when requesting the media stream. // Otherwise, select the first available device. - selectedId = - available.some((d) => d.deviceId === preferredId) || - available.every((d) => d.deviceId === "") - ? preferredId - : available.at(0)?.deviceId; + return (preferredId !== undefined && available.has(preferredId)) || + (available.size === 1 && available.has("")) + ? preferredId + : available.keys().next().value; } + return undefined; + }, [available, preferredId]); + const selectedGroupId = useObservableEagerState( + useMemo( + () => + deviceObserver$.pipe( + map( + (availableRaw) => + availableRaw.find((d) => d.deviceId === selectedId)?.groupId, + ), + ), + [deviceObserver$, selectedId], + ), + ); - return { - available: available - ? // Sometimes browsers (particularly Firefox) can return multiple - // device entries for the exact same device ID; deduplicate them - [...new Map(available.map((d) => [d.deviceId, d])).values()] - : [], + return useMemo( + () => ({ + available, selectedId, + selectedGroupId, select, - }; - }, [available, preferredId, select, alwaysDefault]); + }), + [available, selectedId, selectedGroupId, select], + ); } export const deviceStub: MediaDevice = { - available: [], + available: new Map(), selectedId: undefined, + selectedGroupId: undefined, select: () => {}, }; export const devicesStub: MediaDevices = { @@ -139,15 +186,6 @@ export const MediaDevicesProvider: FC = ({ children }) => { const [numCallersUsingNames, setNumCallersUsingNames] = useState(0); const usingNames = numCallersUsingNames > 0; - // Setting the audio device to something other than 'undefined' breaks echo-cancellation - // and even can introduce multiple different output devices for one call. - const alwaysUseDefaultAudio = isFirefox(); - - // On FF we dont need to query the names - // (call enumerateDevices + create meadia stream to trigger permissions) - // for ouput devices because the selector wont be shown on FF. - const useOutputNames = usingNames && !isFirefox(); - const audioInput = useMediaDevice( "audioinput", audioInputSetting, @@ -156,8 +194,7 @@ export const MediaDevicesProvider: FC = ({ children }) => { const audioOutput = useMediaDevice( "audiooutput", audioOutputSetting, - useOutputNames, - alwaysUseDefaultAudio, + usingNames, ); const videoInput = useMediaDevice( "videoinput", diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts index 83e6153c..72a972a7 100644 --- a/src/livekit/useLiveKit.ts +++ b/src/livekit/useLiveKit.ts @@ -310,18 +310,14 @@ export function useLiveKit( room.localParticipant.audioTrackPublications.values(), ).find((d) => d.source === Track.Source.Microphone)?.track; - const defaultDevice = device.available.find( - (d) => d.deviceId === "default", - ); if ( - defaultDevice && activeMicTrack && // only restart if the stream is still running: LiveKit will detect // when a track stops & restart appropriately, so this is not our job. // Plus, we need to avoid restarting again if the track is already in // the process of being restarted. activeMicTrack.mediaStreamTrack.readyState !== "ended" && - defaultDevice.groupId !== + device.selectedGroupId !== activeMicTrack.mediaStreamTrack.getSettings().groupId ) { // It's different, so restart the track, ie. cause Livekit to do another diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index e8d22704..cc7f4eea 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -13,11 +13,11 @@ import { type MockedFunction, test, vitest, + afterEach, } from "vitest"; import { type MatrixClient } from "matrix-js-sdk/src/client"; import { ConnectionState } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; -import { afterEach } from "node:test"; import { act, type ReactNode } from "react"; import { type CallMembership, @@ -100,13 +100,13 @@ function getMockEnv( ): { vm: CallViewModel; session: MockRTCSession; - remoteRtcMemberships: BehaviorSubject; + remoteRtcMemberships$: BehaviorSubject; } { const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); - const remoteParticipants = of([aliceParticipant]); + const remoteParticipants$ = of([aliceParticipant]); const liveKitRoom = mockLivekitRoom( { localParticipant }, - { remoteParticipants }, + { remoteParticipants$ }, ); const matrixRoom = mockMatrixRoom({ client: { @@ -118,14 +118,14 @@ function getMockEnv( getMember: (userId) => matrixRoomMembers.get(userId) ?? null, }); - const remoteRtcMemberships = new BehaviorSubject( + const remoteRtcMemberships$ = new BehaviorSubject( initialRemoteRtcMemberships, ); const session = new MockRTCSession( matrixRoom, localRtcMember, - ).withMemberships(remoteRtcMemberships); + ).withMemberships(remoteRtcMemberships$); const vm = new CallViewModel( session as unknown as MatrixRTCSession, @@ -135,7 +135,7 @@ function getMockEnv( }, of(ConnectionState.Connected), ); - return { vm, session, remoteRtcMemberships }; + return { vm, session, remoteRtcMemberships$ }; } /** @@ -146,33 +146,33 @@ function getMockEnv( * a noise every time. */ test("plays one sound when entering a call", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); // Joining a call usually means remote participants are added later. act(() => { - remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); + remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); }); expect(playSound).toHaveBeenCalledOnce(); }); // TODO: Same test? test("plays a sound when a user joins", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); act(() => { - remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); + remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]); }); // Play a sound when joining a call. expect(playSound).toBeCalledWith("join"); }); test("plays a sound when a user leaves", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); + const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]); render(); act(() => { - remoteRtcMemberships.next([]); + remoteRtcMemberships$.next([]); }); expect(playSound).toBeCalledWith("left"); }); @@ -185,7 +185,7 @@ test("plays no sound when the participant list is more than the maximum size", ( ); } - const { session, vm, remoteRtcMemberships } = getMockEnv( + const { session, vm, remoteRtcMemberships$ } = getMockEnv( [local, alice], mockRtcMemberships, ); @@ -193,7 +193,7 @@ test("plays no sound when the participant list is more than the maximum size", ( render(); expect(playSound).not.toBeCalled(); act(() => { - remoteRtcMemberships.next( + remoteRtcMemberships$.next( mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1), ); }); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index a363c6f5..a2a0a7f1 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -65,7 +65,7 @@ export function CallEventAudioRenderer({ }, [audioEngineRef, previousRaisedHandCount, raisedHandCount]); useEffect(() => { - const joinSub = vm.memberChanges + const joinSub = vm.memberChanges$ .pipe( filter( ({ joined, ids }) => @@ -77,7 +77,7 @@ export function CallEventAudioRenderer({ void audioEngineRef.current?.playSound("join"); }); - const leftSub = vm.memberChanges + const leftSub = vm.memberChanges$ .pipe( filter( ({ ids, left }) => diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 976e4e94..dde64104 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -110,8 +110,8 @@ export const ActiveCall: FC = (props) => { sfuConfig, props.e2eeSystem, ); - const connStateObservable = useObservable( - (inputs) => inputs.pipe(map(([connState]) => connState)), + const connStateObservable$ = useObservable( + (inputs$) => inputs$.pipe(map(([connState]) => connState)), [connState], ); const [vm, setVm] = useState(null); @@ -131,12 +131,12 @@ export const ActiveCall: FC = (props) => { props.rtcSession, livekitRoom, props.e2eeSystem, - connStateObservable, + connStateObservable$, ); setVm(vm); return (): void => vm.destroy(); } - }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]); + }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]); if (livekitRoom === undefined || vm === null) return null; @@ -225,14 +225,14 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const windowMode = useObservableEagerState(vm.windowMode); - const layout = useObservableEagerState(vm.layout); - const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration); + const windowMode = useObservableEagerState(vm.windowMode$); + const layout = useObservableEagerState(vm.layout$); + const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useObservableEagerState(vm.gridMode); - const showHeader = useObservableEagerState(vm.showHeader); - const showFooter = useObservableEagerState(vm.showFooter); - const switchCamera = useSwitchCamera(vm.localVideo); + const gridMode = useObservableEagerState(vm.gridMode$); + const showHeader = useObservableEagerState(vm.showHeader$); + const showFooter = useObservableEagerState(vm.showFooter$); + const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported @@ -317,15 +317,15 @@ export const InCallView: FC = ({ windowMode, ], ); - const gridBoundsObservable = useObservable( - (inputs) => inputs.pipe(map(([gridBounds]) => gridBounds)), + const gridBoundsObservable$ = useObservable( + (inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)), [gridBounds], ); - const spotlightAlignment = useInitial( + const spotlightAlignment$ = useInitial( () => new BehaviorSubject(defaultSpotlightAlignment), ); - const pipAlignment = useInitial( + const pipAlignment$ = useInitial( () => new BehaviorSubject(defaultPipAlignment), ); @@ -383,15 +383,17 @@ export const InCallView: FC = ({ { className, style, targetWidth, targetHeight, model }, ref, ) { - const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded); + const spotlightExpanded = useObservableEagerState( + vm.spotlightExpanded$, + ); const onToggleExpanded = useObservableEagerState( - vm.toggleSpotlightExpanded, + vm.toggleSpotlightExpanded$, ); const showSpeakingIndicatorsValue = useObservableEagerState( - vm.showSpeakingIndicators, + vm.showSpeakingIndicators$, ); const showSpotlightIndicatorsValue = useObservableEagerState( - vm.showSpotlightIndicators, + vm.showSpotlightIndicators$, ); return model instanceof GridTileViewModel ? ( @@ -424,9 +426,9 @@ export const InCallView: FC = ({ const layouts = useMemo(() => { const inputs = { - minBounds: gridBoundsObservable, - spotlightAlignment, - pipAlignment, + minBounds$: gridBoundsObservable$, + spotlightAlignment$, + pipAlignment$, }; return { grid: makeGridLayout(inputs), @@ -435,7 +437,7 @@ export const InCallView: FC = ({ "spotlight-expanded": makeSpotlightExpandedLayout(inputs), "one-on-one": makeOneOnOneLayout(inputs), }; - }, [gridBoundsObservable, spotlightAlignment, pipAlignment]); + }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); const renderContent = (): JSX.Element => { if (layout.type === "pip") { diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 214a7091..15b7a774 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -159,7 +159,7 @@ export const LobbyView: FC = ({ useTrackProcessorSync(videoTrack); const showSwitchCamera = useShowSwitchCamera( useObservable( - (inputs) => inputs.pipe(map(([video]) => video)), + (inputs$) => inputs$.pipe(map(([video]) => video)), [videoTrack], ), ); diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index 77ab8ace..719315e8 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details. */ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; -import React, { type ReactNode } from "react"; -import { beforeEach } from "vitest"; +import { type ReactNode } from "react"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { useMuteStates } from "./MuteStates"; import { + type DeviceLabel, type MediaDevice, type MediaDevices, MediaDevicesContext, @@ -63,10 +63,11 @@ const mockCamera: MediaDeviceInfo = { }, }; -function mockDevices(available: MediaDeviceInfo[]): MediaDevice { +function mockDevices(available: Map): MediaDevice { return { available, selectedId: "", + selectedGroupId: "", select: (): void => {}, }; } @@ -83,25 +84,29 @@ function mockMediaDevices( } = { microphone: true, speaker: true, camera: true }, ): MediaDevices { return { - audioInput: mockDevices(microphone ? [mockMicrophone] : []), - audioOutput: mockDevices(speaker ? [mockSpeaker] : []), - videoInput: mockDevices(camera ? [mockCamera] : []), + audioInput: mockDevices( + microphone + ? new Map([[mockMicrophone.deviceId, mockMicrophone]]) + : new Map(), + ), + audioOutput: mockDevices( + speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(), + ), + videoInput: mockDevices( + camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(), + ), startUsingDeviceNames: (): void => {}, stopUsingDeviceNames: (): void => {}, }; } describe("useMuteStates", () => { - beforeEach(() => { - vi.spyOn(React, "useContext").mockReturnValue({}); - }); - afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); afterAll(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it("disabled when no input devices", () => { diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index b4f2b43e..4a8aa9dd 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -58,12 +58,12 @@ function useMuteState( ): MuteState { const [enabled, setEnabled] = useReactiveState( (prev) => - device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined, + device.available.size > 0 ? (prev ?? enabledByDefault()) : undefined, [device], ); return useMemo( () => - device.available.length === 0 + device.available.size === 0 ? deviceUnavailable : { enabled: enabled ?? false, diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index 0ab283a9..afa2c6ff 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { render } from "@testing-library/react"; import { afterAll, + afterEach, beforeEach, expect, test, @@ -17,7 +18,6 @@ import { } from "vitest"; import { TooltipProvider } from "@vector-im/compound-web"; import { act, type ReactNode } from "react"; -import { afterEach } from "node:test"; import { MockRoom, diff --git a/src/room/ReactionsOverlay.test.tsx b/src/room/ReactionsOverlay.test.tsx index 8ea17178..5c3f8bf9 100644 --- a/src/room/ReactionsOverlay.test.tsx +++ b/src/room/ReactionsOverlay.test.tsx @@ -6,10 +6,9 @@ Please see LICENSE in the repository root for full details. */ import { render } from "@testing-library/react"; -import { expect, test } from "vitest"; +import { expect, test, afterEach } from "vitest"; import { TooltipProvider } from "@vector-im/compound-web"; import { act, type ReactNode } from "react"; -import { afterEach } from "node:test"; import { MockRoom, diff --git a/src/room/useSwitchCamera.ts b/src/room/useSwitchCamera.ts index 8bbfc92c..1cf5d29d 100644 --- a/src/room/useSwitchCamera.ts +++ b/src/room/useSwitchCamera.ts @@ -31,17 +31,17 @@ import { useLatest } from "../useLatest"; * producing a callback if so. */ export function useSwitchCamera( - video: Observable, + video$: Observable, ): (() => void) | null { const mediaDevices = useMediaDevices(); const setVideoInput = useLatest(mediaDevices.videoInput.select); // Produce an observable like the input 'video' observable, except make it // emit whenever the track is muted or the device changes - const videoTrack: Observable = useObservable( - (inputs) => - inputs.pipe( - switchMap(([video]) => video), + const videoTrack$: Observable = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([video$]) => video$), switchMap((video) => { if (video === null) return of(null); return merge( @@ -53,15 +53,15 @@ export function useSwitchCamera( ); }), ), - [video], + [video$], ); - const switchCamera: Observable<(() => void) | null> = useObservable( - (inputs) => + const switchCamera$: Observable<(() => void) | null> = useObservable( + (inputs$) => platform === "desktop" ? of(null) - : inputs.pipe( - switchMap(([track]) => track), + : inputs$.pipe( + switchMap(([track$]) => track$), map((track) => { if (track === null) return null; const facingMode = facingModeFromLocalTrack(track).facingMode; @@ -86,8 +86,8 @@ export function useSwitchCamera( ); }), ), - [videoTrack], + [videoTrack$], ); - return useObservableEagerState(switchCamera); + return useObservableEagerState(switchCamera$); } diff --git a/src/rtcSessionHelper.test.ts b/src/rtcSessionHelper.test.ts index 7df9f1b3..797312f6 100644 --- a/src/rtcSessionHelper.test.ts +++ b/src/rtcSessionHelper.test.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { expect, test, vi } from "vitest"; +import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery"; import { enterRTCSession } from "../src/rtcSessionHelpers"; import { mockConfig } from "./utils/test"; @@ -36,11 +37,21 @@ test("It joins the correct Session", async () => { mockConfig({ livekit: { livekit_service_url: "http://my-default-service-url.com" }, }); + + vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation( + async (domain) => { + if (domain === "example.org") { + return Promise.resolve(clientWellKnown); + } + return Promise.resolve({}); + }, + ); + const mockedSession = vi.mocked({ room: { roomId: "roomId", client: { - getClientWellKnown: vi.fn().mockReturnValue(clientWellKnown), + getDomain: vi.fn().mockReturnValue("example.org"), }, }, memberships: [], diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index f1c7eb8c..3aa2fbbd 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -13,6 +13,7 @@ import { isLivekitFocus, isLivekitFocusConfig, } from "matrix-js-sdk/src/matrixrtc/LivekitFocus"; +import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery"; import { PosthogAnalytics } from "./analytics/PosthogAnalytics"; import { Config } from "./config/Config"; @@ -43,19 +44,28 @@ async function makePreferredLivekitFoci( preferredFoci.push(focusInUse); } - // Prioritize the client well known over the configured sfu. - const wellKnownFoci = - rtcSession.room.client.getClientWellKnown()?.[FOCI_WK_KEY]; - if (Array.isArray(wellKnownFoci)) { - preferredFoci.push( - ...wellKnownFoci - .filter((f) => !!f) - .filter(isLivekitFocusConfig) - .map((wellKnownFocus) => { - logger.log("Adding livekit focus from well known: ", wellKnownFocus); - return { ...wellKnownFocus, livekit_alias: livekitAlias }; - }), - ); + // Prioritize the .well-known/matrix/client, if available, over the configured SFU + const domain = rtcSession.room.client.getDomain(); + if (domain) { + // we use AutoDiscovery instead of relying on the MatrixClient having already + // been fully configured and started + const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ + FOCI_WK_KEY + ]; + if (Array.isArray(wellKnownFoci)) { + preferredFoci.push( + ...wellKnownFoci + .filter((f) => !!f) + .filter(isLivekitFocusConfig) + .map((wellKnownFocus) => { + logger.log( + "Adding livekit focus from well known: ", + wellKnownFocus, + ); + return { ...wellKnownFocus, livekit_alias: livekitAlias }; + }), + ); + } } const urlFromConf = Config.get().livekit?.livekit_service_url; diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 209bc41e..057b0b0c 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -13,6 +13,7 @@ import { useSetting, duplicateTiles as duplicateTilesSetting, debugTileLayout as debugTileLayoutSetting, + showNonMemberTiles as showNonMemberTilesSetting, } from "./settings"; import type { MatrixClient } from "matrix-js-sdk/src/client"; @@ -26,6 +27,9 @@ export const DeveloperSettingsTab: FC = ({ client }) => { const [debugTileLayout, setDebugTileLayout] = useSetting( debugTileLayoutSetting, ); + const [showNonMemberTiles, setShowNonMemberTiles] = useSetting( + showNonMemberTilesSetting, + ); return ( <> @@ -85,6 +89,20 @@ export const DeveloperSettingsTab: FC = ({ client }) => { } /> + + ): void => { + setShowNonMemberTiles(event.target.checked); + }, + [setShowNonMemberTiles], + )} + /> + ); }; diff --git a/src/settings/DeviceSelection.module.css b/src/settings/DeviceSelection.module.css index daa4510e..6686702f 100644 --- a/src/settings/DeviceSelection.module.css +++ b/src/settings/DeviceSelection.module.css @@ -16,3 +16,7 @@ flex-direction: column; gap: var(--cpd-space-4x); } + +.secondary { + color: var(--cpd-color-text-secondary); +} diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index da4fe11a..aebe0aac 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { type ChangeEvent, type FC, useCallback, useId } from "react"; +import { + type ChangeEvent, + type FC, + type ReactElement, + type ReactNode, + useCallback, + useId, +} from "react"; import { Heading, InlineField, @@ -13,16 +20,23 @@ import { RadioControl, Separator, } from "@vector-im/compound-web"; +import { Trans, useTranslation } from "react-i18next"; import { type MediaDevice } from "../livekit/MediaDevicesContext"; import styles from "./DeviceSelection.module.css"; interface Props { devices: MediaDevice; - caption: string; + title: string; + numberedLabel: (number: number) => string; } -export const DeviceSelection: FC = ({ devices, caption }) => { +export const DeviceSelection: FC = ({ + devices, + title, + numberedLabel, +}) => { + const { t } = useTranslation(); const groupId = useId(); const onChange = useCallback( (e: ChangeEvent) => { @@ -31,7 +45,7 @@ export const DeviceSelection: FC = ({ devices, caption }) => { [devices], ); - if (devices.available.length == 0) return null; + if (devices.available.size == 0) return null; return (
@@ -42,29 +56,53 @@ export const DeviceSelection: FC = ({ devices, caption }) => { as="h4" className={styles.title} > - {caption} + {title}
- {devices.available.map(({ deviceId, label }, index) => ( - - } - > - - - ))} + {[...devices.available].map(([id, label]) => { + let labelText: ReactNode; + switch (label.type) { + case "name": + labelText = label.name; + break; + case "number": + labelText = numberedLabel(label.number); + break; + case "default": + labelText = + label.name === null ? ( + t("settings.devices.default") + ) : ( + + Default{" "} + + ({{ name: label.name } as unknown as ReactElement}) + + + ); + break; + } + + return ( + + } + > + + + ); + })}
); diff --git a/src/settings/PreferencesSettingsTab.tsx b/src/settings/PreferencesSettingsTab.tsx index fd4be1ae..72d2d919 100644 --- a/src/settings/PreferencesSettingsTab.tsx +++ b/src/settings/PreferencesSettingsTab.tsx @@ -41,13 +41,13 @@ export const PreferencesSettingsTab: FC = () => { return (
- {t("settings.preferences_tab_body")} + {t("settings.preferences_tab.introduction")} = ({
+ t("settings.devices.microphone_numbered", { n }) + } + /> + t("settings.devices.speaker_numbered", { n })} /> - {!isFirefox() && ( - - )}

{t("settings.audio_tab.effect_volume_description")}

@@ -141,7 +142,8 @@ export const SettingsModal: FC = ({ t("settings.devices.camera_numbered", { n })} /> diff --git a/src/settings/settings.ts b/src/settings/settings.ts index f3c7c25e..679527ef 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -31,17 +31,17 @@ export class Setting { } } - this._value = new BehaviorSubject(initialValue); - this.value = this._value; + this._value$ = new BehaviorSubject(initialValue); + this.value$ = this._value$; } private readonly key: string; - private readonly _value: BehaviorSubject; - public readonly value: Observable; + private readonly _value$: BehaviorSubject; + public readonly value$: Observable; public readonly setValue = (value: T): void => { - this._value.next(value); + this._value$.next(value); localStorage.setItem(this.key, JSON.stringify(value)); }; } @@ -50,7 +50,7 @@ export class Setting { * React hook that returns a settings's current value and a setter. */ export function useSetting(setting: Setting): [T, (value: T) => void] { - return [useObservableEagerState(setting.value), setting.setValue]; + return [useObservableEagerState(setting.value$), setting.setValue]; } // null = undecided @@ -72,6 +72,10 @@ export const developerMode = new Setting("developer-settings-tab", false); export const duplicateTiles = new Setting("duplicate-tiles", 0); +export const showNonMemberTiles = new Setting( + "show-non-member-tiles", + false, +); export const debugTileLayout = new Setting("debug-tile-layout", false); export const audioInput = new Setting( diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index ad7e8702..c9072006 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -46,6 +46,7 @@ import { type ECConnectionState, } from "../livekit/useECConnectionState"; import { E2eeType } from "../e2ee/e2eeType"; +import { showNonMemberTiles } from "../settings/settings"; vi.mock("@livekit/components-core"); @@ -123,15 +124,15 @@ export type LayoutSummary = | OneOnOneLayoutSummary | PipLayoutSummary; -function summarizeLayout(l: Observable): Observable { - return l.pipe( +function summarizeLayout$(l$: Observable): Observable { + return l$.pipe( switchMap((l) => { switch (l.type) { case "grid": return combineLatest( [ - l.spotlight?.media ?? of(undefined), - ...l.grid.map((vm) => vm.media), + l.spotlight?.media$ ?? of(undefined), + ...l.grid.map((vm) => vm.media$), ], (spotlight, ...grid) => ({ type: l.type, @@ -142,7 +143,7 @@ function summarizeLayout(l: Observable): Observable { case "spotlight-landscape": case "spotlight-portrait": return combineLatest( - [l.spotlight.media, ...l.grid.map((vm) => vm.media)], + [l.spotlight.media$, ...l.grid.map((vm) => vm.media$)], (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -151,7 +152,7 @@ function summarizeLayout(l: Observable): Observable { ); case "spotlight-expanded": return combineLatest( - [l.spotlight.media, l.pip?.media ?? of(undefined)], + [l.spotlight.media$, l.pip?.media$ ?? of(undefined)], (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -160,7 +161,7 @@ function summarizeLayout(l: Observable): Observable { ); case "one-on-one": return combineLatest( - [l.local.media, l.remote.media], + [l.local.media$, l.remote.media$], (local, remote) => ({ type: l.type, local: local.id, @@ -168,7 +169,7 @@ function summarizeLayout(l: Observable): Observable { }), ); case "pip": - return l.spotlight.media.pipe( + return l.spotlight.media$.pipe( map((spotlight) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -185,9 +186,9 @@ function summarizeLayout(l: Observable): Observable { } function withCallViewModel( - remoteParticipants: Observable, - rtcMembers: Observable[]>, - connectionState: Observable, + remoteParticipants$: Observable, + rtcMembers$: Observable[]>, + connectionState$: Observable, speaking: Map>, continuation: (vm: CallViewModel) => void, ): void { @@ -202,10 +203,10 @@ function withCallViewModel( room, localRtcMember, [], - ).withMemberships(rtcMembers); + ).withMemberships(rtcMembers$); const participantsSpy = vi .spyOn(ComponentsCore, "connectedParticipantsObserver") - .mockReturnValue(remoteParticipants); + .mockReturnValue(remoteParticipants$); const mediaSpy = vi .spyOn(ComponentsCore, "observeParticipantMedia") .mockImplementation((p) => @@ -231,7 +232,7 @@ function withCallViewModel( const liveKitRoom = mockLivekitRoom( { localParticipant }, - { remoteParticipants }, + { remoteParticipants$ }, ); const vm = new CallViewModel( @@ -240,7 +241,7 @@ function withCallViewModel( { kind: E2eeType.PER_PARTICIPANT, }, - connectionState, + connectionState$, ); onTestFinished(() => { @@ -275,7 +276,7 @@ test("participants are retained during a focus switch", () => { }), new Map(), (vm) => { - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -319,7 +320,7 @@ test("screen sharing activates spotlight layout", () => { g: () => vm.setGridMode("grid"), }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -362,7 +363,7 @@ test("screen sharing activates spotlight layout", () => { }, }, ); - expectObservable(vm.showSpeakingIndicators).toBe( + expectObservable(vm.showSpeakingIndicators$).toBe( expectedShowSpeakingMarbles, { y: true, @@ -376,16 +377,16 @@ test("screen sharing activates spotlight layout", () => { test("participants stay in the same order unless to appear/disappear", () => { withTestScheduler(({ hot, schedule, expectObservable }) => { - const modeInputMarbles = " a"; + const visibilityInputMarbles = "a"; // First Bob speaks, then Dave, then Alice - const aSpeakingInputMarbles = "n- 1998ms - 1999ms y"; - const bSpeakingInputMarbles = "ny 1998ms n 1999ms -"; - const dSpeakingInputMarbles = "n- 1998ms y 1999ms n"; + const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; + const bSpeakingInputMarbles = " ny 1998ms n 1999ms -"; + const dSpeakingInputMarbles = " n- 1998ms y 1999ms n"; // 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 // hasn't spoken at all. Then when Alice speaks, she should return to her // place at the top. - const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a"; + const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; withCallViewModel( of([aliceParticipant, bobParticipant, daveParticipant]), @@ -397,20 +398,17 @@ test("participants stay in the same order unless to appear/disappear", () => { [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], ]), (vm) => { - schedule(modeInputMarbles, { + schedule(visibilityInputMarbles, { a: () => { // We imagine that only three tiles (the first three) will be visible // on screen at a time - vm.layout.subscribe((layout) => { - if (layout.type === "grid") { - for (let i = 0; i < layout.grid.length; i++) - layout.grid[i].setVisible(i < 3); - } + vm.layout$.subscribe((layout) => { + if (layout.type === "grid") layout.setVisibleTiles(3); }); }, }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -435,6 +433,56 @@ test("participants stay in the same order unless to appear/disappear", () => { }); }); +test("participants adjust order when space becomes constrained", () => { + withTestScheduler(({ hot, schedule, expectObservable }) => { + // Start with all tiles on screen then shrink to 3 + const visibilityInputMarbles = "a-b"; + // Bob and Dave speak + const bSpeakingInputMarbles = " ny"; + const dSpeakingInputMarbles = " ny"; + // Nothing should change when Bob or Dave initially speak, because they are + // on screen. When the screen becomes smaller Alice should move off screen + // to make way for the speakers (specifically, she should swap with Dave). + const expectedLayoutMarbles = " a-b"; + + withCallViewModel( + of([aliceParticipant, bobParticipant, daveParticipant]), + of([aliceRtcMember, bobRtcMember, daveRtcMember]), + of(ConnectionState.Connected), + new Map([ + [bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], + [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], + ]), + (vm) => { + let setVisibleTiles: ((value: number) => void) | null = null; + vm.layout$.subscribe((layout) => { + if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles; + }); + schedule(visibilityInputMarbles, { + a: () => setVisibleTiles!(Infinity), + b: () => setVisibleTiles!(3), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`], + }, + b: { + type: "grid", + spotlight: undefined, + grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`], + }, + }, + ); + }, + ); + }); +}); + test("spotlight speakers swap places", () => { withTestScheduler(({ hot, schedule, expectObservable }) => { // Go immediately into spotlight mode for the test @@ -461,7 +509,7 @@ test("spotlight speakers swap places", () => { (vm) => { schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -509,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => { d: () => window.controls.disablePip(), }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -552,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => { schedule(expandInputMarbles, { a: () => { let toggle: () => void; - vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!)); + vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!)); toggle!(); }, }); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -614,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => { new Map(), (vm) => { vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { @@ -639,6 +687,53 @@ test("participants must have a MatrixRTCSession to be visible", () => { }); }); +test("shows participants without MatrixRTCSession when enabled in settings", () => { + try { + // enable the setting: + showNonMemberTiles.setValue(true); + withTestScheduler(({ hot, expectObservable }) => { + const scenarioInputMarbles = " abc"; + const expectedLayoutMarbles = "abc"; + + withCallViewModel( + hot(scenarioInputMarbles, { + a: [], + b: [aliceParticipant], + c: [aliceParticipant, bobParticipant], + }), + of([]), // No one joins the MatrixRTC session + 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`, `${bobId}:0`], + }, + }, + ); + }, + ); + }); + } finally { + showNonMemberTiles.setValue(showNonMemberTiles.defaultValue); + } +}); + it("should show at least one tile per MatrixRTCSession", () => { withTestScheduler(({ hot, expectObservable }) => { // iterate through some combinations of MatrixRTC memberships @@ -658,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => { new Map(), (vm) => { vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( + expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { a: { diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 72712aa9..36cbbac8 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -62,16 +62,16 @@ import { import { LocalUserMediaViewModel, type MediaViewModel, - observeTrackReference, + observeTrackReference$, RemoteUserMediaViewModel, ScreenShareViewModel, type UserMediaViewModel, } from "./MediaViewModel"; import { accumulate, finalizeValue } from "../utils/observable"; import { ObservableScope } from "./ObservableScope"; -import { duplicateTiles } from "../settings/settings"; +import { duplicateTiles, showNonMemberTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; -import { setPipEnabled } from "../controls"; +import { setPipEnabled$ } from "../controls"; import { type GridTileViewModel, type SpotlightTileViewModel, @@ -82,7 +82,8 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout"; import { oneOnOneLayout } from "./OneOnOneLayout"; import { pipLayout } from "./PipLayout"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; -import { observeSpeaker } from "./observeSpeaker"; +import { observeSpeaker$ } from "./observeSpeaker"; +import { shallowEquals } from "../utils/array"; // How long we wait after a focus switch before showing the real participant // list again @@ -143,18 +144,21 @@ export interface GridLayout { type: "grid"; spotlight?: SpotlightTileViewModel; grid: GridTileViewModel[]; + setVisibleTiles: (value: number) => void; } export interface SpotlightLandscapeLayout { type: "spotlight-landscape"; spotlight: SpotlightTileViewModel; grid: GridTileViewModel[]; + setVisibleTiles: (value: number) => void; } export interface SpotlightPortraitLayout { type: "spotlight-portrait"; spotlight: SpotlightTileViewModel; grid: GridTileViewModel[]; + setVisibleTiles: (value: number) => void; } export interface SpotlightExpandedLayout { @@ -223,18 +227,17 @@ enum SortingBin { interface LayoutScanState { layout: Layout | null; tiles: TileStore; - visibleTiles: Set; } class UserMedia { private readonly scope = new ObservableScope(); public readonly vm: UserMediaViewModel; - private readonly participant: BehaviorSubject< + private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant | undefined >; - public readonly speaker: Observable; - public readonly presenter: Observable; + public readonly speaker$: Observable; + public readonly presenter$: Observable; public constructor( public readonly id: string, member: RoomMember | undefined, @@ -242,13 +245,13 @@ class UserMedia { encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - this.participant = new BehaviorSubject(participant); + this.participant$ = new BehaviorSubject(participant); if (participant?.isLocal) { this.vm = new LocalUserMediaViewModel( this.id, member, - this.participant.asObservable() as Observable, + this.participant$.asObservable() as Observable, encryptionSystem, livekitRoom, ); @@ -256,7 +259,7 @@ class UserMedia { this.vm = new RemoteUserMediaViewModel( id, member, - this.participant.asObservable() as Observable< + this.participant$.asObservable() as Observable< RemoteParticipant | undefined >, encryptionSystem, @@ -264,9 +267,9 @@ class UserMedia { ); } - this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state()); + this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); - this.presenter = this.participant.pipe( + this.presenter$ = this.participant$.pipe( switchMap( (p) => (p && @@ -286,9 +289,9 @@ class UserMedia { public updateParticipant( newParticipant: LocalParticipant | RemoteParticipant | undefined, ): void { - if (this.participant.value !== newParticipant) { + if (this.participant$.value !== newParticipant) { // Update the BehaviourSubject in the UserMedia. - this.participant.next(newParticipant); + this.participant$.next(newParticipant); } } @@ -300,7 +303,7 @@ class UserMedia { class ScreenShare { public readonly vm: ScreenShareViewModel; - private readonly participant: BehaviorSubject< + private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant >; @@ -311,12 +314,12 @@ class ScreenShare { encryptionSystem: EncryptionSystem, liveKitRoom: LivekitRoom, ) { - this.participant = new BehaviorSubject(participant); + this.participant$ = new BehaviorSubject(participant); this.vm = new ScreenShareViewModel( id, member, - this.participant.asObservable(), + this.participant$.asObservable(), encryptionSystem, liveKitRoom, participant.isLocal, @@ -354,8 +357,8 @@ function findMatrixRoomMember( // TODO: Move wayyyy more business logic from the call and lobby views into here export class CallViewModel extends ViewModel { - public readonly localVideo: Observable = - observeTrackReference( + public readonly localVideo$: Observable = + observeTrackReference$( of(this.livekitRoom.localParticipant), Track.Source.Camera, ).pipe( @@ -368,16 +371,16 @@ export class CallViewModel extends ViewModel { /** * The raw list of RemoteParticipants as reported by LiveKit */ - private readonly rawRemoteParticipants: Observable = + private readonly rawRemoteParticipants$: Observable = connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); /** * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * they've left */ - private readonly remoteParticipantHolds: Observable = - this.connectionState.pipe( - withLatestFrom(this.rawRemoteParticipants), + private readonly remoteParticipantHolds$: Observable = + this.connectionState$.pipe( + withLatestFrom(this.rawRemoteParticipants$), mergeMap(([s, ps]) => { // Whenever we switch focuses, we should retain all the previous // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to @@ -389,7 +392,7 @@ export class CallViewModel extends ViewModel { // Wait for time to pass and the connection state to have changed forkJoin([ timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), - this.connectionState.pipe( + this.connectionState$.pipe( filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus), take(1), ), @@ -412,9 +415,9 @@ export class CallViewModel extends ViewModel { /** * The RemoteParticipants including those that are being "held" on the screen */ - private readonly remoteParticipants: Observable = + private readonly remoteParticipants$: Observable = combineLatest( - [this.rawRemoteParticipants, this.remoteParticipantHolds], + [this.rawRemoteParticipants$, this.remoteParticipantHolds$], (raw, holds) => { const result = [...raw]; const resultIds = new Set(result.map((p) => p.identity)); @@ -436,10 +439,10 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display */ - private readonly mediaItems: Observable = combineLatest([ - this.remoteParticipants, + private readonly mediaItems$: Observable = combineLatest([ + this.remoteParticipants$, observeParticipantMedia(this.livekitRoom.localParticipant), - duplicateTiles.value, + duplicateTiles.value$, // Also react to changes in the MatrixRTC session list. // The session list will also be update if a room membership changes. // No additional RoomState event listener needs to be set up. @@ -447,6 +450,7 @@ export class CallViewModel extends ViewModel { this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged, ).pipe(startWith(null)), + showNonMemberTiles.value$, ]).pipe( scan( ( @@ -456,6 +460,7 @@ export class CallViewModel extends ViewModel { { participant: localParticipant }, duplicateTiles, _membershipsChanged, + showNonMemberTiles, ], ) => { const newItems = new Map( @@ -493,9 +498,17 @@ export class CallViewModel extends ViewModel { } for (let i = 0; i < 1 + duplicateTiles; i++) { const indexedMediaId = `${livekitParticipantId}:${i}`; - const prevMedia = prevItems.get(indexedMediaId); + let prevMedia = prevItems.get(indexedMediaId); if (prevMedia && prevMedia instanceof UserMedia) { prevMedia.updateParticipant(participant); + if (prevMedia.vm.member === undefined) { + // We have a previous media created because of the `debugShowNonMember` flag. + // In this case we actually replace the media item. + // This "hack" never occurs if we do not use the `debugShowNonMember` debugging + // option and if we always find a room member for each rtc member (which also + // only fails if we have a fundamental problem) + prevMedia = undefined; + } } yield [ indexedMediaId, @@ -531,7 +544,55 @@ export class CallViewModel extends ViewModel { }.bind(this)(), ); - return newItems; + // Generate non member items (items without a corresponding MatrixRTC member) + // Those items should not be rendered, they are participants in LiveKit that do not have a corresponding + // MatrixRTC members. This cannot be any good: + // - A malicious user impersonates someone + // - Someone injects abusive content + // - The user cannot have encryption keys so it makes no sense to participate + // We can only trust users that have a MatrixRTC member event. + // + // This is still available as a debug option. This can be useful + // - If one wants to test scalability using the LiveKit CLI. + // - If an experimental project does not yet do the MatrixRTC bits. + // - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive. + const newNonMemberItems = showNonMemberTiles + ? new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + for (const participant of remoteParticipants) { + for (let i = 0; i < 1 + duplicateTiles; i++) { + const maybeNonMemberParticipantId = + participant.identity + ":" + i; + if (!newItems.has(maybeNonMemberParticipantId)) { + const nonMemberId = maybeNonMemberParticipantId; + yield [ + nonMemberId, + prevItems.get(nonMemberId) ?? + new UserMedia( + nonMemberId, + undefined, + participant, + this.encryptionSystem, + this.livekitRoom, + ), + ]; + } + } + } + }.bind(this)(), + ) + : new Map(); + if (newNonMemberItems.size > 0) { + logger.debug("Added NonMember items: ", newNonMemberItems); + } + + const combinedNew = new Map([ + ...newNonMemberItems.entries(), + ...newItems.entries(), + ]); + + for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); + return combinedNew; }, new Map(), ), @@ -545,13 +606,13 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type UserMedia */ - private readonly userMedia: Observable = this.mediaItems.pipe( + private readonly userMedia$: Observable = this.mediaItems$.pipe( map((mediaItems) => mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), ), ); - public readonly memberChanges = this.userMedia + public readonly memberChanges$ = this.userMedia$ .pipe(map((mediaItems) => mediaItems.map((m) => m.id))) .pipe( scan( @@ -567,22 +628,22 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type ScreenShare */ - private readonly screenShares: Observable = - this.mediaItems.pipe( + private readonly screenShares$: Observable = + this.mediaItems$.pipe( map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), this.scope.state(), ); - private readonly spotlightSpeaker: Observable = - this.userMedia.pipe( + private readonly spotlightSpeaker$: Observable = + this.userMedia$.pipe( switchMap((mediaItems) => mediaItems.length === 0 ? of([]) : combineLatest( mediaItems.map((m) => - m.vm.speaking.pipe(map((s) => [m, s] as const)), + m.vm.speaking$.pipe(map((s) => [m, s] as const)), ), ), ), @@ -611,64 +672,68 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly grid: Observable = this.userMedia.pipe( - switchMap((mediaItems) => { - const bins = mediaItems.map((m) => - combineLatest( - [ - m.speaker, - m.presenter, - m.vm.videoEnabled, - m.vm instanceof LocalUserMediaViewModel - ? m.vm.alwaysShow - : of(false), - ], - (speaker, presenter, video, alwaysShow) => { - let bin: SortingBin; - if (m.vm.local) - bin = alwaysShow - ? SortingBin.SelfAlwaysShown - : SortingBin.SelfNotAlwaysShown; - else if (presenter) bin = SortingBin.Presenters; - else if (speaker) bin = SortingBin.Speakers; - else if (video) bin = SortingBin.Video; - else bin = SortingBin.NoVideo; + private readonly grid$: Observable = + this.userMedia$.pipe( + switchMap((mediaItems) => { + const bins = mediaItems.map((m) => + combineLatest( + [ + m.speaker$, + m.presenter$, + m.vm.videoEnabled$, + m.vm instanceof LocalUserMediaViewModel + ? m.vm.alwaysShow$ + : of(false), + ], + (speaker, presenter, video, alwaysShow) => { + let bin: SortingBin; + if (m.vm.local) + bin = alwaysShow + ? SortingBin.SelfAlwaysShown + : SortingBin.SelfNotAlwaysShown; + else if (presenter) bin = SortingBin.Presenters; + else if (speaker) bin = SortingBin.Speakers; + else if (video) bin = SortingBin.Video; + else bin = SortingBin.NoVideo; - return [m, bin] as const; - }, - ), - ); - // Sort the media by bin order and generate a tile for each one - return bins.length === 0 - ? of([]) - : combineLatest(bins, (...bins) => - bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), - ); - }), - ); + return [m, bin] as const; + }, + ), + ); + // Sort the media by bin order and generate a tile for each one + return bins.length === 0 + ? of([]) + : combineLatest(bins, (...bins) => + bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), + ); + }), + distinctUntilChanged(shallowEquals), + this.scope.state(), + ); - private readonly spotlight: Observable = - this.screenShares.pipe( + private readonly spotlight$: Observable = + this.screenShares$.pipe( switchMap((screenShares) => { if (screenShares.length > 0) { return of(screenShares.map((m) => m.vm)); } - return this.spotlightSpeaker.pipe( + return this.spotlightSpeaker$.pipe( map((speaker) => (speaker ? [speaker] : [])), ); }), + distinctUntilChanged(shallowEquals), this.scope.state(), ); - private readonly pip: Observable = combineLatest([ - this.screenShares, - this.spotlightSpeaker, - this.mediaItems, + private readonly pip$: Observable = combineLatest([ + this.screenShares$, + this.spotlightSpeaker$, + this.mediaItems$, ]).pipe( switchMap(([screenShares, spotlight, mediaItems]) => { if (screenShares.length > 0) { - return this.spotlightSpeaker; + return this.spotlightSpeaker$; } if (!spotlight || spotlight.local) { return of(null); @@ -685,7 +750,7 @@ export class CallViewModel extends ViewModel { if (!localUserMediaViewModel) { return of(null); } - return localUserMediaViewModel.alwaysShow.pipe( + return localUserMediaViewModel.alwaysShow$.pipe( map((alwaysShow) => { if (alwaysShow) { return localUserMediaViewModel; @@ -698,19 +763,19 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly hasRemoteScreenShares: Observable = - this.spotlight.pipe( + private readonly hasRemoteScreenShares$: Observable = + this.spotlight$.pipe( map((spotlight) => spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel), ), distinctUntilChanged(), ); - private readonly pipEnabled: Observable = setPipEnabled.pipe( + private readonly pipEnabled$: Observable = setPipEnabled$.pipe( startWith(false), ); - private readonly naturalWindowMode: Observable = fromEvent( + private readonly naturalWindowMode$: Observable = fromEvent( window, "resize", ).pipe( @@ -732,30 +797,30 @@ export class CallViewModel extends ViewModel { /** * The general shape of the window. */ - public readonly windowMode: Observable = this.pipEnabled.pipe( - switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode)), + public readonly windowMode$: Observable = this.pipEnabled$.pipe( + switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode$)), ); - private readonly spotlightExpandedToggle = new Subject(); - public readonly spotlightExpanded: Observable = - this.spotlightExpandedToggle.pipe( + private readonly spotlightExpandedToggle$ = new Subject(); + public readonly spotlightExpanded$: Observable = + this.spotlightExpandedToggle$.pipe( accumulate(false, (expanded) => !expanded), this.scope.state(), ); - private readonly gridModeUserSelection = new Subject(); + private readonly gridModeUserSelection$ = new Subject(); /** * The layout mode of the media tile grid. */ - public readonly gridMode: Observable = + public readonly gridMode$: Observable = // If the user hasn't selected spotlight and somebody starts screen sharing, // automatically switch to spotlight mode and reset when screen sharing ends - this.gridModeUserSelection.pipe( + this.gridModeUserSelection$.pipe( startWith(null), switchMap((userSelection) => (userSelection === "spotlight" ? EMPTY - : combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe( + : combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( skip(userSelection === null ? 0 : 1), map( ([hasScreenShares, windowMode]): GridMode => @@ -770,43 +835,41 @@ export class CallViewModel extends ViewModel { ); public setGridMode(value: GridMode): void { - this.gridModeUserSelection.next(value); + this.gridModeUserSelection$.next(value); } - private readonly gridLayoutMedia: Observable = combineLatest( - [this.grid, this.spotlight], - (grid, spotlight) => ({ + private readonly gridLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "grid", spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel) ? spotlight : undefined, grid, - }), - ); + })); - private readonly spotlightLandscapeLayoutMedia: Observable = - combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ + private readonly spotlightLandscapeLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid, })); - private readonly spotlightPortraitLayoutMedia: Observable = - combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ + private readonly spotlightPortraitLayoutMedia$: Observable = + combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid, })); - private readonly spotlightExpandedLayoutMedia: Observable = - combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({ + private readonly spotlightExpandedLayoutMedia$: Observable = + combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({ type: "spotlight-expanded", spotlight, pip: pip ?? undefined, })); - private readonly oneOnOneLayoutMedia: Observable = - this.mediaItems.pipe( + private readonly oneOnOneLayoutMedia$: Observable = + this.mediaItems$.pipe( map((mediaItems) => { if (mediaItems.length !== 2) return null; const local = mediaItems.find((vm) => vm.vm.local)?.vm as @@ -824,129 +887,125 @@ export class CallViewModel extends ViewModel { }), ); - private readonly pipLayoutMedia: Observable = - this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight }))); + private readonly pipLayoutMedia$: Observable = + this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight }))); /** * The media to be used to produce a layout. */ - private readonly layoutMedia: Observable = this.windowMode.pipe( - switchMap((windowMode) => { - switch (windowMode) { - case "normal": - return this.gridMode.pipe( - switchMap((gridMode) => { - switch (gridMode) { - case "grid": - return this.oneOnOneLayoutMedia.pipe( - switchMap((oneOnOne) => - oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne), - ), - ); - case "spotlight": - return this.spotlightExpanded.pipe( - switchMap((expanded) => - expanded - ? this.spotlightExpandedLayoutMedia - : this.spotlightLandscapeLayoutMedia, - ), - ); - } - }), - ); - case "narrow": - return this.oneOnOneLayoutMedia.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? combineLatest( - [this.grid, this.spotlight], - (grid, spotlight) => - grid.length > smallMobileCallThreshold || - spotlight.some((vm) => vm instanceof ScreenShareViewModel) - ? this.spotlightPortraitLayoutMedia - : this.gridLayoutMedia, - ).pipe(switchAll()) - : // The expanded spotlight layout makes for a better one-on-one - // experience in narrow windows - this.spotlightExpandedLayoutMedia, - ), - ); - case "flat": - return this.gridMode.pipe( - switchMap((gridMode) => { - switch (gridMode) { - case "grid": - // Yes, grid mode actually gets you a "spotlight" layout in - // this window mode. - return this.spotlightLandscapeLayoutMedia; - case "spotlight": - return this.spotlightExpandedLayoutMedia; - } - }), - ); - case "pip": - return this.pipLayoutMedia; - } - }), - this.scope.state(), - ); + private readonly layoutMedia$: Observable = + this.windowMode$.pipe( + switchMap((windowMode) => { + switch (windowMode) { + case "normal": + return this.gridMode$.pipe( + switchMap((gridMode) => { + switch (gridMode) { + case "grid": + return this.oneOnOneLayoutMedia$.pipe( + switchMap((oneOnOne) => + oneOnOne === null + ? this.gridLayoutMedia$ + : of(oneOnOne), + ), + ); + case "spotlight": + return this.spotlightExpanded$.pipe( + switchMap((expanded) => + expanded + ? this.spotlightExpandedLayoutMedia$ + : this.spotlightLandscapeLayoutMedia$, + ), + ); + } + }), + ); + case "narrow": + return this.oneOnOneLayoutMedia$.pipe( + switchMap((oneOnOne) => + oneOnOne === null + ? combineLatest( + [this.grid$, this.spotlight$], + (grid, spotlight) => + grid.length > smallMobileCallThreshold || + spotlight.some( + (vm) => vm instanceof ScreenShareViewModel, + ) + ? this.spotlightPortraitLayoutMedia$ + : this.gridLayoutMedia$, + ).pipe(switchAll()) + : // The expanded spotlight layout makes for a better one-on-one + // experience in narrow windows + this.spotlightExpandedLayoutMedia$, + ), + ); + case "flat": + return this.gridMode$.pipe( + switchMap((gridMode) => { + switch (gridMode) { + case "grid": + // Yes, grid mode actually gets you a "spotlight" layout in + // this window mode. + return this.spotlightLandscapeLayoutMedia$; + case "spotlight": + return this.spotlightExpandedLayoutMedia$; + } + }), + ); + case "pip": + return this.pipLayoutMedia$; + } + }), + this.scope.state(), + ); - public readonly layoutInternals: Observable< + // There is a cyclical dependency here: the layout algorithms want to know + // which tiles are on screen, but to know which tiles are on screen we have to + // first render a layout. To deal with this we assume initially that no tiles + // are visible, and loop the data back into the layouts with a Subject. + private readonly visibleTiles$ = new Subject(); + private readonly setVisibleTiles = (value: number): void => + this.visibleTiles$.next(value); + + public readonly layoutInternals$: Observable< LayoutScanState & { layout: Layout } - > = this.layoutMedia.pipe( - // Each layout will produce a set of tiles, and these tiles have an - // observable indicating whether they're visible. We loop this information - // back into the layout process by using switchScan. - switchScan< - LayoutMedia, - LayoutScanState, - Observable + > = combineLatest([ + this.layoutMedia$, + this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), + ]).pipe( + scan< + [LayoutMedia, number], + LayoutScanState & { layout: Layout }, + LayoutScanState >( - ({ tiles: prevTiles, visibleTiles }, media) => { + ({ tiles: prevTiles }, [media, visibleTiles]) => { let layout: Layout; let newTiles: TileStore; switch (media.type) { case "grid": case "spotlight-landscape": case "spotlight-portrait": - [layout, newTiles] = gridLikeLayout(media, visibleTiles, prevTiles); - break; - case "spotlight-expanded": - [layout, newTiles] = spotlightExpandedLayout( + [layout, newTiles] = gridLikeLayout( media, visibleTiles, + this.setVisibleTiles, prevTiles, ); break; + case "spotlight-expanded": + [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); + break; case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, visibleTiles, prevTiles); + [layout, newTiles] = oneOnOneLayout(media, prevTiles); break; case "pip": - [layout, newTiles] = pipLayout(media, visibleTiles, prevTiles); + [layout, newTiles] = pipLayout(media, prevTiles); break; } - // Take all of the 'visible' observables and combine them into one big - // observable array - const visibilities = - newTiles.gridTiles.length === 0 - ? of([]) - : combineLatest(newTiles.gridTiles.map((tile) => tile.visible)); - return visibilities.pipe( - map((visibilities) => ({ - layout: layout, - tiles: newTiles, - visibleTiles: new Set( - newTiles.gridTiles.filter((_tile, i) => visibilities[i]), - ), - })), - ); - }, - { - layout: null, - tiles: TileStore.empty(), - visibleTiles: new Set(), + return { layout, tiles: newTiles }; }, + { layout: null, tiles: TileStore.empty() }, ), this.scope.state(), ); @@ -954,7 +1013,7 @@ export class CallViewModel extends ViewModel { /** * The layout of tiles in the call interface. */ - public readonly layout: Observable = this.layoutInternals.pipe( + public readonly layout$: Observable = this.layoutInternals$.pipe( map(({ layout }) => layout), this.scope.state(), ); @@ -962,18 +1021,18 @@ export class CallViewModel extends ViewModel { /** * The current generation of the tile store, exposed for debugging purposes. */ - public readonly tileStoreGeneration: Observable = - this.layoutInternals.pipe( + public readonly tileStoreGeneration$: Observable = + this.layoutInternals$.pipe( map(({ tiles }) => tiles.generation), this.scope.state(), ); - public showSpotlightIndicators: Observable = this.layout.pipe( + public showSpotlightIndicators$: Observable = this.layout$.pipe( map((l) => l.type !== "grid"), this.scope.state(), ); - public showSpeakingIndicators: Observable = this.layout.pipe( + public showSpeakingIndicators$: Observable = this.layout$.pipe( switchMap((l) => { switch (l.type) { case "spotlight-landscape": @@ -981,7 +1040,7 @@ export class CallViewModel extends ViewModel { // If the spotlight is showing the active speaker, we can do without // speaking indicators as they're a redundant visual cue. But if // screen sharing feeds are in the spotlight we still need them. - return l.spotlight.media.pipe( + return l.spotlight.media$.pipe( map((models: MediaViewModel[]) => models.some((m) => m instanceof ScreenShareViewModel), ), @@ -1000,11 +1059,11 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - public readonly toggleSpotlightExpanded: Observable<(() => void) | null> = - this.windowMode.pipe( + public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = + this.windowMode$.pipe( switchMap((mode) => mode === "normal" - ? this.layout.pipe( + ? this.layout$.pipe( map( (l) => l.type === "spotlight-landscape" || @@ -1015,50 +1074,50 @@ export class CallViewModel extends ViewModel { ), distinctUntilChanged(), map((enabled) => - enabled ? (): void => this.spotlightExpandedToggle.next() : null, + enabled ? (): void => this.spotlightExpandedToggle$.next() : null, ), this.scope.state(), ); - private readonly screenTap = new Subject(); - private readonly controlsTap = new Subject(); - private readonly screenHover = new Subject(); - private readonly screenUnhover = new Subject(); + private readonly screenTap$ = new Subject(); + private readonly controlsTap$ = new Subject(); + private readonly screenHover$ = new Subject(); + private readonly screenUnhover$ = new Subject(); /** * Callback for when the user taps the call view. */ public tapScreen(): void { - this.screenTap.next(); + this.screenTap$.next(); } /** * Callback for when the user taps the call's controls. */ public tapControls(): void { - this.controlsTap.next(); + this.controlsTap$.next(); } /** * Callback for when the user hovers over the call view. */ public hoverScreen(): void { - this.screenHover.next(); + this.screenHover$.next(); } /** * Callback for when the user stops hovering over the call view. */ public unhoverScreen(): void { - this.screenUnhover.next(); + this.screenUnhover$.next(); } - public readonly showHeader: Observable = this.windowMode.pipe( + public readonly showHeader$: Observable = this.windowMode$.pipe( map((mode) => mode !== "pip" && mode !== "flat"), this.scope.state(), ); - public readonly showFooter: Observable = this.windowMode.pipe( + public readonly showFooter$: Observable = this.windowMode$.pipe( switchMap((mode) => { switch (mode) { case "pip": @@ -1073,9 +1132,9 @@ export class CallViewModel extends ViewModel { if (isFirefox()) return of(true); // Show/hide the footer in response to interactions return merge( - this.screenTap.pipe(map(() => "tap screen" as const)), - this.controlsTap.pipe(map(() => "tap controls" as const)), - this.screenHover.pipe(map(() => "hover" as const)), + this.screenTap$.pipe(map(() => "tap screen" as const)), + this.controlsTap$.pipe(map(() => "tap controls" as const)), + this.screenHover$.pipe(map(() => "hover" as const)), ).pipe( switchScan((state, interaction) => { switch (interaction) { @@ -1098,7 +1157,7 @@ export class CallViewModel extends ViewModel { // Show on hover and hide after a timeout return race( timer(showFooterMs), - this.screenUnhover.pipe(take(1)), + this.screenUnhover$.pipe(take(1)), ).pipe( map(() => false), startWith(true), @@ -1117,7 +1176,7 @@ export class CallViewModel extends ViewModel { private readonly matrixRTCSession: MatrixRTCSession, private readonly livekitRoom: LivekitRoom, private readonly encryptionSystem: EncryptionSystem, - private readonly connectionState: Observable, + private readonly connectionState$: Observable, ) { super(); } diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index b846939e..e5a31cf6 100644 --- a/src/state/GridLikeLayout.ts +++ b/src/state/GridLikeLayout.ts @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import { type Layout, type LayoutMedia } from "./CallViewModel"; import { type TileStore } from "./TileStore"; -import { type GridTileViewModel } from "./TileViewModel"; export type GridLikeLayoutType = | "grid" @@ -20,7 +19,8 @@ export type GridLikeLayoutType = */ export function gridLikeLayout( media: LayoutMedia & { type: GridLikeLayoutType }, - visibleTiles: Set, + visibleTiles: number, + setVisibleTiles: (value: number) => void, prevTiles: TileStore, ): [Layout & { type: GridLikeLayoutType }, TileStore] { const update = prevTiles.from(visibleTiles); @@ -37,6 +37,7 @@ export function gridLikeLayout( type: media.type, spotlight: tiles.spotlightTile, grid: tiles.gridTiles, + setVisibleTiles, } as Layout & { type: GridLikeLayoutType }, tiles, ]; diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts index c4e0bee6..18fa13b6 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/MediaViewModel.test.ts @@ -49,7 +49,7 @@ test("control a participant's volume", async () => { expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); }, }); - expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", { + expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", { a: 1, b: 0, c: 0.6, @@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => { a: () => vm.toggleFitContain(), b: () => vm.toggleFitContain(), }); - expectObservable(vm.cropVideo).toBe("abc", { + expectObservable(vm.cropVideo$).toBe("abc", { a: true, b: false, c: true, @@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => { await withLocalMedia(rtcMembership, {}, (vm) => withTestScheduler(({ expectObservable, schedule }) => { 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 @@ -93,7 +93,7 @@ test("local media remembers whether it should always be shown", async () => { (vm) => withTestScheduler(({ expectObservable, schedule }) => { schedule("-a|", { a: () => vm.setAlwaysShow(true) }); - expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); + expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true }); }), ); }); diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index ea015eb8..8100a50d 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string { return displayName; } -export function observeTrackReference( - participant: Observable, +export function observeTrackReference$( + participant$: Observable, source: Track.Source, ): Observable { - return participant.pipe( + return participant$.pipe( switchMap((p) => { if (p) { return observeParticipantMedia(p).pipe( @@ -96,7 +96,7 @@ export function observeTrackReference( ); } -function observeRemoteTrackReceivingOkay( +function observeRemoteTrackReceivingOkay$( participant: Participant, source: Track.Source, ): Observable { @@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay( }; return combineLatest([ - observeTrackReference(of(participant), source), + observeTrackReference$(of(participant), source), interval(1000).pipe(startWith(0)), ]).pipe( switchMap(async ([trackReference]) => { @@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay( ); } -function encryptionErrorObservable( +function encryptionErrorObservable$( room: LivekitRoom, participant: Participant, encryptionSystem: EncryptionSystem, @@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel { /** * The LiveKit video track for this media. */ - public readonly video: Observable; + public readonly video$: Observable; /** * Whether there should be a warning that this media is unencrypted. */ - public readonly unencryptedWarning: Observable; + public readonly unencryptedWarning$: Observable; - public readonly encryptionStatus: Observable; + public readonly encryptionStatus$: Observable; /** * Whether this media corresponds to the local participant. @@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel { public readonly member: RoomMember | undefined, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // livekit. - protected readonly participant: Observable< + protected readonly participant$: Observable< LocalParticipant | RemoteParticipant | undefined >, @@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel { livekitRoom: LivekitRoom, ) { super(); - const audio = observeTrackReference(participant, audioSource).pipe( + const audio$ = observeTrackReference$(participant$, audioSource).pipe( this.scope.state(), ); - this.video = observeTrackReference(participant, videoSource).pipe( + this.video$ = observeTrackReference$(participant$, videoSource).pipe( this.scope.state(), ); - this.unencryptedWarning = combineLatest( - [audio, this.video], + this.unencryptedWarning$ = combineLatest( + [audio$, this.video$], (a, v) => encryptionSystem.kind !== E2eeType.NONE && (a?.publication?.isEncrypted === false || v?.publication?.isEncrypted === false), ).pipe(this.scope.state()); - this.encryptionStatus = this.participant.pipe( + this.encryptionStatus$ = this.participant$.pipe( switchMap((participant): Observable => { if (!participant) { return of(EncryptionStatus.Connecting); @@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel { return of(EncryptionStatus.Okay); } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { return combineLatest([ - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "MissingKey", ), - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "InvalidKey", ), - observeRemoteTrackReceivingOkay(participant, audioSource), - observeRemoteTrackReceivingOkay(participant, videoSource), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), ]).pipe( map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { if (keyMissing) return EncryptionStatus.KeyMissing; @@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel { ); } else { return combineLatest([ - encryptionErrorObservable( + encryptionErrorObservable$( livekitRoom, participant, encryptionSystem, "InvalidKey", ), - observeRemoteTrackReceivingOkay(participant, audioSource), - observeRemoteTrackReceivingOkay(participant, videoSource), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), ]).pipe( map( ([keyInvalid, audioOkay, videoOkay]): @@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether the participant is speaking. */ - public readonly speaking = this.participant.pipe( + public readonly speaking$ = this.participant$.pipe( switchMap((p) => p ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( @@ -353,49 +353,49 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether this participant is sending audio (i.e. is unmuted on their side). */ - public readonly audioEnabled: Observable; + public readonly audioEnabled$: Observable; /** * Whether this participant is sending video. */ - public readonly videoEnabled: Observable; + public readonly videoEnabled$: Observable; - private readonly _cropVideo = new BehaviorSubject(true); + private readonly _cropVideo$ = new BehaviorSubject(true); /** * Whether the tile video should be contained inside the tile or be cropped to fit. */ - public readonly cropVideo: Observable = this._cropVideo; + public readonly cropVideo$: Observable = this._cropVideo$; public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { super( id, member, - participant, + participant$, encryptionSystem, Track.Source.Microphone, Track.Source.Camera, livekitRoom, ); - const media = participant.pipe( + 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), ); - this.videoEnabled = media.pipe( + this.videoEnabled$ = media$.pipe( map((m) => m?.cameraTrack?.isMuted === false), ); } public toggleFitContain(): void { - this._cropVideo.next(!this._cropVideo.value); + this._cropVideo$.next(!this._cropVideo$.value); } public get local(): boolean { @@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether the video should be mirrored. */ - public readonly mirror = this.video.pipe( + public readonly mirror$ = this.video$.pipe( switchMap((v) => { const track = v?.publication?.track; if (!(track instanceof LocalTrack)) return of(false); @@ -428,17 +428,17 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { * Whether to show this tile in a highly visible location near the start of * the grid. */ - public readonly alwaysShow = alwaysShowSelf.value; + public readonly alwaysShow$ = alwaysShowSelf.value$; public readonly setAlwaysShow = alwaysShowSelf.setValue; public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super(id, member, participant$, encryptionSystem, livekitRoom); } } @@ -446,18 +446,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { * A remote participant's user media. */ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { - private readonly locallyMutedToggle = new Subject(); - private readonly localVolumeAdjustment = new Subject(); - private readonly localVolumeCommit = new Subject(); + private readonly locallyMutedToggle$ = new Subject(); + private readonly localVolumeAdjustment$ = new Subject(); + private readonly localVolumeCommit$ = new Subject(); /** * The volume to which this participant's audio is set, as a scalar * multiplier. */ - public readonly localVolume: Observable = merge( - this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)), - this.localVolumeAdjustment, - this.localVolumeCommit.pipe(map(() => "commit" as const)), + public readonly localVolume$: Observable = merge( + this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), + this.localVolumeAdjustment$, + this.localVolumeCommit$.pipe(map(() => "commit" as const)), ).pipe( accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { switch (event) { @@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether this participant's audio is disabled. */ - public readonly locallyMuted: Observable = this.localVolume.pipe( + public readonly locallyMuted$: Observable = this.localVolume$.pipe( map((volume) => volume === 0), this.scope.state(), ); @@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super(id, member, participant$, encryptionSystem, livekitRoom); // Sync the local volume with LiveKit combineLatest([ - participant, - this.localVolume.pipe(this.scope.bind()), + participant$, + this.localVolume$.pipe(this.scope.bind()), ]).subscribe(([p, volume]) => p && p.setVolume(volume)); } public toggleLocallyMuted(): void { - this.locallyMutedToggle.next(); + this.locallyMutedToggle$.next(); } public setLocalVolume(value: number): void { - this.localVolumeAdjustment.next(value); + this.localVolumeAdjustment$.next(value); } public commitLocalVolume(): void { - this.localVolumeCommit.next(); + this.localVolumeCommit$.next(); } } @@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { public constructor( id: string, member: RoomMember | undefined, - participant: Observable, + participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, public readonly local: boolean, @@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { super( id, member, - participant, + participant$, encryptionSystem, Track.Source.ScreenShareAudio, Track.Source.ScreenShare, diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index 5a2e0e9a..254fc03f 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -19,9 +19,9 @@ type MonoTypeOperator = (o: Observable) => Observable; * A scope which limits the execution lifetime of its bound Observables. */ export class ObservableScope { - private readonly ended = new Subject(); + private readonly ended$ = new Subject(); - private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended); + private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended$); /** * Binds an Observable to this scope, so that it completes when the scope @@ -31,8 +31,8 @@ export class ObservableScope { return this.bindImpl; } - private readonly stateImpl: MonoTypeOperator = (o) => - o.pipe( + private readonly stateImpl: MonoTypeOperator = (o$) => + o$.pipe( this.bind(), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: false }), @@ -51,7 +51,7 @@ export class ObservableScope { * Ends the scope, causing any bound Observables to complete. */ public end(): void { - this.ended.next(); - this.ended.complete(); + this.ended$.next(); + this.ended$.complete(); } } diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLayout.ts index f078b7e3..2a0e7ff5 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLayout.ts @@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details. import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./CallViewModel"; import { type TileStore } from "./TileStore"; -import { type GridTileViewModel } from "./TileViewModel"; /** * Produces a one-on-one layout with the given media. */ export function oneOnOneLayout( media: OneOnOneLayoutMedia, - visibleTiles: Set, prevTiles: TileStore, ): [OneOnOneLayout, TileStore] { - const update = prevTiles.from(visibleTiles); + const update = prevTiles.from(2); update.registerGridTile(media.local); update.registerGridTile(media.remote); const tiles = update.build(); diff --git a/src/state/PipLayout.ts b/src/state/PipLayout.ts index 79187752..ad56cdd5 100644 --- a/src/state/PipLayout.ts +++ b/src/state/PipLayout.ts @@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details. import { type PipLayout, type PipLayoutMedia } from "./CallViewModel"; import { type TileStore } from "./TileStore"; -import { type GridTileViewModel } from "./TileViewModel"; /** * Produces a picture-in-picture layout with the given media. */ export function pipLayout( media: PipLayoutMedia, - visibleTiles: Set, prevTiles: TileStore, ): [PipLayout, TileStore] { - const update = prevTiles.from(visibleTiles); + const update = prevTiles.from(0); update.registerSpotlight(media.spotlight, true); const tiles = update.build(); return [ diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 56b1d01d..c14b24a7 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -10,17 +10,15 @@ import { type SpotlightExpandedLayoutMedia, } from "./CallViewModel"; import { type TileStore } from "./TileStore"; -import { type GridTileViewModel } from "./TileViewModel"; /** * Produces an expanded spotlight layout with the given media. */ export function spotlightExpandedLayout( media: SpotlightExpandedLayoutMedia, - visibleTiles: Set, prevTiles: TileStore, ): [SpotlightExpandedLayout, TileStore] { - const update = prevTiles.from(visibleTiles); + const update = prevTiles.from(1); update.registerSpotlight(media.spotlight, true); if (media.pip !== undefined) update.registerGridTile(media.pip); const tiles = update.build(); diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 2464d9eb..4d6878b6 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] { } let DEBUG_ENABLED = false; -debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value)); +debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value)); class SpotlightTileData { - private readonly media_: BehaviorSubject; + private readonly media$: BehaviorSubject; public get media(): MediaViewModel[] { - return this.media_.value; + return this.media$.value; } public set media(value: MediaViewModel[]) { - this.media_.next(value); + this.media$.next(value); } - private readonly maximised_: BehaviorSubject; + private readonly maximised$: BehaviorSubject; public get maximised(): boolean { - return this.maximised_.value; + return this.maximised$.value; } public set maximised(value: boolean) { - this.maximised_.next(value); + this.maximised$.next(value); } public readonly vm: SpotlightTileViewModel; public constructor(media: MediaViewModel[], maximised: boolean) { - this.media_ = new BehaviorSubject(media); - this.maximised_ = new BehaviorSubject(maximised); - this.vm = new SpotlightTileViewModel(this.media_, this.maximised_); + this.media$ = new BehaviorSubject(media); + this.maximised$ = new BehaviorSubject(maximised); + this.vm = new SpotlightTileViewModel(this.media$, this.maximised$); } public destroy(): void { @@ -51,19 +51,19 @@ class SpotlightTileData { } class GridTileData { - private readonly media_: BehaviorSubject; + private readonly media$: BehaviorSubject; public get media(): UserMediaViewModel { - return this.media_.value; + return this.media$.value; } public set media(value: UserMediaViewModel) { - this.media_.next(value); + this.media$.next(value); } public readonly vm: GridTileViewModel; public constructor(media: UserMediaViewModel) { - this.media_ = new BehaviorSubject(media); - this.vm = new GridTileViewModel(this.media_); + this.media$ = new BehaviorSubject(media); + this.vm = new GridTileViewModel(this.media$); } public destroy(): void { @@ -101,7 +101,7 @@ export class TileStore { * Creates a builder which can be used to update the collection, passing * ownership of the tiles to the updated collection. */ - public from(visibleTiles: Set): TileStoreBuilder { + public from(visibleTiles: number): TileStoreBuilder { return new TileStoreBuilder( this.spotlight, this.grid, @@ -123,7 +123,10 @@ export class TileStoreBuilder { "speaking" in this.prevSpotlight.media[0] && this.prevSpotlight.media[0]; - private readonly prevGridByMedia = new Map( + private readonly prevGridByMedia: Map< + MediaViewModel, + [GridTileData, number] + > = new Map( this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const), ); @@ -146,7 +149,7 @@ export class TileStoreBuilder { spotlight: SpotlightTileData | null, grid: GridTileData[], ) => TileStore, - private readonly visibleTiles: Set, + private readonly visibleTiles: number, /** * A number incremented on each update, just for debugging purposes. */ @@ -204,10 +207,8 @@ export class TileStoreBuilder { const prev = this.prevGridByMedia.get(this.spotlight.media[0]); if (prev !== undefined) { const [entry, prevIndex] = prev; - const previouslyVisible = this.visibleTiles.has(entry.vm); - const nowVisible = this.visibleTiles.has( - this.prevGrid[this.numGridEntries]?.vm, - ); + const previouslyVisible = prevIndex < this.visibleTiles; + const nowVisible = this.numGridEntries < this.visibleTiles; // If it doesn't need to move between the visible/invisible sections of // the grid, then we can keep it where it was and swap the media @@ -236,17 +237,15 @@ export class TileStoreBuilder { const prev = this.prevGridByMedia.get(media); if (prev === undefined) { // Create a new tile - (this.visibleTiles.has(this.prevGrid[this.numGridEntries]?.vm) + (this.numGridEntries < this.visibleTiles ? this.visibleGridEntries : this.invisibleGridEntries ).push(new GridTileData(media)); } else { // Reuse the existing tile const [entry, prevIndex] = prev; - const previouslyVisible = this.visibleTiles.has(entry.vm); - const nowVisible = this.visibleTiles.has( - this.prevGrid[this.numGridEntries]?.vm, - ); + const previouslyVisible = prevIndex < this.visibleTiles; + const nowVisible = this.numGridEntries < this.visibleTiles; // If it doesn't need to move between the visible/invisible sections of // the grid, then we can keep it exactly where it was previously if (previouslyVisible === nowVisible) diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 53bc8648..5815df54 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { BehaviorSubject, type Observable } from "rxjs"; +import { type Observable } from "rxjs"; import { ViewModel } from "./ViewModel"; import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; @@ -18,23 +18,15 @@ function createId(): string { export class GridTileViewModel extends ViewModel { public readonly id = createId(); - private readonly visible_ = new BehaviorSubject(false); - /** - * Whether the tile is visible within the current viewport. - */ - public readonly visible: Observable = this.visible_; - - public setVisible = (value: boolean): void => this.visible_.next(value); - - public constructor(public readonly media: Observable) { + public constructor(public readonly media$: Observable) { super(); } } export class SpotlightTileViewModel extends ViewModel { public constructor( - public readonly media: Observable, - public readonly maximised: Observable, + public readonly media$: Observable, + public readonly maximised$: Observable, ) { super(); } diff --git a/src/state/observeSpeaker.test.ts b/src/state/observeSpeaker.test.ts index daa5f033..2a73482c 100644 --- a/src/state/observeSpeaker.test.ts +++ b/src/state/observeSpeaker.test.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { describe, test } from "vitest"; import { withTestScheduler } from "../utils/test"; -import { observeSpeaker } from "./observeSpeaker"; +import { observeSpeaker$ } from "./observeSpeaker"; const yesNo = { y: true, @@ -22,40 +22,36 @@ describe("observeSpeaker", () => { // should default to false when no input is given const speakingInputMarbles = ""; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("after no speaking", () => { const speakingInputMarbles = "n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with speaking for 1ms", () => { const speakingInputMarbles = "y n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with speaking for 999ms", () => { const speakingInputMarbles = "y 999ms n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -63,20 +59,18 @@ describe("observeSpeaker", () => { const speakingInputMarbles = "y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); test("with consecutive speaking then stops speaking", () => { const speakingInputMarbles = "y y y y y y y y y y n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); }); @@ -87,10 +81,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y"; const expectedOutputMarbles = "n 999ms y"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -98,10 +91,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y 1s n "; const expectedOutputMarbles = "n 999ms y 60s n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); @@ -109,10 +101,9 @@ describe("observeSpeaker", () => { const speakingInputMarbles = " y 5s n "; const expectedOutputMarbles = "n 999ms y 64s n"; withTestScheduler(({ hot, expectObservable }) => { - expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( - expectedOutputMarbles, - yesNo, - ); + expectObservable( + observeSpeaker$(hot(speakingInputMarbles, yesNo)), + ).toBe(expectedOutputMarbles, yesNo); }); }); }); diff --git a/src/state/observeSpeaker.ts b/src/state/observeSpeaker.ts index cce43ef9..8817af25 100644 --- a/src/state/observeSpeaker.ts +++ b/src/state/observeSpeaker.ts @@ -18,16 +18,16 @@ import { * Require 1 second of continuous speaking to become a speaker, and 60 second of * continuous silence to stop being considered a speaker */ -export function observeSpeaker( - isSpeakingObservable: Observable, +export function observeSpeaker$( + isSpeakingObservable$: Observable, ): Observable { - const distinct = isSpeakingObservable.pipe(distinctUntilChanged()); + const distinct$ = isSpeakingObservable$.pipe(distinctUntilChanged()); - return distinct.pipe( + return distinct$.pipe( // Either change to the new value after the timer or re-emit the same value if it toggles back // (audit will return the latest (toggled back) value) before the timeout. audit((s) => - merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))), + merge(timer(s ? 1000 : 60000), distinct$.pipe(filter((s1) => s1 !== s))), ), // Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->.. startWith(false), diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 73c17527..8c6b2d9b 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -83,13 +83,13 @@ const UserMediaTile = forwardRef( ref, ) => { const { t } = useTranslation(); - const video = useObservableEagerState(vm.video); - const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); - const encryptionStatus = useObservableEagerState(vm.encryptionStatus); - const audioEnabled = useObservableEagerState(vm.audioEnabled); - const videoEnabled = useObservableEagerState(vm.videoEnabled); - const speaking = useObservableEagerState(vm.speaking); - const cropVideo = useObservableEagerState(vm.cropVideo); + const video = useObservableEagerState(vm.video$); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); + const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); + const audioEnabled = useObservableEagerState(vm.audioEnabled$); + const videoEnabled = useObservableEagerState(vm.videoEnabled$); + const speaking = useObservableEagerState(vm.speaking$); + const cropVideo = useObservableEagerState(vm.cropVideo$); const onSelectFitContain = useCallback( (e: Event) => { e.preventDefault(); @@ -198,8 +198,8 @@ interface LocalUserMediaTileProps extends TileProps { const LocalUserMediaTile = forwardRef( ({ vm, onOpenProfile, ...props }, ref) => { const { t } = useTranslation(); - const mirror = useObservableEagerState(vm.mirror); - const alwaysShow = useObservableEagerState(vm.alwaysShow); + const mirror = useObservableEagerState(vm.mirror$); + const alwaysShow = useObservableEagerState(vm.alwaysShow$); const latestAlwaysShow = useLatest(alwaysShow); const onSelectAlwaysShow = useCallback( (e: Event) => { @@ -249,8 +249,8 @@ const RemoteUserMediaTile = forwardRef< RemoteUserMediaTileProps >(({ vm, ...props }, ref) => { const { t } = useTranslation(); - const locallyMuted = useObservableEagerState(vm.locallyMuted); - const localVolume = useObservableEagerState(vm.localVolume); + const locallyMuted = useObservableEagerState(vm.locallyMuted$); + const localVolume = useObservableEagerState(vm.localVolume$); const onSelectMute = useCallback( (e: Event) => { e.preventDefault(); @@ -316,7 +316,7 @@ export const GridTile = forwardRef( ({ vm, onOpenProfile, ...props }, theirRef) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); - const media = useObservableEagerState(vm.media); + const media = useObservableEagerState(vm.media$); const displayName = useDisplayName(media); if (media instanceof LocalUserMediaViewModel) { diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index a1c3d46f..c72bad81 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -72,7 +72,7 @@ const SpotlightLocalUserMediaItem = forwardRef< HTMLDivElement, SpotlightLocalUserMediaItemProps >(({ vm, ...props }, ref) => { - const mirror = useObservableEagerState(vm.mirror); + const mirror = useObservableEagerState(vm.mirror$); return ; }); @@ -86,8 +86,8 @@ const SpotlightUserMediaItem = forwardRef< HTMLDivElement, SpotlightUserMediaItemProps >(({ vm, ...props }, ref) => { - const videoEnabled = useObservableEagerState(vm.videoEnabled); - const cropVideo = useObservableEagerState(vm.cropVideo); + const videoEnabled = useObservableEagerState(vm.videoEnabled$); + const cropVideo = useObservableEagerState(vm.cropVideo$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { @@ -110,7 +110,7 @@ interface SpotlightItemProps { vm: MediaViewModel; targetWidth: number; targetHeight: number; - intersectionObserver: Observable; + intersectionObserver$: Observable; /** * Whether this item should act as a scroll snapping point. */ @@ -124,7 +124,7 @@ const SpotlightItem = forwardRef( vm, targetWidth, targetHeight, - intersectionObserver, + intersectionObserver$, snap, "aria-hidden": ariaHidden, }, @@ -133,15 +133,15 @@ const SpotlightItem = forwardRef( const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const displayName = useDisplayName(vm); - const video = useObservableEagerState(vm.video); - const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); - const encryptionStatus = useObservableEagerState(vm.encryptionStatus); + const video = useObservableEagerState(vm.video$); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); + const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); // Hook this item up to the intersection observer useEffect(() => { const element = ourRef.current!; let prevIo: IntersectionObserver | null = null; - const subscription = intersectionObserver.subscribe((io) => { + const subscription = intersectionObserver$.subscribe((io) => { prevIo?.unobserve(element); io.observe(element); prevIo = io; @@ -150,7 +150,7 @@ const SpotlightItem = forwardRef( subscription.unsubscribe(); prevIo?.unobserve(element); }; - }, [intersectionObserver]); + }, [intersectionObserver$]); const baseProps: SpotlightItemBaseProps & RefAttributes = { ref, @@ -208,10 +208,10 @@ export const SpotlightTile = forwardRef( theirRef, ) => { const { t } = useTranslation(); - const [ourRef, root] = useObservableRef(null); + const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); - const maximised = useObservableEagerState(vm.maximised); - const media = useObservableEagerState(vm.media); + const maximised = useObservableEagerState(vm.maximised$); + const media = useObservableEagerState(vm.media$); const [visibleId, setVisibleId] = useState( media[0]?.id, ); @@ -225,9 +225,9 @@ export const SpotlightTile = forwardRef( // hooked up to the root element and the items. Because the items will run // their effects before their parent does, we need to do this dance with an // Observable to actually give them the intersection observer. - const intersectionObserver = useInitial>( + const intersectionObserver$ = useInitial>( () => - root.pipe( + root$.pipe( map( (r) => new IntersectionObserver( @@ -295,7 +295,7 @@ export const SpotlightTile = forwardRef( vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} - intersectionObserver={intersectionObserver} + intersectionObserver$={intersectionObserver$} // This is how we get the container to scroll to the right media // when the previous/next buttons are clicked: we temporarily // remove all scroll snap points except for just the one media diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index 565208b1..9f97f1b6 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { expect, test, vitest } from "vitest"; +import { expect, test, vitest, afterEach } from "vitest"; import { type FC } from "react"; import { render } from "@testing-library/react"; -import { afterEach } from "node:test"; import userEvent from "@testing-library/user-event"; import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext"; @@ -101,7 +100,8 @@ test("will use the correct device", () => { audioInput: deviceStub, audioOutput: { selectedId: "chosen-device", - available: [], + selectedGroupId: "", + available: new Map(), select: () => {}, }, videoInput: deviceStub, diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 00000000..70ecbd89 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,16 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +/** + * Determine whether two arrays are equal by shallow comparison. + */ +export function shallowEquals(first: A[], second: A[]): boolean { + if (first.length !== second.length) return false; + for (let i = 0; i < first.length; i++) + if (first[i] !== second[i]) return false; + return true; +} diff --git a/src/utils/observable.ts b/src/utils/observable.ts index a54c0293..977bdf79 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -15,10 +15,10 @@ const nothing = Symbol("nothing"); * callback will not be invoked. */ export function finalizeValue(callback: (finalValue: T) => void) { - return (source: Observable): Observable => + return (source$: Observable): Observable => defer(() => { let finalValue: T | typeof nothing = nothing; - return source.pipe( + return source$.pipe( tap((value) => (finalValue = value)), finalize(() => { if (finalValue !== nothing) callback(finalValue); @@ -35,6 +35,6 @@ export function accumulate( initial: State, update: (state: State, event: Event) => State, ) { - return (events: Observable): Observable => - events.pipe(scan(update, initial), startWith(initial)); + return (events$: Observable): Observable => + events$.pipe(scan(update, initial), startWith(initial)); } diff --git a/src/utils/test.ts b/src/utils/test.ts index 1cd21f01..db0d8959 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -77,14 +77,14 @@ export function withTestScheduler( continuation({ ...helpers, schedule(marbles, actions) { - const actionsObservable = helpers + const actionsObservable$ = helpers .cold(marbles) .pipe(map((value) => actions[value]())); const results = Object.fromEntries( Object.keys(actions).map((value) => [value, undefined] as const), ); // Run the actions and verify that none of them error - helpers.expectObservable(actionsObservable).toBe(marbles, results); + helpers.expectObservable(actionsObservable$).toBe(marbles, results); }, }), ); @@ -157,16 +157,16 @@ export function mockMatrixRoom(room: Partial): MatrixRoom { export function mockLivekitRoom( room: Partial, { - remoteParticipants, - }: { remoteParticipants?: Observable } = {}, + remoteParticipants$, + }: { remoteParticipants$?: Observable } = {}, ): LivekitRoom { const livekitRoom = { ...mockEmitter(), ...room, } as Partial as LivekitRoom; - if (remoteParticipants) { + if (remoteParticipants$) { livekitRoom.remoteParticipants = new Map(); - remoteParticipants.subscribe((newRemoteParticipants) => { + remoteParticipants$.subscribe((newRemoteParticipants) => { livekitRoom.remoteParticipants.clear(); newRemoteParticipants.forEach((p) => { livekitRoom.remoteParticipants.set(p.identity, p); @@ -238,7 +238,7 @@ export async function withRemoteMedia( { kind: E2eeType.PER_PARTICIPANT, }, - mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }), + mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), ); try { await continuation(vm); @@ -277,9 +277,9 @@ export class MockRTCSession extends TypedEventEmitter< } public withMemberships( - rtcMembers: Observable[]>, + rtcMembers$: Observable[]>, ): MockRTCSession { - rtcMembers.subscribe((m) => { + rtcMembers$.subscribe((m) => { const old = this.memberships; // always prepend the local participant const updated = [this.localMembership, ...(m as CallMembership[])]; diff --git a/yarn.lock b/yarn.lock index 880d8e52..64d418fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1969,10 +1969,10 @@ "@mediapipe/holistic" "0.5.1675471629" "@mediapipe/tasks-vision" "0.10.9" -"@matrix-org/matrix-sdk-crypto-wasm@^9.0.0": - version "9.1.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-9.1.0.tgz#f889653eb4fafaad2a963654d586bd34de62acd5" - integrity sha512-CtPoNcoRW6ehwxpRQAksG3tR+NJ7k4DV02nMFYTDwQtie1V4R8OTY77BjEIs97NOblhtS26jU8m1lWsOBEz0Og== +"@matrix-org/matrix-sdk-crypto-wasm@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-12.0.0.tgz#e3a5150ccbb21d5e98ee3882e7057b9f17fb962a" + integrity sha512-nkkXAxUIk9UTso4TbU6Bgqsv/rJShXQXRx0ti/W+AWXHJ2HoH4sL5LsXkc7a8yYGn8tyXqxGPsYA1UeHqLwm0Q== "@matrix-org/olm@3.2.15": version "3.2.15" @@ -2363,6 +2363,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2" integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA== +"@radix-ui/primitive@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3" + integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA== + "@radix-ui/react-arrow@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a" @@ -2380,11 +2385,26 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-collection@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.1.tgz#be2c7e01d3508e6d4b6d838f492e7d182f17d3b0" + integrity sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-compose-refs@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74" integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw== +"@radix-ui/react-compose-refs@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec" + integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw== + "@radix-ui/react-context-menu@^2.2.1": version "2.2.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-2.2.2.tgz#efcddc559fc3011721b65148f062d04027f76c7a" @@ -2408,21 +2428,21 @@ integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q== "@radix-ui/react-dialog@^1.0.4", "@radix-ui/react-dialog@^1.1.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz#d9345575211d6f2d13e209e84aec9a8584b54d6c" - integrity sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA== + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.3.tgz#87cf49f619a6a0f6219980678be0f7c31978dee1" + integrity sha512-ujGvqQNkZ0J7caQyl8XuZRj2/TIrYcOGwqz5TeD1OMcCdfBuEMP0D12ve+8J5F9XuNUth3FAKFWo/wt0E/GJrQ== dependencies: - "@radix-ui/primitive" "1.1.0" - "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" "@radix-ui/react-context" "1.1.1" - "@radix-ui/react-dismissable-layer" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.2" "@radix-ui/react-focus-guards" "1.1.1" - "@radix-ui/react-focus-scope" "1.1.0" + "@radix-ui/react-focus-scope" "1.1.1" "@radix-ui/react-id" "1.1.0" - "@radix-ui/react-portal" "1.1.2" - "@radix-ui/react-presence" "1.1.1" - "@radix-ui/react-primitive" "2.0.0" - "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" "@radix-ui/react-use-controllable-state" "1.1.0" aria-hidden "^1.1.1" react-remove-scroll "2.6.0" @@ -2443,6 +2463,17 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-escape-keydown" "1.1.0" +"@radix-ui/react-dismissable-layer@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.2.tgz#771594b202f32bc8ffeb278c565f10c513814aee" + integrity sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + "@radix-ui/react-dropdown-menu@^2.1.1": version "2.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz#acc49577130e3c875ef0133bd1e271ea3392d924" @@ -2470,6 +2501,15 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-focus-scope@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz#5c602115d1db1c4fcfa0fae4c3b09bb8919853cb" + integrity sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-form@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-form/-/react-form-0.1.0.tgz#7111a6aa54a2bde0d11fb72643f9ffc871ac58ad" @@ -2544,6 +2584,14 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-portal@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.3.tgz#b0ea5141103a1671b715481b13440763d2ac4440" + integrity sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-presence@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz#98aba423dba5e0c687a782c0669dcd99de17f9b1" @@ -2552,6 +2600,14 @@ "@radix-ui/react-compose-refs" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-presence@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc" + integrity sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-primitive@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884" @@ -2559,6 +2615,13 @@ dependencies: "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-primitive@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz#6d9efc550f7520135366f333d1e820cf225fad9e" + integrity sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg== + dependencies: + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-progress@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.1.0.tgz#28c267885ec154fc557ec7a66cb462787312f7e2" @@ -2590,17 +2653,17 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-slider@^1.1.2": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.2.1.tgz#acb0804309890f3cd7a224b2b0c4c4704f32921b" - integrity sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw== + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.2.2.tgz#4ca883e3f0dea7b97d43c6cbc6c4305c64e75a86" + integrity sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA== dependencies: "@radix-ui/number" "1.1.0" - "@radix-ui/primitive" "1.1.0" - "@radix-ui/react-collection" "1.1.0" - "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-collection" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" "@radix-ui/react-context" "1.1.1" "@radix-ui/react-direction" "1.1.0" - "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-primitive" "2.0.1" "@radix-ui/react-use-controllable-state" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" "@radix-ui/react-use-previous" "1.1.0" @@ -2613,6 +2676,13 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.0" +"@radix-ui/react-slot@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3" + integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-use-callback-ref@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" @@ -2657,11 +2727,11 @@ "@radix-ui/react-use-layout-effect" "1.1.0" "@radix-ui/react-visually-hidden@^1.0.3": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz#ad47a8572580f7034b3807c8e6740cd41038a5a2" - integrity sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ== + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz#f7b48c1af50dfdc366e92726aee6d591996c5752" + integrity sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg== dependencies: - "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-primitive" "2.0.1" "@radix-ui/rect@1.1.0": version "1.1.0" @@ -2727,231 +2797,236 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.25.0.tgz#3e7eda4c0c1de6d2415343002d742ff95e38dca7" integrity sha512-CC/ZqFZwlAIbU1wUPisHyV/XRc5RydFrNLtgl3dGYskdwPZdt4HERtKm50a/+DtTlKeCq9IXFEWR+P6blwjqBA== -"@rollup/rollup-android-arm-eabi@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz#462e7ecdd60968bc9eb95a20d185e74f8243ec1b" - integrity sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ== +"@rollup/rollup-android-arm-eabi@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz#7f4c4d8cd5ccab6e95d6750dbe00321c1f30791e" + integrity sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ== "@rollup/rollup-android-arm64@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.25.0.tgz#04f679231acf7284f1f8a1f7250d0e0944865ba8" integrity sha512-/Y76tmLGUJqVBXXCfVS8Q8FJqYGhgH4wl4qTA24E9v/IJM0XvJCGQVSW1QZ4J+VURO9h8YCa28sTFacZXwK7Rg== -"@rollup/rollup-android-arm64@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz#78a2b8a8a55f71a295eb860a654ae90a2b168f40" - integrity sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA== +"@rollup/rollup-android-arm64@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz#17ea71695fb1518c2c324badbe431a0bd1879f2d" + integrity sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA== "@rollup/rollup-darwin-arm64@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.25.0.tgz#ecea723041621747d0772af93b54752edf26467a" integrity sha512-YVT6L3UrKTlC0FpCZd0MGA7NVdp7YNaEqkENbWQ7AOVOqd/7VzyHpgIpc1mIaxRAo1ZsJRH45fq8j4N63I/vvg== -"@rollup/rollup-darwin-arm64@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz#5b783af714f434f1e66e3cdfa3817e0b99216d84" - integrity sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q== +"@rollup/rollup-darwin-arm64@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz#dac0f0d0cfa73e7d5225ae6d303c13c8979e7999" + integrity sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ== "@rollup/rollup-darwin-x64@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.25.0.tgz#28e6e0687092f31e20982fc104779d48c643fc21" integrity sha512-ZRL+gexs3+ZmmWmGKEU43Bdn67kWnMeWXLFhcVv5Un8FQcx38yulHBA7XR2+KQdYIOtD0yZDWBCudmfj6lQJoA== -"@rollup/rollup-darwin-x64@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz#f72484e842521a5261978034e18e20f778a2850d" - integrity sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w== +"@rollup/rollup-darwin-x64@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz#8f63baa1d31784904a380d2e293fa1ddf53dd4a2" + integrity sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ== "@rollup/rollup-freebsd-arm64@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.25.0.tgz#99e9173b8aef3d1ef086983da70413988206e530" integrity sha512-xpEIXhiP27EAylEpreCozozsxWQ2TJbOLSivGfXhU4G1TBVEYtUPi2pOZBnvGXHyOdLAUUhPnJzH3ah5cqF01g== -"@rollup/rollup-freebsd-arm64@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz#3c919dff72b2fe344811a609c674a8347b033f62" - integrity sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ== +"@rollup/rollup-freebsd-arm64@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz#30ed247e0df6e8858cdc6ae4090e12dbeb8ce946" + integrity sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA== "@rollup/rollup-freebsd-x64@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.25.0.tgz#f3a1ef941f8d3c6b2b036484c69a7b2d3d9ebbd7" integrity sha512-sC5FsmZGlJv5dOcURrsnIK7ngc3Kirnx3as2XU9uER+zjfyqIjdcMVgzy4cOawhsssqzoAX19qmxgJ8a14Qrqw== -"@rollup/rollup-freebsd-x64@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz#b62a3a8365b363b3fdfa6da11a9188b6ab4dca7c" - integrity sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA== +"@rollup/rollup-freebsd-x64@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz#57846f382fddbb508412ae07855b8a04c8f56282" + integrity sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ== "@rollup/rollup-linux-arm-gnueabihf@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.25.0.tgz#9ba6adcc33f26f2a0c6ee658f0bbda4de8da2f75" integrity sha512-uD/dbLSs1BEPzg564TpRAQ/YvTnCds2XxyOndAO8nJhaQcqQGFgv/DAVko/ZHap3boCvxnzYMa3mTkV/B/3SWA== -"@rollup/rollup-linux-arm-gnueabihf@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz#0d02cc55bd229bd8ca5c54f65f916ba5e0591c94" - integrity sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w== +"@rollup/rollup-linux-arm-gnueabihf@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz#378ca666c9dae5e6f94d1d351e7497c176e9b6df" + integrity sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA== "@rollup/rollup-linux-arm-musleabihf@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.25.0.tgz#62f2426fa9016ec884f4fa779d7b62d5ba02a41a" integrity sha512-ZVt/XkrDlQWegDWrwyC3l0OfAF7yeJUF4fq5RMS07YM72BlSfn2fQQ6lPyBNjt+YbczMguPiJoCfaQC2dnflpQ== -"@rollup/rollup-linux-arm-musleabihf@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz#c51d379263201e88a60e92bd8e90878f0c044425" - integrity sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg== +"@rollup/rollup-linux-arm-musleabihf@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz#a692eff3bab330d5c33a5d5813a090c15374cddb" + integrity sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg== "@rollup/rollup-linux-arm64-gnu@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.25.0.tgz#f98ec111a231d35e0c6d3404e3d80f67f9d5b9f8" integrity sha512-qboZ+T0gHAW2kkSDPHxu7quaFaaBlynODXpBVnPxUgvWYaE84xgCKAPEYE+fSMd3Zv5PyFZR+L0tCdYCMAtG0A== -"@rollup/rollup-linux-arm64-gnu@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz#93ce2addc337b5cfa52b84f8e730d2e36eb4339b" - integrity sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg== +"@rollup/rollup-linux-arm64-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz#6b1719b76088da5ac1ae1feccf48c5926b9e3db9" + integrity sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA== "@rollup/rollup-linux-arm64-musl@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.25.0.tgz#4b36ffb8359f959f2c29afd187603c53368b6723" integrity sha512-ndWTSEmAaKr88dBuogGH2NZaxe7u2rDoArsejNslugHZ+r44NfWiwjzizVS1nUOHo+n1Z6qV3X60rqE/HlISgw== -"@rollup/rollup-linux-arm64-musl@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz#730af6ddc091a5ba5baac28a3510691725dc808b" - integrity sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw== +"@rollup/rollup-linux-arm64-musl@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz#865baf5b6f5ff67acb32e5a359508828e8dc5788" + integrity sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A== + +"@rollup/rollup-linux-loongarch64-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz#23c6609ba0f7fa7a7f2038b6b6a08555a5055a87" + integrity sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA== "@rollup/rollup-linux-powerpc64le-gnu@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.25.0.tgz#52f4b39e6783505d168a745b79d86474fde71680" integrity sha512-BVSQvVa2v5hKwJSy6X7W1fjDex6yZnNKy3Kx1JGimccHft6HV0THTwNtC2zawtNXKUu+S5CjXslilYdKBAadzA== -"@rollup/rollup-linux-powerpc64le-gnu@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz#b5565aac20b4de60ca1e557f525e76478b5436af" - integrity sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ== +"@rollup/rollup-linux-powerpc64le-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz#652ef0d9334a9f25b9daf85731242801cb0fc41c" + integrity sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A== "@rollup/rollup-linux-riscv64-gnu@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.25.0.tgz#49195be7e6a7d68d482b12461e2ea914e31ff977" integrity sha512-G4hTREQrIdeV0PE2JruzI+vXdRnaK1pg64hemHq2v5fhv8C7WjVaeXc9P5i4Q5UC06d/L+zA0mszYIKl+wY8oA== -"@rollup/rollup-linux-riscv64-gnu@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz#d488290bf9338bad4ae9409c4aa8a1728835a20b" - integrity sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g== +"@rollup/rollup-linux-riscv64-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz#1eb6651839ee6ebca64d6cc64febbd299e95e6bd" + integrity sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA== "@rollup/rollup-linux-s390x-gnu@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.25.0.tgz#4b8d50a205eac7b46cdcb9c50d4a6ae5994c02e0" integrity sha512-9T/w0kQ+upxdkFL9zPVB6zy9vWW1deA3g8IauJxojN4bnz5FwSsUAD034KpXIVX5j5p/rn6XqumBMxfRkcHapQ== -"@rollup/rollup-linux-s390x-gnu@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz#eb2e3f3a06acf448115045c11a5a96868c95a556" - integrity sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw== +"@rollup/rollup-linux-s390x-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz#015c52293afb3ff2a293cf0936b1d43975c1e9cd" + integrity sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg== "@rollup/rollup-linux-x64-gnu@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.25.0.tgz#dfcceebc5ccac7fc2db19471996026258c81b55f" integrity sha512-ThcnU0EcMDn+J4B9LD++OgBYxZusuA7iemIIiz5yzEcFg04VZFzdFjuwPdlURmYPZw+fgVrFzj4CA64jSTG4Ig== -"@rollup/rollup-linux-x64-gnu@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz#065952ef2aea7e837dc7e02aa500feeaff4fc507" - integrity sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw== +"@rollup/rollup-linux-x64-gnu@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz#b83001b5abed2bcb5e2dbeec6a7e69b194235c1e" + integrity sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw== "@rollup/rollup-linux-x64-musl@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.25.0.tgz#192f78bad8429711d63a31dc0a7d3312e2df850e" integrity sha512-zx71aY2oQxGxAT1JShfhNG79PnjYhMC6voAjzpu/xmMjDnKNf6Nl/xv7YaB/9SIa9jDYf8RBPWEnjcdlhlv1rQ== -"@rollup/rollup-linux-x64-musl@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz#3435d484d05f5c4d1ffd54541b4facce2887103a" - integrity sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw== +"@rollup/rollup-linux-x64-musl@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz#6cc7c84cd4563737f8593e66f33b57d8e228805b" + integrity sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g== "@rollup/rollup-win32-arm64-msvc@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.25.0.tgz#f4ec076579634f780b4e5896ae7f59f3e38e0c60" integrity sha512-JT8tcjNocMs4CylWY/CxVLnv8e1lE7ff1fi6kbGocWwxDq9pj30IJ28Peb+Y8yiPNSF28oad42ApJB8oUkwGww== -"@rollup/rollup-win32-arm64-msvc@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz#69682a2a10d9fedc334f87583cfca83c39c08077" - integrity sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg== +"@rollup/rollup-win32-arm64-msvc@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz#631ffeee094d71279fcd1fe8072bdcf25311bc11" + integrity sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A== "@rollup/rollup-win32-ia32-msvc@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.25.0.tgz#5458eab1929827e4f805cefb90bd09ecf7eeed2b" integrity sha512-dRLjLsO3dNOfSN6tjyVlG+Msm4IiZnGkuZ7G5NmpzwF9oOc582FZG05+UdfTbz5Jd4buK/wMb6UeHFhG18+OEg== -"@rollup/rollup-win32-ia32-msvc@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz#b64470f9ac79abb386829c56750b9a4711be3332" - integrity sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A== +"@rollup/rollup-win32-ia32-msvc@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz#06d1d60d5b9f718e8a6c4a43f82e3f9e3254587f" + integrity sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA== "@rollup/rollup-win32-x64-msvc@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.25.0.tgz#93415e7e707e4b156d77c5950b983b58f4bc33f3" integrity sha512-/RqrIFtLB926frMhZD0a5oDa4eFIbyNEwLLloMTEjmqfwZWXywwVVOVmwTsuyhC9HKkVEZcOOi+KV4U9wmOdlg== -"@rollup/rollup-win32-x64-msvc@4.28.0": - version "4.28.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz#cb313feef9ac6e3737067fdf34f42804ac65a6f2" - integrity sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ== +"@rollup/rollup-win32-x64-msvc@4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz#4dff5c4259ebe6c5b4a8f2c5bc3829b7a8447ff0" + integrity sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA== "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@sentry-internal/browser-utils@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.43.0.tgz#b064908a537d1cc17d8ddaf0f4c5d712557cbf40" - integrity sha512-5WhJZ3SA5sZVDBwOsChDd5JCzYcwBX7sEqBqEcm3pFru6TUihEnFIJmDIbreIyrQMwUhs3dTxnfnidgjr5z1Ag== +"@sentry-internal/browser-utils@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.45.0.tgz#8e9217b8e8a4242c9a8244dce648289eaa1e38a0" + integrity sha512-MX/E/C+W5I9jkGD1PsbZ2hpCc7YuizNKmEbuGPxQPfUSIPrdE2wpo6ZfIhEbxq9m/trl1oRCN4PXi3BB7dlYYg== dependencies: - "@sentry/core" "8.43.0" + "@sentry/core" "8.45.0" -"@sentry-internal/feedback@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.43.0.tgz#9477b999c9bca62335eb944a6f7246a96beb0111" - integrity sha512-rcGR2kzFu4vLXBQbI9eGJwjyToyjl36O2q/UKbiZBNJ5IFtDvKRLke6jIHq/YqiHPfFGpVtq5M/lYduDfA/eaQ== +"@sentry-internal/feedback@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.45.0.tgz#cfd7f54d5089682a2768c1229a5efcda4d9561fe" + integrity sha512-WerpfkKrKPAlnQuqjEgKXZtrx68cla7GyOkNOeL40JQbY4/By4Qjx1atUOmgk/FdjrCLPw+jQQY9pXRpMRqqRw== dependencies: - "@sentry/core" "8.43.0" + "@sentry/core" "8.45.0" -"@sentry-internal/replay-canvas@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.43.0.tgz#f5672a08c9eb588afa0bf36f07b9f5c29b5c9920" - integrity sha512-rL8G7E1GtozH8VNalRrBQNjYDJ5ChWS/vpQI5hUG11PZfvQFXEVatLvT3uO2l0xIlHm4idTsHOSLTe/usxnogQ== +"@sentry-internal/replay-canvas@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.45.0.tgz#46f39402ff0cfee4ae05191af20b4e4fac6f474c" + integrity sha512-LZ8kBuzO5gutDiWnCyYEzBMDLq9PIllcsWsXRpKoau0Zqs3DbyRolI11dNnxmUSh7UW21FksxBpqn5yPmUMbag== dependencies: - "@sentry-internal/replay" "8.43.0" - "@sentry/core" "8.43.0" + "@sentry-internal/replay" "8.45.0" + "@sentry/core" "8.45.0" -"@sentry-internal/replay@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.43.0.tgz#4e2e3844f52b47b16bf816d21857921bbfe85d62" - integrity sha512-geV5/zejLfGGwWHjylzrb1w8NI3U37GMG9/53nmv13FmTXUDF5XF2lh41KXFVYwvp7Ha4bd1FRQ9IU9YtBWskw== +"@sentry-internal/replay@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.45.0.tgz#e94d250de235491888694f7cf0f637114adb4b9a" + integrity sha512-SOFwFpzx0B6lxhLl2hBnxvybo7gdB5TMY8dOHMwXgk5A2+BXvSpvWXnr33yqUlBmC8R3LeFTB3C0plzM5lhkJg== dependencies: - "@sentry-internal/browser-utils" "8.43.0" - "@sentry/core" "8.43.0" + "@sentry-internal/browser-utils" "8.45.0" + "@sentry/core" "8.45.0" "@sentry/babel-plugin-component-annotate@2.22.7": version "2.22.7" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.22.7.tgz#604c7e33d48528a13477e7af597c4d5fca51b8bd" integrity sha512-aa7XKgZMVl6l04NY+3X7BP7yvQ/s8scn8KzQfTLrGRarziTlMGrsCOBQtCNWXOPEbtxAIHpZ9dsrAn5EJSivOQ== -"@sentry/browser@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.43.0.tgz#4eec67bc6fb278727304045b612ac392674cade6" - integrity sha512-LGvLLnfmR8+AEgFmd7Q7KHiOTiV0P1Lvio2ENDELhEqJOIiICauttibVmig+AW02qg4kMeywvleMsUYaZv2RVA== +"@sentry/browser@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.45.0.tgz#2e8f7b8b1a7860863aae4d716b9748a21789f0e0" + integrity sha512-Y+BcfpXY1eEkOYOzgLGkx1YH940uMAymYOxfSZSvC+Vx6xHuaGT05mIFef/aeZbyu2AUs6JjdvD1BRBZlHg78w== dependencies: - "@sentry-internal/browser-utils" "8.43.0" - "@sentry-internal/feedback" "8.43.0" - "@sentry-internal/replay" "8.43.0" - "@sentry-internal/replay-canvas" "8.43.0" - "@sentry/core" "8.43.0" + "@sentry-internal/browser-utils" "8.45.0" + "@sentry-internal/feedback" "8.45.0" + "@sentry-internal/replay" "8.45.0" + "@sentry-internal/replay-canvas" "8.45.0" + "@sentry/core" "8.45.0" "@sentry/bundler-plugin-core@2.22.7": version "2.22.7" @@ -3021,18 +3096,18 @@ "@sentry/cli-win32-i686" "2.39.1" "@sentry/cli-win32-x64" "2.39.1" -"@sentry/core@8.43.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.43.0.tgz#e96a489e87a9999199f5ac27d8860da37c1fa8b4" - integrity sha512-ktyovtjkTMNud+kC/XfqHVCoQKreIKgx/hgeRvzPwuPyd1t1KzYmRL3DBkbcWVnyOPpVTHn+RsEI1eRcVYHtvw== +"@sentry/core@8.45.0": + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.45.0.tgz#a03a1b666989898ce7fb33f9ec279ea08450b317" + integrity sha512-4YTuBipWSh4JrtSYS5GxUQBAcAgOIkEoFfFbwVcr3ivijOacJLRXTBn3rpcy1CKjBq0PHDGR+2RGRYC+bNAMxg== "@sentry/react@^8.0.0": - version "8.43.0" - resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.43.0.tgz#ad49bd16b0b1897613ef5cbd2f0a49b2b41f98a9" - integrity sha512-PsTzLrYio/FOJU537Y5Gj9jJi7OMHEjdttsC9INUxy5062LOd8ObtHsjE0mopLaSYEwUfSROQOBZCwmISh8ByQ== + version "8.45.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.45.0.tgz#9a1bfbbbb3575fffb92796acc28ad5bb93a6855a" + integrity sha512-xuJBDATJKAHOxpR5IBfGFWJxXb05GMPGGpk8UoWai1Mh50laAQ0/WW+5sDAKrCjXoA+JZ6fb3DP8EE2X93n1nw== dependencies: - "@sentry/browser" "8.43.0" - "@sentry/core" "8.43.0" + "@sentry/browser" "8.45.0" + "@sentry/core" "8.45.0" hoist-non-react-statics "^3.3.2" "@sentry/vite-plugin@^2.0.0": @@ -3308,9 +3383,9 @@ undici-types "~6.19.8" "@types/node@^22.0.0": - version "22.10.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766" - integrity sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ== + version "22.10.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" + integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== dependencies: undici-types "~6.20.0" @@ -3337,9 +3412,9 @@ "@types/node" "*" "@types/react-dom@^18.3.0": - version "18.3.3" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.3.tgz#3654138d0da1b0c7916f6ed0dc1cc2b576d47650" - integrity sha512-uTYkxTLkYp41nq/ULXyXMtkNT1vu5fXJoqad6uTNCOGat5t9cLgF4vMNLBXsTOXpdOI44XzKPY1M5RRm0bQHuw== + version "18.3.5" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.5.tgz#45f9f87398c5dcea085b715c58ddcf1faf65f716" + integrity sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q== "@types/react-router-dom@^5.3.3": version "5.3.3" @@ -4075,7 +4150,17 @@ broccoli-plugin@^4.0.7: rimraf "^3.0.2" symlink-or-copy "^1.3.1" -browserslist@^4.23.1, browserslist@^4.23.3, browserslist@^4.24.0, browserslist@^4.24.2: +browserslist@^4.23.1, browserslist@^4.23.3: + version "4.24.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.3.tgz#5fc2725ca8fb3c1432e13dac278c7cc103e026d2" + integrity sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA== + dependencies: + caniuse-lite "^1.0.30001688" + electron-to-chromium "^1.5.73" + node-releases "^2.0.19" + update-browserslist-db "^1.1.1" + +browserslist@^4.24.0, browserslist@^4.24.2: version "4.24.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.2.tgz#f5845bc91069dbd55ee89faf9822e1d885d16580" integrity sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg== @@ -4141,15 +4226,10 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001646: - version "1.0.30001680" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz#5380ede637a33b9f9f1fc6045ea99bd142f3da5e" - integrity sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA== - -caniuse-lite@^1.0.30001669: - version "1.0.30001687" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz#d0ac634d043648498eedf7a3932836beba90ebae" - integrity sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ== +caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669, caniuse-lite@^1.0.30001688: + version "1.0.30001688" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001688.tgz#f9d3ede749f083ce0db4c13db9d828adaf2e8d0a" + integrity sha512-Nmqpru91cuABu/DTCXbM2NSRHzM2uVHfPnhJ/1zEAJx/ILBRVmz3pzH4N7DZqbdG0gWClsCC05Oj0mJ/1AWMbA== caseless@~0.12.0: version "0.12.0" @@ -4440,10 +4520,10 @@ css-blank-pseudo@^7.0.1: dependencies: postcss-selector-parser "^7.0.0" -css-has-pseudo@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-7.0.1.tgz#adbb51821e51f7a7c1d2df4d12827870cc311137" - integrity sha512-EOcoyJt+OsuKfCADgLT7gADZI5jMzIe/AeI6MeAYKiFBDmNmM7kk46DtSfMj5AohUJisqVzopBpnQTlvbyaBWg== +css-has-pseudo@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-7.0.2.tgz#fb42e8de7371f2896961e1f6308f13c2c7019b72" + integrity sha512-nzol/h+E0bId46Kn2dQH5VElaknX2Sr0hFuB/1EomdC7j+OISt2ZzK7EHX9DZDY53WbIVAR7FYKSO2XnSf07MQ== dependencies: "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -4475,10 +4555,10 @@ css.escape@^1.5.1: resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== -cssdb@^8.2.1: - version "8.2.1" - resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.2.1.tgz#62a5d9a41e2c86f1d7c35981098fc5ce47c5766c" - integrity sha512-KwEPys7lNsC8OjASI8RrmwOYYDcm0JOW9zQhcV83ejYcQkirTEyeAGui8aO2F5PiS6SLpxuTzl6qlMElIdsgIg== +cssdb@^8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.2.3.tgz#7e6980bb5a785a9b4eb2a21bd38d50624b56cb46" + integrity sha512-9BDG5XmJrJQQnJ51VFxXCAtpZ5ebDlAREmO8sxMOVU0aSxN/gocbctjIG5LMh3WBUq+xTlb/jw2LoljBEqraTA== cssesc@^3.0.0: version "3.0.0" @@ -4738,10 +4818,10 @@ easy-table@1.2.0: optionalDependencies: wcwidth "^1.0.1" -electron-to-chromium@^1.5.41: - version "1.5.72" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.72.tgz#a732805986d3a5b5fedd438ddf4616c7d78ac2df" - integrity sha512-ZpSAUOZ2Izby7qnZluSrAlGgGQzucmFbN0n64dYzocYxnxV5ufurpj3VgEe4cUp7ir9LmeLxNYo8bVnlM8bQHw== +electron-to-chromium@^1.5.41, electron-to-chromium@^1.5.73: + version "1.5.73" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.73.tgz#f32956ce40947fa3c8606726a96cd8fb5bb5f720" + integrity sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg== emoji-regex@^8.0.0: version "8.0.0" @@ -5902,13 +5982,6 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - is-array-buffer@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" @@ -6329,9 +6402,9 @@ kleur@^3.0.3: integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== knip@^5.27.2: - version "5.39.2" - resolved "https://registry.yarnpkg.com/knip/-/knip-5.39.2.tgz#1faacd8d8ef36b509b2f6e396cce85b645abb04e" - integrity sha512-BuvuWRllLWV/r2G4m9ggNH+DZ6gouP/dhtJPXVlMbWNF++w9/EfrF6k2g7YBKCwjzCC+PXmYtpH8S2t8RjnY4Q== + version "5.40.0" + resolved "https://registry.yarnpkg.com/knip/-/knip-5.40.0.tgz#6da9113d9d0c696fc3e5dc3f3a281db57b4b828a" + integrity sha512-EzBfQDz4YBzYnMLueWnaaVr15mneqZs1c3RanttciuVuRcodlNjzAmR2nch/khlRdVABAxAdMGFxfSvhvcH1NA== dependencies: "@nodelib/fs.walk" "1.2.8" "@snyk/github-codeowners" "1.1.0" @@ -6449,7 +6522,7 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -6529,11 +6602,11 @@ matrix-events-sdk@0.0.1: integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== matrix-js-sdk@matrix-org/matrix-js-sdk#develop: - version "34.12.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/edac6a9983bd604c17535a9ae673dc979c7b61c4" + version "34.13.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e4182eb75227c283a18704727021e99ced72868d" dependencies: "@babel/runtime" "^7.12.5" - "@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0" + "@matrix-org/matrix-sdk-crypto-wasm" "^12.0.0" "@matrix-org/olm" "3.2.15" another-json "^0.2.0" bs58 "^6.0.0" @@ -6638,9 +6711,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare@^1.4.0: version "1.4.0" @@ -6667,7 +6740,7 @@ node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" -node-releases@^2.0.18: +node-releases@^2.0.18, node-releases@^2.0.19: version "2.0.19" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== @@ -7168,9 +7241,9 @@ postcss-place@^10.0.0: postcss-value-parser "^4.2.0" postcss-preset-env@^10.0.0: - version "10.1.1" - resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.1.1.tgz#6ee631272353fb1c4a9711943e9b80a178ffce44" - integrity sha512-wqqsnBFD6VIwcHHRbhjTOcOi4qRVlB26RwSr0ordPj7OubRRxdWebv/aLjKLRR8zkZrbxZyuus03nOIgC5elMQ== + version "10.1.2" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.1.2.tgz#ea9c25d92045ef06edd78f9945d2586107aab3e3" + integrity sha512-OqUBZ9ByVfngWhMNuBEMy52Izj07oIFA6K/EOGBlaSv+P12MiE1+S2cqXtS1VuW82demQ/Tzc7typYk3uHunkA== dependencies: "@csstools/postcss-cascade-layers" "^5.0.1" "@csstools/postcss-color-function" "^4.0.6" @@ -7207,9 +7280,9 @@ postcss-preset-env@^10.0.0: autoprefixer "^10.4.19" browserslist "^4.23.1" css-blank-pseudo "^7.0.1" - css-has-pseudo "^7.0.1" + css-has-pseudo "^7.0.2" css-prefers-color-scheme "^10.0.0" - cssdb "^8.2.1" + cssdb "^8.2.3" postcss-attribute-case-insensitive "^7.0.1" postcss-clamp "^4.1.0" postcss-color-functional-notation "^7.0.6" @@ -7421,9 +7494,9 @@ react-error-boundary@^3.1.0: "@babel/runtime" "^7.12.5" react-i18next@^15.0.0: - version "15.1.4" - resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.1.4.tgz#65c03c31a5e42202000652e163f22f23a9306a60" - integrity sha512-2tai71gmehbvl9ZIqPMqlCCkm/cbeV1G4STpmM3C8Uzo6T2l8jDvZxEVSsQKt8blP9X34iRFP/k1ROqG2296MQ== + version "15.2.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.2.0.tgz#6b51650e1e93eb4d235a4d533fcf61b3bbf4ea10" + integrity sha512-iJNc8111EaDtVTVMKigvBtPHyrJV+KblWG73cUxqp+WmJCcwkzhWNFXmkAD5pwP2Z4woeDj/oXDdbjDsb3Gutg== dependencies: "@babel/runtime" "^7.25.0" html-parse-stringify "^3.0.1" @@ -7444,11 +7517,11 @@ react-refresh@^0.14.2: integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== react-remove-scroll-bar@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" - integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== dependencies: - react-style-singleton "^2.2.1" + react-style-singleton "^2.2.2" tslib "^2.0.0" react-remove-scroll@2.6.0: @@ -7490,13 +7563,12 @@ react-router@5.3.4: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-style-singleton@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" - integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g== +react-style-singleton@^2.2.1, react-style-singleton@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== dependencies: get-nonce "^1.0.0" - invariant "^2.2.4" tslib "^2.0.0" react-use-clipboard@^1.0.7: @@ -7788,30 +7860,31 @@ rollup@^4.20.0: fsevents "~2.3.2" rollup@^4.23.0: - version "4.28.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.28.0.tgz#eb8d28ed43ef60a18f21d0734d230ee79dd0de77" - integrity sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ== + version "4.28.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.28.1.tgz#7718ba34d62b449dfc49adbfd2f312b4fe0df4de" + integrity sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg== dependencies: "@types/estree" "1.0.6" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.28.0" - "@rollup/rollup-android-arm64" "4.28.0" - "@rollup/rollup-darwin-arm64" "4.28.0" - "@rollup/rollup-darwin-x64" "4.28.0" - "@rollup/rollup-freebsd-arm64" "4.28.0" - "@rollup/rollup-freebsd-x64" "4.28.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.28.0" - "@rollup/rollup-linux-arm-musleabihf" "4.28.0" - "@rollup/rollup-linux-arm64-gnu" "4.28.0" - "@rollup/rollup-linux-arm64-musl" "4.28.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.28.0" - "@rollup/rollup-linux-riscv64-gnu" "4.28.0" - "@rollup/rollup-linux-s390x-gnu" "4.28.0" - "@rollup/rollup-linux-x64-gnu" "4.28.0" - "@rollup/rollup-linux-x64-musl" "4.28.0" - "@rollup/rollup-win32-arm64-msvc" "4.28.0" - "@rollup/rollup-win32-ia32-msvc" "4.28.0" - "@rollup/rollup-win32-x64-msvc" "4.28.0" + "@rollup/rollup-android-arm-eabi" "4.28.1" + "@rollup/rollup-android-arm64" "4.28.1" + "@rollup/rollup-darwin-arm64" "4.28.1" + "@rollup/rollup-darwin-x64" "4.28.1" + "@rollup/rollup-freebsd-arm64" "4.28.1" + "@rollup/rollup-freebsd-x64" "4.28.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.28.1" + "@rollup/rollup-linux-arm-musleabihf" "4.28.1" + "@rollup/rollup-linux-arm64-gnu" "4.28.1" + "@rollup/rollup-linux-arm64-musl" "4.28.1" + "@rollup/rollup-linux-loongarch64-gnu" "4.28.1" + "@rollup/rollup-linux-powerpc64le-gnu" "4.28.1" + "@rollup/rollup-linux-riscv64-gnu" "4.28.1" + "@rollup/rollup-linux-s390x-gnu" "4.28.1" + "@rollup/rollup-linux-x64-gnu" "4.28.1" + "@rollup/rollup-linux-x64-musl" "4.28.1" + "@rollup/rollup-win32-arm64-msvc" "4.28.1" + "@rollup/rollup-win32-ia32-msvc" "4.28.1" + "@rollup/rollup-win32-x64-msvc" "4.28.1" fsevents "~2.3.2" rrweb-cssom@^0.7.1: @@ -7891,9 +7964,9 @@ safe-regex-test@^1.0.3: integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== sass@^1.42.1: - version "1.82.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70" - integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q== + version "1.83.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.0.tgz#e36842c0b88a94ed336fd16249b878a0541d536f" + integrity sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw== dependencies: chokidar "^4.0.0" immutable "^5.0.2" @@ -8689,9 +8762,9 @@ use-callback-ref@^1.3.0: tslib "^2.0.0" use-sidecar@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" - integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw== + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== dependencies: detect-node-es "^1.1.0" tslib "^2.0.0" @@ -8836,9 +8909,9 @@ vite@^5.0.0: fsevents "~2.3.3" vite@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.2.tgz#7a22630c73c7b663335ddcdb2390971ffbc14993" - integrity sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g== + version "6.0.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.3.tgz#cc01f403e326a9fc1e064235df8a6de084c8a491" + integrity sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw== dependencies: esbuild "^0.24.0" postcss "^8.4.49" @@ -9180,6 +9253,6 @@ zod-validation-error@^3.0.3: integrity sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ== zod@^3.22.4: - version "3.24.0" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.0.tgz#babb32313f7c5f4a99812feee806d186b4f76bde" - integrity sha512-Hz+wiY8yD0VLA2k/+nsg2Abez674dDGTai33SwNvMPuf9uIrBC9eFgIMQxBBbHFxVXi8W+5nX9DcAh9YNSQm/w== + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==