fix(multiboards): restore stable time-filter suggestions

This commit is contained in:
Tommaso Casaburi
2026-04-18 15:58:27 +07:00
parent d07f5b3962
commit 49422591b8
51 changed files with 1296 additions and 119 deletions
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "جاري التحقق من التحديثات...", "checking_for_updates": "جاري التحقق من التحديثات...",
"new_version_found": "تم العثور على إصدار جديد", "new_version_found": "تم العثور على إصدار جديد",
"site_legal_meta_license_text": "5chan هو برنامج مجاني ومفتوح المصدر بموجب GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan هو برنامج مجاني ومفتوح المصدر بموجب GPL-3.0-or-later.",
"community_offline_info": "قد تكون هذه اللوحة غير متصلة وقد يفشل النشر." "community_offline_info": "قد تكون هذه اللوحة غير متصلة وقد يفشل النشر.",
"more_threads_last_week": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الأسبوع الماضي</1>]",
"more_threads_last_month": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الشهر الماضي</1>]",
"more_threads_last_year": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من السنة الماضية</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "আপডেট পরীক্ষা করা হচ্ছে...", "checking_for_updates": "আপডেট পরীক্ষা করা হচ্ছে...",
"new_version_found": "নতুন সংস্করণ পাওয়া গেছে", "new_version_found": "নতুন সংস্করণ পাওয়া গেছে",
"site_legal_meta_license_text": "5chan হলো GPL-3.0-or-later-এর অধীনে মুক্ত এবং ওপেন-সোর্স সফটওয়্যার।", "site_legal_meta_license_text": "5chan হলো GPL-3.0-or-later-এর অধীনে মুক্ত এবং ওপেন-সোর্স সফটওয়্যার।",
"community_offline_info": "এই বোর্ডটি অফলাইনে থাকতে পারে এবং প্রকাশনা ব্যর্থ হতে পারে।" "community_offline_info": "এই বোর্ডটি অফলাইনে থাকতে পারে এবং প্রকাশনা ব্যর্থ হতে পারে।",
"more_threads_last_week": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত সপ্তাহের আরও থ্রেড দেখান</1>]",
"more_threads_last_month": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত মাসের আরও থ্রেড দেখান</1>]",
"more_threads_last_year": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত বছরের আরও থ্রেড দেখান</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Kontroluji aktualizace...", "checking_for_updates": "Kontroluji aktualizace...",
"new_version_found": "nalezena nová verze", "new_version_found": "nalezena nová verze",
"site_legal_meta_license_text": "5chan je svobodný software s otevřeným zdrojovým kódem pod licencí GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan je svobodný software s otevřeným zdrojovým kódem pod licencí GPL-3.0-or-later.",
"community_offline_info": "Tato deska může být offline a publikování může selhat." "community_offline_info": "Tato deska může být offline a publikování může selhat.",
"more_threads_last_week": "{{count}} vláken od {{currentTimeFilterName}} — [<1>Ukázat více vláken z minulého týdne</1>]",
"more_threads_last_month": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého měsíce</1>]",
"more_threads_last_year": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého roku</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Tjekker for opdateringer...", "checking_for_updates": "Tjekker for opdateringer...",
"new_version_found": "ny version fundet", "new_version_found": "ny version fundet",
"site_legal_meta_license_text": "5chan er fri software med åben kildekode under GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan er fri software med åben kildekode under GPL-3.0-or-later.",
"community_offline_info": "Dette board kan være offline og publicering kan mislykkes." "community_offline_info": "Dette board kan være offline og publicering kan mislykkes.",
"more_threads_last_week": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste uge</1>]",
"more_threads_last_month": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste måned</1>]",
"more_threads_last_year": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste år</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Suche nach Updates...", "checking_for_updates": "Suche nach Updates...",
"new_version_found": "neue Version gefunden", "new_version_found": "neue Version gefunden",
"site_legal_meta_license_text": "5chan ist freie Open-Source-Software unter GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan ist freie Open-Source-Software unter GPL-3.0-or-later.",
"community_offline_info": "Dieses Board könnte offline sein und die Veröffentlichung könnte fehlschlagen." "community_offline_info": "Dieses Board könnte offline sein und die Veröffentlichung könnte fehlschlagen.",
"more_threads_last_week": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads aus der letzten Woche anzeigen</1>]",
"more_threads_last_month": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Monat anzeigen</1>]",
"more_threads_last_year": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Jahr anzeigen</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Έλεγχος για ενημερώσεις...", "checking_for_updates": "Έλεγχος για ενημερώσεις...",
"new_version_found": "βρέθηκε νέα έκδοση", "new_version_found": "βρέθηκε νέα έκδοση",
"site_legal_meta_license_text": "Το 5chan είναι ελεύθερο λογισμικό ανοιχτού κώδικα υπό την GPL-3.0-or-later.", "site_legal_meta_license_text": "Το 5chan είναι ελεύθερο λογισμικό ανοιχτού κώδικα υπό την GPL-3.0-or-later.",
"community_offline_info": "Αυτός ο πίνακας μπορεί να είναι εκτός σύνδεσης και η δημοσίευση μπορεί να αποτύχει." "community_offline_info": "Αυτός ο πίνακας μπορεί να είναι εκτός σύνδεσης και η δημοσίευση μπορεί να αποτύχει.",
"more_threads_last_week": "{{count}} θέματα από {{currentTimeFilterName}} — [<1>Εμφάνιση περισσότερων θεμάτων από την περασμένη εβδομάδα</1>]",
"more_threads_last_month": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από τον τελευταίο μήνα</1>]",
"more_threads_last_year": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από το περασμένο έτος</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "checking for updates...", "checking_for_updates": "checking for updates...",
"new_version_found": "new version found", "new_version_found": "new version found",
"site_legal_meta_license_text": "5chan is free and open-source software under GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan is free and open-source software under GPL-3.0-or-later.",
"community_offline_info": "This board might be offline and publishing might fail." "community_offline_info": "This board might be offline and publishing might fail.",
"more_threads_last_week": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Week</1>]",
"more_threads_last_month": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Month</1>]",
"more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Buscando actualizaciones...", "checking_for_updates": "Buscando actualizaciones...",
"new_version_found": "nueva versión encontrada", "new_version_found": "nueva versión encontrada",
"site_legal_meta_license_text": "5chan es software libre y de código abierto bajo GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan es software libre y de código abierto bajo GPL-3.0-or-later.",
"community_offline_info": "Este tablero podría estar offline y la publicación podría fallar." "community_offline_info": "Este tablero podría estar offline y la publicación podría fallar.",
"more_threads_last_week": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Mostrar más hilos de la semana pasada</1>]",
"more_threads_last_month": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del mes pasado</1>]",
"more_threads_last_year": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del año pasado</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "در حال بررسی به‌روزرسانی‌ها...", "checking_for_updates": "در حال بررسی به‌روزرسانی‌ها...",
"new_version_found": "نسخه جدید یافت شد", "new_version_found": "نسخه جدید یافت شد",
"site_legal_meta_license_text": "5chan نرم‌افزار آزاد و متن‌باز تحت GPL-3.0-or-later است.", "site_legal_meta_license_text": "5chan نرم‌افزار آزاد و متن‌باز تحت GPL-3.0-or-later است.",
"community_offline_info": "این بورد ممکن است آفلاین باشد و انتشار ممکن است با شکست مواجه شود." "community_offline_info": "این بورد ممکن است آفلاین باشد و انتشار ممکن است با شکست مواجه شود.",
"more_threads_last_week": "{{count}} موضوع از {{currentTimeFilterName}} — [<1>نمایش موضوعات بیشتر از هفته گذشته</1>]",
"more_threads_last_month": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های ماه گذشته</1>]",
"more_threads_last_year": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های سال گذشته</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Tarkistetaan päivityksiä...", "checking_for_updates": "Tarkistetaan päivityksiä...",
"new_version_found": "uusi versio löytyi", "new_version_found": "uusi versio löytyi",
"site_legal_meta_license_text": "5chan on vapaata ja avoimen lähdekoodin ohjelmistoa GPL-3.0-or-later -lisenssillä.", "site_legal_meta_license_text": "5chan on vapaata ja avoimen lähdekoodin ohjelmistoa GPL-3.0-or-later -lisenssillä.",
"community_offline_info": "Tämä taulu voi olla offline-tilassa ja julkaisu voi epäonnistua." "community_offline_info": "Tämä taulu voi olla offline-tilassa ja julkaisu voi epäonnistua.",
"more_threads_last_week": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää ketjuja viime viikolta</1>]",
"more_threads_last_month": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime kuun ketjuja</1>]",
"more_threads_last_year": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime vuoden ketjuja</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Sinusuri ang mga update...", "checking_for_updates": "Sinusuri ang mga update...",
"new_version_found": "may bagong bersyon na nahanap", "new_version_found": "may bagong bersyon na nahanap",
"site_legal_meta_license_text": "Ang 5chan ay libre at open-source na software sa ilalim ng GPL-3.0-or-later.", "site_legal_meta_license_text": "Ang 5chan ay libre at open-source na software sa ilalim ng GPL-3.0-or-later.",
"community_offline_info": "Maaaring offline ang board na ito at maaaring mabigo ang pag-publish." "community_offline_info": "Maaaring offline ang board na ito at maaaring mabigo ang pag-publish.",
"more_threads_last_week": "{{count}} mga thread mula sa {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula sa nakaraang linggo</1>]",
"more_threads_last_month": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang buwan</1>]",
"more_threads_last_year": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang taon</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Vérification des mises à jour...", "checking_for_updates": "Vérification des mises à jour...",
"new_version_found": "nouvelle version trouvée", "new_version_found": "nouvelle version trouvée",
"site_legal_meta_license_text": "5chan est un logiciel libre et open source sous GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan est un logiciel libre et open source sous GPL-3.0-or-later.",
"community_offline_info": "Ce tableau pourrait être hors ligne et la publication pourrait échouer." "community_offline_info": "Ce tableau pourrait être hors ligne et la publication pourrait échouer.",
"more_threads_last_week": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de la semaine dernière</1>]",
"more_threads_last_month": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils du mois dernier</1>]",
"more_threads_last_year": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de l'année dernière</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "בודק עדכונים...", "checking_for_updates": "בודק עדכונים...",
"new_version_found": "נמצאה גרסה חדשה", "new_version_found": "נמצאה גרסה חדשה",
"site_legal_meta_license_text": "5chan היא תוכנה חופשית וקוד פתוח תחת GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan היא תוכנה חופשית וקוד פתוח תחת GPL-3.0-or-later.",
"community_offline_info": "הלוח הזה עשוי להיות לא מקוון והפרסום עלול להיכשל." "community_offline_info": "הלוח הזה עשוי להיות לא מקוון והפרסום עלול להיכשל.",
"more_threads_last_week": "{{count}} אשכולות מאז {{currentTimeFilterName}} — [<1>הצג עוד אשכולות מהשבוע שעבר</1>]",
"more_threads_last_month": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהחודש שעבר</1>]",
"more_threads_last_year": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהשנה שעברה</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "अपडेट जाँच रहा है...", "checking_for_updates": "अपडेट जाँच रहा है...",
"new_version_found": "नया संस्करण मिला", "new_version_found": "नया संस्करण मिला",
"site_legal_meta_license_text": "5chan GPL-3.0-or-later के तहत मुक्त और ओपन-सोर्स सॉफ़्टवेयर है।", "site_legal_meta_license_text": "5chan GPL-3.0-or-later के तहत मुक्त और ओपन-सोर्स सॉफ़्टवेयर है।",
"community_offline_info": "यह बोर्ड ऑफ़लाइन हो सकता है और प्रकाशन विफल हो सकता है।" "community_offline_info": "यह बोर्ड ऑफ़लाइन हो सकता है और प्रकाशन विफल हो सकता है।",
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} से — [<1>पिछले सप्ताह के और थ्रेड दिखाएं</1>]",
"more_threads_last_month": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>अगले महीने के अधिक थ्रेड्स दिखाएं</1>]",
"more_threads_last_year": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>पिछले साल के अधिक थ्रेड्स दिखाएं</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Frissítések keresése...", "checking_for_updates": "Frissítések keresése...",
"new_version_found": "új verzió található", "new_version_found": "új verzió található",
"site_legal_meta_license_text": "Az 5chan szabad és nyílt forráskódú szoftver a GPL-3.0-or-later alatt.", "site_legal_meta_license_text": "Az 5chan szabad és nyílt forráskódú szoftver a GPL-3.0-or-later alatt.",
"community_offline_info": "Ez a tábla offline lehet, és a közzététel sikertelen lehet." "community_offline_info": "Ez a tábla offline lehet, és a közzététel sikertelen lehet.",
"more_threads_last_week": "{{count}} szál a {{currentTimeFilterName}} óta — [<1>Mutass több szálat a múlt héten</1>]",
"more_threads_last_month": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt hónapból</1>]",
"more_threads_last_year": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt évből</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Memeriksa pembaruan...", "checking_for_updates": "Memeriksa pembaruan...",
"new_version_found": "versi baru ditemukan", "new_version_found": "versi baru ditemukan",
"site_legal_meta_license_text": "5chan adalah perangkat lunak bebas dan open-source di bawah GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan adalah perangkat lunak bebas dan open-source di bawah GPL-3.0-or-later.",
"community_offline_info": "Papan ini mungkin offline dan publikasi mungkin gagal." "community_offline_info": "Papan ini mungkin offline dan publikasi mungkin gagal.",
"more_threads_last_week": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Show lebih banyak thread dari minggu lalu</1>]",
"more_threads_last_month": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari bulan lalu</1>]",
"more_threads_last_year": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari tahun lalu</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Controllo aggiornamenti...", "checking_for_updates": "Controllo aggiornamenti...",
"new_version_found": "nuova versione trovata", "new_version_found": "nuova versione trovata",
"site_legal_meta_license_text": "5chan è software libero e open-source sotto GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan è software libero e open-source sotto GPL-3.0-or-later.",
"community_offline_info": "Questa bacheca potrebbe essere offline e la pubblicazione potrebbe fallire." "community_offline_info": "Questa bacheca potrebbe essere offline e la pubblicazione potrebbe fallire.",
"more_threads_last_week": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra più thread dalla scorsa settimana</1>]",
"more_threads_last_month": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread del mese scorso</1>]",
"more_threads_last_year": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread dell'anno scorso</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "更新を確認しています...", "checking_for_updates": "更新を確認しています...",
"new_version_found": "新しいバージョンが見つかりました", "new_version_found": "新しいバージョンが見つかりました",
"site_legal_meta_license_text": "5chanはGPL-3.0-or-laterのもとで提供される自由かつオープンソースのソフトウェアです。", "site_legal_meta_license_text": "5chanはGPL-3.0-or-laterのもとで提供される自由かつオープンソースのソフトウェアです。",
"community_offline_info": "このボードはオフラインの可能性があり、公開が失敗する可能性があります。" "community_offline_info": "このボードはオフラインの可能性があり、公開が失敗する可能性があります。",
"more_threads_last_week": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先週のスレッドをさらに表示</1>]",
"more_threads_last_month": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先月のスレッドをさらに表示</1>]",
"more_threads_last_year": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>去年のスレッドをさらに表示</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "업데이트 확인 중...", "checking_for_updates": "업데이트 확인 중...",
"new_version_found": "새 버전을 찾았습니다", "new_version_found": "새 버전을 찾았습니다",
"site_legal_meta_license_text": "5chan은 GPL-3.0-or-later에 따른 자유 오픈 소스 소프트웨어입니다.", "site_legal_meta_license_text": "5chan은 GPL-3.0-or-later에 따른 자유 오픈 소스 소프트웨어입니다.",
"community_offline_info": "이 게시판이 오프라인일 수 있으며 게시가 실패할 수 있습니다." "community_offline_info": "이 게시판이 오프라인일 수 있으며 게시가 실패할 수 있습니다.",
"more_threads_last_week": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 주의 더 많은 스레드 보기</1>]",
"more_threads_last_month": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 달의 스레드 더 보기</1>]",
"more_threads_last_year": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>작년의 스레드 더 보기</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "अपडेट तपासत आहे...", "checking_for_updates": "अपडेट तपासत आहे...",
"new_version_found": "नवीन आवृत्ती सापडली", "new_version_found": "नवीन आवृत्ती सापडली",
"site_legal_meta_license_text": "5chan हे GPL-3.0-or-later अंतर्गत मुक्त आणि ओपन-सोर्स सॉफ्टवेअर आहे.", "site_legal_meta_license_text": "5chan हे GPL-3.0-or-later अंतर्गत मुक्त आणि ओपन-सोर्स सॉफ्टवेअर आहे.",
"community_offline_info": "हे बोर्ड ऑफलाइन असू शकते आणि प्रकाशन अयशस्वी होऊ शकते." "community_offline_info": "हे बोर्ड ऑफलाइन असू शकते आणि प्रकाशन अयशस्वी होऊ शकते.",
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} पासून — [<1>गेल्या आठवड्यातील अधिक थ्रेड्स पहा</1>]",
"more_threads_last_month": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या महिन्यातील आणखी थ्रेड्स दाखवा</1>]",
"more_threads_last_year": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या वर्षातील आणखी थ्रेड्स दाखवा</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Controleren op updates...", "checking_for_updates": "Controleren op updates...",
"new_version_found": "nieuwe versie gevonden", "new_version_found": "nieuwe versie gevonden",
"site_legal_meta_license_text": "5chan is vrije en open-source software onder GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan is vrije en open-source software onder GPL-3.0-or-later.",
"community_offline_info": "Dit bord kan offline zijn en publiceren kan mislukken." "community_offline_info": "Dit bord kan offline zijn en publiceren kan mislukken.",
"more_threads_last_week": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Toon meer threads van vorige week</1>]",
"more_threads_last_month": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen maand tonen</1>]",
"more_threads_last_year": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen jaar tonen</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Sjekker etter oppdateringer...", "checking_for_updates": "Sjekker etter oppdateringer...",
"new_version_found": "ny versjon funnet", "new_version_found": "ny versjon funnet",
"site_legal_meta_license_text": "5chan er fri programvare med åpen kildekode under GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan er fri programvare med åpen kildekode under GPL-3.0-or-later.",
"community_offline_info": "Dette brettet kan være offline og publisering kan mislykkes." "community_offline_info": "Dette brettet kan være offline og publisering kan mislykkes.",
"more_threads_last_week": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige uke</1>]",
"more_threads_last_month": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige måned</1>]",
"more_threads_last_year": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige år</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Sprawdzanie aktualizacji...", "checking_for_updates": "Sprawdzanie aktualizacji...",
"new_version_found": "znaleziono nową wersję", "new_version_found": "znaleziono nową wersję",
"site_legal_meta_license_text": "5chan to wolne i otwarte oprogramowanie na licencji GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan to wolne i otwarte oprogramowanie na licencji GPL-3.0-or-later.",
"community_offline_info": "Ta tablica może być offline i publikacja może się nie powieść." "community_offline_info": "Ta tablica może być offline i publikacja może się nie powieść.",
"more_threads_last_week": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego tygodnia</1>]",
"more_threads_last_month": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego miesiąca</1>]",
"more_threads_last_year": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego roku</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Verificando atualizações...", "checking_for_updates": "Verificando atualizações...",
"new_version_found": "nova versão encontrada", "new_version_found": "nova versão encontrada",
"site_legal_meta_license_text": "5chan é software livre e de código aberto sob GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan é software livre e de código aberto sob GPL-3.0-or-later.",
"community_offline_info": "Este board pode estar offline e a publicação pode falhar." "community_offline_info": "Este board pode estar offline e a publicação pode falhar.",
"more_threads_last_week": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos da semana passada</1>]",
"more_threads_last_month": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do mês passado</1>]",
"more_threads_last_year": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do ano passado</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Se verifică actualizările...", "checking_for_updates": "Se verifică actualizările...",
"new_version_found": "versiune nouă găsită", "new_version_found": "versiune nouă găsită",
"site_legal_meta_license_text": "5chan este software liber și open-source sub GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan este software liber și open-source sub GPL-3.0-or-later.",
"community_offline_info": "Acest panou poate fi offline și publicarea poate eșua." "community_offline_info": "Acest panou poate fi offline și publicarea poate eșua.",
"more_threads_last_week": "{{count}} fire de discuție de la {{currentTimeFilterName}} — [<1>Arată mai multe fire din săptămâna trecută</1>]",
"more_threads_last_month": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de luna trecută</1>]",
"more_threads_last_year": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de anul trecut</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Проверка обновлений...", "checking_for_updates": "Проверка обновлений...",
"new_version_found": "найдена новая версия", "new_version_found": "найдена новая версия",
"site_legal_meta_license_text": "5chan — свободное программное обеспечение с открытым исходным кодом под GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan — свободное программное обеспечение с открытым исходным кодом под GPL-3.0-or-later.",
"community_offline_info": "Эта доска может быть офлайн, и публикация может не удаться." "community_offline_info": "Эта доска может быть офлайн, и публикация может не удаться.",
"more_threads_last_week": "{{count}} тредов с {{currentTimeFilterName}} — [<1>Показать больше тредов за прошлую неделю</1>]",
"more_threads_last_month": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого месяца</1>]",
"more_threads_last_year": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого года</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Duke kontrolluar për përditësime...", "checking_for_updates": "Duke kontrolluar për përditësime...",
"new_version_found": "u gjet version i ri", "new_version_found": "u gjet version i ri",
"site_legal_meta_license_text": "5chan është softuer i lirë dhe me burim të hapur nën GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan është softuer i lirë dhe me burim të hapur nën GPL-3.0-or-later.",
"community_offline_info": "Ky bord mund të jetë offline dhe publikimi mund të dështojë." "community_offline_info": "Ky bord mund të jetë offline dhe publikimi mund të dështojë.",
"more_threads_last_week": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga java e kaluar</1>]",
"more_threads_last_month": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga muaji i kaluar</1>]",
"more_threads_last_year": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga viti i kaluar</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Söker efter uppdateringar...", "checking_for_updates": "Söker efter uppdateringar...",
"new_version_found": "ny version hittades", "new_version_found": "ny version hittades",
"site_legal_meta_license_text": "5chan är fri programvara med öppen källkod under GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan är fri programvara med öppen källkod under GPL-3.0-or-later.",
"community_offline_info": "Den här tavlan kan vara offline och publicering kan misslyckas." "community_offline_info": "Den här tavlan kan vara offline och publicering kan misslyckas.",
"more_threads_last_week": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra veckan</1>]",
"more_threads_last_month": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra månaden</1>]",
"more_threads_last_year": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra året</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "నవీకరణలను తనిఖీ చేస్తోంది...", "checking_for_updates": "నవీకరణలను తనిఖీ చేస్తోంది...",
"new_version_found": "కొత్త వెర్షన్ కనుగొనబడింది", "new_version_found": "కొత్త వెర్షన్ కనుగొనబడింది",
"site_legal_meta_license_text": "5chan GPL-3.0-or-later కింద ఉచిత మరియు ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.", "site_legal_meta_license_text": "5chan GPL-3.0-or-later కింద ఉచిత మరియు ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.",
"community_offline_info": "ఈ బోర్డ్ ఆఫ్‌లైన్‌లో ఉండవచ్చు మరియు ప్రచురణ విఫలమవచ్చు." "community_offline_info": "ఈ బోర్డ్ ఆఫ్‌లైన్‌లో ఉండవచ్చు మరియు ప్రచురణ విఫలమవచ్చు.",
"more_threads_last_week": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత వారానికి మరిన్ని థ్రెడ్‌లు చూపించు</1>]",
"more_threads_last_month": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత నెల నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</1>]",
"more_threads_last_year": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత సంవత్సరం నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "กำลังตรวจสอบการอัปเดต...", "checking_for_updates": "กำลังตรวจสอบการอัปเดต...",
"new_version_found": "พบเวอร์ชันใหม่", "new_version_found": "พบเวอร์ชันใหม่",
"site_legal_meta_license_text": "5chan เป็นซอฟต์แวร์เสรีและโอเพนซอร์สภายใต้ GPL-3.0-or-later", "site_legal_meta_license_text": "5chan เป็นซอฟต์แวร์เสรีและโอเพนซอร์สภายใต้ GPL-3.0-or-later",
"community_offline_info": "บอร์ดนี้อาจออฟไลน์และการเผยแพร่อาจล้มเหลว" "community_offline_info": "บอร์ดนี้อาจออฟไลน์และการเผยแพร่อาจล้มเหลว",
"more_threads_last_week": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้เพิ่มเติมจากสัปดาห์ที่แล้ว</1>]",
"more_threads_last_month": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากเดือนที่แล้วเพิ่มเติม</1>]",
"more_threads_last_year": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากปีที่แล้วเพิ่มเติม</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Güncellemeler kontrol ediliyor...", "checking_for_updates": "Güncellemeler kontrol ediliyor...",
"new_version_found": "yeni sürüm bulundu", "new_version_found": "yeni sürüm bulundu",
"site_legal_meta_license_text": "5chan, GPL-3.0-or-later kapsamında özgür ve açık kaynaklı yazılımdır.", "site_legal_meta_license_text": "5chan, GPL-3.0-or-later kapsamında özgür ve açık kaynaklı yazılımdır.",
"community_offline_info": "Bu pano çevrimdışı olabilir ve yayınlama başarısız olabilir." "community_offline_info": "Bu pano çevrimdışı olabilir ve yayınlama başarısız olabilir.",
"more_threads_last_week": "{{count}} başlık {{currentTimeFilterName}}den itibaren — [<1>Geçen haftadan daha fazla başlık göster</1>]",
"more_threads_last_month": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen aydan daha fazla konu göster</1>]",
"more_threads_last_year": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen yıldan daha fazla konu göster</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Перевірка оновлень...", "checking_for_updates": "Перевірка оновлень...",
"new_version_found": "знайдено нову версію", "new_version_found": "знайдено нову версію",
"site_legal_meta_license_text": "5chan — це вільне програмне забезпечення з відкритим кодом за GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan — це вільне програмне забезпечення з відкритим кодом за GPL-3.0-or-later.",
"community_offline_info": "Ця дошка може бути офлайн, і публікація може не вдатися." "community_offline_info": "Ця дошка може бути офлайн, і публікація може не вдатися.",
"more_threads_last_week": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого тижня</1>]",
"more_threads_last_month": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого місяця</1>]",
"more_threads_last_year": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого року</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "اپڈیٹس چیک کیے جا رہے ہیں...", "checking_for_updates": "اپڈیٹس چیک کیے جا رہے ہیں...",
"new_version_found": "نیا ورژن ملا", "new_version_found": "نیا ورژن ملا",
"site_legal_meta_license_text": "5chan GPL-3.0-or-later کے تحت آزاد اور اوپن سورس سافٹ ویئر ہے۔", "site_legal_meta_license_text": "5chan GPL-3.0-or-later کے تحت آزاد اور اوپن سورس سافٹ ویئر ہے۔",
"community_offline_info": "یہ بورڈ آف لائن ہو سکتا ہے اور اشاعت ناکام ہو سکتی ہے۔" "community_offline_info": "یہ بورڈ آف لائن ہو سکتا ہے اور اشاعت ناکام ہو سکتی ہے۔",
"more_threads_last_week": "{{count}} تھریڈ {{currentTimeFilterName}} سے — [<1>گزشتہ ہفتے کے مزید تھریڈ دیکھیں</1>]",
"more_threads_last_month": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>پچھلے ماہ کے مزید تھریڈز دکھائیں</1>]",
"more_threads_last_year": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>گزشتہ سال کے مزید تھریڈز دکھائیں</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "Đang kiểm tra cập nhật...", "checking_for_updates": "Đang kiểm tra cập nhật...",
"new_version_found": "đã tìm thấy phiên bản mới", "new_version_found": "đã tìm thấy phiên bản mới",
"site_legal_meta_license_text": "5chan là phần mềm miễn phí và mã nguồn mở theo GPL-3.0-or-later.", "site_legal_meta_license_text": "5chan là phần mềm miễn phí và mã nguồn mở theo GPL-3.0-or-later.",
"community_offline_info": "Bảng này có thể đang offline và việc xuất bản có thể thất bại." "community_offline_info": "Bảng này có thể đang offline và việc xuất bản có thể thất bại.",
"more_threads_last_week": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Xem thêm chủ đề từ tuần trước</1>]",
"more_threads_last_month": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ tháng trước</1>]",
"more_threads_last_year": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ năm ngoái</1>]"
} }
+4 -1
View File
@@ -324,5 +324,8 @@
"checking_for_updates": "正在检查更新...", "checking_for_updates": "正在检查更新...",
"new_version_found": "发现新版本", "new_version_found": "发现新版本",
"site_legal_meta_license_text": "5chan 是采用 GPL-3.0-or-later 的自由开源软件。", "site_legal_meta_license_text": "5chan 是采用 GPL-3.0-or-later 的自由开源软件。",
"community_offline_info": "此看板可能处于离线状态,发布可能会失败。" "community_offline_info": "此看板可能处于离线状态,发布可能会失败。",
"more_threads_last_week": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多来自上周的主题</1>]",
"more_threads_last_month": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多上个月的主题</1>]",
"more_threads_last_year": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多去年的主题</1>]"
} }
+1
View File
@@ -111,6 +111,7 @@ function makeNamedComponent(name: string) {
vi.mock('../components/board-buttons', () => ({ vi.mock('../components/board-buttons', () => ({
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'), DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'), MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
})); }));
+1 -1
View File
@@ -298,7 +298,7 @@ const App = () => {
} }
/> />
<Route element={<BoardLayout />}> <Route element={<BoardLayout />}>
{/* Canonical multiboard routes (no time filter) */} {/* Canonical multiboard routes (time filter lives in ?t=) */}
<Route path='/all' element={boardFeedElement} /> <Route path='/all' element={boardFeedElement} />
<Route path='/all/settings' element={boardFeedElement} /> <Route path='/all/settings' element={boardFeedElement} />
<Route path='/all/catalog' element={catalogFeedElement} /> <Route path='/all/catalog' element={catalogFeedElement} />
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons'; import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store'; import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>; const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -184,7 +185,12 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string
createElement( createElement(
Routes, Routes,
{}, {},
createElement(Route, { path: '/all', element }),
createElement(Route, { path: '/all/catalog', element }), createElement(Route, { path: '/all/catalog', element }),
createElement(Route, { path: '/subs', element }),
createElement(Route, { path: '/subs/catalog', element }),
createElement(Route, { path: '/mod', element }),
createElement(Route, { path: '/mod/catalog', element }),
createElement(Route, { path: '/mod/queue', element }), createElement(Route, { path: '/mod/queue', element }),
createElement(Route, { path: '/:boardIdentifier/catalog', element }), createElement(Route, { path: '/:boardIdentifier/catalog', element }),
createElement(Route, { path: '/:boardIdentifier/archive', element }), createElement(Route, { path: '/:boardIdentifier/archive', element }),
@@ -240,6 +246,8 @@ describe('BoardButtons', () => {
testState.subscribed = false; testState.subscribed = false;
testState.viewMode = 'compact'; testState.viewMode = 'compact';
useThreadLiveUpdatesStore.getState().resetState(); useThreadLiveUpdatesStore.getState().resetState();
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
Object.defineProperty(globalThis, 'alert', { Object.defineProperty(globalThis, 'alert', {
configurable: true, configurable: true,
value: vi.fn(), value: vi.fn(),
@@ -264,6 +272,8 @@ describe('BoardButtons', () => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
}); });
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => { it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
@@ -300,7 +310,7 @@ describe('BoardButtons', () => {
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => { it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
testState.filteredCount = 4; testState.filteredCount = 4;
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog'); await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=24h');
expect(container.textContent).toContain('filtered_threads'); expect(container.textContent).toContain('filtered_threads');
expect(container.textContent).toContain('4'); expect(container.textContent).toContain('4');
@@ -309,21 +319,55 @@ describe('BoardButtons', () => {
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search'); expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select')); const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
expect(selects).toHaveLength(4); expect(selects).toHaveLength(5);
await changeSelect(selects[0]!, 'replyCount'); await changeSelect(selects[0]!, 'replyCount');
await changeSelect(selects[1]!, 'Large'); await changeSelect(selects[1]!, 'Large');
await changeSelect(selects[2]!, 'On'); await changeSelect(selects[2]!, 'On');
await changeSelect(selects[3]!, 'nsfw'); await changeSelect(selects[3]!, 'nsfw');
await changeSelect(selects[4]!, '1w');
await clickButton('refresh'); await clickButton('refresh');
expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount'); expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount');
expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large'); expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large');
expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true); expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true);
expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw'); expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=1w' });
expect(testState.resetMock).toHaveBeenCalledTimes(1); expect(testState.resetMock).toHaveBeenCalledTimes(1);
}); });
it('preserves the current multiboard time filter when searching OPs', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - 3 * 24 * 60 * 60 * 1000));
await renderWithRoute(createElement(DesktopBoardButtons), '/all?t=last');
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
expect(searchInput).toBeTruthy();
await act(async () => {
if (searchInput) {
searchInput.value = 'cats';
searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
}
});
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last&q=cats' });
});
it('lets multiboard views switch back to the last-visit filter alias', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - (2 * 24 * 60 * 60 + 1) * 1000));
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=1w');
const timeFilterSelect = Array.from(container.querySelectorAll<HTMLSelectElement>('select')).at(-1);
expect(timeFilterSelect).toBeTruthy();
expect(Array.from(timeFilterSelect?.options || []).some((option) => option.value === 'last')).toBe(true);
await changeSelect(timeFilterSelect!, 'last');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last' });
});
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => { it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
testState.commentsByCid = { testState.commentsByCid = {
'comment-1': { 'comment-1': {
+112 -28
View File
@@ -17,11 +17,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store'; import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import useTimeFilter from '../../hooks/use-time-filter';
import CatalogFilters from '../catalog-filters'; import CatalogFilters from '../catalog-filters';
import CatalogSearch from '../catalog-search'; import CatalogSearch from '../catalog-search';
import Tooltip from '../tooltip'; import Tooltip from '../tooltip';
import { ModQueueButton } from '../../views/mod-queue/mod-queue'; import { ModQueueButton } from '../../views/mod-queue/mod-queue';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
import styles from './board-buttons.module.css'; import styles from './board-buttons.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
@@ -35,14 +37,39 @@ interface BoardButtonsProps {
isTopbar?: boolean; isTopbar?: boolean;
} }
const getMultiboardPath = ({
isInAllView,
isInCatalogView,
isInSubscriptionsView,
isInModView,
}: Pick<BoardButtonsProps, 'isInAllView' | 'isInCatalogView' | 'isInSubscriptionsView' | 'isInModView'>) => {
if (isInAllView) {
return isInCatalogView ? '/all/catalog' : '/all';
}
if (isInSubscriptionsView) {
return isInCatalogView ? '/subs/catalog' : '/subs';
}
if (isInModView) {
return isInCatalogView ? '/mod/catalog' : '/mod';
}
return null;
};
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => { export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
const directories = useDirectories(); const directories = useDirectories();
const { timeFilterValue } = useTimeFilter();
const createCatalogLink = () => { const createCatalogLink = () => {
if (isInAllView) return `/all/catalog`; const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: true, isInSubscriptionsView, isInModView });
if (isInSubscriptionsView) return `/subs/catalog`; if (multiboardPath) {
if (isInModView) return `/mod/catalog`; return {
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, timeFilterValue),
};
}
let boardPath = ''; let boardPath = '';
if (address) { if (address) {
boardPath = getBoardPath(address, directories); boardPath = getBoardPath(address, directories);
@@ -94,12 +121,12 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => { export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
const params = useParams(); const params = useParams();
const directories = useDirectories(); const directories = useDirectories();
const { timeFilterValue } = useTimeFilter();
const createReturnLink = () => { const createReturnLink = () => {
if (isInAllView) return `/all`;
if (isInSubscriptionsView) return `/subs`;
if (isInModQueueView) { if (isInModQueueView) {
// If in mod queue view, return to /mod or /:boardIdentifier // If in mod queue view, return to /mod or /:boardIdentifier
if (params?.boardIdentifier) { if (params?.boardIdentifier) {
@@ -107,7 +134,13 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
} }
return `/mod`; return `/mod`;
} }
if (isInModView) return `/mod`; const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: false, isInSubscriptionsView, isInModView });
if (multiboardPath) {
return {
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, timeFilterValue, { removeKeys: ['q'] }),
};
}
let boardPath = ''; let boardPath = '';
if (address) { if (address) {
boardPath = getBoardPath(address, directories); boardPath = getBoardPath(address, directories);
@@ -351,6 +384,42 @@ const ShowOPCommentOption = () => {
); );
}; };
const TimeFilter = ({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView, isTopbar = false }: BoardButtonsProps) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { lastVisitTimeFilterName, timeFilterValue, timeFilterValues } = useTimeFilter();
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView });
if (!multiboardPath) {
return null;
}
const changeTimeFilter = (event: React.ChangeEvent<HTMLSelectElement>) => {
navigate({
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, event.target.value),
});
};
return (
<>
{!isTopbar && (
<>
<span>{t('filter')}</span>:&nbsp;
</>
)}
<select onChange={changeTimeFilter} className={[styles.feedName, styles.menuItem, 'capitalize'].join(' ')} value={timeFilterValue}>
{timeFilterValues.map((value) => (
<option key={value} value={value}>
{getTimeFilterOptionLabel(value, lastVisitTimeFilterName)}
</option>
))}
</select>
</>
);
};
const AllFeedFilter = () => { const AllFeedFilter = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { filter, setFilter } = useAllFeedFilterStore(); const { filter, setFilter } = useAllFeedFilterStore();
@@ -395,6 +464,7 @@ export const MobileBoardButtons = () => {
const { filteredCount, searchText } = useCatalogFiltersStore(); const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView; const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll; const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
const showBottomButton = !effectiveInfiniteScroll; const showBottomButton = !effectiveInfiniteScroll;
@@ -447,24 +517,24 @@ export const MobileBoardButtons = () => {
</span> </span>
) )
)} )}
{isInAllView && ( {(isInAllView || showTimeFilter || isInCatalogView) && (
<> <>
<hr /> <hr />
<div className={styles.options}> <div className={styles.options}>
<AllFeedFilter /> {(isInAllView || showTimeFilter) && (
</div> <div>
</> {isInAllView && <AllFeedFilter />}{' '}
)} {showTimeFilter && (
{isInCatalogView && ( <TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<> )}
<hr /> </div>
<div className={styles.options}> )}
<div> {isInCatalogView && (
<SortOptions /> <ImageSizeOptions /> <div className={styles.mobileCatalogOptionsPadding}>
</div> <SortOptions /> <ImageSizeOptions />
<div className={styles.mobileCatalogOptionsPadding}> <ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch /> </div>
</div> )}
</div> </div>
</> </>
)} )}
@@ -479,6 +549,16 @@ export const MobileBoardButtons = () => {
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />} {!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />} {!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
</div> </div>
{showTimeFilter && (
<>
<hr />
<div className={styles.options}>
<div>
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
</div>
</div>
</>
)}
</> </>
)} )}
</div> </div>
@@ -550,6 +630,7 @@ export const DesktopBoardButtons = () => {
const { filteredCount, searchText } = useCatalogFiltersStore(); const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView; const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll; const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll; const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll;
@@ -653,6 +734,9 @@ export const DesktopBoardButtons = () => {
</> </>
)} )}
{isInAllView && <AllFeedFilter />} {isInAllView && <AllFeedFilter />}
{showTimeFilter && (
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
)}
{showVoteButton && ( {showVoteButton && (
<> <>
[<VoteButton />] [<VoteButton />]
@@ -693,19 +777,19 @@ const SearchOPsBar = () => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
const searchQuery = (event.target as HTMLInputElement).value.trim(); const searchQuery = (event.target as HTMLInputElement).value.trim();
if (searchQuery) { if (searchQuery) {
let catalogUrl = ''; const params = new URLSearchParams(location.search);
params.set('q', searchQuery);
const search = `?${params.toString()}`;
if (isInAllView) { if (isInAllView) {
catalogUrl = `/all/catalog?q=${encodeURIComponent(searchQuery)}`; navigate({ pathname: '/all/catalog', search });
} else if (isInSubscriptionsView) { } else if (isInSubscriptionsView) {
catalogUrl = `/subs/catalog?q=${encodeURIComponent(searchQuery)}`; navigate({ pathname: '/subs/catalog', search });
} else if (isInModView) { } else if (isInModView) {
catalogUrl = `/mod/catalog?q=${encodeURIComponent(searchQuery)}`; navigate({ pathname: '/mod/catalog', search });
} else { } else {
catalogUrl = `/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`; navigate(`/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`);
} }
navigate(catalogUrl);
} }
} }
}; };
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store'; import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils'; 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 Board from '../../views/board';
import Catalog from '../../views/catalog'; import Catalog from '../../views/catalog';
import styles from './feed-cache-container.module.css'; import styles from './feed-cache-container.module.css';
@@ -9,20 +10,23 @@ import styles from './feed-cache-container.module.css';
interface FeedContextFromKey { interface FeedContextFromKey {
viewType: 'all' | 'subs' | 'mod' | 'board'; viewType: 'all' | 'subs' | 'mod' | 'board';
boardIdentifier?: string; boardIdentifier?: string;
timeFilterName?: string;
} }
const parseFeedKey = (key: string): FeedContextFromKey => { const parseFeedKey = (key: string): FeedContextFromKey => {
const segments = key.split('/').filter(Boolean); const [pathname, search = ''] = key.split('?');
const segments = pathname.split('/').filter(Boolean);
const timeFilterName = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM) || undefined;
const filteredSegments = segments.filter((s) => s !== 'catalog'); const filteredSegments = segments.filter((s) => s !== 'catalog');
if (filteredSegments[0] === 'all') { if (filteredSegments[0] === 'all') {
return { viewType: 'all' }; return { viewType: 'all', timeFilterName };
} }
if (filteredSegments[0] === 'subs') { if (filteredSegments[0] === 'subs') {
return { viewType: 'subs' }; return { viewType: 'subs', timeFilterName };
} }
if (filteredSegments[0] === 'mod') { if (filteredSegments[0] === 'mod') {
return { viewType: 'mod' }; return { viewType: 'mod', timeFilterName };
} }
return { return {
@@ -42,9 +46,21 @@ const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
return ( return (
<div className={isVisible ? styles.visible : styles.hidden}> <div className={isVisible ? styles.visible : styles.hidden}>
{feed.type === 'catalog' ? ( {feed.type === 'catalog' ? (
<Catalog feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} /> <Catalog
feedCacheKey={feed.key}
viewType={context.viewType}
boardIdentifier={context.boardIdentifier}
timeFilterNameFromCache={context.timeFilterName}
isVisible={isVisible}
/>
) : ( ) : (
<Board feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} /> <Board
feedCacheKey={feed.key}
viewType={context.viewType}
boardIdentifier={context.boardIdentifier}
timeFilterNameFromCache={context.timeFilterName}
isVisible={isVisible}
/>
)} )}
</div> </div>
); );
@@ -54,7 +70,7 @@ const FeedCacheContainer = () => {
const location = useLocation(); const location = useLocation();
const { cachedFeeds, accessFeed } = useFeedCacheStore(); const { cachedFeeds, accessFeed } = useFeedCacheStore();
const currentFeedKey = getFeedCacheKey(location.pathname); const currentFeedKey = getFeedCacheKey(location.pathname, location.search);
const isOnFeedRoute = isFeedRoute(location.pathname); const isOnFeedRoute = isFeedRoute(location.pathname);
const feedType = getFeedType(location.pathname); const feedType = getFeedType(location.pathname);
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
interface UseSuggestionFeedLoaderOptions {
currentFeedLength: number;
feedLength: number;
hasMore: boolean;
loadMore: () => Promise<void>;
requestKey: string;
shouldLoad: boolean;
}
const EMPTY_REQUEST_KEY = '';
export const useSuggestionFeedLoader = ({ currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad }: UseSuggestionFeedLoaderOptions) => {
const lastRequestKeyRef = useRef(EMPTY_REQUEST_KEY);
useEffect(() => {
if (!shouldLoad || !requestKey || !hasMore || feedLength > currentFeedLength) {
lastRequestKeyRef.current = EMPTY_REQUEST_KEY;
return;
}
const nextRequestKey = `${requestKey}:${currentFeedLength}:${feedLength}`;
if (lastRequestKeyRef.current === nextRequestKey) {
return;
}
lastRequestKeyRef.current = nextRequestKey;
void loadMore();
}, [currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad]);
};
+56
View File
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
getEffectiveTimeFilterName,
getEffectiveTimeFilterSeconds,
getStableLastVisitTimeFilterName,
getSelectedTimeFilterValue,
getTimeFilterOptionValues,
touchLastVisitTimestamp,
} from '../lib/utils/time-filter-utils';
declare global {
interface Window {
_5chanLastVisitTimestampIntervalId?: number;
_5chanLastVisitTimestampSubscribers?: number;
}
}
const ensureLastVisitTimestampTracking = (): (() => void) => {
if (typeof window === 'undefined') {
return () => {};
}
touchLastVisitTimestamp();
window._5chanLastVisitTimestampSubscribers = (window._5chanLastVisitTimestampSubscribers || 0) + 1;
if (!window._5chanLastVisitTimestampIntervalId) {
window._5chanLastVisitTimestampIntervalId = window.setInterval(() => {
touchLastVisitTimestamp();
}, 60 * 1000);
}
return () => {
window._5chanLastVisitTimestampSubscribers = Math.max((window._5chanLastVisitTimestampSubscribers || 1) - 1, 0);
if (window._5chanLastVisitTimestampSubscribers === 0 && window._5chanLastVisitTimestampIntervalId) {
window.clearInterval(window._5chanLastVisitTimestampIntervalId);
delete window._5chanLastVisitTimestampIntervalId;
}
};
};
const useTimeFilter = (timeFilterNameOverride?: string) => {
const location = useLocation();
useEffect(() => ensureLastVisitTimestampTracking(), []);
const lastVisitTimeFilterName = getStableLastVisitTimeFilterName();
const timeFilterValue = timeFilterNameOverride || getSelectedTimeFilterValue(location.search);
const timeFilterName = timeFilterNameOverride || getEffectiveTimeFilterName(location.search, lastVisitTimeFilterName);
const timeFilterSeconds = getEffectiveTimeFilterSeconds('', timeFilterName);
const timeFilterValues = getTimeFilterOptionValues(lastVisitTimeFilterName);
return { timeFilterSeconds, timeFilterName, timeFilterValue, timeFilterValues, lastVisitTimeFilterName };
};
export default useTimeFilter;
+21 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { import {
areSameBoardAddress, areSameBoardAddress,
extractDirectoryFromTitle, extractDirectoryFromTitle,
@@ -20,6 +20,7 @@ import {
normalizeMultiboardFeedPath, normalizeMultiboardFeedPath,
stripPageFromFeedPath, stripPageFromFeedPath,
} from '../route-utils'; } from '../route-utils';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY, touchLastVisitTimestamp } from '../time-filter-utils';
const communities = [ const communities = [
{ address: 'business.eth', title: '/biz/ - Business & Finance' }, { address: 'business.eth', title: '/biz/ - Business & Finance' },
@@ -32,6 +33,11 @@ const communities = [
{ address: 'random.eth', directoryCode: 'b', title: 'Random' }, { address: 'random.eth', directoryCode: 'b', title: 'Random' },
]; ];
beforeEach(() => {
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
});
describe('directory mapping helpers', () => { describe('directory mapping helpers', () => {
it('extracts short codes from titled directories', () => { it('extracts short codes from titled directories', () => {
expect(extractDirectoryFromTitle('/biz/ - Business & Finance')).toBe('biz'); expect(extractDirectoryFromTitle('/biz/ - Business & Finance')).toBe('biz');
@@ -189,9 +195,23 @@ describe('feed cache helpers', () => {
expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz'); expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz');
expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog'); expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog');
expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz'); expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz');
expect(getFeedCacheKey('/all')).toBe('/all?t=24h');
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=24h');
expect(getFeedCacheKey('/all/catalog', '?t=1w&q=cats')).toBe('/all/catalog?t=1w');
expect(getFeedCacheKey('/biz/archive')).toBeNull(); expect(getFeedCacheKey('/biz/archive')).toBeNull();
}); });
it('keeps the last-visit alias stable after the current visit starts updating storage', () => {
const justOverTwoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000 + 1000);
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(justOverTwoDaysAgo));
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
touchLastVisitTimestamp();
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
});
it('returns null cache keys for non-feed routes', () => { it('returns null cache keys for non-feed routes', () => {
expect(getFeedCacheKey('/pending/3')).toBeNull(); expect(getFeedCacheKey('/pending/3')).toBeNull();
expect(getFeedCacheKey('/biz/mod/queue')).toBeNull(); expect(getFeedCacheKey('/biz/mod/queue')).toBeNull();
+8 -2
View File
@@ -1,4 +1,5 @@
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories'; import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
import { getEffectiveTimeFilterName, getSearchWithTimeFilter } from './time-filter-utils';
/** /**
* Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz") * Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz")
@@ -256,7 +257,7 @@ export const getPageFromFeedPath = (pathname: string): number => {
return 1; return 1;
}; };
export const getFeedCacheKey = (pathname: string): string | null => { export const getFeedCacheKey = (pathname: string, search = ''): string | null => {
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
normalizedPath = normalizedPath.replace(/\/settings$/, ''); normalizedPath = normalizedPath.replace(/\/settings$/, '');
@@ -274,7 +275,12 @@ export const getFeedCacheKey = (pathname: string): string | null => {
} }
if (isFeedRoute(pathname)) { if (isFeedRoute(pathname)) {
return stripPageFromFeedPath(normalizedPath); const strippedFeedPath = stripPageFromFeedPath(normalizedPath);
if (isMultiboardFeedPath(strippedFeedPath)) {
const timeFilterName = getEffectiveTimeFilterName(search);
return `${strippedFeedPath}${getSearchWithTimeFilter('', timeFilterName)}`;
}
return strippedFeedPath;
} }
return null; return null;
+249
View File
@@ -0,0 +1,249 @@
const DAY_IN_SECONDS = 24 * 60 * 60;
const LAST_VISIT_STORAGE_KEY = '5chanLastVisitTimestamp';
const LAST_VISIT_TIME_FILTER_VALUE = 'last';
const TIME_FILTER_QUERY_PARAM = 't';
const FALLBACK_TIME_FILTER_NAME = '24h';
const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS;
const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS;
const YEAR_IN_SECONDS = 365 * DAY_IN_SECONDS;
declare global {
interface Window {
_5chanLastVisitTimeFilterSnapshot?: string;
}
}
const presetTimeFilterSeconds: Record<string, number | undefined> = {
'1h': 60 * 60,
'12h': 12 * 60 * 60,
'24h': DAY_IN_SECONDS,
'48h': 2 * DAY_IN_SECONDS,
'1w': WEEK_IN_SECONDS,
'1m': MONTH_IN_SECONDS,
'1y': YEAR_IN_SECONDS,
all: undefined,
};
const timeFilterPresetNames = ['1h', '12h', '24h', '48h', '1w', '1m', '1y', 'all'] as const;
export { LAST_VISIT_STORAGE_KEY, TIME_FILTER_QUERY_PARAM };
const convertDynamicTimeFilterNameToSeconds = (timeFilterName: string): number | undefined => {
const match = timeFilterName.match(/^(\d+)([hdwmy])$/);
if (!match) {
return undefined;
}
const [, rawValue, unit] = match;
const value = Number.parseInt(rawValue, 10);
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
switch (unit) {
case 'h':
return value * 60 * 60;
case 'd':
return value * DAY_IN_SECONDS;
case 'w':
return value * 7 * DAY_IN_SECONDS;
case 'm':
return value * MONTH_IN_SECONDS;
case 'y':
return value * YEAR_IN_SECONDS;
default:
return undefined;
}
};
const getTimeFilterSeconds = (timeFilterName: string | null | undefined): number | undefined => {
if (!timeFilterName || timeFilterName === 'all') {
return undefined;
}
if (timeFilterName in presetTimeFilterSeconds) {
return presetTimeFilterSeconds[timeFilterName as keyof typeof presetTimeFilterSeconds];
}
return convertDynamicTimeFilterNameToSeconds(timeFilterName);
};
const isValidTimeFilterName = (timeFilterName: string | null | undefined): timeFilterName is string =>
timeFilterName === 'all' || typeof getTimeFilterSeconds(timeFilterName) === 'number';
const readLastVisitTimestamp = (): number | null => {
if (typeof window === 'undefined') {
return null;
}
const storedValue = window.localStorage.getItem(LAST_VISIT_STORAGE_KEY);
if (!storedValue) {
return null;
}
const parsedValue = Number.parseInt(storedValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : null;
};
export const touchLastVisitTimestamp = (timestamp = Date.now()) => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(timestamp));
};
const getLastVisitTimeFilterName = (now = Date.now(), lastVisitTimestamp = readLastVisitTimestamp()): string => {
const secondsSinceLastVisit = lastVisitTimestamp ? (now - lastVisitTimestamp) / 1000 : Number.POSITIVE_INFINITY;
if (secondsSinceLastVisit > 30 * DAY_IN_SECONDS) {
return '1m';
}
if (secondsSinceLastVisit > 7 * DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / (7 * DAY_IN_SECONDS))}w`;
}
if (secondsSinceLastVisit > DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / DAY_IN_SECONDS)}d`;
}
return FALLBACK_TIME_FILTER_NAME;
};
export const getStableLastVisitTimeFilterName = (): string => {
if (typeof window === 'undefined') {
return getLastVisitTimeFilterName();
}
if (!window._5chanLastVisitTimeFilterSnapshot) {
window._5chanLastVisitTimeFilterSnapshot = getLastVisitTimeFilterName();
}
return window._5chanLastVisitTimeFilterSnapshot;
};
export const clearStableLastVisitTimeFilterName = () => {
if (typeof window === 'undefined') {
return;
}
delete window._5chanLastVisitTimeFilterSnapshot;
};
const getExplicitTimeFilterValueFromSearch = (search: string): string | undefined => {
const timeFilterValue = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM);
if (timeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return timeFilterValue;
}
return isValidTimeFilterName(timeFilterValue) ? timeFilterValue : undefined;
};
export const getEffectiveTimeFilterName = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): string => {
const explicitTimeFilterValue = getExplicitTimeFilterValueFromSearch(search);
if (explicitTimeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return fallbackTimeFilterName;
}
return explicitTimeFilterValue ?? fallbackTimeFilterName;
};
export const getSelectedTimeFilterValue = (search: string): string => getExplicitTimeFilterValueFromSearch(search) ?? LAST_VISIT_TIME_FILTER_VALUE;
export const getTimeFilterOptionValues = (lastVisitTimeFilterName: string): string[] => {
const optionValues = [LAST_VISIT_TIME_FILTER_VALUE, ...timeFilterPresetNames];
const seenLabels = new Set<string>();
return optionValues.filter((value) => {
const label = value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
if (seenLabels.has(label)) {
return false;
}
seenLabels.add(label);
return true;
});
};
export const getTimeFilterOptionLabel = (value: string, lastVisitTimeFilterName: string): string =>
value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
export const getEffectiveTimeFilterSeconds = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): number | undefined => {
const effectiveTimeFilterName = getEffectiveTimeFilterName(search, fallbackTimeFilterName);
const effectiveTimeFilterSeconds = getTimeFilterSeconds(effectiveTimeFilterName);
if (effectiveTimeFilterName === 'all') {
return undefined;
}
return effectiveTimeFilterSeconds ?? presetTimeFilterSeconds[FALLBACK_TIME_FILTER_NAME];
};
export const getSearchWithTimeFilter = (
search: string,
timeFilterName: string | undefined,
options?: {
removeKeys?: string[];
},
): string => {
const params = new URLSearchParams(search);
for (const key of options?.removeKeys || []) {
params.delete(key);
}
if (timeFilterName) {
params.set(TIME_FILTER_QUERY_PARAM, timeFilterName);
} else {
params.delete(TIME_FILTER_QUERY_PARAM);
}
const nextSearch = params.toString();
return nextSearch ? `?${nextSearch}` : '';
};
export type TimeFilterSuggestion =
| {
i18nKey: 'more_threads_last_week';
timeFilterName: '1w';
}
| {
i18nKey: 'more_threads_last_month';
timeFilterName: '1m';
}
| {
i18nKey: 'more_threads_last_year';
timeFilterName: '1y';
};
export const getTimeFilterSuggestion = (
currentFeedLength: number,
weeklyFeedLength: number,
monthlyFeedLength: number,
yearlyFeedLength: number,
currentTimeFilterSeconds?: number,
): TimeFilterSuggestion | null => {
if (typeof currentTimeFilterSeconds !== 'number') {
return null;
}
if (currentTimeFilterSeconds < WEEK_IN_SECONDS && weeklyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_week',
timeFilterName: '1w',
};
}
if (currentTimeFilterSeconds < MONTH_IN_SECONDS && monthlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_month',
timeFilterName: '1m',
};
}
if (currentTimeFilterSeconds < YEAR_IN_SECONDS && yearlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_year',
timeFilterName: '1y',
};
}
return null;
};
+141 -6
View File
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Board, { type BoardProps } from '../board'; import Board, { type BoardProps } from '../board';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>; const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -32,9 +33,11 @@ const testState = vi.hoisted(() => ({
}, },
} as Record<string, { address: string; features?: Record<string, unknown> }>, } as Record<string, { address: string; features?: Record<string, unknown> }>,
feed: [] as TestComment[], feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
feedStateString: 'syncing', feedStateString: 'syncing',
filteredDirectoryAddresses: ['music-posting.eth'] as string[], filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false, hasMore: false,
respectPostsPerPageForNewerThan: new Set<number>(),
lastVirtuosoDefaultItemHeight: undefined as number | undefined, lastVirtuosoDefaultItemHeight: undefined as number | undefined,
lastVirtuosoIncreaseViewportBy: undefined as { top: number; bottom: number } | undefined, lastVirtuosoIncreaseViewportBy: undefined as { top: number; bottom: number } | undefined,
lastVirtuosoMinOverscanItemCount: undefined as { top: number; bottom: number } | undefined, lastVirtuosoMinOverscanItemCount: undefined as { top: number; bottom: number } | undefined,
@@ -63,6 +66,7 @@ const testState = vi.hoisted(() => ({
})); }));
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactNode>; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]),
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
}), }),
@@ -90,18 +94,51 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi
return scopedComments; return scopedComments;
}; };
const getScopedFeed = (options?: { filter?: { filter: (comment: TestComment) => boolean }; newerThan?: number; postsPerPage?: number }) => {
let scopedFeed = [...testState.feed];
if (typeof options?.newerThan === 'number') {
const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan;
scopedFeed = scopedFeed.filter((comment) => (comment.timestamp ?? Math.floor(Date.now() / 1000)) > newerThanTimestamp);
}
if (options?.filter) {
scopedFeed = scopedFeed.filter((comment) => options.filter?.filter(comment));
}
if (typeof options?.postsPerPage === 'number' && testState.respectPostsPerPageForNewerThan.has(options?.newerThan ?? -1)) {
scopedFeed = scopedFeed.slice(0, options.postsPerPage);
}
return scopedFeed;
};
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => testState.account, useAccount: () => testState.account,
useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
testState.accountCommentsCalls.push(options); testState.accountCommentsCalls.push(options);
return { accountComments: getScopedAccountComments(options) }; return { accountComments: getScopedAccountComments(options) };
}, },
useFeed: () => ({ useFeed: (options?: {
feed: testState.feed, communities?: unknown[];
hasMore: testState.hasMore, filter?: { filter: (comment: TestComment) => boolean };
loadMore: testState.loadMoreMock, newerThan?: number;
reset: testState.resetMock, postsPerPage?: number;
}), sortType?: string;
}) => {
testState.feedOptionsCalls.push({
communitiesLength: options?.communities?.length,
newerThan: options?.newerThan,
postsPerPage: options?.postsPerPage,
sortType: options?.sortType,
});
return {
feed: getScopedFeed(options),
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
};
},
useCommunity: () => testState.community, useCommunity: () => testState.community,
})); }));
@@ -286,9 +323,11 @@ describe('Board', () => {
}, },
}; };
testState.feed = []; testState.feed = [];
testState.feedOptionsCalls = [];
testState.feedStateString = 'syncing'; testState.feedStateString = 'syncing';
testState.filteredDirectoryAddresses = ['music-posting.eth']; testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false; testState.hasMore = false;
testState.respectPostsPerPageForNewerThan = new Set();
testState.lastVirtuosoDefaultItemHeight = undefined; testState.lastVirtuosoDefaultItemHeight = undefined;
testState.lastVirtuosoIncreaseViewportBy = undefined; testState.lastVirtuosoIncreaseViewportBy = undefined;
testState.lastVirtuosoMinOverscanItemCount = undefined; testState.lastVirtuosoMinOverscanItemCount = undefined;
@@ -315,6 +354,8 @@ describe('Board', () => {
testState.setEnableInfiniteScrollMock.mockReset(); testState.setEnableInfiniteScrollMock.mockReset();
testState.setResetFunctionMock.mockReset(); testState.setResetFunctionMock.mockReset();
document.title = 'before'; document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
Object.defineProperty(window, 'scrollTo', { Object.defineProperty(window, 'scrollTo', {
configurable: true, configurable: true,
value: vi.fn(), value: vi.fn(),
@@ -329,6 +370,8 @@ describe('Board', () => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
}); });
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => { it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
@@ -446,6 +489,98 @@ describe('Board', () => {
expect(container.textContent).toContain('not_subscribed_to_any_board'); expect(container.textContent).toContain('not_subscribed_to_any_board');
}); });
it('passes multiboard time filters to useFeed and honors cached overrides', async () => {
testState.feed = [{ cid: 'all-post', communityAddress: 'music-posting.eth' }];
await renderBoard({
boardProps: { viewType: 'all', timeFilterNameFromCache: '1w' },
initialEntry: '/all?t=24h',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
newerThan: 7 * 24 * 60 * 60,
postsPerPage: 2,
sortType: 'active',
}),
]),
);
});
it('shows a wider multiboard time-filter suggestion when older threads exist', async () => {
const now = Math.floor(Date.now() / 1000);
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String((now - 3 * 24 * 60 * 60) * 1000));
testState.feed = [
{ cid: 'recent-post', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 },
{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
];
await renderBoard({
boardProps: { viewType: 'all' },
initialEntry: '/all?t=last',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60 }),
]),
);
expect(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all?t=1w')).toBe(true);
});
it('keeps broader suggestion feeds on the base page size so their identities stay stable while scrolling', async () => {
const now = Math.floor(Date.now() / 1000);
testState.feed = [
{ cid: 'post-1', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 },
{ cid: 'post-2', communityAddress: 'music-posting.eth', timestamp: now - 3 * 24 * 60 * 60 },
{ cid: 'post-3', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
{ cid: 'post-4', communityAddress: 'music-posting.eth', timestamp: now - 20 * 24 * 60 * 60 },
];
testState.pageSizes = {
guiPostsPerPage: 2,
infiniteFeedPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.respectPostsPerPageForNewerThan = new Set([30 * 24 * 60 * 60, 365 * 24 * 60 * 60]);
await renderBoard({
boardProps: { viewType: 'all' },
initialEntry: '/all?t=1w',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, postsPerPage: 2, sortType: 'active' }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, postsPerPage: 2, sortType: 'active' }),
]),
);
});
it('disables suggestion probe feeds for hidden cached multiboard views', async () => {
testState.feed = [{ cid: 'recent-post', communityAddress: 'music-posting.eth' }];
await renderBoard({
boardProps: { viewType: 'all', isVisible: false },
initialEntry: '/all?t=24h',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }),
]),
);
});
it('surfaces board load errors when the feed is empty', async () => { it('surfaces board load errors when the feed is empty', async () => {
testState.community = { testState.community = {
error: new Error('board failed'), error: new Error('board failed'),
+6 -1
View File
@@ -12,6 +12,11 @@
padding: 15px 0 20px 5px; padding: 15px 0 20px 5px;
} }
.morePostsSuggestion {
color: var(--body-font-color);
padding-bottom: 5px;
}
.footer a { .footer a {
cursor: pointer; cursor: pointer;
text-decoration: var(--button-text-decoration); text-decoration: var(--button-text-decoration);
@@ -47,4 +52,4 @@
border-image-source: url('/assets/garland.png'); border-image-source: url('/assets/garland.png');
border-style: solid; border-style: solid;
padding-top: 50px; padding-top: 50px;
} }
+117 -11
View File
@@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate, useNavigationType, useParams } from 're
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks'; import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks';
import { useCommunityField } from '../../hooks/use-stable-community'; import { useCommunityField } from '../../hooks/use-stable-community';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css'; import styles from './board.module.css';
import mobileFooterStyles from '../../components/footer/footer.module.css'; import mobileFooterStyles from '../../components/footer/footer.module.css';
import { shouldShowSnow } from '../../lib/snow'; import { shouldShowSnow } from '../../lib/snow';
@@ -18,10 +18,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
import usePostNumberStore from '../../stores/use-post-number-store'; import usePostNumberStore from '../../stores/use-post-number-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
import useTimeFilter from '../../hooks/use-time-filter';
import { getPageSlice } from '../../lib/utils/board-feed-pagination'; import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils'; import { getPageFromFeedPath, getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates'; import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import ErrorDisplay from '../../components/error-display/error-display'; import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis'; import LoadingEllipsis from '../../components/loading-ellipsis';
@@ -32,6 +35,9 @@ import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60; const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
const WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
const MONTH_IN_SECONDS = 30 * 24 * 60 * 60;
const YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
// Keep the hook on its indexed fast path when this view should not inject local posts. // Keep the hook on its indexed fast path when this view should not inject local posts.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
@@ -42,9 +48,12 @@ interface BoardFooterProps {
communityAddresses: string[]; communityAddresses: string[];
hasMore: boolean; hasMore: boolean;
combinedFeedLength: number; combinedFeedLength: number;
isInAllView: boolean;
isInSubscriptionsView: boolean; isInSubscriptionsView: boolean;
isInModView: boolean; isInModView: boolean;
currentTimeFilterName: string;
moreThreadsSuggestion: TimeFilterSuggestion | null;
moreThreadsSuggestionPathname: string | null;
moreThreadsSuggestionSearch: string;
communityState: string | undefined; communityState: string | undefined;
subscriptionsLength: number; subscriptionsLength: number;
accountCommunityAddressesLength: number; accountCommunityAddressesLength: number;
@@ -59,9 +68,12 @@ const BoardFooter = ({
communityAddresses, communityAddresses,
hasMore, hasMore,
combinedFeedLength, combinedFeedLength,
isInAllView,
isInSubscriptionsView, isInSubscriptionsView,
isInModView, isInModView,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
moreThreadsSuggestionSearch,
communityState, communityState,
subscriptionsLength, subscriptionsLength,
accountCommunityAddressesLength, accountCommunityAddressesLength,
@@ -72,10 +84,26 @@ const BoardFooter = ({
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
let footerContent; let footerContent;
if (combinedFeedLength === 0) { if (moreThreadsSuggestion && moreThreadsSuggestionPathname) {
footerContent = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads'); footerContent = t('no_threads');
} }
if (hasMore || (communityAddresses && communityAddresses.length === 0)) { if (communityAddresses && communityAddresses.length === 0) {
footerContent = null; footerContent = null;
} }
return ( return (
@@ -100,16 +128,20 @@ export interface BoardProps {
feedCacheKey?: string; feedCacheKey?: string;
viewType?: 'all' | 'subs' | 'mod' | 'board'; viewType?: 'all' | 'subs' | 'mod' | 'board';
boardIdentifier?: string; boardIdentifier?: string;
timeFilterNameFromCache?: string;
isVisible?: boolean; isVisible?: boolean;
} }
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, isVisible = true }: BoardProps) => { const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: BoardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const params = useParams(); const params = useParams();
const isInAllView = viewType ? viewType === 'all' : false; const isInAllView = viewType ? viewType === 'all' : false;
const isInSubscriptionsView = viewType ? viewType === 'subs' : false; const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
const isInModView = viewType ? viewType === 'mod' : false; const isInModView = viewType ? viewType === 'mod' : false;
const { timeFilterName, timeFilterSeconds } = useTimeFilter(timeFilterNameFromCache);
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const multiboardTimeFilterSeconds = isMultiboardView ? timeFilterSeconds : undefined;
const directories = useDirectories(); const directories = useDirectories();
const resolvedAddressFromUrl = useResolvedCommunityAddress(); const resolvedAddressFromUrl = useResolvedCommunityAddress();
@@ -164,11 +196,76 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
sortType: BOARD_SORT_TYPE, sortType: BOARD_SORT_TYPE,
postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage, postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
filter: excludeArchivedFilter, filter: excludeArchivedFilter,
newerThan: multiboardTimeFilterSeconds,
}), }),
[communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter], [communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter, multiboardTimeFilterSeconds],
); );
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const shouldProbeSuggestionFeeds = isVisible && isMultiboardView && typeof multiboardTimeFilterSeconds === 'number';
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < WEEK_IN_SECONDS;
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < MONTH_IN_SECONDS;
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < YEAR_IN_SECONDS;
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = infiniteFeedPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
} = useFeed({
communities: shouldProbeWeeklyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: WEEK_IN_SECONDS,
});
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
} = useFeed({
communities: shouldProbeMonthlyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: MONTH_IN_SECONDS,
});
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
} = useFeed({
communities: shouldProbeYearlyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: YEAR_IN_SECONDS,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
shouldLoad: shouldProbeYearlyFeed,
});
const accountCommentLookupOptions = useMemo( const accountCommentLookupOptions = useMemo(
() => () =>
communityAddress communityAddress
@@ -230,6 +327,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)), () => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages], [effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
); );
const moreThreadsSuggestion = useMemo(
() => (isMultiboardView ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, multiboardTimeFilterSeconds) : null),
[feed.length, isMultiboardView, monthlyFeed.length, multiboardTimeFilterSeconds, weeklyFeed.length, yearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all' : isInSubscriptionsView ? '/subs' : isInModView ? '/mod' : null;
const registerComments = usePostNumberStore((state) => state.registerComments); const registerComments = usePostNumberStore((state) => state.registerComments);
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]); const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
const currentPageFeed = useMemo( const currentPageFeed = useMemo(
@@ -238,7 +340,6 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
); );
const navigate = useNavigate(); const navigate = useNavigate();
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size'; const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size';
const feedVirtualizationMode = useMemo( const feedVirtualizationMode = useMemo(
() => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode), () => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode),
@@ -305,9 +406,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
communityAddresses={communityAddresses} communityAddresses={communityAddresses}
hasMore={hasMore} hasMore={hasMore}
combinedFeedLength={combinedFeed.length} combinedFeedLength={combinedFeed.length}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView} isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView} isInModView={isInModView}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
communityState={communityState} communityState={communityState}
subscriptionsLength={subscriptions?.length || 0} subscriptionsLength={subscriptions?.length || 0}
accountCommunityAddressesLength={accountCommunityAddresses?.length || 0} accountCommunityAddressesLength={accountCommunityAddresses?.length || 0}
@@ -380,9 +484,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
communityAddresses, communityAddresses,
hasMore, hasMore,
combinedFeed.length, combinedFeed.length,
isInAllView,
isInSubscriptionsView, isInSubscriptionsView,
isInModView, isInModView,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityState, communityState,
communityAddress, communityAddress,
subscriptions?.length, subscriptions?.length,
@@ -400,7 +506,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
); );
const virtuosoRef = useRef<VirtuosoHandle | null>(null); const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`; const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}${location.search}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType(); const navigationType = useNavigationType();
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 }; const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined; const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
+148 -8
View File
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog'; import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>; const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -35,6 +36,7 @@ const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] }, account: { subscriptions: [] as string[] },
accountComments: [] as TestComment[], accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>, accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
accountCommunityAddresses: [] as string[],
directoryByAddress: { directoryByAddress: {
'music-posting.eth': { 'music-posting.eth': {
address: 'music-posting.eth', address: 'music-posting.eth',
@@ -43,7 +45,7 @@ const testState = vi.hoisted(() => ({
} as Record<string, { address: string; features?: Record<string, unknown> }>, } as Record<string, { address: string; features?: Record<string, unknown> }>,
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
feed: [] as TestComment[], feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ postsPerPage?: number }>, feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
filterItems: [] as FilterItem[], filterItems: [] as FilterItem[],
filteredDirectoryAddresses: ['music-posting.eth'] as string[], filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false, hasMore: false,
@@ -55,6 +57,7 @@ const testState = vi.hoisted(() => ({
maxGuiPages: 3, maxGuiPages: 3,
paginationFeedPostsPerPage: 6, paginationFeedPostsPerPage: 6,
}, },
respectPostsPerPageForNewerThan: new Set<number>(),
resetMock: vi.fn(), resetMock: vi.fn(),
resolvedCommunityAddress: 'music-posting.eth' as string | undefined, resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
searchText: '', searchText: '',
@@ -89,6 +92,7 @@ function useCatalogFiltersStoreMock<T>(selector?: (state: ReturnType<typeof getC
useCatalogFiltersStoreMock.getState = getCatalogFiltersState; useCatalogFiltersStoreMock.getState = getCatalogFiltersState;
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactNode>; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]),
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
}), }),
@@ -116,16 +120,46 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi
return scopedComments; return scopedComments;
}; };
const getScopedFeed = (options?: { filter?: { filter: (comment: TestComment) => boolean }; newerThan?: number; postsPerPage?: number }) => {
let scopedFeed = [...testState.feed];
if (typeof options?.newerThan === 'number') {
const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan;
scopedFeed = scopedFeed.filter((comment) => (comment.timestamp ?? Math.floor(Date.now() / 1000)) > newerThanTimestamp);
}
if (options?.filter) {
scopedFeed = scopedFeed.filter((comment) => options.filter?.filter(comment));
}
if (typeof options?.postsPerPage === 'number' && testState.respectPostsPerPageForNewerThan.has(options?.newerThan ?? -1)) {
scopedFeed = scopedFeed.slice(0, options.postsPerPage);
}
return scopedFeed;
};
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => testState.account, useAccount: () => testState.account,
useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
testState.accountCommentsCalls.push(options); testState.accountCommentsCalls.push(options);
return { accountComments: getScopedAccountComments(options) }; return { accountComments: getScopedAccountComments(options) };
}, },
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean }; postsPerPage?: number }) => { useFeed: (options: {
testState.feedOptionsCalls.push({ postsPerPage: options.postsPerPage }); communities?: unknown[];
filter?: { filter: (comment: TestComment) => boolean };
newerThan?: number;
postsPerPage?: number;
sortType?: string;
}) => {
testState.feedOptionsCalls.push({
communitiesLength: options.communities?.length,
newerThan: options.newerThan,
postsPerPage: options.postsPerPage,
sortType: options.sortType,
});
return { return {
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed, feed: getScopedFeed(options),
hasMore: testState.hasMore, hasMore: testState.hasMore,
loadMore: testState.loadMoreMock, loadMore: testState.loadMoreMock,
reset: testState.resetMock, reset: testState.resetMock,
@@ -179,6 +213,10 @@ vi.mock('../../../hooks/use-board-feed-page-size', () => ({
useBoardFeedPageSize: () => testState.pageSizes, useBoardFeedPageSize: () => testState.pageSizes,
})); }));
vi.mock('../../../hooks/use-account-community-addresses', () => ({
useAccountCommunityAddresses: () => testState.accountCommunityAddresses,
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
})); }));
@@ -264,8 +302,8 @@ let root: Root;
const LocationProbe = () => { const LocationProbe = () => {
const location = useLocation(); const location = useLocation();
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
latestLocation = location.pathname; latestLocation = `${location.pathname}${location.search}`;
}, [location.pathname]); }, [location.pathname, location.search]);
return null; return null;
}; };
@@ -305,6 +343,7 @@ describe('Catalog', () => {
testState.account = { subscriptions: [] }; testState.account = { subscriptions: [] };
testState.accountComments = []; testState.accountComments = [];
testState.accountCommentsCalls = []; testState.accountCommentsCalls = [];
testState.accountCommunityAddresses = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.directoryByAddress = { testState.directoryByAddress = {
'music-posting.eth': { 'music-posting.eth': {
@@ -323,6 +362,7 @@ describe('Catalog', () => {
maxGuiPages: 3, maxGuiPages: 3,
paginationFeedPostsPerPage: 6, paginationFeedPostsPerPage: 6,
}; };
testState.respectPostsPerPageForNewerThan = new Set();
testState.resolvedCommunityAddress = 'music-posting.eth'; testState.resolvedCommunityAddress = 'music-posting.eth';
testState.searchText = ''; testState.searchText = '';
testState.showOPComment = true; testState.showOPComment = true;
@@ -341,6 +381,8 @@ describe('Catalog', () => {
testState.setCurrentCommunityAddressMock.mockReset(); testState.setCurrentCommunityAddressMock.mockReset();
testState.setResetFunctionMock.mockReset(); testState.setResetFunctionMock.mockReset();
document.title = 'before'; document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
container = document.createElement('div'); container = document.createElement('div');
document.body.appendChild(container); document.body.appendChild(container);
@@ -350,6 +392,8 @@ describe('Catalog', () => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
}); });
it('applies catalog filters and promotes top matches', async () => { it('applies catalog filters and promotes top matches', async () => {
@@ -408,13 +452,109 @@ describe('Catalog', () => {
}); });
expect(latestLocation).toBe('/all/catalog'); expect(latestLocation).toBe('/all/catalog');
expect(testState.feedOptionsCalls.at(-1)?.postsPerPage).toBe(24); expect(testState.feedOptionsCalls).toEqual(expect.arrayContaining([expect.objectContaining({ postsPerPage: 24 })]));
const loadMoreCallCountBeforeEndReached = testState.loadMoreMock.mock.calls.length;
await act(async () => { await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1); expect(testState.loadMoreMock.mock.calls.length).toBe(loadMoreCallCountBeforeEndReached + 1);
});
it('passes multiboard time filters to useFeed and keeps query params during canonical redirects', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all', timeFilterNameFromCache: '48h' },
initialEntry: '/all/catalog/7?t=24h',
routePath: '/all/*',
});
expect(latestLocation).toBe('/all/catalog?t=24h');
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
newerThan: 48 * 60 * 60,
postsPerPage: 24,
sortType: 'new',
}),
]),
);
});
it('shows a wider multiboard catalog time-filter suggestion when older threads exist', async () => {
const now = Math.floor(Date.now() / 1000);
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String((now - 3 * 24 * 60 * 60) * 1000));
testState.feed = [
{ cid: 'recent-post', title: 'recent', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 },
{ cid: 'older-post', title: 'older', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=last',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60 }),
]),
);
expect(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all/catalog?t=1w')).toBe(true);
});
it('keeps broader catalog suggestion feeds on the base page size so their identities stay stable while scrolling', async () => {
const now = Math.floor(Date.now() / 1000);
testState.feed = [
...Array.from({ length: 25 }, (_, index) => ({
cid: `post-${index + 1}`,
title: `thread ${index + 1}`,
communityAddress: 'music-posting.eth',
timestamp: now - (index + 1) * 60 * 60,
})),
{ cid: 'older-post', title: 'older thread', communityAddress: 'music-posting.eth', timestamp: now - 20 * 24 * 60 * 60 },
];
testState.pageSizes = {
guiPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.respectPostsPerPageForNewerThan = new Set([30 * 24 * 60 * 60, 365 * 24 * 60 * 60]);
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=1w',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
]),
);
});
it('disables broader suggestion probe feeds when the multiboard filter is already all', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=all',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }),
]),
);
}); });
it('captures Virtuoso state on pagehide instead of wiring a scroll hot-path listener', async () => { it('captures Virtuoso state on pagehide instead of wiring a scroll hot-path listener', async () => {
+5 -1
View File
@@ -3,6 +3,10 @@
color: var(--post-mobile-abbr-text-color); color: var(--post-mobile-abbr-text-color);
} }
.morePostsSuggestion {
padding-bottom: 5px;
}
.footer a { .footer a {
text-decoration: var(--button-text-decoration); text-decoration: var(--button-text-decoration);
color: var(--button-desktop-text-color); color: var(--button-desktop-text-color);
@@ -16,4 +20,4 @@
.footer { .footer {
padding-left: 5px; padding-left: 5px;
} }
} }
+191 -16
View File
@@ -1,14 +1,17 @@
import { useDeferredValue, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; import { useDeferredValue, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react';
import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers'; import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses'; import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string'; import { useFeedStateString } from '../../hooks/use-state-string';
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
import useTimeFilter from '../../hooks/use-time-filter';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import useWindowWidth from '../../hooks/use-window-width'; import useWindowWidth from '../../hooks/use-window-width';
import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store';
@@ -27,6 +30,7 @@ import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort'; import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
import { import {
getCatalogRowHeightEstimates, getCatalogRowHeightEstimates,
getPretextItemSizeFromElement, getPretextItemSizeFromElement,
@@ -37,6 +41,9 @@ import {
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60; const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
const WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
const MONTH_IN_SECONDS = 30 * 24 * 60 * 60;
const YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
// Keep the hook on its indexed fast path when this view should not inject local posts. // Keep the hook on its indexed fast path when this view should not inject local posts.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
export const getCatalogRenderFeed = <T,>(processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] => export const getCatalogRenderFeed = <T,>(processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] =>
@@ -46,6 +53,10 @@ interface CatalogFooterProps {
communityAddresses: string[]; communityAddresses: string[];
hasMore: boolean; hasMore: boolean;
combinedFeedLength: number; combinedFeedLength: number;
currentTimeFilterName: string;
moreThreadsSuggestion: TimeFilterSuggestion | null;
moreThreadsSuggestionPathname: string | null;
moreThreadsSuggestionSearch: string;
/** When false, suppress the loading ellipsis (e.g. non-infinite mode) */ /** When false, suppress the loading ellipsis (e.g. non-infinite mode) */
showLoadingEllipsis?: boolean; showLoadingEllipsis?: boolean;
} }
@@ -53,18 +64,44 @@ interface CatalogFooterProps {
// Defined outside Catalog to preserve component identity across renders (Virtuoso optimization) // Defined outside Catalog to preserve component identity across renders (Virtuoso optimization)
// The useFeedStateString hook is called here instead of in Catalog to isolate re-renders // The useFeedStateString hook is called here instead of in Catalog to isolate re-renders
// caused by backend IPFS state changes to just this footer component // caused by backend IPFS state changes to just this footer component
const CatalogFooter = ({ communityAddresses, hasMore, combinedFeedLength, showLoadingEllipsis = true }: CatalogFooterProps) => { const CatalogFooter = ({
communityAddresses,
hasMore,
combinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
moreThreadsSuggestionSearch,
showLoadingEllipsis = true,
}: CatalogFooterProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
let footerContent; let footerContent;
if (combinedFeedLength === 0) { if (moreThreadsSuggestion && moreThreadsSuggestionPathname) {
footerContent = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads'); footerContent = t('no_threads');
} }
if (hasMore || (communityAddresses && communityAddresses.length === 0)) { if (hasMore || (communityAddresses && communityAddresses.length === 0)) {
footerContent = ( footerContent = (
<> <>
{footerContent}
{showLoadingEllipsis && ( {showLoadingEllipsis && (
<div className={styles.stateString}> <div className={styles.stateString}>
<LoadingEllipsis string={loadingStateString} /> <LoadingEllipsis string={loadingStateString} />
@@ -113,6 +150,7 @@ const createContentFilter = (
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[], filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
communityAddress: string, communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void, onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
trackMatches = true,
) => { ) => {
// Create a unique key based on the enabled filter items // Create a unique key based on the enabled filter items
const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== ''); const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '');
@@ -135,7 +173,7 @@ const createContentFilter = (
if (commentMatchesPattern(comment, pattern)) { if (commentMatchesPattern(comment, pattern)) {
// Find the original filter index to increment count // Find the original filter index to increment count
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
if (filterIndex !== -1) { if (trackMatches && filterIndex !== -1) {
if (onFilterMatch) { if (onFilterMatch) {
onFilterMatch(filterIndex, comment.cid, communityAddress); onFilterMatch(filterIndex, comment.cid, communityAddress);
} else { } else {
@@ -165,8 +203,9 @@ const createCombinedFilter = (
searchText: string, searchText: string,
communityAddress: string, communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void, onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
trackMatches = true,
) => { ) => {
const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch); const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch, trackMatches);
const searchFilter = { const searchFilter = {
filter: (comment: Comment) => { filter: (comment: Comment) => {
@@ -192,10 +231,11 @@ export interface CatalogProps {
feedCacheKey?: string; feedCacheKey?: string;
viewType?: 'all' | 'subs' | 'mod' | 'board'; viewType?: 'all' | 'subs' | 'mod' | 'board';
boardIdentifier?: string; boardIdentifier?: string;
timeFilterNameFromCache?: string;
isVisible?: boolean; isVisible?: boolean;
} }
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, isVisible = true }: CatalogProps) => { const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: CatalogProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -206,6 +246,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const isInModView = viewType ? viewType === 'mod' : false; const isInModView = viewType ? viewType === 'mod' : false;
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView; const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const { timeFilterName, timeFilterSeconds } = useTimeFilter(timeFilterNameFromCache);
const multiboardTimeFilterSeconds = isMultiboard ? timeFilterSeconds : undefined;
// Single-board catalogs always cap at maxGuiPages (no infinite scroll beyond the board's page limit) // Single-board catalogs always cap at maxGuiPages (no infinite scroll beyond the board's page limit)
const effectiveInfiniteScroll = isMultiboard; const effectiveInfiniteScroll = isMultiboard;
@@ -222,6 +264,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const account = useAccount(); const account = useAccount();
const subscriptions = account?.subscriptions; const subscriptions = account?.subscriptions;
const accountCommunityAddresses = useAccountCommunityAddresses();
const filteredDirectoryAddresses = useFilteredDirectoryAddresses(); const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
const communityAddresses = useMemo(() => { const communityAddresses = useMemo(() => {
@@ -231,9 +274,12 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
if (isInSubscriptionsView) { if (isInSubscriptionsView) {
return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values
} }
if (isInModView) {
return accountCommunityAddresses;
}
// Only include communityAddress if it's defined // Only include communityAddress if it's defined
return communityAddress ? [communityAddress] : []; return communityAddress ? [communityAddress] : [];
}, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]); }, [accountCommunityAddresses, isInAllView, isInSubscriptionsView, isInModView, communityAddress, filteredDirectoryAddresses, subscriptions]);
const communities = useCommunityIdentifiers(communityAddresses); const communities = useCommunityIdentifiers(communityAddresses);
const communityIdentifier = useCommunityIdentifier(communityAddress); const communityIdentifier = useCommunityIdentifier(communityAddress);
@@ -252,9 +298,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
if (!(isInAllView || isInSubscriptionsView || isInModView)) return; if (!(isInAllView || isInSubscriptionsView || isInModView)) return;
const canonical = normalizeMultiboardFeedPath(location.pathname); const canonical = normalizeMultiboardFeedPath(location.pathname);
if (location.pathname !== canonical) { if (location.pathname !== canonical) {
navigate(canonical, { replace: true }); navigate({ pathname: canonical, search: location.search }, { replace: true });
} }
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, navigate]); }, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, location.search, navigate]);
const { sortType } = useSortingStore(); const { sortType } = useSortingStore();
const feedSortType = sortType === 'new' ? 'new' : 'active'; const feedSortType = sortType === 'new' ? 'new' : 'active';
@@ -280,10 +326,90 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
sortType: feedSortType, sortType: feedSortType,
postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage, postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage,
filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch), filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch),
newerThan: multiboardTimeFilterSeconds,
}; };
}, [communities, feedSortType, isMultiboard, paginationFeedPostsPerPage, multiboardCatalogPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]); }, [
communities,
feedSortType,
isMultiboard,
paginationFeedPostsPerPage,
multiboardCatalogPostsPerPage,
filterItems,
searchText,
communityAddress,
handleFilterMatch,
multiboardTimeFilterSeconds,
]);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const shouldProbeSuggestionFeeds = isVisible && isMultiboard && typeof multiboardTimeFilterSeconds === 'number';
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < WEEK_IN_SECONDS;
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < MONTH_IN_SECONDS;
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < YEAR_IN_SECONDS;
const suggestionFilter = useMemo(
() => createCombinedFilter(filterItems, searchText, communityAddress || 'all', undefined, false),
[communityAddress, filterItems, searchText],
);
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = multiboardCatalogPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
} = useFeed({
communities: shouldProbeWeeklyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: WEEK_IN_SECONDS,
});
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
} = useFeed({
communities: shouldProbeMonthlyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: MONTH_IN_SECONDS,
});
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
} = useFeed({
communities: shouldProbeYearlyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: YEAR_IN_SECONDS,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
shouldLoad: shouldProbeYearlyFeed,
});
const accountCommentLookupOptions = useMemo( const accountCommentLookupOptions = useMemo(
() => () =>
communityAddress communityAddress
@@ -346,6 +472,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)), () => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages], [effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
); );
const moreThreadsSuggestion = useMemo(
() => (isMultiboard ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, multiboardTimeFilterSeconds) : null),
[feed.length, isMultiboard, monthlyFeed.length, multiboardTimeFilterSeconds, weeklyFeed.length, yearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all/catalog' : isInSubscriptionsView ? '/subs/catalog' : isInModView ? '/mod/catalog' : null;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]); const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]);
@@ -373,7 +504,16 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() => ({ () => ({
Footer: () => ( Footer: () => (
<> <>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} /> <CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop <PageFooterDesktop
firstRow={ firstRow={
<CatalogFooterFirstRow <CatalogFooterFirstRow
@@ -395,12 +535,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</> </>
), ),
}), }),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], [
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
); );
const catalogFooter = useMemo( const catalogFooter = useMemo(
() => ( () => (
<> <>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} /> <CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop <PageFooterDesktop
firstRow={ firstRow={
<CatalogFooterFirstRow <CatalogFooterFirstRow
@@ -421,7 +583,20 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</PageFooterMobile> </PageFooterMobile>
</> </>
), ),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], [
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
); );
const isFeedLoaded = feed.length > 0 || state === 'failed'; const isFeedLoaded = feed.length > 0 || state === 'failed';
@@ -529,7 +704,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 }; const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 };
const virtuosoRef = useRef<VirtuosoHandle | null>(null); const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`; const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}${location.search}-${sortType}-catalog`;
const navigationType = useNavigationType(); const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false); const hasBeenVisibleRef = useRef(false);