From 075b917c3633cbe79dbd8ebe217dd8f31c395706 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 8 Jun 2026 22:47:04 +0700 Subject: [PATCH 1/2] chore(flags): derive flag-test coords from board-flags source (#1165) * feat(board-header): show subtitle on /all view Add all_subtitle copy and render it in the board header when browsing the aggregated all-directories feed. * chore(flags): derive flag-test coords from board-flags source comment-flags.test.ts hard-coded the pol/pony sprite x/y, duplicating the literals already pinned in board-flags.test.ts. A recent sprite remap updated one file and not the other, leaving master red. Derive the coords from getBoardFlagDefinition (the same source the runtime uses) so the assertion can't drift; labels stay literal as readable pins, and board-flags.test.ts remains the single place pinning the actual values. --- public/translations/ar/default.json | 3 ++- public/translations/bn/default.json | 3 ++- public/translations/cs/default.json | 3 ++- public/translations/da/default.json | 3 ++- public/translations/de/default.json | 3 ++- public/translations/el/default.json | 3 ++- public/translations/en/default.json | 3 ++- public/translations/es/default.json | 3 ++- public/translations/fa/default.json | 3 ++- public/translations/fi/default.json | 3 ++- public/translations/fil/default.json | 3 ++- public/translations/fr/default.json | 3 ++- public/translations/he/default.json | 3 ++- public/translations/hi/default.json | 3 ++- public/translations/hu/default.json | 3 ++- public/translations/id/default.json | 3 ++- public/translations/it/default.json | 3 ++- public/translations/ja/default.json | 3 ++- public/translations/ko/default.json | 3 ++- public/translations/mr/default.json | 3 ++- public/translations/nl/default.json | 3 ++- public/translations/no/default.json | 3 ++- public/translations/pl/default.json | 3 ++- public/translations/pt/default.json | 3 ++- public/translations/ro/default.json | 3 ++- public/translations/ru/default.json | 3 ++- public/translations/sq/default.json | 3 ++- public/translations/sv/default.json | 3 ++- public/translations/te/default.json | 3 ++- public/translations/th/default.json | 3 ++- public/translations/tr/default.json | 3 ++- public/translations/uk/default.json | 3 ++- public/translations/ur/default.json | 3 ++- public/translations/vi/default.json | 3 ++- public/translations/zh/default.json | 3 ++- .../board-header/__tests__/board-header.test.tsx | 1 + src/components/board-header/board-header.tsx | 6 +++--- src/lib/__tests__/comment-flags.test.ts | 10 ++++++++-- 38 files changed, 82 insertions(+), 40 deletions(-) diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 9ef61239..16c26023 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -406,5 +406,6 @@ "not_a_file": "ليس ملفًا", "tor_author_flag_label": "هذا المستخدم يستخدم Tor", "copy_share_link": "نسخ رابط المشاركة", - "directory_submit_board": "أرسل لوحتك" + "directory_submit_board": "أرسل لوحتك", + "all_subtitle": "المنتدى الأعلى تصنيفاً من كل دليل" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index eb88c7e1..2c26e4a8 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -406,5 +406,6 @@ "not_a_file": "ফাইল নয়", "tor_author_flag_label": "এই ব্যবহারকারী Tor ব্যবহার করছেন", "copy_share_link": "শেয়ার লিংক কপি করুন", - "directory_submit_board": "আপনার বোর্ড জমা দিন" + "directory_submit_board": "আপনার বোর্ড জমা দিন", + "all_subtitle": "প্রতিটি ডিরেক্টরি থেকে সর্বোচ্চ স্কোর করা বোর্ড" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 85f9de43..abaff73c 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -406,5 +406,6 @@ "not_a_file": "není soubor", "tor_author_flag_label": "Tento uživatel je na síti Tor", "copy_share_link": "Kopírovat odkaz pro sdílení", - "directory_submit_board": "Odeslat svou nástěnku" + "directory_submit_board": "Odeslat svou nástěnku", + "all_subtitle": "Fórum s nejvyšším skóre z každého adresáře" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 3fffe751..c93c14ee 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -406,5 +406,6 @@ "not_a_file": "ikke en fil", "tor_author_flag_label": "Denne bruger er på Tor", "copy_share_link": "Kopier delingslinkk", - "directory_submit_board": "Indsend dit board" + "directory_submit_board": "Indsend dit board", + "all_subtitle": "Forummet med højeste pointtal fra hvert katalog" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index a0751326..42719dc7 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -406,5 +406,6 @@ "not_a_file": "keine Datei", "tor_author_flag_label": "Dieser Benutzer ist auf Tor", "copy_share_link": "Freigabelink kopieren", - "directory_submit_board": "Reiche dein Board ein" + "directory_submit_board": "Reiche dein Board ein", + "all_subtitle": "Das höchstbewertete Forum aus jedem Verzeichnis" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 68db2a63..35d8c901 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -406,5 +406,6 @@ "not_a_file": "δεν είναι αρχείο", "tor_author_flag_label": "Αυτός ο χρήστης είναι στο Tor", "copy_share_link": "Αντιγραφή ενός συνδέσμου", - "directory_submit_board": "Υπόβαλε το board σου" + "directory_submit_board": "Υπόβαλε το board σου", + "all_subtitle": "Το Φόρουμ με την υψηλότερη βαθμολογία από κάθε κατάλογο" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 2c21ed68..ecb5745b 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -425,5 +425,6 @@ "not_a_file": "not a file", "tor_author_flag_label": "This user is on Tor", "copy_share_link": "Copy share link", - "directory_submit_board": "Submit Your Board" + "directory_submit_board": "Submit Your Board", + "all_subtitle": "The highest-scoring board from each directory" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index fac120f1..5654cd21 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -406,5 +406,6 @@ "not_a_file": "no es un archivo", "tor_author_flag_label": "Este usuario está en Tor", "copy_share_link": "Copiar enlace compartido", - "directory_submit_board": "Envía tu board" + "directory_submit_board": "Envía tu board", + "all_subtitle": "El foro de mayor puntuación de cada directorio" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 11e40557..7f01cedc 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -406,5 +406,6 @@ "not_a_file": "فایل نیست", "tor_author_flag_label": "این کاربر روی Tor است", "copy_share_link": "کپی کردن لینک اشتراک", - "directory_submit_board": "برد خود را ارسال کنید" + "directory_submit_board": "برد خود را ارسال کنید", + "all_subtitle": "تالار بالاترین امتیاز از هر دایرکتوری" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 00c84b35..4adb9c8e 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -406,5 +406,6 @@ "not_a_file": "ei ole tiedosto", "tor_author_flag_label": "Tämä käyttäjä on Torissa", "copy_share_link": "Kopioi jakolinkkii", - "directory_submit_board": "Lähetä boardisi" + "directory_submit_board": "Lähetä boardisi", + "all_subtitle": "Korkeimman pistemäärän saanut foorumi jokaisesta hakemistosta" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 9a99af6c..3a6d5966 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -406,5 +406,6 @@ "not_a_file": "hindi file", "tor_author_flag_label": "Ang user na ito ay nasa Tor", "copy_share_link": "Kopyahin ang link sa pagbabahagi", - "directory_submit_board": "Isumite ang board mo" + "directory_submit_board": "Isumite ang board mo", + "all_subtitle": "Ang pinakamataas na nakakuha ng puntos na Board mula sa bawat direktoryo" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 7eef44a2..0f3d80f0 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -406,5 +406,6 @@ "not_a_file": "pas un fichier", "tor_author_flag_label": "Cet utilisateur est sur Tor", "copy_share_link": "Copier le lien de partage", - "directory_submit_board": "Soumettez votre board" + "directory_submit_board": "Soumettez votre board", + "all_subtitle": "Le forum de plus haute note de chaque répertoire" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 44fa151f..d5437aee 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -406,5 +406,6 @@ "not_a_file": "לא קובץ", "tor_author_flag_label": "המשתמש הזה נמצא ב-Tor", "copy_share_link": "העתק קישור שיתוף", - "directory_submit_board": "שלח את הבורד שלך" + "directory_submit_board": "שלח את הבורד שלך", + "all_subtitle": "הפורום בעל הציון הגבוה ביותר מכל ספריה" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 9f0f6651..38d29171 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -406,5 +406,6 @@ "not_a_file": "फ़ाइल नहीं है", "tor_author_flag_label": "यह उपयोगकर्ता Tor पर है", "copy_share_link": "शेयर लिंक कॉपी करें", - "directory_submit_board": "अपना बोर्ड सबमिट करें" + "directory_submit_board": "अपना बोर्ड सबमिट करें", + "all_subtitle": "हर डायरेक्टरी से सबसे अधिक स्कोर किया गया फोरम" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index b151a7e6..0ed8ceb7 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -406,5 +406,6 @@ "not_a_file": "nem fájl", "tor_author_flag_label": "Ez a felhasználó a Tor hálózatán van", "copy_share_link": "Megosztási hivatkozás másolása", - "directory_submit_board": "Küldd be a boardodat" + "directory_submit_board": "Küldd be a boardodat", + "all_subtitle": "A legmagasabb pontszámú Fórum minden könyvtárból" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index a0924c69..0579b4d7 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -406,5 +406,6 @@ "not_a_file": "bukan file", "tor_author_flag_label": "Pengguna ini ada di Tor", "copy_share_link": "Salin tautan bagian", - "directory_submit_board": "Kirim board Anda" + "directory_submit_board": "Kirim board Anda", + "all_subtitle": "Forum dengan skor tertinggi dari setiap direktori" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 3732162f..6c1f1524 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -406,5 +406,6 @@ "not_a_file": "non è un file", "tor_author_flag_label": "Questo utente è su Tor", "copy_share_link": "Copia link condiviso", - "directory_submit_board": "Invia la tua board" + "directory_submit_board": "Invia la tua board", + "all_subtitle": "Il board con il punteggio più alto da ogni directory" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 254174ca..f8526115 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -406,5 +406,6 @@ "not_a_file": "ファイルではありません", "tor_author_flag_label": "このユーザーはTorを使用しています", "copy_share_link": "シェアリンクをコピー", - "directory_submit_board": "掲示板を提出する" + "directory_submit_board": "掲示板を提出する", + "all_subtitle": "各ディレクトリから最もスコアの高い掲示板" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 438f8619..f02f6e3e 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -406,5 +406,6 @@ "not_a_file": "파일이 아님", "tor_author_flag_label": "이 사용자는 Tor를 사용 중입니다", "copy_share_link": "공유 링크 복사", - "directory_submit_board": "게시판 제출하기" + "directory_submit_board": "게시판 제출하기", + "all_subtitle": "각 디렉토리의 가장 높은 점수 게시판" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index a9a6afb8..d682ea33 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -406,5 +406,6 @@ "not_a_file": "फाइल नाही", "tor_author_flag_label": "हा वापरकर्ता Tor वर आहे", "copy_share_link": "शेअर लिंक कॉपी करा", - "directory_submit_board": "तुमचा बोर्ड सबमिट करा" + "directory_submit_board": "तुमचा बोर्ड सबमिट करा", + "all_subtitle": "प्रत्येक डायरेक्टरीमधील सर्वोच्च स्कोर केलेले फोरम" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 32e490ed..ff415564 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -406,5 +406,6 @@ "not_a_file": "geen bestand", "tor_author_flag_label": "Deze gebruiker zit op Tor", "copy_share_link": "Delen link kopiëren", - "directory_submit_board": "Dien je board in" + "directory_submit_board": "Dien je board in", + "all_subtitle": "Het Forum met de hoogste score van elke directory" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index d052be15..cc96bcb3 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -406,5 +406,6 @@ "not_a_file": "ikke en fil", "tor_author_flag_label": "Denne brukeren er på Tor", "copy_share_link": "Kopier delingslenke", - "directory_submit_board": "Send inn boardet ditt" + "directory_submit_board": "Send inn boardet ditt", + "all_subtitle": "Forummet med høyest poengsum fra hver katalog" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 2e155b6c..ad8b6a92 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -406,5 +406,6 @@ "not_a_file": "to nie plik", "tor_author_flag_label": "Ten użytkownik jest w sieci Tor", "copy_share_link": "Kopiuj link udostępniania", - "directory_submit_board": "Prześlij swój board" + "directory_submit_board": "Prześlij swój board", + "all_subtitle": "Forum o najwyższej punktacji z każdego katalogu" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 04011ed3..643b5728 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -406,5 +406,6 @@ "not_a_file": "não é um arquivo", "tor_author_flag_label": "Este usuário está no Tor", "copy_share_link": "Copiar link compartilhado", - "directory_submit_board": "Envie seu board" + "directory_submit_board": "Envie seu board", + "all_subtitle": "O Fórum de mais alta pontuação de cada diretório" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index c67d04a0..51aa9101 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -406,5 +406,6 @@ "not_a_file": "nu este fișier", "tor_author_flag_label": "Acest utilizator este pe Tor", "copy_share_link": "Copiezi link-ul de partajare", - "directory_submit_board": "Trimite boardul tău" + "directory_submit_board": "Trimite boardul tău", + "all_subtitle": "Forumul cu cea mai mare punctaj din fiecare director" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index be9f5029..61276eff 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -406,5 +406,6 @@ "not_a_file": "не файл", "tor_author_flag_label": "Этот пользователь находится в Tor", "copy_share_link": "Копировать ссылку для общего доступа", - "directory_submit_board": "Отправить свою доску" + "directory_submit_board": "Отправить свою доску", + "all_subtitle": "Форум с наивысшим рейтингом из каждого каталога" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index b4237d39..07ea2ff8 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -406,5 +406,6 @@ "not_a_file": "nuk është skedar", "tor_author_flag_label": "Ky përdorues është në Tor", "copy_share_link": "Kopjo lidhjen e ndarjes", - "directory_submit_board": "Dërgo bordin tënd" + "directory_submit_board": "Dërgo bordin tënd", + "all_subtitle": "Forumi me pikëzimin më të lartë nga secila drejtori" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 1ce52b2a..fffb3060 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -406,5 +406,6 @@ "not_a_file": "inte en fil", "tor_author_flag_label": "Den här användaren är på Tor", "copy_share_link": "Kopiera delningslänk", - "directory_submit_board": "Skicka in ditt board" + "directory_submit_board": "Skicka in ditt board", + "all_subtitle": "Forummet med högst poäng från varje katalog" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 841a08e2..a9e95c92 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -406,5 +406,6 @@ "not_a_file": "ఫైల్ కాదు", "tor_author_flag_label": "ఈ వినియోగదారు Tor లో ఉన్నారు", "copy_share_link": "భాగస్వామ్య లింక్‌ను కాపీ చేయండి", - "directory_submit_board": "మీ బోర్డును సమర్పించండి" + "directory_submit_board": "మీ బోర్డును సమర్పించండి", + "all_subtitle": "ప్రతిదిరెక్టరీ నుండి అత్యధిక స్కోర్ చేసిన ఫోరం" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 0f0f1e2f..c3153398 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -406,5 +406,6 @@ "not_a_file": "ไม่ใช่ไฟล์", "tor_author_flag_label": "ผู้ใช้นี้อยู่บน Tor", "copy_share_link": "คัดลอกลิงก์แชร์", - "directory_submit_board": "ส่งบอร์ดของคุณ" + "directory_submit_board": "ส่งบอร์ดของคุณ", + "all_subtitle": "กระดานที่ได้คะแนนสูงสุดจากแต่ละไดเรกทอรี่" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 970a4413..c758890a 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -406,5 +406,6 @@ "not_a_file": "dosya değil", "tor_author_flag_label": "Bu kullanıcı Tor'da", "copy_share_link": "Paylaşma bağlantısını kopyala", - "directory_submit_board": "Panonu Gönder" + "directory_submit_board": "Panonu Gönder", + "all_subtitle": "Her dizinden en yüksek skorlu Forum" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index a5ded08b..a9a13e49 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -406,5 +406,6 @@ "not_a_file": "не файл", "tor_author_flag_label": "Цей користувач у Tor", "copy_share_link": "Копіювати посилання для спільного доступу", - "directory_submit_board": "Надіслати свою дошку" + "directory_submit_board": "Надіслати свою дошку", + "all_subtitle": "Форум з найвищим рейтингом з кожного каталогу" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 3c6a4454..764ee8a9 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -406,5 +406,6 @@ "not_a_file": "فائل نہیں ہے", "tor_author_flag_label": "یہ صارف Tor پر ہے", "copy_share_link": "شیئر لنک کاپی کریں", - "directory_submit_board": "اپنا بورڈ جمع کرائیں" + "directory_submit_board": "اپنا بورڈ جمع کرائیں", + "all_subtitle": "ہر ڈائریکٹری سے سب سے زیادہ سکور پیغام بورڈ" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 8eb2ed8c..2bd88cdb 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -406,5 +406,6 @@ "not_a_file": "không phải tệp", "tor_author_flag_label": "Người dùng này đang ở trên Tor", "copy_share_link": "Sao chép liên kết chia sẻ", - "directory_submit_board": "Gửi bảng của bạn" + "directory_submit_board": "Gửi bảng của bạn", + "all_subtitle": "Diễn đàn có điểm cao nhất từ mỗi thư mục" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 1e51f1a7..292f29db 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -406,5 +406,6 @@ "not_a_file": "不是文件", "tor_author_flag_label": "该用户正在使用 Tor", "copy_share_link": "复制分享链接", - "directory_submit_board": "提交你的版块" + "directory_submit_board": "提交你的版块", + "all_subtitle": "来自每个目录的最高评分论坛" } diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx index 6eb383a9..df90681a 100644 --- a/src/components/board-header/__tests__/board-header.test.tsx +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -169,6 +169,7 @@ describe('BoardHeader', () => { expect(container.textContent).toContain('/all/ - All 5chan Directories'); expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png'); expect(container.textContent).not.toContain('subscriptions_subtitle'); + expect(container.textContent).toContain('all_subtitle'); }); it('renders a clickable subscriptions subtitle that navigates to subscription settings', async () => { diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 3c01dde8..4075288e 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -83,7 +83,7 @@ const BoardHeader = () => { ? '/mod/ - Boards You Moderate' : defaultCommunity?.title || stableCommunity?.title; const subtitle = isInAllView - ? '' + ? t('all_subtitle') : isInSubscriptionsView ? subscriptionsSubtitle : isInModView @@ -124,9 +124,9 @@ const BoardHeader = () => { > {subtitle} - ) : isInDirectoryListView ? ( + ) : isInDirectoryListView || isInAllView ? ( {subtitle} - ) : !isInAllView && !isInModView && subtitle ? ( + ) : !isInModView && subtitle ? ( {subtitle} ) : ( subtitle diff --git a/src/lib/__tests__/comment-flags.test.ts b/src/lib/__tests__/comment-flags.test.ts index b9b205a4..d9525caa 100644 --- a/src/lib/__tests__/comment-flags.test.ts +++ b/src/lib/__tests__/comment-flags.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { getCommentFlagFlairs, getAuthorFlagFlairs, getAuthorFlagViewModels, TOR_AUTHOR_FLAG_LABEL_KEY } from '../comment-flags'; +import { getBoardFlagDefinition } from '../board-flags'; describe('comment-flags', () => { it('parses the pkc-js challenge flair example for country flags', () => { @@ -21,8 +22,13 @@ describe('comment-flags', () => { const flags = getAuthorFlagViewModels([{ text: 'flag:country:DE' }, { text: 'flag:pol:AC' }, { text: 'flag:pony:AJ' }], 1000); expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']); - expect(flags[1]).toMatchObject({ label: 'Anarcho-Capitalist', x: 0, y: 0 }); - expect(flags[2]).toMatchObject({ label: 'Applejack', x: 48, y: 0 }); + + // Pin the human-readable labels here, but derive the sprite coords from the board-flags source of + // truth so this test can't drift when the sheet is remapped (board-flags.test.ts pins the values). + const politicalFlag = getBoardFlagDefinition('pol', 'AC'); + const ponyFlag = getBoardFlagDefinition('pony', 'AJ'); + expect(flags[1]).toMatchObject({ label: 'Anarcho-Capitalist', x: politicalFlag?.x, y: politicalFlag?.y }); + expect(flags[2]).toMatchObject({ label: 'Applejack', x: ponyFlag?.x, y: ponyFlag?.y }); }); it('labels the xx country flag as Tor traffic', () => { From 245d3164c34a8dc55dd11f2ffed92b74a089e2ba Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 8 Jun 2026 22:55:30 +0700 Subject: [PATCH 2/2] fix(media playback): stop hidden and offscreen media (#1164) * fix(media playback): stop hidden and offscreen media * fix(media playback): keep offscreen embeds suspended --- src/__tests__/app.test.tsx | 33 +-- src/app.tsx | 34 +-- .../__tests__/feed-cache-container.test.tsx | 122 +++++++++ .../feed-cache-container.tsx | 20 +- .../use-suspend-offscreen-media-playback.ts | 8 + .../__tests__/media-playback-utils.test.ts | 241 +++++++++++++++++ src/lib/utils/media-playback-utils.ts | 243 ++++++++++++++++++ 7 files changed, 665 insertions(+), 36 deletions(-) create mode 100644 src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx create mode 100644 src/hooks/use-suspend-offscreen-media-playback.ts create mode 100644 src/lib/utils/__tests__/media-playback-utils.test.ts create mode 100644 src/lib/utils/media-playback-utils.ts diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 32abd69e..2a0b14b3 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -111,6 +111,7 @@ vi.mock('../lib/snow', () => ({ vi.mock('../lib/utils/preload-utils', () => ({ preloadReplyModal: vi.fn(), preloadThemeAssets: vi.fn(), + resolveAssetUrl: (path: string) => path, })); function makeNamedComponent(name: string) { @@ -149,29 +150,29 @@ const MockSettingsModal = () => { ); }; -vi.mock('../components/board-buttons', () => ({ +vi.mock('../components/board-buttons/board-buttons', () => ({ DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'), MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'), MobileBoardButtons: makeNamedComponent('mobile-board-buttons'), })); -vi.mock('../components/board-header', () => ({ +vi.mock('../components/board-header/board-header', () => ({ default: makeNamedComponent('board-header'), })); -vi.mock('../components/feed-cache-container', () => ({ +vi.mock('../components/feed-cache-container/feed-cache-container', () => ({ default: makeNamedComponent('feed-cache-container'), })); -vi.mock('../components/post-form', () => ({ +vi.mock('../components/post-form/post-form', () => ({ default: MockPostForm, })); -vi.mock('../components/board-blotter', () => ({ +vi.mock('../components/board-blotter/board-blotter', () => ({ default: makeNamedComponent('board-blotter'), })); -vi.mock('../components/boards-bar', () => ({ +vi.mock('../components/boards-bar/boards-bar', () => ({ default: MockBoardsBar, })); @@ -179,7 +180,7 @@ vi.mock('../views/board', () => ({ default: makeNamedComponent('board-view'), })); -vi.mock('../views/blotter', () => ({ +vi.mock('../views/blotter/blotter', () => ({ default: makeNamedComponent('blotter-view'), })); @@ -187,39 +188,39 @@ vi.mock('../views/catalog', () => ({ default: makeNamedComponent('catalog-view'), })); -vi.mock('../views/faq', () => ({ +vi.mock('../views/faq/faq', () => ({ default: makeNamedComponent('faq-view'), })); -vi.mock('../views/home', () => ({ +vi.mock('../views/home/home', () => ({ default: makeNamedComponent('home-view'), })); -vi.mock('../views/mod-queue', () => ({ +vi.mock('../views/mod-queue/mod-queue', () => ({ default: makeNamedComponent('mod-queue-view'), })); -vi.mock('../views/not-allowed', () => ({ +vi.mock('../views/not-allowed/not-allowed', () => ({ default: makeNamedComponent('not-allowed-view'), })); -vi.mock('../views/not-found', () => ({ +vi.mock('../views/not-found/not-found', () => ({ default: makeNamedComponent('not-found-view'), })); -vi.mock('../views/pass', () => ({ +vi.mock('../views/pass/pass', () => ({ default: makeNamedComponent('pass-view'), })); -vi.mock('../views/pending-post', () => ({ +vi.mock('../views/pending-post/pending-post', () => ({ default: makeNamedComponent('pending-post-view'), })); -vi.mock('../views/post', () => ({ +vi.mock('../views/post/post', () => ({ default: makeNamedComponent('post-view'), })); -vi.mock('../views/rules', () => ({ +vi.mock('../views/rules/rules', () => ({ default: makeNamedComponent('rules-view'), })); diff --git a/src/app.tsx b/src/app.tsx index b2d768cf..e53fc89f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -16,6 +16,7 @@ import { useDirectories } from './hooks/use-directories'; import { useCommunityIdentifier } from './hooks/use-community-identifiers'; import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address'; import useSafeAccountComment from './hooks/use-safe-account-comment'; +import useSuspendOffscreenMediaPlayback from './hooks/use-suspend-offscreen-media-playback'; import { getCommentCommunityAddress } from './lib/utils/comment-utils'; import { getBoardPath, @@ -32,24 +33,24 @@ import { isFlashBoardRoute, } from './lib/utils/route-utils'; import styles from './app.module.css'; -import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons'; -import Blotter from './views/blotter'; -import Faq from './views/faq'; -import Home from './views/home'; +import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons'; +import Blotter from './views/blotter/blotter'; +import Faq from './views/faq/faq'; +import Home from './views/home/home'; import Archive from './views/archive/archive'; import Directory from './views/directory/directory'; -import ModQueueView from './views/mod-queue'; -import NotAllowed from './views/not-allowed'; -import NotFound from './views/not-found'; -import Pass from './views/pass'; -import PendingPost from './views/pending-post'; -import Post from './views/post'; -import Rules from './views/rules'; -import BoardHeader from './components/board-header'; -import FeedCacheContainer from './components/feed-cache-container'; -import PostForm from './components/post-form'; -import BoardBlotter from './components/board-blotter'; -import BoardsBar from './components/boards-bar'; +import ModQueueView from './views/mod-queue/mod-queue'; +import NotAllowed from './views/not-allowed/not-allowed'; +import NotFound from './views/not-found/not-found'; +import Pass from './views/pass/pass'; +import PendingPost from './views/pending-post/pending-post'; +import Post from './views/post/post'; +import Rules from './views/rules/rules'; +import BoardHeader from './components/board-header/board-header'; +import FeedCacheContainer from './components/feed-cache-container/feed-cache-container'; +import PostForm from './components/post-form/post-form'; +import BoardBlotter from './components/board-blotter/board-blotter'; +import BoardsBar from './components/boards-bar/boards-bar'; import ExternalQuoteStatus from './components/external-quote-status/external-quote-status'; const AccountDataEditor = lazy(() => import('./views/account-data-editor')); @@ -189,6 +190,7 @@ const BoardLayout = () => { const GlobalLayout = () => { useTheme(); + useSuspendOffscreenMediaPlayback(); const { activeCid, diff --git a/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx b/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx new file mode 100644 index 00000000..e0c57c67 --- /dev/null +++ b/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useFeedCacheStore from '../../../stores/use-feed-cache-store'; +import FeedCacheContainer from '../feed-cache-container'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +vi.mock('../../../views/catalog/catalog', () => ({ + default: () => createElement('div', { 'data-testid': 'catalog' }), +})); + +vi.mock('../../../views/board/board', () => ({ + default: ({ feedCacheKey }: { feedCacheKey: string }) => + createElement( + 'div', + { 'data-testid': `board-${feedCacheKey}` }, + feedCacheKey === '/a' + ? createElement( + React.Fragment, + {}, + createElement('video', { 'data-testid': 'cached-video', src: 'https://cdn.example.com/video.mp4' }), + createElement('iframe', { 'data-testid': 'cached-iframe', src: 'https://player.example.com/embed', title: 'remote embed' }), + createElement('iframe', { 'data-testid': 'cached-srcdoc-iframe', srcDoc: '

srcdoc embed

', title: 'srcdoc embed' }), + ) + : null, + ), +})); + +let container: HTMLDivElement; +let iframeRectSpy: ReturnType; +let latestPathname = ''; +let pauseSpy: ReturnType; +let root: Root; + +const NavigateButtons = () => { + const navigate = useNavigate(); + return createElement( + React.Fragment, + {}, + createElement('button', { 'data-testid': 'navigate-a', onClick: () => navigate('/a'), type: 'button' }, 'a'), + createElement('button', { 'data-testid': 'navigate-b', onClick: () => navigate('/b'), type: 'button' }, 'b'), + ); +}; + +const LocationProbe = () => { + latestPathname = useLocation().pathname; + return null; +}; + +const flushEffects = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +describe('FeedCacheContainer', () => { + beforeEach(() => { + useFeedCacheStore.getState().clearFeeds(); + iframeRectSpy = vi.spyOn(window.HTMLIFrameElement.prototype, 'getBoundingClientRect').mockReturnValue({ + bottom: 240, + height: 240, + left: 0, + right: 320, + top: 0, + width: 320, + x: 0, + y: 0, + toJSON: () => '', + }); + pauseSpy = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); + latestPathname = ''; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useFeedCacheStore.getState().clearFeeds(); + iframeRectSpy.mockRestore(); + pauseSpy.mockRestore(); + }); + + it('suspends playable media while a cached feed is hidden and restores iframe embeds when visible again', async () => { + await act(async () => { + root.render( + createElement(MemoryRouter, { initialEntries: ['/a'] }, createElement(FeedCacheContainer), createElement(NavigateButtons), createElement(LocationProbe)), + ); + }); + await flushEffects(); + + expect(latestPathname).toBe('/a'); + expect(container.querySelector('[data-testid="cached-video"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('

srcdoc embed

'); + + await act(async () => { + container.querySelector('[data-testid="navigate-b"]')?.click(); + }); + await flushEffects(); + + expect(latestPathname).toBe('/b'); + expect(pauseSpy).toHaveBeenCalled(); + expect(container.querySelector('[data-testid="cached-video"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('about:blank'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBeNull(); + + await act(async () => { + container.querySelector('[data-testid="navigate-a"]')?.click(); + }); + await flushEffects(); + + expect(latestPathname).toBe('/a'); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('

srcdoc embed

'); + }); +}); diff --git a/src/components/feed-cache-container/feed-cache-container.tsx b/src/components/feed-cache-container/feed-cache-container.tsx index 054612b3..dbb9400e 100644 --- a/src/components/feed-cache-container/feed-cache-container.tsx +++ b/src/components/feed-cache-container/feed-cache-container.tsx @@ -1,10 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store'; import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils'; import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils'; -import Board from '../../views/board'; -import Catalog from '../../views/catalog'; +import { restoreSuspendedMediaPlayback, suspendMediaPlayback } from '../../lib/utils/media-playback-utils'; +import Board from '../../views/board/board'; +import Catalog from '../../views/catalog/catalog'; import styles from './feed-cache-container.module.css'; interface FeedContextFromKey { @@ -41,10 +42,21 @@ interface CachedFeedWrapperProps { } const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => { + const containerRef = useRef(null); const context = parseFeedKey(feed.key); + useEffect(() => { + const container = containerRef.current; + if (isVisible) { + restoreSuspendedMediaPlayback(container, { visibleOnly: true }); + return; + } + + suspendMediaPlayback(container); + }, [isVisible]); + return ( -
+
{feed.type === 'catalog' ? ( { + useEffect(() => observeOffscreenMediaPlayback(document.body), []); +}; + +export default useSuspendOffscreenMediaPlayback; diff --git a/src/lib/utils/__tests__/media-playback-utils.test.ts b/src/lib/utils/__tests__/media-playback-utils.test.ts new file mode 100644 index 00000000..76768a3e --- /dev/null +++ b/src/lib/utils/__tests__/media-playback-utils.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { observeOffscreenMediaPlayback, restoreSuspendedMediaPlayback } from '../media-playback-utils'; + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + + callback: IntersectionObserverCallback; + observedElements = new Set(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + MockIntersectionObserver.instances.push(this); + } + + disconnect() { + this.observedElements.clear(); + } + + observe(element: Element) { + this.observedElements.add(element); + } + + takeRecords() { + return []; + } + + unobserve(element: Element) { + this.observedElements.delete(element); + } + + trigger(target: Element, isIntersecting: boolean) { + this.callback( + [ + { + intersectionRatio: isIntersecting ? 1 : 0, + isIntersecting, + target, + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ); + } +} + +const nextMicrotask = () => Promise.resolve(); + +const createExpandedMedia = () => { + const container = document.createElement('span'); + container.dataset.expandedMedia = 'true'; + + const video = document.createElement('video'); + video.src = 'https://cdn.example.com/video.webm'; + const iframe = document.createElement('iframe'); + iframe.src = 'https://player.example.com/embed'; + const srcdocIframe = document.createElement('iframe'); + srcdocIframe.srcdoc = '

embedded player

'; + + container.append(video, iframe, srcdocIframe); + + return { container, iframe, srcdocIframe, video }; +}; + +describe('media-playback-utils', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + MockIntersectionObserver.instances = []; + vi.stubGlobal('innerHeight', 600); + vi.stubGlobal('innerWidth', 800); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('suspends expanded media after it stays offscreen for the delay and restores iframe embeds when it returns', async () => { + const root = document.createElement('div'); + const { container, iframe, srcdocIframe, video } = createExpandedMedia(); + const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {}); + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + + expect(observer.observedElements.has(container)).toBe(true); + + observer.trigger(container, false); + await vi.advanceTimersByTimeAsync(999); + + expect(pauseMock).not.toHaveBeenCalled(); + expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(srcdocIframe.getAttribute('srcdoc')).toBe('

embedded player

'); + + await vi.advanceTimersByTimeAsync(1); + + expect(pauseMock).toHaveBeenCalledTimes(1); + expect(iframe.getAttribute('src')).toBe('about:blank'); + expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); + + observer.trigger(container, true); + + expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(srcdocIframe.getAttribute('srcdoc')).toBe('

embedded player

'); + + cleanup(); + root.remove(); + }); + + it('leaves offscreen iframe embeds suspended during visible-only restore until they intersect', async () => { + const root = document.createElement('div'); + const { container, iframe, srcdocIframe, video } = createExpandedMedia(); + vi.spyOn(video, 'pause').mockImplementation(() => {}); + vi.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ + bottom: 900, + height: 300, + left: 0, + right: 400, + top: 600, + width: 400, + x: 0, + y: 600, + toJSON: () => '', + }); + vi.spyOn(srcdocIframe, 'getBoundingClientRect').mockReturnValue({ + bottom: 900, + height: 300, + left: 0, + right: 400, + top: 600, + width: 400, + x: 0, + y: 600, + toJSON: () => '', + }); + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + + observer.trigger(container, false); + await vi.advanceTimersByTimeAsync(1000); + + restoreSuspendedMediaPlayback(container, { visibleOnly: true }); + + expect(iframe.getAttribute('src')).toBe('about:blank'); + expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); + + observer.trigger(container, true); + + expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(srcdocIframe.getAttribute('srcdoc')).toBe('

embedded player

'); + + cleanup(); + root.remove(); + }); + + it('cancels the offscreen suspend when expanded media returns before the delay passes', async () => { + const root = document.createElement('div'); + const { container, iframe, video } = createExpandedMedia(); + const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {}); + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + + observer.trigger(container, false); + await vi.advanceTimersByTimeAsync(999); + observer.trigger(container, true); + await vi.advanceTimersByTimeAsync(1); + + expect(pauseMock).not.toHaveBeenCalled(); + expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); + + cleanup(); + root.remove(); + }); + + it('observes expanded media added after the offscreen observer starts', async () => { + const root = document.createElement('div'); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + const { container } = createExpandedMedia(); + + root.append(container); + await nextMicrotask(); + + expect(observer.observedElements.has(container)).toBe(true); + + cleanup(); + root.remove(); + }); + + it('observes playable media inserted into an expanded container after startup', async () => { + const root = document.createElement('div'); + const container = document.createElement('span'); + container.dataset.expandedMedia = 'true'; + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + const video = document.createElement('video'); + + expect(observer.observedElements.has(container)).toBe(false); + + container.append(video); + await nextMicrotask(); + + expect(observer.observedElements.has(container)).toBe(true); + + cleanup(); + root.remove(); + }); + + it('observes media when an existing playable container becomes expanded', async () => { + const root = document.createElement('div'); + const { container } = createExpandedMedia(); + container.removeAttribute('data-expanded-media'); + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + + expect(observer.observedElements.has(container)).toBe(false); + + container.dataset.expandedMedia = 'true'; + await nextMicrotask(); + + expect(observer.observedElements.has(container)).toBe(true); + + cleanup(); + root.remove(); + }); +}); diff --git a/src/lib/utils/media-playback-utils.ts b/src/lib/utils/media-playback-utils.ts new file mode 100644 index 00000000..d55b7d34 --- /dev/null +++ b/src/lib/utils/media-playback-utils.ts @@ -0,0 +1,243 @@ +const BLANK_IFRAME_SRC = 'about:blank'; +const EXPANDED_MEDIA_ATTRIBUTE = 'data-expanded-media'; +const EXPANDED_MEDIA_SELECTOR = `[${EXPANDED_MEDIA_ATTRIBUTE}="true"]`; +const PLAYABLE_MEDIA_SELECTOR = 'video, audio, iframe'; +const OFFSCREEN_MEDIA_SUSPEND_DELAY_MS = 1000; + +interface SuspendedIframeState { + src: string | null; + srcdoc: string | null; +} + +export interface SuspendedMediaPlaybackStats { + audioVideoCount: number; + iframeCount: number; + iframeSrcClearedCount: number; + iframeSrcdocClearedCount: number; +} + +interface OffscreenMediaPlaybackOptions { + delayMs?: number; + selector?: string; +} + +interface RestoreSuspendedMediaPlaybackOptions { + visibleOnly?: boolean; +} + +const suspendedIframes = new WeakMap(); + +const isElementInViewport = (element: Element): boolean => { + const rect = element.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + + return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < viewportHeight && rect.left < viewportWidth; +}; + +export const suspendMediaPlayback = (container: ParentNode | null): SuspendedMediaPlaybackStats => { + const stats = { + audioVideoCount: 0, + iframeCount: 0, + iframeSrcClearedCount: 0, + iframeSrcdocClearedCount: 0, + }; + + if (!container) { + return stats; + } + + for (const media of container.querySelectorAll('video, audio')) { + stats.audioVideoCount += 1; + media.pause(); + } + + for (const iframe of container.querySelectorAll('iframe')) { + stats.iframeCount += 1; + const src = iframe.getAttribute('src'); + const srcdoc = iframe.getAttribute('srcdoc'); + + if (!suspendedIframes.has(iframe)) { + suspendedIframes.set(iframe, { src, srcdoc }); + } + + if (src && src !== BLANK_IFRAME_SRC) { + iframe.setAttribute('src', BLANK_IFRAME_SRC); + stats.iframeSrcClearedCount += 1; + } + + if (srcdoc) { + iframe.removeAttribute('srcdoc'); + stats.iframeSrcdocClearedCount += 1; + } + } + + return stats; +}; + +export const restoreSuspendedMediaPlayback = (container: ParentNode | null, options: RestoreSuspendedMediaPlaybackOptions = {}) => { + if (!container) { + return; + } + + for (const iframe of container.querySelectorAll('iframe')) { + if (options.visibleOnly && !isElementInViewport(iframe)) { + continue; + } + + const suspendedState = suspendedIframes.get(iframe); + if (!suspendedState) { + continue; + } + + if (suspendedState.src) { + iframe.setAttribute('src', suspendedState.src); + } else { + iframe.removeAttribute('src'); + } + + if (suspendedState.srcdoc) { + iframe.setAttribute('srcdoc', suspendedState.srcdoc); + } else { + iframe.removeAttribute('srcdoc'); + } + + suspendedIframes.delete(iframe); + } +}; + +const hasPlayableMedia = (element: Element): boolean => Boolean(element.querySelector(PLAYABLE_MEDIA_SELECTOR)); + +export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => { + if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) { + return () => {}; + } + + const delayMs = options.delayMs ?? OFFSCREEN_MEDIA_SUSPEND_DELAY_MS; + const selector = options.selector ?? EXPANDED_MEDIA_SELECTOR; + const observedElements = new Set(); + const pendingSuspends = new WeakMap>(); + + const clearPendingSuspend = (element: Element) => { + const timeout = pendingSuspends.get(element); + if (timeout === undefined) { + return; + } + + clearTimeout(timeout); + pendingSuspends.delete(element); + }; + + const intersectionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const target = entry.target; + clearPendingSuspend(target); + + if (entry.isIntersecting || entry.intersectionRatio > 0) { + restoreSuspendedMediaPlayback(target); + continue; + } + + const timeout = setTimeout(() => { + pendingSuspends.delete(target); + if (target.isConnected) { + suspendMediaPlayback(target); + } + }, delayMs); + pendingSuspends.set(target, timeout); + } + }); + + const observeElement = (element: Element) => { + if (observedElements.has(element) || !hasPlayableMedia(element)) { + return; + } + + observedElements.add(element); + intersectionObserver.observe(element); + }; + + const syncElementObservation = (element: Element) => { + if (element.matches(selector) && hasPlayableMedia(element)) { + observeElement(element); + return; + } + + unobserveElement(element); + }; + + const syncClosestMediaContainer = (element: Element) => { + const mediaElement = element.matches(selector) ? element : element.closest(selector); + if (mediaElement) { + syncElementObservation(mediaElement); + } + }; + + const observeTree = (node: ParentNode) => { + if (node instanceof Element && node.matches(selector)) { + observeElement(node); + } + + for (const element of node.querySelectorAll(selector)) { + observeElement(element); + } + }; + + const unobserveElement = (element: Element) => { + clearPendingSuspend(element); + if (!observedElements.delete(element)) { + return; + } + + intersectionObserver.unobserve(element); + }; + + const unobserveTree = (node: ParentNode) => { + if (node instanceof Element && node.matches(selector)) { + unobserveElement(node); + } + + for (const element of node.querySelectorAll(selector)) { + unobserveElement(element); + } + }; + + observeTree(root); + + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.target instanceof Element) { + syncElementObservation(mutation.target); + continue; + } + + for (const node of mutation.addedNodes) { + if (node instanceof Element) { + observeTree(node); + syncClosestMediaContainer(node); + } + } + + for (const node of mutation.removedNodes) { + if (node instanceof Element) { + unobserveTree(node); + } + } + + if (mutation.target instanceof Element) { + syncClosestMediaContainer(mutation.target); + } + } + }); + mutationObserver.observe(root, { attributeFilter: [EXPANDED_MEDIA_ATTRIBUTE], attributes: true, childList: true, subtree: true }); + + return () => { + mutationObserver.disconnect(); + for (const element of observedElements) { + clearPendingSuspend(element); + intersectionObserver.unobserve(element); + } + observedElements.clear(); + intersectionObserver.disconnect(); + }; +};