From 2c3d21f3441eadab547e3be5ec1622e7cc9df82d Mon Sep 17 00:00:00 2001 From: Linerly Date: Tue, 27 Jun 2023 06:52:20 +0000 Subject: [PATCH 01/18] Translated using Weblate (Indonesian) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/id/ --- public/locales/id/app.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/locales/id/app.json b/public/locales/id/app.json index 9d891b3..1388dae 100644 --- a/public/locales/id/app.json +++ b/public/locales/id/app.json @@ -53,7 +53,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Pengguna lain sedang mencoba bergabung ke panggilan ini dari versi yang tidak kompatibel. Pengguna berikut seharusnya memastikan bahwa mereka telah memuat ulang peramban mereka: <1>{userLis}", "Password": "Kata sandi", "Passwords must match": "Kata sandi harus cocok", - "Press and hold to talk over {{name}}": "Tekan dan tahan untuk berbicara pada {{name}}", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha ditutup", "Recaptcha not loaded": "Recaptcha tidak dimuat", @@ -113,5 +112,8 @@ "<0>Thanks for your feedback!": "<0>Terima kasih atas masukan Anda!", "How did it go?": "Bagaimana rasanya?", "{{count}} stars|one": "{{count}} bintang", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.", + "Show connection stats": "Tampilkan statistik koneksi", + "{{displayName}} is presenting": "{{displayName}} sedang menampilkan", + "{{count}} stars|other": "{{count}} bintang" } From a857ee3ce45e705bd11e4cfd89d3bd3ba93b960e Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Mon, 26 Jun 2023 20:53:52 +0000 Subject: [PATCH 02/18] Translated using Weblate (Ukrainian) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/uk/ --- public/locales/uk/app.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/locales/uk/app.json b/public/locales/uk/app.json index 8e3a578..6e5ab0c 100644 --- a/public/locales/uk/app.json +++ b/public/locales/uk/app.json @@ -39,7 +39,6 @@ "Recaptcha not loaded": "Recaptcha не завантажено", "Recaptcha dismissed": "Recaptcha не пройдено", "Profile": "Профіль", - "Press and hold spacebar to talk": "Затисніть пробіл, щоб говорити", "Passwords must match": "Паролі відрізняються", "Password": "Пароль", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Інші користувачі намагаються приєднатися до цього виклику з несумісних версій. Ці користувачі повинні переконатися, що вони оновили сторінки своїх браузерів:<1>{userLis}", @@ -94,7 +93,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Створити обліковий запис або <2>Отримати доступ як гість", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Уже маєте обліковий запис?<1><0>Увійти Or <2>Отримати доступ як гість", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{count}} people connected|one": "{{count}} під'єднується", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Приєднатися до виклику зараз<1>Or<2>Скопіювати посилання на виклик і приєднатися пізніше", "Element Call Home": "Домівка Element Call", "Copy": "Копіювати", @@ -115,5 +113,7 @@ "{{count}} stars|other": "{{count}} зірок", "{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.", "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.", - "How did it go?": "Вам усе сподобалось?" + "How did it go?": "Вам усе сподобалось?", + "{{displayName}} is presenting": "{{displayName}} представляє", + "Show connection stats": "Показати стан з'єднання" } From 62e2ac92953707cf2509dbd5713794954de0d311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 27 Jun 2023 06:23:56 +0000 Subject: [PATCH 03/18] Translated using Weblate (Estonian) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/et/ --- public/locales/et/app.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/locales/et/app.json b/public/locales/et/app.json index 2b5fcbf..8c6a4af 100644 --- a/public/locales/et/app.json +++ b/public/locales/et/app.json @@ -4,7 +4,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Loo konto Või <2>Sisene külalisena", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>On sul juba konto?<1><0>Logi sisse Või <2>Logi sisse külalisena", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{count}} people connected|other": "{{count}} osalejat liitunud", "Invite people": "Kutsu inimesi", "Invite": "Kutsu", "Inspector": "Inspektor", @@ -114,5 +113,7 @@ "How did it go?": "Kuidas sujus?", "{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.", "<0>Thanks for your feedback!": "<0>Täname Sind tagasiside eest!", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.", + "Show connection stats": "Näita ühenduse statistikat", + "{{displayName}} is presenting": "{{displayName}} on esitlemas" } From 0ea88188e1d57e4ebdf29d5b0418603d60fea6ad Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Tue, 27 Jun 2023 02:45:51 +0000 Subject: [PATCH 04/18] Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hant/ --- public/locales/zh-Hant/app.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/locales/zh-Hant/app.json b/public/locales/zh-Hant/app.json index 5ac0980..b507a75 100644 --- a/public/locales/zh-Hant/app.json +++ b/public/locales/zh-Hant/app.json @@ -3,7 +3,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>建立帳號 或<2>以訪客身份登入", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>已經有帳號?<1><0>登入 或<2>以訪客身份登入", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (連結中...)", "Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。", "Developer Settings": "開發者設定", "<0>Submitting debug logs will help us track down the problem.": "<0>送出除錯紀錄,可幫助我們修正問題。", @@ -47,7 +46,6 @@ "Recaptcha not loaded": "驗證碼未載入", "Recaptcha dismissed": "略過驗證碼", "Profile": "個人檔案", - "Press and hold spacebar to talk": "說話時請按住空白鍵", "Passwords must match": "密碼必須相符", "Password": "密碼", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}", @@ -115,5 +113,7 @@ "<0>We'd love to hear your feedback so we can improve your experience.": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。", "{{count}} stars|one": "{{count}} 個星星", "{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。", - "How did it go?": "進展如何?" + "How did it go?": "進展如何?", + "{{displayName}} is presenting": "{{displayName}} 正在展示", + "Show connection stats": "顯示連線統計資料" } From f80c5971b92e6214ead35ab0fa3550f4c5df572f Mon Sep 17 00:00:00 2001 From: Weblate Date: Tue, 27 Jun 2023 07:07:19 +0000 Subject: [PATCH 05/18] Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/ --- public/locales/bg/app.json | 1 - public/locales/cs/app.json | 1 - public/locales/de/app.json | 1 - public/locales/el/app.json | 3 +-- public/locales/es/app.json | 1 - public/locales/fa/app.json | 2 -- public/locales/fr/app.json | 1 - public/locales/ja/app.json | 1 - public/locales/pl/app.json | 2 -- public/locales/ru/app.json | 2 -- public/locales/sk/app.json | 2 -- public/locales/zh-Hans/app.json | 2 -- 12 files changed, 1 insertion(+), 18 deletions(-) diff --git a/public/locales/bg/app.json b/public/locales/bg/app.json index 0aea00e..4cf4665 100644 --- a/public/locales/bg/app.json +++ b/public/locales/bg/app.json @@ -53,7 +53,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Други потребители се опитват да се присъединят в разговора от несъвместими версии. Следните потребители трябва да проверят дали са презаредили браузърите си<1>{userLis}", "Password": "Парола", "Passwords must match": "Паролите не съвпадат", - "Press and hold to talk over {{name}}": "Натиснете и задръжте за да говорите заедно с {{name}}", "Profile": "Профил", "Recaptcha dismissed": "Recaptcha отхвърлена", "Recaptcha not loaded": "Recaptcha не е заредена", diff --git a/public/locales/cs/app.json b/public/locales/cs/app.json index 91f1c36..40bcd5a 100644 --- a/public/locales/cs/app.json +++ b/public/locales/cs/app.json @@ -62,7 +62,6 @@ "Inspector": "Insepktor", "Incompatible versions!": "Nekompatibilní verze!", "Incompatible versions": "Nekompatibilní verze", - "{{count}} people connected|other": "{{count}} lidí připojeno", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>Terms and conditions": "Tato stárnka je chráněna pomocí ReCAPTCHA a Google <2>zásad ochrany osobních údajů a <6>podmínky služby platí.<9>Kliknutím na \"Registrovat\", souhlasíte s <12>Pravidly a podmínkami", "Walkie-talkie call name": "Jméno vysílačkového hovoru", "Walkie-talkie call": "Vysílačkový hovor", diff --git a/public/locales/de/app.json b/public/locales/de/app.json index c7eb44e..ef6a8f3 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -52,7 +52,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}", "Password": "Passwort", "Passwords must match": "Passwörter müssen übereinstimmen", - "Press and hold to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen gedrückt halten", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha abgelehnt", "Recaptcha not loaded": "Recaptcha nicht geladen", diff --git a/public/locales/el/app.json b/public/locales/el/app.json index 79157df..d61ba14 100644 --- a/public/locales/el/app.json +++ b/public/locales/el/app.json @@ -71,6 +71,5 @@ "Close": "Κλείσιμο", "Change layout": "Αλλαγή διάταξης", "Camera": "Κάμερα", - "Audio": "Ήχος", - "{{name}} (Connecting...)": "{{name}} (Συνδέεται...)" + "Audio": "Ήχος" } diff --git a/public/locales/es/app.json b/public/locales/es/app.json index 318ece5..facea46 100644 --- a/public/locales/es/app.json +++ b/public/locales/es/app.json @@ -94,7 +94,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Crear una cuenta o <2>Acceder como invitado", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Unirse ahora<1>Or<2>Copiar el enlace y unirse más tarde", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>¿Ya tienes una cuenta?<1><0>Iniciar sesión o <2>Acceder como invitado", - "{{name}} (Connecting...)": "{{name}} (Conectando...)", "Element Call Home": "Inicio de Element Call", "Copy": "Copiar", "<0>Submitting debug logs will help us track down the problem.": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.", diff --git a/public/locales/fa/app.json b/public/locales/fa/app.json index 9006512..0877ab7 100644 --- a/public/locales/fa/app.json +++ b/public/locales/fa/app.json @@ -54,7 +54,6 @@ "<0>Why not finish by setting up a password to keep your account?<1>You'll be able to keep your name and set an avatar for use on future calls": "<0>چرا یک رمز عبور برای حساب کاربری خود تنظیم نمی‌کنید؟<1>شما می‌توانید نام خود را حفظ کنید و یک آواتار برای تماس‌های آینده بسازید", "<0>Create an account Or <2>Access as a guest": "<0>ساخت حساب کاربری Or <2>دسترسی به عنوان میهمان", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>از قبل حساب کاربری دارید؟<1><0>ورود Or <2>به عنوان یک میهمان وارد شوید", - "{{count}} people connected|other": "{{count}} نفر متصل هستند", "Local volume": "حجم داخلی", "Inspector": "بازرس", "Incompatible versions!": "نسخه‌های ناسازگار!", @@ -72,7 +71,6 @@ "Register": "ثبت‌نام", "Recaptcha not loaded": "کپچا بارگیری نشد", "Recaptcha dismissed": "ریکپچا رد شد", - "Press and hold spacebar to talk": "برای صحبت کردن کلید فاصله را فشار داده و نگه دارید", "Passwords must match": "رمز عبور باید همخوانی داشته باشد", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "کاربران دیگر تلاش می‌کنند با ورژن‌های ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}", "Not registered yet? <2>Create an account": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری", diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index 91b3ba7..353a255 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -50,7 +50,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Des utilisateurs essayent de rejoindre cet appel à partir de versions incompatibles. Ces utilisateurs doivent rafraîchir la page dans leur navigateur : <1>{userLis}", "Password": "Mot de passe", "Passwords must match": "Les mots de passe doivent correspondre", - "Press and hold to talk over {{name}}": "Appuyez et maintenez enfoncé pour parler par dessus {{name}}", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha refusé", "Recaptcha not loaded": "Recaptcha non chargé", diff --git a/public/locales/ja/app.json b/public/locales/ja/app.json index 0687914..9aac334 100644 --- a/public/locales/ja/app.json +++ b/public/locales/ja/app.json @@ -1,5 +1,4 @@ { - "{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>既にアカウントをお持ちですか?<1><0>ログインまたは<2>ゲストとしてアクセス", "<0>Create an account Or <2>Access as a guest": "<0>アカウントを作成または<2>ゲストとしてアクセス", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>今すぐ通話に参加<1>または<2>通話リンクをコピーし、後で参加", diff --git a/public/locales/pl/app.json b/public/locales/pl/app.json index c8ae0a8..3e2b1d4 100644 --- a/public/locales/pl/app.json +++ b/public/locales/pl/app.json @@ -40,7 +40,6 @@ "Recaptcha not loaded": "Recaptcha nie została załadowana", "Recaptcha dismissed": "Recaptcha odrzucona", "Profile": "Profil", - "Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić", "Passwords must match": "Hasła muszą pasować", "Password": "Hasło", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Inni użytkownicy próbują dołączyć do tego połączenia przy użyciu niekompatybilnych wersji. Powinni oni upewnić się, że odświeżyli stronę w swoich przeglądarkach:<1>{userLis}", @@ -93,7 +92,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Utwórz konto lub <2>Dołącz jako gość", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Masz już konto?<1><0>Zaloguj się lub <2>Dołącz jako gość", "{{names}}, {{name}}": "{{names}}, {{name}}", - "This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.", "Copy": "Kopiuj", "<0>Submitting debug logs will help us track down the problem.": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.", "<0>Oops, something's gone wrong.": "<0>Ojej, coś poszło nie tak.", diff --git a/public/locales/ru/app.json b/public/locales/ru/app.json index 3041cce..5d20cb6 100644 --- a/public/locales/ru/app.json +++ b/public/locales/ru/app.json @@ -10,7 +10,6 @@ "Submit feedback": "Отправить отзыв", "Sending debug logs…": "Отправка журнала отладки…", "Select an option": "Выберите вариант", - "Press and hold spacebar to talk over {{name}}": "Чтобы говорить поверх участника {{name}}, нажмите и удерживайте [Пробел]", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}", "Grid layout menu": "Меню \"Расположение сеткой\"", "By clicking \"Join call now\", you agree to our <2>Terms and conditions": "Нажимая \"Присоединиться сейчас\", вы соглашаетесь с нашими <2>положениями и условиями", @@ -94,7 +93,6 @@ "Call link copied": "Ссылка на звонок скопирована", "Avatar": "Аватар", "Audio": "Аудио", - "{{name}} is presenting": "{{name}} показывает", "Element Call Home": "Главная Element Call", "Copy": "Копировать", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Присоединиться сейчас к звонку<1>или<1><2>Скопировать ссылку на звонок и присоединиться позже", diff --git a/public/locales/sk/app.json b/public/locales/sk/app.json index bae6aac..9266ee6 100644 --- a/public/locales/sk/app.json +++ b/public/locales/sk/app.json @@ -22,7 +22,6 @@ "Recaptcha not loaded": "Recaptcha sa nenačítala", "Recaptcha dismissed": "Recaptcha zamietnutá", "Profile": "Profil", - "Press and hold spacebar to talk": "Stlačte a podržte medzerník, ak chcete hovoriť", "Passwords must match": "Heslá sa musia zhodovať", "Password": "Heslo", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Ostatní používatelia sa pokúšajú pripojiť k tomuto hovoru z nekompatibilných verzií. Títo používatelia by sa mali uistiť, že si obnovili svoje prehliadače:<1>{userLis}", @@ -97,7 +96,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Vytvoriť konto Alebo <2>Prihlásiť sa ako hosť", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Už máte konto?<1><0>Prihláste sa Alebo <2>Prihlásiť sa ako hosť", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (Pripájanie...)", "<0>Submitting debug logs will help us track down the problem.": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.", "<0>Oops, something's gone wrong.": "<0>Hups, niečo sa pokazilo.", "Expose developer settings in the settings window.": "Zobraziť nastavenia pre vývojárov v okne nastavení.", diff --git a/public/locales/zh-Hans/app.json b/public/locales/zh-Hans/app.json index 46f6a9a..a7126f8 100644 --- a/public/locales/zh-Hans/app.json +++ b/public/locales/zh-Hans/app.json @@ -31,7 +31,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>创建账户 Or <2>以访客身份继续", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>已有账户?<1><0>登录 Or <2>以访客身份继续", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (正在连接……)", "Inspector": "检查器", "Show call inspector": "显示通话检查器", "Share screen": "屏幕共享", @@ -47,7 +46,6 @@ "Recaptcha not loaded": "reCaptcha未加载", "Recaptcha dismissed": "reCaptcha验证失败", "Profile": "个人信息", - "Press and hold spacebar to talk": "按住空格键发言", "Passwords must match": "密码必须匹配", "Password": "密码", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "其他用户正试图从不兼容的版本加入这一呼叫。这些用户应该确保已经刷新了浏览器:<1>{userLis}", From cd4e5d3543c09582bda5729d50ee124275a9e542 Mon Sep 17 00:00:00 2001 From: Michael Kaye <1917473+michaelkaye@users.noreply.github.com> Date: Wed, 14 Jun 2023 17:36:26 +0100 Subject: [PATCH 06/18] Push code coverage percentages to codecov.io. --- .github/workflows/test.yaml | 3 +++ package.json | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 161557d..5143f30 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,3 +16,6 @@ jobs: run: "yarn install" - name: Jest run: "yarn run test" + - name: Upload to codecov + uses: codecov/codecov-action@v3 + flags: unittests diff --git a/package.json b/package.json index 6a077d0..360c84c 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,11 @@ "\\.(css|less|svg)+$": "identity-obj-proxy", "^\\./IndexedDBWorker\\?worker$": "/test/mocks/workerMock.ts", "^\\./olm$": "/test/mocks/olmMock.ts" - } + }, + "collectCoverage": true, + "coverageReporters": [ + "text", + "cobertura" + ] } } From 11733784a6af9932190752d7f553d4c91de4fa3e Mon Sep 17 00:00:00 2001 From: Michael Kaye <1917473+michaelkaye@users.noreply.github.com> Date: Thu, 22 Jun 2023 09:18:17 +0100 Subject: [PATCH 07/18] Fix typo in github action config. --- .github/workflows/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5143f30..06dccc2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,4 +18,5 @@ jobs: run: "yarn run test" - name: Upload to codecov uses: codecov/codecov-action@v3 - flags: unittests + with: + flags: unittests From cc35f243f2ddfb974e36cc09c3429df5afa952a2 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 27 Jun 2023 12:19:06 -0400 Subject: [PATCH 08/18] Make NewVideoGrid support arbitrary layout systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In preparation for adding layouts other than big grid to the NewVideoGrid component, I've abstracted the grid layout system into an interface called Layout. For now, the only implementation of this interface is BigGrid, but this will allow us to easily plug in Spotlight, SplitGrid, and OneOnOne layout systems so we can get rid of the old VideoGrid component and have One Grid to Rule Them All™. Please do shout if any of this seems obtuse or underdocumented, because I'm not super happy with how approachable the NewVideoGrid code looks right now… Incidentally, this refactoring made it way easier to save the state of the grid while in fullscreen / another layout, so I went ahead and did that. --- src/room/InCallView.tsx | 7 +- src/video-grid/BigGrid.module.css | 29 ++ src/video-grid/{model.ts => BigGrid.tsx} | 257 ++++++++++-- src/video-grid/Layout.ts | 74 ++++ src/video-grid/NewVideoGrid.module.css | 11 +- src/video-grid/NewVideoGrid.tsx | 368 ++++++++---------- src/video-grid/VideoGrid.tsx | 2 + .../{model-test.ts => BigGrid-test.ts} | 51 +-- 8 files changed, 501 insertions(+), 298 deletions(-) create mode 100644 src/video-grid/BigGrid.module.css rename src/video-grid/{model.ts => BigGrid.tsx} (74%) create mode 100644 src/video-grid/Layout.ts rename test/video-grid/{model-test.ts => BigGrid-test.ts} (88%) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7125720..4a3fc4a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -73,7 +73,7 @@ import { useJoinRule } from "./useJoinRule"; import { ParticipantInfo } from "./useGroupCall"; import { ItemData, TileContent } from "../video-grid/VideoTile"; import { Config } from "../config/Config"; -import { NewVideoGrid } from "../video-grid/NewVideoGrid"; +import { NewVideoGrid, useLayoutStates } from "../video-grid/NewVideoGrid"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal } from "../settings/SettingsModal"; import { InviteModal } from "./InviteModal"; @@ -253,6 +253,10 @@ export function InCallView({ const prefersReducedMotion = usePrefersReducedMotion(); + // This state is lifted out of NewVideoGrid so that layout states can be + // restored after a layout switch or upon exiting fullscreen + const layoutStates = useLayoutStates(); + const renderContent = (): JSX.Element => { if (items.length === 0) { return ( @@ -282,6 +286,7 @@ export function InCallView({ items={items} layout={layout} disableAnimations={prefersReducedMotion || isSafari} + layoutStates={layoutStates} > {(props) => ( ; + readonly item: TileDescriptor; /** * Whether this cell is the origin (top left corner) of the tile. */ - origin: boolean; + readonly origin: boolean; /** * The width, in columns, of the tile. */ - columns: number; + readonly columns: number; /** * The height, in rows, of the tile. */ - rows: number; + readonly rows: number; } -export interface Grid { +export interface BigGridState { + readonly columns: number; + /** + * The cells of the grid, in left-to-right top-to-bottom order. + * undefined = empty. + */ + readonly cells: (Cell | undefined)[]; +} + +interface MutableBigGridState { columns: number; /** * The cells of the grid, in left-to-right top-to-bottom order. @@ -58,7 +73,7 @@ export interface Grid { * @returns An array in which each cell holds the index of the next cell to move * to to reach the destination, or null if it is the destination. */ -export function getPaths(dest: number, g: Grid): (number | null)[] { +export function getPaths(dest: number, g: BigGridState): (number | null)[] { const destRow = row(dest, g); const destColumn = column(dest, g); @@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] { return edges as (number | null)[]; } -const findLast1By1Index = (g: Grid): number | null => +const findLast1By1Index = (g: BigGridState): number | null => findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1); -export function row(index: number, g: Grid): number { +export function row(index: number, g: BigGridState): number { return Math.floor(index / g.columns); } -export function column(index: number, g: Grid): number { +export function column(index: number, g: BigGridState): number { return ((index % g.columns) + g.columns) % g.columns; } -function inArea(index: number, start: number, end: number, g: Grid): boolean { +function inArea( + index: number, + start: number, + end: number, + g: BigGridState +): boolean { const indexColumn = column(index, g); const indexRow = row(index, g); return ( @@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean { function* cellsInArea( start: number, end: number, - g: Grid + g: BigGridState ): Generator { const startColumn = column(start, g); const endColumn = column(end, g); @@ -149,7 +169,7 @@ function* cellsInArea( export function forEachCellInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => void ): void { for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); @@ -158,7 +178,7 @@ export function forEachCellInArea( function allCellsInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => boolean ): boolean { for (const i of cellsInArea(start, end, g)) { @@ -172,16 +192,19 @@ const areaEnd = ( start: number, columns: number, rows: number, - g: Grid + g: BigGridState ): number => start + columns - 1 + g.columns * (rows - 1); -const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] }); +const cloneGrid = (g: BigGridState): BigGridState => ({ + ...g, + cells: [...g.cells], +}); /** * Gets the index of the next gap in the grid that should be backfilled by 1×1 * tiles. */ -function getNextGap(g: Grid): number | null { +function getNextGap(g: BigGridState): number | null { const last1By1Index = findLast1By1Index(g); if (last1By1Index === null) return null; @@ -204,7 +227,7 @@ function getNextGap(g: Grid): number | null { /** * Gets the index of the origin of the tile to which the given cell belongs. */ -function getOrigin(g: Grid, index: number): number { +function getOrigin(g: BigGridState, index: number): number { const initialColumn = column(index, g); for ( @@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number { * along the way. * Precondition: the destination area must consist of only 1×1 tiles. */ -function moveTile(g: Grid, from: number, to: number) { +function moveTileUnchecked(g: BigGridState, from: number, to: number) { const tile = g.cells[from]!; const fromEnd = areaEnd(from, tile.columns, tile.rows, g); const toEnd = areaEnd(to, tile.columns, tile.rows, g); @@ -262,10 +285,15 @@ function moveTile(g: Grid, from: number, to: number) { /** * Moves the tile at index "from" over to index "to", if there is space. */ -export function tryMoveTile(g: Grid, from: number, to: number): Grid { +export function moveTile( + g: BigGridState, + from: number, + to: number +): BigGridState { const tile = g.cells[from]!; if ( + to !== from && // Skip the operation if nothing would move to >= 0 && to < g.cells.length && column(to, g) <= g.columns - tile.columns @@ -283,7 +311,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { if (allCellsInArea(to, toEnd, g, displaceable)) { // The target space is free; move const gClone = cloneGrid(g); - moveTile(gClone, from, to); + moveTileUnchecked(gClone, from, to); return gClone; } } @@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { * enlarged tiles around when necessary. * @returns Whether the tile was actually pushed */ -function pushTileUp(g: Grid, from: number): boolean { +function pushTileUp(g: BigGridState, from: number): boolean { const tile = g.cells[from]!; // TODO: pushing large tiles sideways might be more successful in some @@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean { ); if (cellsAboveAreDisplacable) { - moveTile(g, from, from - g.columns); + moveTileUnchecked(g, from, from - g.columns); return true; } else { return false; @@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean { /** * Backfill any gaps in the grid. */ -export function fillGaps(g: Grid): Grid { - const result = cloneGrid(g); +export function fillGaps(g: BigGridState): BigGridState { + const result = cloneGrid(g) as MutableBigGridState; // This will hopefully be the size of the grid after we're done here, assuming // that we can pack the large tiles tightly enough @@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid { return result; } -function createRows(g: Grid, count: number, atRow: number): Grid { +function createRows( + g: BigGridState, + count: number, + atRow: number +): BigGridState { const result = { columns: g.columns, cells: new Array(g.cells.length + g.columns * count), @@ -430,9 +462,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid { } /** - * Adds a set of new items into the grid. + * Adds a set of new items into the grid. (May leave gaps.) */ -export function addItems(items: TileDescriptor[], g: Grid): Grid { +export function addItems( + items: TileDescriptor[], + g: BigGridState +): BigGridState { let result = cloneGrid(g); for (const item of items) { @@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { }; let placeAt: number; - let hasGaps: boolean; if (item.placeNear === undefined) { // This item has no special placement requests, so let's put it // uneventfully at the end of the grid placeAt = result.cells.length; - hasGaps = false; } else { // This item wants to be placed near another; let's put it on a row // directly below the related tile @@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (placeNear === -1) { // Can't find the related tile, so let's give up and place it at the end placeAt = result.cells.length; - hasGaps = false; } else { const placeNearCell = result.cells[placeNear]!; const placeNearEnd = areaEnd( @@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { placeNear + Math.floor(placeNearCell.columns / 2) + result.columns * placeNearCell.rows; - hasGaps = true; } } @@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (item.largeBaseSize) { // Cycle the tile size once to set up the tile with its larger base size // This also fills any gaps in the grid, hence no extra call to fillGaps - result = cycleTileSize(item.id, result); - } else if (hasGaps) { - result = fillGaps(result); + result = cycleTileSize(result, item); } } return result; } -const largeTileDimensions = (g: Grid): [number, number] => [ +const largeTileDimensions = (g: BigGridState): [number, number] => [ Math.min(3, Math.max(2, g.columns - 1)), 2, ]; -const extraLargeTileDimensions = (g: Grid): [number, number] => +const extraLargeTileDimensions = (g: BigGridState): [number, number] => g.columns > 3 ? [4, 3] : [g.columns, 2]; /** @@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] => * @param g The grid. * @returns The updated grid. */ -export function cycleTileSize(tileId: string, g: Grid): Grid { - const from = g.cells.findIndex((c) => c?.item.id === tileId); +export function cycleTileSize( + g: BigGridState, + tile: TileDescriptor +): BigGridState { + const from = g.cells.findIndex((c) => c?.item === tile); if (from === -1) return g; // Tile removed, no change const fromCell = g.cells[from]!; const fromWidth = fromCell.columns; @@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { /** * Resizes the grid to a new column width. */ -export function resize(g: Grid, columns: number): Grid { - const result: Grid = { columns, cells: [] }; +export function resize(g: BigGridState, columns: number): BigGridState { + const result: BigGridState = { columns, cells: [] }; const [largeColumns, largeRows] = largeTileDimensions(result); // Copy each tile from the old grid to the resized one in the same order @@ -640,6 +672,7 @@ export function resize(g: Grid, columns: number): Grid { for (const cell of g.cells) { if (cell?.origin) { + // TODO make aware of extra large tiles const [nextColumns, nextRows] = cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1]; @@ -672,7 +705,7 @@ export function resize(g: Grid, columns: number): Grid { /** * Promotes speakers to the first page of the grid. */ -export function promoteSpeakers(g: Grid) { +export function promoteSpeakers(g: BigGridState) { // This is all a bit of a hack right now, because we don't know if the designs // will stick with this approach in the long run // We assume that 4 rows are probably about 1 page @@ -694,10 +727,148 @@ export function promoteSpeakers(g: Grid) { toCell === undefined || (toCell.columns === 1 && toCell.rows === 1) ) { - moveTile(g, from, to); + moveTileUnchecked(g, from, to); break; } } } } } + +/** + * The algorithm for updating a grid with a new set of tiles. + */ +function updateTiles( + g: BigGridState, + tiles: TileDescriptor[] +): BigGridState { + // Step 1: Update tiles that still exist, and remove tiles that have left + // the grid + const itemsById = new Map(tiles.map((i) => [i.id, i])); + const grid1: BigGridState = { + ...g, + cells: g.cells.map((c) => { + if (c === undefined) return undefined; + const item = itemsById.get(c.item.id); + return item === undefined ? undefined : { ...c, item }; + }), + }; + + // Step 2: Add new tiles + const existingItemIds = new Set( + grid1.cells.filter((c) => c !== undefined).map((c) => c!.item.id) + ); + const newItems = tiles.filter((i) => !existingItemIds.has(i.id)); + const grid2 = addItems(newItems, grid1); + + // Step 3: Promote speakers to the top + promoteSpeakers(grid2); + + return fillGaps(grid2); +} + +function updateBounds(g: BigGridState, bounds: RectReadOnly): BigGridState { + const columns = Math.max(2, Math.floor(bounds.width * 0.0045)); + return columns === g.columns ? g : resize(g, columns); +} + +const Slots: FC<{ s: BigGridState }> = memo(({ s: g }) => { + const areas = new Array<(number | null)[]>( + Math.ceil(g.cells.length / g.columns) + ); + for (let i = 0; i < areas.length; i++) + areas[i] = new Array(g.columns).fill(null); + + let slotCount = 0; + for (let i = 0; i < g.cells.length; i++) { + const cell = g.cells[i]; + if (cell?.origin) { + const slotEnd = i + cell.columns - 1 + g.columns * (cell.rows - 1); + forEachCellInArea( + i, + slotEnd, + g, + (_c, j) => (areas[row(j, g)][column(j, g)] = slotCount) + ); + slotCount++; + } + } + + const style = { + gridTemplateAreas: areas + .map( + (row) => + `'${row + .map((slotId) => (slotId === null ? "." : `s${slotId}`)) + .join(" ")}'` + ) + .join(" "), + gridTemplateColumns: `repeat(${g.columns}, 1fr)`, + }; + + const slots = new Array(slotCount); + for (let i = 0; i < slotCount; i++) + slots[i] = ; + + return ( +
+ {slots} +
+ ); +}); + +/** + * Given a tile and numbers in the range [0, 1) describing a position within the + * tile, this returns the index of the specific cell in which that position + * lies. + */ +function positionOnTileToCell( + g: BigGridState, + tileOriginIndex: number, + xPositionOnTile: number, + yPositionOnTile: number +): number { + const tileOrigin = g.cells[tileOriginIndex]!; + const columnOnTile = Math.floor(xPositionOnTile * tileOrigin.columns); + const rowOnTile = Math.floor(yPositionOnTile * tileOrigin.rows); + return tileOriginIndex + columnOnTile + g.columns * rowOnTile; +} + +function dragTile( + g: BigGridState, + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number +): BigGridState { + const fromOrigin = g.cells.findIndex((c) => c?.item === from); + const toOrigin = g.cells.findIndex((c) => c?.item === to); + const fromCell = positionOnTileToCell( + g, + fromOrigin, + xPositionOnFrom, + yPositionOnFrom + ); + const toCell = positionOnTileToCell( + g, + toOrigin, + xPositionOnTo, + yPositionOnTo + ); + + return moveTile(g, fromOrigin, fromOrigin + toCell - fromCell); +} + +export const BigGrid: Layout = { + emptyState: { columns: 4, cells: [] }, + updateTiles, + updateBounds, + getTiles: (g) => g.cells.filter((c) => c?.origin).map((c) => c!.item), + canDragTile: () => true, + dragTile, + toggleFocus: cycleTileSize, + Slots, + rememberState: false, +}; diff --git a/src/video-grid/Layout.ts b/src/video-grid/Layout.ts new file mode 100644 index 0000000..d4467aa --- /dev/null +++ b/src/video-grid/Layout.ts @@ -0,0 +1,74 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { ComponentType } from "react"; +import type { RectReadOnly } from "react-use-measure"; +import type { TileDescriptor } from "./VideoGrid"; + +/** + * A video grid layout system with concrete states of type State. + */ +export interface Layout { + /** + * The layout state for zero tiles. + */ + readonly emptyState: State; + /** + * Updates/adds/removes tiles in a way that looks natural in the context of + * the given initial state. + */ + readonly updateTiles: (s: State, tiles: TileDescriptor[]) => State; + /** + * Adapts the layout to a new container size. + */ + readonly updateBounds: (s: State, bounds: RectReadOnly) => State; + /** + * Gets tiles in the order created by the layout. + */ + readonly getTiles: (s: State) => TileDescriptor[]; + /** + * Determines whether a tile is draggable. + */ + readonly canDragTile: (s: State, tile: TileDescriptor) => boolean; + /** + * Drags the tile 'from' to the location of the tile 'to' (if possible). + * The position parameters are numbers in the range [0, 1) describing the + * specific positions on 'from' and 'to' that the drag gesture is targeting. + */ + readonly dragTile: ( + s: State, + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => State; + /** + * Toggles the focus of the given tile (if this layout has the concept of + * focus). + */ + readonly toggleFocus?: (s: State, tile: TileDescriptor) => State; + /** + * A React component generating the slot elements for a given layout state. + */ + readonly Slots: ComponentType<{ s: State }>; + /** + * Whether the state of this layout should be remembered even while a + * different layout is active. + */ + readonly rememberState: boolean; +} diff --git a/src/video-grid/NewVideoGrid.module.css b/src/video-grid/NewVideoGrid.module.css index 7e34a2d..c822b41 100644 --- a/src/video-grid/NewVideoGrid.module.css +++ b/src/video-grid/NewVideoGrid.module.css @@ -23,11 +23,8 @@ limitations under the License. overflow-x: hidden; } -.slotGrid { +.slots { position: relative; - display: grid; - grid-auto-rows: 163px; - gap: 8px; } .slot { @@ -38,10 +35,4 @@ limitations under the License. .grid { padding: 0 22px var(--footerHeight); } - - .slotGrid { - grid-auto-rows: 183px; - column-gap: 18px; - row-gap: 21px; - } } diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 754b029..97d9f2c 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -17,17 +17,17 @@ limitations under the License. import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { - Dispatch, + CSSProperties, + FC, ReactNode, - SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import useMeasure from "react-use-measure"; -import { zipWith } from "lodash"; +import useMeasure, { RectReadOnly } from "react-use-measure"; +import { zip } from "lodash"; import styles from "./NewVideoGrid.module.css"; import { @@ -38,98 +38,85 @@ import { } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; import { useMergedRefs } from "../useMergedRefs"; -import { - Grid, - Cell, - row, - column, - fillGaps, - forEachCellInArea, - cycleTileSize, - addItems, - tryMoveTile, - resize, - promoteSpeakers, -} from "./model"; import { TileWrapper } from "./TileWrapper"; +import { BigGrid } from "./BigGrid"; +import { Layout } from "./Layout"; -interface GridState extends Grid { - /** - * The ID of the current state of the grid. - */ - generation: number; -} +export const useLayoutStates = () => { + const layoutStates = useRef, unknown>>(); + if (layoutStates.current === undefined) layoutStates.current = new Map(); + return layoutStates.current; +}; -const useGridState = ( - columns: number | null, - items: TileDescriptor[] -): [GridState | null, Dispatch>] => { - const [grid, setGrid_] = useReactiveState( - (prevGrid = null) => { - if (prevGrid === null) { - // We can't do anything if the column count isn't known yet - if (columns === null) { - return null; - } else { - prevGrid = { generation: 0, columns, cells: [] }; - } - } +const useGrid = ( + layout: Layout, + items: TileDescriptor[], + bounds: RectReadOnly, + layoutStates: Map, unknown> +) => { + const prevLayout = useRef>(layout); + const prevState = layoutStates.get(layout); - // Step 1: Update tiles that still exist, and remove tiles that have left - // the grid - const itemsById = new Map(items.map((i) => [i.id, i])); - const grid1: Grid = { - ...prevGrid, - cells: prevGrid.cells.map((c) => { - if (c === undefined) return undefined; - const item = itemsById.get(c.item.id); - return item === undefined ? undefined : { ...c, item }; - }), - }; + const [state, setState] = useReactiveState(() => { + // If the bounds aren't known yet, don't add anything to the layout + if (bounds.width === 0) { + return layout.emptyState; + } else { + if (layout !== prevLayout.current && !prevLayout.current.rememberState) + layoutStates.delete(prevLayout.current); - // Step 2: Resize the grid if necessary and backfill gaps left behind by - // removed tiles - // Resizing already takes care of backfilling gaps - const grid2 = - columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1); + const baseState = layoutStates.get(layout) ?? layout.emptyState; + return layout.updateTiles(layout.updateBounds(baseState, bounds), items); + } + }, [layout, items, bounds]); - // Step 3: Add new tiles to the end of the grid - const existingItemIds = new Set( - grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id) - ); - const newItems = items.filter((i) => !existingItemIds.has(i.id)); - const grid3 = addItems(newItems, grid2); + const generation = useRef(0); + if (state !== prevState) generation.current++; - // Step 4: Promote speakers to the top - promoteSpeakers(grid3); + prevLayout.current = layout; + // No point in remembering an empty state, plus it would end up clobbering the + // real saved state while restoring a layout + if (state !== layout.emptyState) layoutStates.set(layout, state); - return { ...grid3, generation: prevGrid.generation + 1 }; - }, - [columns, items] - ); - - const setGrid: Dispatch> = useCallback( - (action) => { - if (typeof action === "function") { - setGrid_((prevGrid) => - prevGrid === null - ? null - : { - ...(action as (prev: Grid) => Grid)(prevGrid), - generation: prevGrid.generation + 1, - } - ); - } else { - setGrid_((prevGrid) => ({ - ...action, - generation: prevGrid?.generation ?? 1, - })); - } - }, - [setGrid_] - ); - - return [grid, setGrid]; + return { + grid: state, + orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), + generation: generation.current, + canDragTile: useCallback( + (tile: TileDescriptor) => layout.canDragTile(state, tile), + [layout, state] + ), + dragTile: useCallback( + ( + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => + setState((s) => + layout.dragTile( + s, + from, + to, + xPositionOnFrom, + yPositionOnFrom, + xPositionOnTo, + yPositionOnTo + ) + ), + [layout, setState] + ), + toggleFocus: useMemo( + () => + layout.toggleFocus && + ((tile: TileDescriptor) => + setState((s) => layout.toggleFocus!(s, tile))), + [layout, setState] + ), + slots: , + }; }; interface Rect { @@ -151,12 +138,21 @@ interface DragState { cursorY: number; } +interface SlotProps { + style?: CSSProperties; +} + +export const Slot: FC = ({ style }) => ( +
+); + /** * An interactive, animated grid of video tiles. */ export function NewVideoGrid({ items, disableAnimations, + layoutStates, children, }: Props) { // Overview: This component lays out tiles by rendering an invisible template @@ -169,36 +165,36 @@ export function NewVideoGrid({ // most recently rendered generation of the grid, and watch it with a // MutationObserver. - const [slotGrid, setSlotGrid] = useState(null); - const [slotGridGeneration, setSlotGridGeneration] = useState(0); + const [slotsRoot, setSlotsRoot] = useState(null); + const [renderedGeneration, setRenderedGeneration] = useState(0); useEffect(() => { - if (slotGrid !== null) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + if (slotsRoot !== null) { + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); const observer = new MutationObserver((mutations) => { if (mutations.some((m) => m.type === "attributes")) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); } }); - observer.observe(slotGrid, { attributes: true }); + observer.observe(slotsRoot, { attributes: true }); return () => observer.disconnect(); } - }, [slotGrid, setSlotGridGeneration]); + }, [slotsRoot, setRenderedGeneration]); const [gridRef1, gridBounds] = useMeasure(); const gridRef2 = useRef(null); const gridRef = useMergedRefs(gridRef1, gridRef2); const slotRects = useMemo(() => { - if (slotGrid === null) return []; + if (slotsRoot === null) return []; - const slots = slotGrid.getElementsByClassName(styles.slot); + const slots = slotsRoot.getElementsByClassName(styles.slot); const rects = new Array(slots.length); for (let i = 0; i < slots.length; i++) { const slot = slots[i] as HTMLElement; @@ -214,32 +210,34 @@ export function NewVideoGrid({ // The rects may change due to the grid being resized or rerendered, but // eslint can't statically verify this // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slotGrid, slotGridGeneration, gridBounds]); + }, [slotsRoot, renderedGeneration, gridBounds]); - const columns = useMemo( - () => - // The grid bounds might not be known yet - gridBounds.width === 0 - ? null - : Math.max(2, Math.floor(gridBounds.width * 0.0045)), - [gridBounds] - ); - - const [grid, setGrid] = useGridState(columns, items); + // TODO: Implement more layouts and select the right one here + const layout = BigGrid; + const { + grid, + orderedItems, + generation, + canDragTile, + dragTile, + toggleFocus, + slots, + } = useGrid(layout as Layout, items, gridBounds, layoutStates); const [tiles] = useReactiveState( (prevTiles) => { // If React hasn't yet rendered the current generation of the grid, skip // the update, because grid and slotRects will be out of sync - if (slotGridGeneration !== grid?.generation) return prevTiles ?? []; + if (renderedGeneration !== generation) return prevTiles ?? []; - const tileCells = grid.cells.filter((c) => c?.origin) as Cell[]; const tileRects = new Map, Rect>( - zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect]) + zip(orderedItems, slotRects) as [TileDescriptor, Rect][] ); + // In order to not break drag gestures, it's critical that we render tiles + // in a stable order (that of 'items') return items.map((item) => ({ ...tileRects.get(item)!, item })); }, - [slotRects, grid, slotGridGeneration] + [slotRects, grid, renderedGeneration] ); // Drag state is stored in a ref rather than component state, because we use @@ -288,8 +286,6 @@ export function NewVideoGrid({ const animateDraggedTile = (endOfGesture: boolean) => { const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; const tile = tiles.find((t) => t.item.id === tileId)!; - const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId); - const originCell = grid!.cells[originIndex]!; springRef.current .find((c) => (c.item as Tile).item.id === tileId) @@ -320,36 +316,23 @@ export function NewVideoGrid({ } ); - const columns = grid!.columns; - const rows = row(grid!.cells.length - 1, grid!) + 1; - - const cursorColumn = Math.floor( - (cursorX / slotGrid!.clientWidth) * columns - ); - const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows); - - const cursorColumnOnTile = Math.floor( - ((cursorX - tileX) / tile.width) * originCell.columns - ); - const cursorRowOnTile = Math.floor( - ((cursorY - tileY) / tile.height) * originCell.rows + const overTile = tiles.find( + (t) => + cursorX >= t.x && + cursorX < t.x + t.width && + cursorY >= t.y && + cursorY < t.y + t.height ); - const dest = - Math.max( - 0, - Math.min( - columns - originCell.columns, - cursorColumn - cursorColumnOnTile - ) - ) + - grid!.columns * - Math.max( - 0, - Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile) - ); - - if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest)); + if (overTile !== undefined) + dragTile( + tile.item, + overTile.item, + (cursorX - tileX) / tile.width, + (cursorY - tileY) / tile.height, + (cursorX - overTile.x) / overTile.width, + (cursorY - overTile.y) / overTile.height + ); }; // Callback for useDrag. We could call useDrag here, but the default @@ -367,29 +350,33 @@ export function NewVideoGrid({ }: Parameters>[0] ) => { if (tap) { - setGrid((g) => cycleTileSize(tileId, g!)); + toggleFocus?.(items.find((i) => i.id === tileId)!); } else { - const tileSpring = springRef.current - .find((c) => (c.item as Tile).item.id === tileId)! - .get(); + const tileController = springRef.current.find( + (c) => (c.item as Tile).item.id === tileId + )!; - if (dragState.current === null) { - dragState.current = { - tileId, - tileX: tileSpring.x, - tileY: tileSpring.y, - cursorX: initialX - gridBounds.x, - cursorY: initialY - gridBounds.y + scrollOffset.current, - }; + if (canDragTile((tileController.item as Tile).item)) { + if (dragState.current === null) { + const tileSpring = tileController.get(); + dragState.current = { + tileId, + tileX: tileSpring.x, + tileY: tileSpring.y, + cursorX: initialX - gridBounds.x, + cursorY: initialY - gridBounds.y + scrollOffset.current, + }; + } + + dragState.current.tileX += dx; + dragState.current.tileY += dy; + dragState.current.cursorX += dx; + dragState.current.cursorY += dy; + + animateDraggedTile(last); + + if (last) dragState.current = null; } - dragState.current.tileX += dx; - dragState.current.tileY += dy; - dragState.current.cursorX += dx; - dragState.current.cursorY += dy; - - animateDraggedTile(last); - - if (last) dragState.current = null; } }; @@ -411,52 +398,6 @@ export function NewVideoGrid({ { target: gridRef2 } ); - const slotGridStyle = useMemo(() => { - if (grid === null) return {}; - - const areas = new Array<(number | null)[]>( - Math.ceil(grid.cells.length / grid.columns) - ); - for (let i = 0; i < areas.length; i++) - areas[i] = new Array(grid.columns).fill(null); - - let slotId = 0; - for (let i = 0; i < grid.cells.length; i++) { - const cell = grid.cells[i]; - if (cell?.origin) { - const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1); - forEachCellInArea( - i, - slotEnd, - grid, - (_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId) - ); - slotId++; - } - } - - return { - gridTemplateAreas: areas - .map( - (row) => - `'${row - .map((slotId) => (slotId === null ? "." : `s${slotId}`)) - .join(" ")}'` - ) - .join(" "), - gridTemplateColumns: `repeat(${columns}, 1fr)`, - }; - }, [grid, columns]); - - const slots = useMemo(() => { - const slots = new Array(items.length); - for (let i = 0; i < items.length; i++) - slots[i] = ( -
- ); - return slots; - }, [items.length]); - // Render nothing if the grid has yet to be generated if (grid === null) { return
; @@ -465,10 +406,9 @@ export function NewVideoGrid({ return (
{slots}
diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index d87f58b..acf3cf5 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer" import styles from "./VideoGrid.module.css"; import { Layout } from "../room/GridLayoutMenu"; import { TileWrapper } from "./TileWrapper"; +import { Layout as LayoutSystem } from "./Layout"; interface TilePosition { x: number; @@ -817,6 +818,7 @@ export interface VideoGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; + layoutStates: Map, unknown>; children: (props: ChildrenProperties) => React.ReactNode; } diff --git a/test/video-grid/model-test.ts b/test/video-grid/BigGrid-test.ts similarity index 88% rename from test/video-grid/model-test.ts rename to test/video-grid/BigGrid-test.ts index 3699487..b035bd2 100644 --- a/test/video-grid/model-test.ts +++ b/test/video-grid/BigGrid-test.ts @@ -20,23 +20,23 @@ import { cycleTileSize, fillGaps, forEachCellInArea, - Grid, + BigGridState, resize, row, - tryMoveTile, -} from "../../src/video-grid/model"; + moveTile, +} from "../../src/video-grid/BigGrid"; import { TileDescriptor } from "../../src/video-grid/VideoGrid"; /** * Builds a grid from a string specifying the contents of each cell as a letter. */ -function mkGrid(spec: string): Grid { +function mkGrid(spec: string): BigGridState { const secondNewline = spec.indexOf("\n", 1); const columns = secondNewline === -1 ? spec.length : secondNewline - 1; const cells = spec.match(/[a-z ]/g) ?? ([] as string[]); const areas = new Set(cells); areas.delete(" "); // Space represents an empty cell, not an area - const grid: Grid = { columns, cells: new Array(cells.length) }; + const grid: BigGridState = { columns, cells: new Array(cells.length) }; for (const area of areas) { const start = cells.indexOf(area); @@ -60,12 +60,12 @@ function mkGrid(spec: string): Grid { /** * Turns a grid into a string showing the contents of each cell as a letter. */ -function showGrid(g: Grid): string { +function showGrid(g: BigGridState): string { let result = "\n"; - g.cells.forEach((c, i) => { + for (let i = 0; i < g.cells.length; i++) { if (i > 0 && i % g.columns == 0) result += "\n"; - result += c?.item.id ?? " "; - }); + result += g.cells[i]?.item.id ?? " "; + } return result; } @@ -222,21 +222,12 @@ function testCycleTileSize( output: string ): void { test(`cycleTileSize ${title}`, () => { - expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).toBe(output); + const grid = mkGrid(input); + const tile = grid.cells.find((c) => c?.item.id === tileId)!.item; + expect(showGrid(cycleTileSize(grid, tile))).toBe(output); }); } -testCycleTileSize( - "does nothing if the tile is not present", - "z", - ` -abcd -efgh`, - ` -abcd -efgh` -); - testCycleTileSize( "expands a tile to 2×2 in a 3 column layout", "c", @@ -345,8 +336,8 @@ abc def`, ` abc -gfe -d` + g +def` ); testAddItems( @@ -362,19 +353,19 @@ gge d` ); -function testTryMoveTile( +function testMoveTile( title: string, from: number, to: number, input: string, output: string ): void { - test(`tryMoveTile ${title}`, () => { - expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output); + test(`moveTile ${title}`, () => { + expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output); }); } -testTryMoveTile( +testMoveTile( "refuses to move a tile too far to the left", 1, -1, @@ -384,7 +375,7 @@ abc`, abc` ); -testTryMoveTile( +testMoveTile( "refuses to move a tile too far to the right", 1, 3, @@ -394,7 +385,7 @@ abc`, abc` ); -testTryMoveTile( +testMoveTile( "moves a large tile to an unoccupied space", 3, 1, @@ -408,7 +399,7 @@ bcc d e` ); -testTryMoveTile( +testMoveTile( "refuses to move a large tile to an occupied space", 3, 1, From d91be3433d07f79b27949c04bd7040790a869833 Mon Sep 17 00:00:00 2001 From: Glandos Date: Tue, 27 Jun 2023 09:15:18 +0000 Subject: [PATCH 09/18] Translated using Weblate (French) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/ --- public/locales/fr/app.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index 353a255..837c677 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -113,5 +113,7 @@ "{{count}} stars|one": "{{count}} favori", "{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.", "<0>Thanks for your feedback!": "<0>Merci pour votre commentaire !", - "How did it go?": "Comment cela s’est-il passé ?" + "How did it go?": "Comment cela s’est-il passé ?", + "{{displayName}} is presenting": "{{displayName}} est à l’écran", + "Show connection stats": "Afficher les statistiques de la connexion" } From a5df558264348d4e8508c4bd45c9efca47662f27 Mon Sep 17 00:00:00 2001 From: Jozef Gaal Date: Tue, 27 Jun 2023 14:24:34 +0000 Subject: [PATCH 10/18] Translated using Weblate (Slovak) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/sk/ --- public/locales/sk/app.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/locales/sk/app.json b/public/locales/sk/app.json index 9266ee6..57b0e26 100644 --- a/public/locales/sk/app.json +++ b/public/locales/sk/app.json @@ -113,5 +113,7 @@ "{{count}} stars|other": "{{count}} hviezdičiek", "{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.", "<0>Thanks for your feedback!": "<0> Ďakujeme za vašu spätnú väzbu!", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.", + "{{displayName}} is presenting": "{{displayName}} prezentuje", + "Show connection stats": "Zobraziť štatistiky pripojenia" } From 12e008a434839bc16499ca73924277e21831a832 Mon Sep 17 00:00:00 2001 From: Vri Date: Wed, 28 Jun 2023 04:04:23 +0000 Subject: [PATCH 11/18] Translated using Weblate (German) Currently translated at 100.0% (117 of 117 strings) Translation: Element Call/element-call Translate-URL: https://translate.element.io/projects/element-call/element-call/de/ --- public/locales/de/app.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/locales/de/app.json b/public/locales/de/app.json index ef6a8f3..516ad79 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -113,5 +113,7 @@ "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Wir würden uns freuen, deine Rückmeldung zu hören, um deine Erfahrung verbessern zu können.", "How did it go?": "Wie ist es gelaufen?", "{{count}} stars|one": "{{count}} Stern", - "<0>Thanks for your feedback!": "<0>Danke für deine Rückmeldung!" + "<0>Thanks for your feedback!": "<0>Danke für deine Rückmeldung!", + "{{displayName}} is presenting": "{{displayName}} präsentiert", + "Show connection stats": "Verbindungsstatistiken zeigen" } From 1c6ef974576c5a39f4fb6a31595d0cd28c34e09b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 28 Jun 2023 10:59:36 -0400 Subject: [PATCH 12/18] Improve typing around layouts and grid components --- src/room/InCallView.tsx | 3 +- src/video-grid/BigGrid.tsx | 3 +- src/video-grid/Layout.ts | 74 ------------- src/video-grid/Layout.tsx | 178 ++++++++++++++++++++++++++++++++ src/video-grid/NewVideoGrid.tsx | 112 +++----------------- src/video-grid/TileWrapper.tsx | 9 +- src/video-grid/VideoGrid.tsx | 4 +- 7 files changed, 207 insertions(+), 176 deletions(-) delete mode 100644 src/video-grid/Layout.ts create mode 100644 src/video-grid/Layout.tsx diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 4a3fc4a..a8a171b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -73,7 +73,7 @@ import { useJoinRule } from "./useJoinRule"; import { ParticipantInfo } from "./useGroupCall"; import { ItemData, TileContent } from "../video-grid/VideoTile"; import { Config } from "../config/Config"; -import { NewVideoGrid, useLayoutStates } from "../video-grid/NewVideoGrid"; +import { NewVideoGrid } from "../video-grid/NewVideoGrid"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal } from "../settings/SettingsModal"; import { InviteModal } from "./InviteModal"; @@ -83,6 +83,7 @@ import { VideoTile } from "../video-grid/VideoTile"; import { UserChoices, useLiveKit } from "../livekit/useLiveKit"; import { useMediaDevices } from "../livekit/useMediaDevices"; import { useFullscreen } from "./useFullscreen"; +import { useLayoutStates } from "../video-grid/Layout"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // There is currently a bug in Safari our our code with cloning and sending MediaStreams diff --git a/src/video-grid/BigGrid.tsx b/src/video-grid/BigGrid.tsx index 650278d..f08676f 100644 --- a/src/video-grid/BigGrid.tsx +++ b/src/video-grid/BigGrid.tsx @@ -865,7 +865,8 @@ export const BigGrid: Layout = { emptyState: { columns: 4, cells: [] }, updateTiles, updateBounds, - getTiles: (g) => g.cells.filter((c) => c?.origin).map((c) => c!.item), + getTiles: (g) => + g.cells.filter((c) => c?.origin).map((c) => c!.item as T), canDragTile: () => true, dragTile, toggleFocus: cycleTileSize, diff --git a/src/video-grid/Layout.ts b/src/video-grid/Layout.ts deleted file mode 100644 index d4467aa..0000000 --- a/src/video-grid/Layout.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2023 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type { ComponentType } from "react"; -import type { RectReadOnly } from "react-use-measure"; -import type { TileDescriptor } from "./VideoGrid"; - -/** - * A video grid layout system with concrete states of type State. - */ -export interface Layout { - /** - * The layout state for zero tiles. - */ - readonly emptyState: State; - /** - * Updates/adds/removes tiles in a way that looks natural in the context of - * the given initial state. - */ - readonly updateTiles: (s: State, tiles: TileDescriptor[]) => State; - /** - * Adapts the layout to a new container size. - */ - readonly updateBounds: (s: State, bounds: RectReadOnly) => State; - /** - * Gets tiles in the order created by the layout. - */ - readonly getTiles: (s: State) => TileDescriptor[]; - /** - * Determines whether a tile is draggable. - */ - readonly canDragTile: (s: State, tile: TileDescriptor) => boolean; - /** - * Drags the tile 'from' to the location of the tile 'to' (if possible). - * The position parameters are numbers in the range [0, 1) describing the - * specific positions on 'from' and 'to' that the drag gesture is targeting. - */ - readonly dragTile: ( - s: State, - from: TileDescriptor, - to: TileDescriptor, - xPositionOnFrom: number, - yPositionOnFrom: number, - xPositionOnTo: number, - yPositionOnTo: number - ) => State; - /** - * Toggles the focus of the given tile (if this layout has the concept of - * focus). - */ - readonly toggleFocus?: (s: State, tile: TileDescriptor) => State; - /** - * A React component generating the slot elements for a given layout state. - */ - readonly Slots: ComponentType<{ s: State }>; - /** - * Whether the state of this layout should be remembered even while a - * different layout is active. - */ - readonly rememberState: boolean; -} diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx new file mode 100644 index 0000000..2b29594 --- /dev/null +++ b/src/video-grid/Layout.tsx @@ -0,0 +1,178 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ComponentType, useCallback, useMemo, useRef } from "react"; + +import type { RectReadOnly } from "react-use-measure"; +import { useReactiveState } from "../useReactiveState"; +import type { TileDescriptor } from "./VideoGrid"; + +/** + * A video grid layout system with concrete states of type State. + */ +// Ideally State would be parameterized by the tile data type, but then that +// makes Layout a higher-kinded type, which isn't achievable in TypeScript +// (unless you invoke some dark type-level computation magic… 😏) +// So we're stuck with these types being a little too strong. +export interface Layout { + /** + * The layout state for zero tiles. + */ + readonly emptyState: State; + /** + * Updates/adds/removes tiles in a way that looks natural in the context of + * the given initial state. + */ + readonly updateTiles: (s: State, tiles: TileDescriptor[]) => State; + /** + * Adapts the layout to a new container size. + */ + readonly updateBounds: (s: State, bounds: RectReadOnly) => State; + /** + * Gets tiles in the order created by the layout. + */ + readonly getTiles: (s: State) => TileDescriptor[]; + /** + * Determines whether a tile is draggable. + */ + readonly canDragTile: (s: State, tile: TileDescriptor) => boolean; + /** + * Drags the tile 'from' to the location of the tile 'to' (if possible). + * The position parameters are numbers in the range [0, 1) describing the + * specific positions on 'from' and 'to' that the drag gesture is targeting. + */ + readonly dragTile: ( + s: State, + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => State; + /** + * Toggles the focus of the given tile (if this layout has the concept of + * focus). + */ + readonly toggleFocus?: (s: State, tile: TileDescriptor) => State; + /** + * A React component generating the slot elements for a given layout state. + */ + readonly Slots: ComponentType<{ s: State }>; + /** + * Whether the state of this layout should be remembered even while a + * different layout is active. + */ + readonly rememberState: boolean; +} + +/** + * A version of Map with stronger types that allow us to save layout states in a + * type-safe way. + */ +export interface LayoutStatesMap { + get(layout: Layout): State | undefined; + set(layout: Layout, state: State): LayoutStatesMap; + delete(layout: Layout): boolean; +} + +/** + * Hook creating a Map to store layout states in. + */ +export const useLayoutStates = (): LayoutStatesMap => { + const layoutStates = useRef>(); + if (layoutStates.current === undefined) layoutStates.current = new Map(); + return layoutStates.current as LayoutStatesMap; +}; + +/** + * Hook which uses the provided layout system to arrange a set of items into a + * concrete layout state, and provides callbacks for user interaction. + */ +export const useLayout = ( + layout: Layout, + items: TileDescriptor[], + bounds: RectReadOnly, + layoutStates: LayoutStatesMap +) => { + const prevLayout = useRef>(); + const prevState = layoutStates.get(layout); + + const [state, setState] = useReactiveState(() => { + // If the bounds aren't known yet, don't add anything to the layout + if (bounds.width === 0) { + return layout.emptyState; + } else { + if ( + prevLayout.current !== undefined && + layout !== prevLayout.current && + !prevLayout.current.rememberState + ) + layoutStates.delete(prevLayout.current); + + const baseState = layoutStates.get(layout) ?? layout.emptyState; + return layout.updateTiles(layout.updateBounds(baseState, bounds), items); + } + }, [layout, items, bounds]); + + const generation = useRef(0); + if (state !== prevState) generation.current++; + + prevLayout.current = layout as Layout; + // No point in remembering an empty state, plus it would end up clobbering the + // real saved state while restoring a layout + if (state !== layout.emptyState) layoutStates.set(layout, state); + + return { + state, + orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), + generation: generation.current, + canDragTile: useCallback( + (tile: TileDescriptor) => layout.canDragTile(state, tile), + [layout, state] + ), + dragTile: useCallback( + ( + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => + setState((s) => + layout.dragTile( + s, + from, + to, + xPositionOnFrom, + yPositionOnFrom, + xPositionOnTo, + yPositionOnTo + ) + ), + [layout, setState] + ), + toggleFocus: useMemo( + () => + layout.toggleFocus && + ((tile: TileDescriptor) => + setState((s) => layout.toggleFocus!(s, tile))), + [layout, setState] + ), + slots: , + }; +}; diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 97d9f2c..b88128d 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -20,13 +20,12 @@ import React, { CSSProperties, FC, ReactNode, - useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import useMeasure, { RectReadOnly } from "react-use-measure"; +import useMeasure from "react-use-measure"; import { zip } from "lodash"; import styles from "./NewVideoGrid.module.css"; @@ -40,84 +39,7 @@ import { useReactiveState } from "../useReactiveState"; import { useMergedRefs } from "../useMergedRefs"; import { TileWrapper } from "./TileWrapper"; import { BigGrid } from "./BigGrid"; -import { Layout } from "./Layout"; - -export const useLayoutStates = () => { - const layoutStates = useRef, unknown>>(); - if (layoutStates.current === undefined) layoutStates.current = new Map(); - return layoutStates.current; -}; - -const useGrid = ( - layout: Layout, - items: TileDescriptor[], - bounds: RectReadOnly, - layoutStates: Map, unknown> -) => { - const prevLayout = useRef>(layout); - const prevState = layoutStates.get(layout); - - const [state, setState] = useReactiveState(() => { - // If the bounds aren't known yet, don't add anything to the layout - if (bounds.width === 0) { - return layout.emptyState; - } else { - if (layout !== prevLayout.current && !prevLayout.current.rememberState) - layoutStates.delete(prevLayout.current); - - const baseState = layoutStates.get(layout) ?? layout.emptyState; - return layout.updateTiles(layout.updateBounds(baseState, bounds), items); - } - }, [layout, items, bounds]); - - const generation = useRef(0); - if (state !== prevState) generation.current++; - - prevLayout.current = layout; - // No point in remembering an empty state, plus it would end up clobbering the - // real saved state while restoring a layout - if (state !== layout.emptyState) layoutStates.set(layout, state); - - return { - grid: state, - orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), - generation: generation.current, - canDragTile: useCallback( - (tile: TileDescriptor) => layout.canDragTile(state, tile), - [layout, state] - ), - dragTile: useCallback( - ( - from: TileDescriptor, - to: TileDescriptor, - xPositionOnFrom: number, - yPositionOnFrom: number, - xPositionOnTo: number, - yPositionOnTo: number - ) => - setState((s) => - layout.dragTile( - s, - from, - to, - xPositionOnFrom, - yPositionOnFrom, - xPositionOnTo, - yPositionOnTo - ) - ), - [layout, setState] - ), - toggleFocus: useMemo( - () => - layout.toggleFocus && - ((tile: TileDescriptor) => - setState((s) => layout.toggleFocus!(s, tile))), - [layout, setState] - ), - slots: , - }; -}; +import { useLayout } from "./Layout"; interface Rect { x: number; @@ -126,8 +48,8 @@ interface Rect { height: number; } -interface Tile extends Rect { - item: TileDescriptor; +interface Tile extends Rect { + item: TileDescriptor; } interface DragState { @@ -215,23 +137,23 @@ export function NewVideoGrid({ // TODO: Implement more layouts and select the right one here const layout = BigGrid; const { - grid, + state: grid, orderedItems, generation, canDragTile, dragTile, toggleFocus, slots, - } = useGrid(layout as Layout, items, gridBounds, layoutStates); + } = useLayout(layout, items, gridBounds, layoutStates); - const [tiles] = useReactiveState( + const [tiles] = useReactiveState[]>( (prevTiles) => { // If React hasn't yet rendered the current generation of the grid, skip // the update, because grid and slotRects will be out of sync if (renderedGeneration !== generation) return prevTiles ?? []; - const tileRects = new Map, Rect>( - zip(orderedItems, slotRects) as [TileDescriptor, Rect][] + const tileRects = new Map( + zip(orderedItems, slotRects) as [TileDescriptor, Rect][] ); // In order to not break drag gestures, it's critical that we render tiles // in a stable order (that of 'items') @@ -247,8 +169,8 @@ export function NewVideoGrid({ const [tileTransitions, springRef] = useTransition( tiles, () => ({ - key: ({ item }: Tile) => item.id, - from: ({ x, y, width, height }: Tile) => ({ + key: ({ item }: Tile) => item.id, + from: ({ x, y, width, height }: Tile) => ({ opacity: 0, scale: 0, shadow: 1, @@ -261,7 +183,7 @@ export function NewVideoGrid({ immediate: disableAnimations, }), enter: { opacity: 1, scale: 1, immediate: disableAnimations }, - update: ({ item, x, y, width, height }: Tile) => + update: ({ item, x, y, width, height }: Tile) => item.id === dragState.current?.tileId ? null : { @@ -275,7 +197,7 @@ export function NewVideoGrid({ config: { mass: 0.7, tension: 252, friction: 25 }, }) // react-spring's types are bugged and can't infer the spring type - ) as unknown as [TransitionFn, SpringRef]; + ) as unknown as [TransitionFn, TileSpring>, SpringRef]; // Because we're using react-spring in imperative mode, we're responsible for // firing animations manually whenever the tiles array updates @@ -288,7 +210,7 @@ export function NewVideoGrid({ const tile = tiles.find((t) => t.item.id === tileId)!; springRef.current - .find((c) => (c.item as Tile).item.id === tileId) + .find((c) => (c.item as Tile).item.id === tileId) ?.start( endOfGesture ? { @@ -353,10 +275,10 @@ export function NewVideoGrid({ toggleFocus?.(items.find((i) => i.id === tileId)!); } else { const tileController = springRef.current.find( - (c) => (c.item as Tile).item.id === tileId + (c) => (c.item as Tile).item.id === tileId )!; - if (canDragTile((tileController.item as Tile).item)) { + if (canDragTile((tileController.item as Tile).item)) { if (dragState.current === null) { const tileSpring = tileController.get(); dragState.current = { @@ -422,7 +344,7 @@ export function NewVideoGrid({ data={tile.item.data} {...spring} > - {children as (props: ChildrenProperties) => ReactNode} + {children as (props: ChildrenProperties) => ReactNode} ))}
diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx index b9f84b5..09b67aa 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/video-grid/TileWrapper.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, memo, ReactNode, RefObject, useRef } from "react"; +import React, { memo, ReactNode, RefObject, useRef } from "react"; import { EventTypes, Handler, useDrag } from "@use-gesture/react"; import { SpringValue, to } from "@react-spring/web"; @@ -47,7 +47,7 @@ interface Props { * A wrapper around a tile in a video grid. This component exists to decouple * child components from the grid. */ -export const TileWrapper: FC> = memo( +export const TileWrapper = memo( ({ id, onDragRef, @@ -97,4 +97,7 @@ export const TileWrapper: FC> = memo( ); } -); + // We pretend this component is a simple function rather than a + // NamedExoticComponent, because that's the only way we can fit in a type + // parameter +) as (props: Props) => JSX.Element; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index acf3cf5..a9b847b 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -42,7 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer" import styles from "./VideoGrid.module.css"; import { Layout } from "../room/GridLayoutMenu"; import { TileWrapper } from "./TileWrapper"; -import { Layout as LayoutSystem } from "./Layout"; +import { LayoutStatesMap } from "./Layout"; interface TilePosition { x: number; @@ -818,7 +818,7 @@ export interface VideoGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; - layoutStates: Map, unknown>; + layoutStates: LayoutStatesMap; children: (props: ChildrenProperties) => React.ReactNode; } From 87bd9cf026c8b0b5ad79fed184393019ac0462cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 28 Jun 2023 18:11:51 +0200 Subject: [PATCH 13/18] Remove avatar from header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/Header.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Header.tsx b/src/Header.tsx index c79953c..d9ad1e9 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -24,9 +24,7 @@ import styles from "./Header.module.css"; import { useModalTriggerState } from "./Modal"; import { Button } from "./button"; import { ReactComponent as Logo } from "./icons/Logo.svg"; -import { ReactComponent as VideoIcon } from "./icons/Video.svg"; import { Subtitle } from "./typography/Typography"; -import { Avatar, Size } from "./Avatar"; import { IncompatibleVersionModal } from "./IncompatibleVersionModal"; interface HeaderProps extends HTMLAttributes { @@ -122,15 +120,6 @@ interface RoomHeaderInfo { export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) { return ( <> -
- - -
{roomName} From 8cafe0f25d8d4a459d8e635d073e53ffce56e2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 29 Jun 2023 08:03:30 +0200 Subject: [PATCH 14/18] Remove `roomAvatarUrl` from `MatrixInfo` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/Header.tsx | 3 +-- src/room/GroupCallView.tsx | 3 --- src/room/InCallView.tsx | 5 +---- src/room/LobbyView.tsx | 5 +---- src/room/VideoPreview.tsx | 1 - 5 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/Header.tsx b/src/Header.tsx index d9ad1e9..8974bcc 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -114,10 +114,9 @@ export function HeaderLogo({ className }: HeaderLogoProps) { interface RoomHeaderInfo { roomName: string; - avatarUrl: string | null; } -export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) { +export function RoomHeaderInfo({ roomName }: RoomHeaderInfo) { return ( <> diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 471bfa4..3872ec2 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -35,7 +35,6 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useProfile } from "../profile/useProfile"; import { UserChoices } from "../livekit/useLiveKit"; import { findDeviceByName } from "../media-utils"; -import { useRoomAvatar } from "./useRoomAvatar"; declare global { interface Window { @@ -82,14 +81,12 @@ export function GroupCallView({ }, [groupCall]); const { displayName, avatarUrl } = useProfile(client); - const roomAvatarUrl = useRoomAvatar(groupCall.room); const matrixInfo: MatrixInfo = { displayName, avatarUrl, roomName: groupCall.room.name, roomIdOrAlias, - roomAvatarUrl, }; useEffect(() => { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7125720..617c66a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -389,10 +389,7 @@ export function InCallView({ {!hideHeader && maximisedParticipant === null && (
- + - + diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index ed31f6e..6c7cbf4 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -35,7 +35,6 @@ export type MatrixInfo = { avatarUrl: string; roomName: string; roomIdOrAlias: string; - roomAvatarUrl: string | null; }; interface Props { From 52ed76a02f7e90c054443508a275f39fc8da54c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 29 Jun 2023 18:33:06 +0200 Subject: [PATCH 15/18] Double click to cycle size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/video-grid/NewVideoGrid.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index b88128d..d6c3718 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -257,6 +257,11 @@ export function NewVideoGrid({ ); }; + const [lastTappedTileId, setLastTappedTileId] = useState( + undefined + ); + const [lastTapTime, setLastTapTime] = useState(0); + // Callback for useDrag. We could call useDrag here, but the default // pattern of spreading {...bind()} across the children to bind the gesture // ends up breaking memoization and ruining this component's performance. @@ -272,7 +277,14 @@ export function NewVideoGrid({ }: Parameters>[0] ) => { if (tap) { - toggleFocus?.(items.find((i) => i.id === tileId)!); + const now = Date.now(); + + if (tileId === lastTappedTileId && now - lastTapTime < 500) { + toggleFocus?.(items.find((i) => i.id === tileId)!); + } + + setLastTappedTileId(tileId); + setLastTapTime(now); } else { const tileController = springRef.current.find( (c) => (c.item as Tile).item.id === tileId From 17450b453195aedeb3abb4d91d12672135e85688 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 30 Jun 2023 18:21:18 -0400 Subject: [PATCH 16/18] Fix big grid crashing due to missing React import by fixing the cause rather than the symptom: this upgrades the code to use the new, recommended JSX transform mode of React 17+, which no longer requires you to import React manually just to write JSX. --- .gitignore | 1 + .storybook/preview.jsx | 1 - package.json | 5 +- src/App.tsx | 2 +- src/Avatar.tsx | 6 +- src/ClientContext.tsx | 2 +- src/Facepile.tsx | 2 +- src/FullScreenView.tsx | 2 +- src/Header.stories.jsx | 1 - src/Header.tsx | 2 +- src/IncompatibleVersionModal.tsx | 4 +- src/ListBox.tsx | 6 +- src/Menu.tsx | 2 +- src/Modal.tsx | 2 +- src/SequenceDiagramViewerPage.tsx | 2 +- src/Tooltip.tsx | 2 +- src/UserMenu.tsx | 2 +- src/UserMenuContainer.tsx | 2 +- src/analytics/AnalyticsNotice.tsx | 2 +- src/auth/LoginPage.tsx | 2 +- src/auth/RegisterPage.tsx | 2 +- src/button/Button.tsx | 2 +- src/button/CopyButton.tsx | 1 - src/button/LinkButton.tsx | 2 +- src/button/VolumeIcon.tsx | 2 - src/form/Form.tsx | 2 +- src/home/CallList.tsx | 1 - src/home/CallTypeDropdown.tsx | 2 +- src/home/HomePage.tsx | 1 - src/home/JoinExistingCallModal.tsx | 1 - src/home/RegisteredView.tsx | 7 +- src/home/UnauthenticatedView.tsx | 2 +- src/input/AvatarInputField.tsx | 4 +- src/input/Input.tsx | 13 +- src/input/SelectInput.tsx | 2 +- src/input/StarRatingInput.tsx | 2 +- src/livekit/useLiveKit.ts | 6 +- src/main.tsx | 2 +- src/popover/Popover.tsx | 2 +- src/popover/PopoverMenu.tsx | 2 +- src/room/CallEndedView.tsx | 2 +- src/room/GridLayoutMenu.tsx | 2 +- src/room/GroupCallInspector.tsx | 12 +- src/room/GroupCallLoader.tsx | 2 +- src/room/GroupCallView.tsx | 2 +- src/room/InCallView.tsx | 11 +- src/room/InviteModal.tsx | 2 +- src/room/LobbyView.tsx | 8 +- src/room/RageshakeRequestModal.tsx | 2 +- src/room/RoomAuthView.tsx | 2 +- src/room/RoomPage.tsx | 2 +- src/room/RoomRedirect.tsx | 2 +- src/room/VideoPreview.tsx | 14 +- src/settings/FeedbackSettingsTab.tsx | 2 +- src/settings/ProfileSettingsTab.tsx | 2 +- src/settings/SettingsModal.tsx | 10 +- src/tabs/Tabs.stories.tsx | 4 +- src/tabs/Tabs.tsx | 2 +- src/typography/Typography.stories.tsx | 4 +- src/video-grid/BigGrid.tsx | 1 - src/video-grid/NewVideoGrid.tsx | 2 +- src/video-grid/TileWrapper.tsx | 2 +- src/video-grid/VideoGrid.tsx | 4 +- src/video-grid/VideoTile.tsx | 8 +- test/home/CallList-test.tsx | 2 - tsconfig.json | 2 +- vite.config.js | 7 +- yarn.lock | 706 ++++++++++++++++++-------- 68 files changed, 600 insertions(+), 329 deletions(-) diff --git a/.gitignore b/.gitignore index 263dea0..6ad79d7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-ssr .idea/ public/config.json /coverage +yarn-error.log diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index 9fda6df..928e10e 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -1,4 +1,3 @@ -import React from "react"; import { addDecorator } from "@storybook/react"; import { MemoryRouter } from "react-router-dom"; import { usePageFocusStyle } from "../src/usePageFocusStyle"; diff --git a/package.json b/package.json index 360c84c..c98dbe5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/grecaptcha": "^3.0.4", "@types/sdp-transform": "^2.4.5", "@use-gesture/react": "^10.2.11", + "@vitejs/plugin-react": "^4.0.1", "classnames": "^2.3.1", "color-hash": "^2.0.1", "events": "^3.3.0", @@ -105,9 +106,9 @@ "storybook-builder-vite": "^0.1.12", "typescript": "^4.9.5", "typescript-strict-plugin": "^2.0.1", - "vite": "^2.4.2", + "vite": "^4.2.0", "vite-plugin-html-template": "^1.1.0", - "vite-plugin-svgr": "^0.4.0" + "vite-plugin-svgr": "^3.2.0" }, "jest": { "testEnvironment": "jsdom", diff --git a/src/App.tsx b/src/App.tsx index f859a19..d020655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { OverlayProvider } from "@react-aria/overlays"; diff --git a/src/Avatar.tsx b/src/Avatar.tsx index a6d151f..9549cd4 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useMemo, CSSProperties } from "react"; +import { useMemo, CSSProperties, HTMLAttributes, FC } from "react"; import classNames from "classnames"; import { MatrixClient } from "matrix-js-sdk/src/client"; @@ -62,7 +62,7 @@ function hashStringToArrIndex(str: string, arrLength: number) { const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) => src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src; -interface Props extends React.HTMLAttributes { +interface Props extends HTMLAttributes { bgKey?: string; src?: string; size?: Size | number; @@ -71,7 +71,7 @@ interface Props extends React.HTMLAttributes { fallback: string; } -export const Avatar: React.FC = ({ +export const Avatar: FC = ({ bgKey, src, fallback, diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 08593a4..c69dd44 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { +import { FC, useCallback, useEffect, diff --git a/src/Facepile.tsx b/src/Facepile.tsx index 79e2788..f5ffff6 100644 --- a/src/Facepile.tsx +++ b/src/Facepile.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes, useMemo } from "react"; +import { HTMLAttributes, useMemo } from "react"; import classNames from "classnames"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 774e55a..95a8a7a 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useCallback, useEffect } from "react"; +import { ReactNode, useCallback, useEffect } from "react"; import { useLocation } from "react-router-dom"; import classNames from "classnames"; import { Trans, useTranslation } from "react-i18next"; diff --git a/src/Header.stories.jsx b/src/Header.stories.jsx index 2ab1696..e5c4473 100644 --- a/src/Header.stories.jsx +++ b/src/Header.stories.jsx @@ -1,4 +1,3 @@ -import React from "react"; import { GridLayoutMenu } from "./room/GridLayoutMenu"; import { Header, diff --git a/src/Header.tsx b/src/Header.tsx index 8974bcc..8d754b9 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import classNames from "classnames"; -import React, { HTMLAttributes, ReactNode, useCallback } from "react"; +import { HTMLAttributes, ReactNode, useCallback } from "react"; import { Link } from "react-router-dom"; import { Room } from "matrix-js-sdk/src/models/room"; import { useTranslation } from "react-i18next"; diff --git a/src/IncompatibleVersionModal.tsx b/src/IncompatibleVersionModal.tsx index 49c9044..d11822d 100644 --- a/src/IncompatibleVersionModal.tsx +++ b/src/IncompatibleVersionModal.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import React, { useMemo } from "react"; +import { FC, useMemo } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Modal, ModalContent } from "./Modal"; @@ -27,7 +27,7 @@ interface Props { onClose: () => void; } -export const IncompatibleVersionModal: React.FC = ({ +export const IncompatibleVersionModal: FC = ({ userIds, room, onClose, diff --git a/src/ListBox.tsx b/src/ListBox.tsx index 121f367..0ee2542 100644 --- a/src/ListBox.tsx +++ b/src/ListBox.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useRef } from "react"; +import { MutableRefObject, PointerEvent, useCallback, useRef } from "react"; import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox"; import { ListState } from "@react-stately/list"; import { Node } from "@react-types/shared"; @@ -26,7 +26,7 @@ interface ListBoxProps extends AriaListBoxOptions { optionClassName: string; state: ListState; className?: string; - listBoxRef?: React.MutableRefObject; + listBoxRef?: MutableRefObject; } export function ListBox({ @@ -84,7 +84,7 @@ function Option({ item, state, className }: OptionProps) { delete optionProps.onPointerUp; optionProps.onClick = useCallback( (e) => { - origPointerUp(e as unknown as React.PointerEvent); + origPointerUp(e as unknown as PointerEvent); }, [origPointerUp] ); diff --git a/src/Menu.tsx b/src/Menu.tsx index 0a995af..1e15841 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Key, useRef, useState } from "react"; +import { Key, useRef, useState } from "react"; import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu"; import { TreeState, useTreeState } from "@react-stately/tree"; import { mergeProps } from "@react-aria/utils"; diff --git a/src/Modal.tsx b/src/Modal.tsx index 6c24f0a..0721753 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -16,7 +16,7 @@ limitations under the License. /* eslint-disable jsx-a11y/no-autofocus */ -import React, { useRef, useMemo, ReactNode } from "react"; +import { useRef, useMemo, ReactNode } from "react"; import { useOverlay, usePreventScroll, diff --git a/src/SequenceDiagramViewerPage.tsx b/src/SequenceDiagramViewerPage.tsx index e04837d..208352f 100644 --- a/src/SequenceDiagramViewerPage.tsx +++ b/src/SequenceDiagramViewerPage.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx index c2f64f2..e090633 100644 --- a/src/Tooltip.tsx +++ b/src/Tooltip.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { +import { ForwardedRef, forwardRef, ReactElement, diff --git a/src/UserMenu.tsx b/src/UserMenu.tsx index afb4248..5648edd 100644 --- a/src/UserMenu.tsx +++ b/src/UserMenu.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Item } from "@react-stately/collections"; import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; diff --git a/src/UserMenuContainer.tsx b/src/UserMenuContainer.tsx index 0a116d9..4702c4f 100644 --- a/src/UserMenuContainer.tsx +++ b/src/UserMenuContainer.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useState } from "react"; +import { useCallback, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; import { useClient } from "./ClientContext"; diff --git a/src/analytics/AnalyticsNotice.tsx b/src/analytics/AnalyticsNotice.tsx index feceef7..544de61 100644 --- a/src/analytics/AnalyticsNotice.tsx +++ b/src/analytics/AnalyticsNotice.tsx @@ -1,4 +1,4 @@ -import React, { FC } from "react"; +import { FC } from "react"; import { Trans } from "react-i18next"; import { Link } from "../typography/Typography"; diff --git a/src/auth/LoginPage.tsx b/src/auth/LoginPage.tsx index 68cd326..c5a090f 100644 --- a/src/auth/LoginPage.tsx +++ b/src/auth/LoginPage.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, FormEvent, useCallback, useRef, useState } from "react"; +import { FC, FormEvent, useCallback, useRef, useState } from "react"; import { useHistory, useLocation, Link } from "react-router-dom"; import { Trans, useTranslation } from "react-i18next"; diff --git a/src/auth/RegisterPage.tsx b/src/auth/RegisterPage.tsx index 5eec26d..fd8e3f0 100644 --- a/src/auth/RegisterPage.tsx +++ b/src/auth/RegisterPage.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { +import { ChangeEvent, FC, FormEvent, diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 289f2b9..50184f0 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React, { forwardRef, useCallback } from "react"; +import { forwardRef, useCallback } from "react"; import { PressEvent } from "@react-types/shared"; import classNames from "classnames"; import { useButton } from "@react-aria/button"; diff --git a/src/button/CopyButton.tsx b/src/button/CopyButton.tsx index 7453e45..91c8e75 100644 --- a/src/button/CopyButton.tsx +++ b/src/button/CopyButton.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { useTranslation } from "react-i18next"; import useClipboard from "react-use-clipboard"; diff --git a/src/button/LinkButton.tsx b/src/button/LinkButton.tsx index e918965..3f3b1d5 100644 --- a/src/button/LinkButton.tsx +++ b/src/button/LinkButton.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes } from "react"; +import { HTMLAttributes } from "react"; import { Link } from "react-router-dom"; import classNames from "classnames"; import * as H from "history"; diff --git a/src/button/VolumeIcon.tsx b/src/button/VolumeIcon.tsx index d4958e4..163699f 100644 --- a/src/button/VolumeIcon.tsx +++ b/src/button/VolumeIcon.tsx @@ -15,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; - import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg"; import { ReactComponent as AudioLow } from "../icons/AudioLow.svg"; import { ReactComponent as Audio } from "../icons/Audio.svg"; diff --git a/src/form/Form.tsx b/src/form/Form.tsx index fd98a62..5496179 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import classNames from "classnames"; -import React, { FormEventHandler, forwardRef, ReactNode } from "react"; +import { FormEventHandler, forwardRef, ReactNode } from "react"; import styles from "./Form.module.css"; diff --git a/src/home/CallList.tsx b/src/home/CallList.tsx index b2a7774..545dfad 100644 --- a/src/home/CallList.tsx +++ b/src/home/CallList.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { Link } from "react-router-dom"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; diff --git a/src/home/CallTypeDropdown.tsx b/src/home/CallTypeDropdown.tsx index 991c528..712523d 100644 --- a/src/home/CallTypeDropdown.tsx +++ b/src/home/CallTypeDropdown.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC } from "react"; +import { FC } from "react"; import { Item } from "@react-stately/collections"; import { useTranslation } from "react-i18next"; diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index 7ff6efc..4c5522d 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { useTranslation } from "react-i18next"; import { useClient } from "../ClientContext"; diff --git a/src/home/JoinExistingCallModal.tsx b/src/home/JoinExistingCallModal.tsx index 078d317..dfa6b09 100644 --- a/src/home/JoinExistingCallModal.tsx +++ b/src/home/JoinExistingCallModal.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { PressEvent } from "@react-types/shared"; import { useTranslation } from "react-i18next"; diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index 41559d6..ee696d2 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -14,12 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { - useState, - useCallback, - FormEvent, - FormEventHandler, -} from "react"; +import { useState, useCallback, FormEvent, FormEventHandler } from "react"; import { useHistory } from "react-router-dom"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { useTranslation } from "react-i18next"; diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 44f16ad..1fb1a84 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, useCallback, useState, FormEventHandler } from "react"; +import { FC, useCallback, useState, FormEventHandler } from "react"; import { useHistory } from "react-router-dom"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { Trans, useTranslation } from "react-i18next"; diff --git a/src/input/AvatarInputField.tsx b/src/input/AvatarInputField.tsx index aec4880..9280fad 100644 --- a/src/input/AvatarInputField.tsx +++ b/src/input/AvatarInputField.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import { useObjectRef } from "@react-aria/utils"; -import React, { AllHTMLAttributes, useEffect } from "react"; +import { AllHTMLAttributes, ChangeEvent, useEffect } from "react"; import { useCallback } from "react"; import { useState } from "react"; import { forwardRef } from "react"; @@ -51,7 +51,7 @@ export const AvatarInputField = forwardRef( const currentInput = fileInputRef.current; const onChange = (e: Event) => { - const inputEvent = e as unknown as React.ChangeEvent; + const inputEvent = e as unknown as ChangeEvent; if (inputEvent.target.files.length > 0) { setObjUrl(URL.createObjectURL(inputEvent.target.files[0])); setRemoved(false); diff --git a/src/input/Input.tsx b/src/input/Input.tsx index 3548d5b..a983f71 100644 --- a/src/input/Input.tsx +++ b/src/input/Input.tsx @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent, FC, forwardRef, ReactNode, useId } from "react"; +import { + ChangeEvent, + FC, + ForwardedRef, + forwardRef, + ReactNode, + useId, +} from "react"; import classNames from "classnames"; import styles from "./Input.module.css"; @@ -114,7 +121,7 @@ export const InputField = forwardRef< {type === "textarea" ? (