From 49422591b830af504e1823d33fd1bf3d5679cedb Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sat, 18 Apr 2026 15:58:27 +0700 Subject: [PATCH] fix(multiboards): restore stable time-filter suggestions --- public/translations/ar/default.json | 5 +- public/translations/bn/default.json | 5 +- public/translations/cs/default.json | 5 +- public/translations/da/default.json | 5 +- public/translations/de/default.json | 5 +- public/translations/el/default.json | 5 +- public/translations/en/default.json | 5 +- public/translations/es/default.json | 5 +- public/translations/fa/default.json | 5 +- public/translations/fi/default.json | 5 +- public/translations/fil/default.json | 5 +- public/translations/fr/default.json | 5 +- public/translations/he/default.json | 5 +- public/translations/hi/default.json | 5 +- public/translations/hu/default.json | 5 +- public/translations/id/default.json | 5 +- public/translations/it/default.json | 5 +- public/translations/ja/default.json | 5 +- public/translations/ko/default.json | 5 +- public/translations/mr/default.json | 5 +- public/translations/nl/default.json | 5 +- public/translations/no/default.json | 5 +- public/translations/pl/default.json | 5 +- public/translations/pt/default.json | 5 +- public/translations/ro/default.json | 5 +- public/translations/ru/default.json | 5 +- public/translations/sq/default.json | 5 +- public/translations/sv/default.json | 5 +- public/translations/te/default.json | 5 +- public/translations/th/default.json | 5 +- public/translations/tr/default.json | 5 +- public/translations/uk/default.json | 5 +- public/translations/ur/default.json | 5 +- public/translations/vi/default.json | 5 +- public/translations/zh/default.json | 5 +- src/__tests__/app.test.tsx | 1 + src/app.tsx | 2 +- .../__tests__/board-buttons.test.tsx | 48 +++- .../board-buttons/board-buttons.tsx | 140 ++++++++-- .../feed-cache-container.tsx | 30 ++- src/hooks/use-suggestion-feed-loader.ts | 31 +++ src/hooks/use-time-filter.ts | 56 ++++ src/lib/utils/__tests__/route-utils.test.ts | 22 +- src/lib/utils/route-utils.ts | 10 +- src/lib/utils/time-filter-utils.ts | 249 ++++++++++++++++++ src/views/board/__tests__/board.test.tsx | 147 ++++++++++- src/views/board/board.module.css | 7 +- src/views/board/board.tsx | 128 ++++++++- src/views/catalog/__tests__/catalog.test.tsx | 156 ++++++++++- src/views/catalog/catalog.module.css | 6 +- src/views/catalog/catalog.tsx | 207 +++++++++++++-- 51 files changed, 1296 insertions(+), 119 deletions(-) create mode 100644 src/hooks/use-suggestion-feed-loader.ts create mode 100644 src/hooks/use-time-filter.ts create mode 100644 src/lib/utils/time-filter-utils.ts diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index feb14d82..1d1a0bea 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "جاري التحقق من التحديثات...", "new_version_found": "تم العثور على إصدار جديد", "site_legal_meta_license_text": "5chan هو برنامج مجاني ومفتوح المصدر بموجب GPL-3.0-or-later.", - "community_offline_info": "قد تكون هذه اللوحة غير متصلة وقد يفشل النشر." + "community_offline_info": "قد تكون هذه اللوحة غير متصلة وقد يفشل النشر.", + "more_threads_last_week": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الأسبوع الماضي]", + "more_threads_last_month": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الشهر الماضي]", + "more_threads_last_year": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من السنة الماضية]" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index e00bd632..4444c060 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "আপডেট পরীক্ষা করা হচ্ছে...", "new_version_found": "নতুন সংস্করণ পাওয়া গেছে", "site_legal_meta_license_text": "5chan হলো GPL-3.0-or-later-এর অধীনে মুক্ত এবং ওপেন-সোর্স সফটওয়্যার।", - "community_offline_info": "এই বোর্ডটি অফলাইনে থাকতে পারে এবং প্রকাশনা ব্যর্থ হতে পারে।" + "community_offline_info": "এই বোর্ডটি অফলাইনে থাকতে পারে এবং প্রকাশনা ব্যর্থ হতে পারে।", + "more_threads_last_week": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত সপ্তাহের আরও থ্রেড দেখান]", + "more_threads_last_month": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত মাসের আরও থ্রেড দেখান]", + "more_threads_last_year": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত বছরের আরও থ্রেড দেখান]" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 258350d6..33576c43 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Kontroluji aktualizace...", "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.", - "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]", + "more_threads_last_month": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého měsíce]", + "more_threads_last_year": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého roku]" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index b5209bfe..a14db81f 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Tjekker for opdateringer...", "new_version_found": "ny version fundet", "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]", + "more_threads_last_month": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste måned]", + "more_threads_last_year": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste år]" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 388e367a..95c3e6fc 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Suche nach Updates...", "new_version_found": "neue Version gefunden", "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]", + "more_threads_last_month": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Monat anzeigen]", + "more_threads_last_year": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Jahr anzeigen]" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 2bbd59c4..e12c5b34 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Έλεγχος για ενημερώσεις...", "new_version_found": "βρέθηκε νέα έκδοση", "site_legal_meta_license_text": "Το 5chan είναι ελεύθερο λογισμικό ανοιχτού κώδικα υπό την GPL-3.0-or-later.", - "community_offline_info": "Αυτός ο πίνακας μπορεί να είναι εκτός σύνδεσης και η δημοσίευση μπορεί να αποτύχει." + "community_offline_info": "Αυτός ο πίνακας μπορεί να είναι εκτός σύνδεσης και η δημοσίευση μπορεί να αποτύχει.", + "more_threads_last_week": "{{count}} θέματα από {{currentTimeFilterName}} — [<1>Εμφάνιση περισσότερων θεμάτων από την περασμένη εβδομάδα]", + "more_threads_last_month": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από τον τελευταίο μήνα]", + "more_threads_last_year": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από το περασμένο έτος]" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 95b2337a..278f2392 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "checking for updates...", "new_version_found": "new version found", "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]", + "more_threads_last_month": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Month]", + "more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year]" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index a6bc101b..b848ea64 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Buscando actualizaciones...", "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.", - "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]", + "more_threads_last_month": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del mes pasado]", + "more_threads_last_year": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del año pasado]" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index a1fbe86e..b561c360 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "در حال بررسی به‌روزرسانی‌ها...", "new_version_found": "نسخه جدید یافت شد", "site_legal_meta_license_text": "5chan نرم‌افزار آزاد و متن‌باز تحت GPL-3.0-or-later است.", - "community_offline_info": "این بورد ممکن است آفلاین باشد و انتشار ممکن است با شکست مواجه شود." + "community_offline_info": "این بورد ممکن است آفلاین باشد و انتشار ممکن است با شکست مواجه شود.", + "more_threads_last_week": "{{count}} موضوع از {{currentTimeFilterName}} — [<1>نمایش موضوعات بیشتر از هفته گذشته]", + "more_threads_last_month": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های ماه گذشته]", + "more_threads_last_year": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های سال گذشته]" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index a141f80e..939ca2eb 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Tarkistetaan päivityksiä...", "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ä.", - "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]", + "more_threads_last_month": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime kuun ketjuja]", + "more_threads_last_year": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime vuoden ketjuja]" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 9713cfb0..57c8fb57 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Sinusuri ang mga update...", "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.", - "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]", + "more_threads_last_month": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang buwan]", + "more_threads_last_year": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang taon]" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index a9449f89..41445e63 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Vérification des mises à jour...", "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.", - "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]", + "more_threads_last_month": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils du mois dernier]", + "more_threads_last_year": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de l'année dernière]" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 99d052c5..50172e03 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "בודק עדכונים...", "new_version_found": "נמצאה גרסה חדשה", "site_legal_meta_license_text": "5chan היא תוכנה חופשית וקוד פתוח תחת GPL-3.0-or-later.", - "community_offline_info": "הלוח הזה עשוי להיות לא מקוון והפרסום עלול להיכשל." + "community_offline_info": "הלוח הזה עשוי להיות לא מקוון והפרסום עלול להיכשל.", + "more_threads_last_week": "{{count}} אשכולות מאז {{currentTimeFilterName}} — [<1>הצג עוד אשכולות מהשבוע שעבר]", + "more_threads_last_month": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהחודש שעבר]", + "more_threads_last_year": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהשנה שעברה]" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 7be0faff..56da6e84 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "अपडेट जाँच रहा है...", "new_version_found": "नया संस्करण मिला", "site_legal_meta_license_text": "5chan GPL-3.0-or-later के तहत मुक्त और ओपन-सोर्स सॉफ़्टवेयर है।", - "community_offline_info": "यह बोर्ड ऑफ़लाइन हो सकता है और प्रकाशन विफल हो सकता है।" + "community_offline_info": "यह बोर्ड ऑफ़लाइन हो सकता है और प्रकाशन विफल हो सकता है।", + "more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} से — [<1>पिछले सप्ताह के और थ्रेड दिखाएं]", + "more_threads_last_month": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>अगले महीने के अधिक थ्रेड्स दिखाएं]", + "more_threads_last_year": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>पिछले साल के अधिक थ्रेड्स दिखाएं]" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 2f79ac0d..e77d4f63 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Frissítések keresése...", "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.", - "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]", + "more_threads_last_month": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt hónapból]", + "more_threads_last_year": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt évből]" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 57c6c0ff..961d371f 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Memeriksa pembaruan...", "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.", - "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]", + "more_threads_last_month": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari bulan lalu]", + "more_threads_last_year": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari tahun lalu]" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 4728c650..c62e6e7e 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Controllo aggiornamenti...", "new_version_found": "nuova versione trovata", "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]", + "more_threads_last_month": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread del mese scorso]", + "more_threads_last_year": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread dell'anno scorso]" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index e4221d30..1b389c5b 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "更新を確認しています...", "new_version_found": "新しいバージョンが見つかりました", "site_legal_meta_license_text": "5chanはGPL-3.0-or-laterのもとで提供される自由かつオープンソースのソフトウェアです。", - "community_offline_info": "このボードはオフラインの可能性があり、公開が失敗する可能性があります。" + "community_offline_info": "このボードはオフラインの可能性があり、公開が失敗する可能性があります。", + "more_threads_last_week": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先週のスレッドをさらに表示]", + "more_threads_last_month": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先月のスレッドをさらに表示]", + "more_threads_last_year": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>去年のスレッドをさらに表示]" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index fe0e66c8..310dc9d5 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "업데이트 확인 중...", "new_version_found": "새 버전을 찾았습니다", "site_legal_meta_license_text": "5chan은 GPL-3.0-or-later에 따른 자유 오픈 소스 소프트웨어입니다.", - "community_offline_info": "이 게시판이 오프라인일 수 있으며 게시가 실패할 수 있습니다." + "community_offline_info": "이 게시판이 오프라인일 수 있으며 게시가 실패할 수 있습니다.", + "more_threads_last_week": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 주의 더 많은 스레드 보기]", + "more_threads_last_month": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 달의 스레드 더 보기]", + "more_threads_last_year": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>작년의 스레드 더 보기]" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index ff996f0c..81a25f1c 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "अपडेट तपासत आहे...", "new_version_found": "नवीन आवृत्ती सापडली", "site_legal_meta_license_text": "5chan हे GPL-3.0-or-later अंतर्गत मुक्त आणि ओपन-सोर्स सॉफ्टवेअर आहे.", - "community_offline_info": "हे बोर्ड ऑफलाइन असू शकते आणि प्रकाशन अयशस्वी होऊ शकते." + "community_offline_info": "हे बोर्ड ऑफलाइन असू शकते आणि प्रकाशन अयशस्वी होऊ शकते.", + "more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} पासून — [<1>गेल्या आठवड्यातील अधिक थ्रेड्स पहा]", + "more_threads_last_month": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या महिन्यातील आणखी थ्रेड्स दाखवा]", + "more_threads_last_year": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या वर्षातील आणखी थ्रेड्स दाखवा]" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 1465d073..32908f46 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Controleren op updates...", "new_version_found": "nieuwe versie gevonden", "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]", + "more_threads_last_month": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen maand tonen]", + "more_threads_last_year": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen jaar tonen]" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index fd1b27b7..c580f9a0 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Sjekker etter oppdateringer...", "new_version_found": "ny versjon funnet", "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]", + "more_threads_last_month": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige måned]", + "more_threads_last_year": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige år]" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index ba5ed4b2..9d7f55c4 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Sprawdzanie aktualizacji...", "new_version_found": "znaleziono nową wersję", "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]", + "more_threads_last_month": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego miesiąca]", + "more_threads_last_year": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego roku]" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 956c915e..ad0050c5 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Verificando atualizações...", "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.", - "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]", + "more_threads_last_month": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do mês passado]", + "more_threads_last_year": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do ano passado]" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index c2881ebe..96e8dd07 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Se verifică actualizările...", "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.", - "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ă]", + "more_threads_last_month": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de luna trecută]", + "more_threads_last_year": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de anul trecut]" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 37074160..521a830f 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Проверка обновлений...", "new_version_found": "найдена новая версия", "site_legal_meta_license_text": "5chan — свободное программное обеспечение с открытым исходным кодом под GPL-3.0-or-later.", - "community_offline_info": "Эта доска может быть офлайн, и публикация может не удаться." + "community_offline_info": "Эта доска может быть офлайн, и публикация может не удаться.", + "more_threads_last_week": "{{count}} тредов с {{currentTimeFilterName}} — [<1>Показать больше тредов за прошлую неделю]", + "more_threads_last_month": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого месяца]", + "more_threads_last_year": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого года]" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 7eb2b51c..1dca147e 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Duke kontrolluar për përditësime...", "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.", - "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]", + "more_threads_last_month": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga muaji i kaluar]", + "more_threads_last_year": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga viti i kaluar]" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index ccdd1047..50c1d5a3 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Söker efter uppdateringar...", "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.", - "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]", + "more_threads_last_month": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra månaden]", + "more_threads_last_year": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra året]" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 8f6be4d1..804bce7b 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "నవీకరణలను తనిఖీ చేస్తోంది...", "new_version_found": "కొత్త వెర్షన్ కనుగొనబడింది", "site_legal_meta_license_text": "5chan GPL-3.0-or-later కింద ఉచిత మరియు ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.", - "community_offline_info": "ఈ బోర్డ్ ఆఫ్‌లైన్‌లో ఉండవచ్చు మరియు ప్రచురణ విఫలమవచ్చు." + "community_offline_info": "ఈ బోర్డ్ ఆఫ్‌లైన్‌లో ఉండవచ్చు మరియు ప్రచురణ విఫలమవచ్చు.", + "more_threads_last_week": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత వారానికి మరిన్ని థ్రెడ్‌లు చూపించు]", + "more_threads_last_month": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత నెల నుండి మరిన్ని థ్రెడ్‌లను చూపించండి]", + "more_threads_last_year": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత సంవత్సరం నుండి మరిన్ని థ్రెడ్‌లను చూపించండి]" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 5b1b033d..15a45ae5 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "กำลังตรวจสอบการอัปเดต...", "new_version_found": "พบเวอร์ชันใหม่", "site_legal_meta_license_text": "5chan เป็นซอฟต์แวร์เสรีและโอเพนซอร์สภายใต้ GPL-3.0-or-later", - "community_offline_info": "บอร์ดนี้อาจออฟไลน์และการเผยแพร่อาจล้มเหลว" + "community_offline_info": "บอร์ดนี้อาจออฟไลน์และการเผยแพร่อาจล้มเหลว", + "more_threads_last_week": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้เพิ่มเติมจากสัปดาห์ที่แล้ว]", + "more_threads_last_month": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากเดือนที่แล้วเพิ่มเติม]", + "more_threads_last_year": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากปีที่แล้วเพิ่มเติม]" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 91ed5354..8cde47ad 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Güncellemeler kontrol ediliyor...", "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.", - "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]", + "more_threads_last_month": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen aydan daha fazla konu göster]", + "more_threads_last_year": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen yıldan daha fazla konu göster]" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 59c9d364..d27e74cb 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Перевірка оновлень...", "new_version_found": "знайдено нову версію", "site_legal_meta_license_text": "5chan — це вільне програмне забезпечення з відкритим кодом за GPL-3.0-or-later.", - "community_offline_info": "Ця дошка може бути офлайн, і публікація може не вдатися." + "community_offline_info": "Ця дошка може бути офлайн, і публікація може не вдатися.", + "more_threads_last_week": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого тижня]", + "more_threads_last_month": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого місяця]", + "more_threads_last_year": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого року]" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 7d2b7491..d84fd1ce 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "اپڈیٹس چیک کیے جا رہے ہیں...", "new_version_found": "نیا ورژن ملا", "site_legal_meta_license_text": "5chan GPL-3.0-or-later کے تحت آزاد اور اوپن سورس سافٹ ویئر ہے۔", - "community_offline_info": "یہ بورڈ آف لائن ہو سکتا ہے اور اشاعت ناکام ہو سکتی ہے۔" + "community_offline_info": "یہ بورڈ آف لائن ہو سکتا ہے اور اشاعت ناکام ہو سکتی ہے۔", + "more_threads_last_week": "{{count}} تھریڈ {{currentTimeFilterName}} سے — [<1>گزشتہ ہفتے کے مزید تھریڈ دیکھیں]", + "more_threads_last_month": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>پچھلے ماہ کے مزید تھریڈز دکھائیں]", + "more_threads_last_year": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>گزشتہ سال کے مزید تھریڈز دکھائیں]" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 12480aac..25553ad0 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "Đang kiểm tra cập nhật...", "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.", - "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]", + "more_threads_last_month": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ tháng trước]", + "more_threads_last_year": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ năm ngoái]" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 6079ae1c..fe2308d6 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -324,5 +324,8 @@ "checking_for_updates": "正在检查更新...", "new_version_found": "发现新版本", "site_legal_meta_license_text": "5chan 是采用 GPL-3.0-or-later 的自由开源软件。", - "community_offline_info": "此看板可能处于离线状态,发布可能会失败。" + "community_offline_info": "此看板可能处于离线状态,发布可能会失败。", + "more_threads_last_week": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多来自上周的主题]", + "more_threads_last_month": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多上个月的主题]", + "more_threads_last_year": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多去年的主题]" } diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index fc12d1b8..31babc05 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -111,6 +111,7 @@ function makeNamedComponent(name: string) { vi.mock('../components/board-buttons', () => ({ DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'), + MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'), MobileBoardButtons: makeNamedComponent('mobile-board-buttons'), })); diff --git a/src/app.tsx b/src/app.tsx index 63268832..0f98e596 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -298,7 +298,7 @@ const App = () => { } /> }> - {/* Canonical multiboard routes (no time filter) */} + {/* Canonical multiboard routes (time filter lives in ?t=) */} diff --git a/src/components/board-buttons/__tests__/board-buttons.test.tsx b/src/components/board-buttons/__tests__/board-buttons.test.tsx index c1d7f429..be35c91d 100644 --- a/src/components/board-buttons/__tests__/board-buttons.test.tsx +++ b/src/components/board-buttons/__tests__/board-buttons.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons'; 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; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -184,7 +185,12 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string createElement( Routes, {}, + createElement(Route, { path: '/all', 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: '/:boardIdentifier/catalog', element }), createElement(Route, { path: '/:boardIdentifier/archive', element }), @@ -240,6 +246,8 @@ describe('BoardButtons', () => { testState.subscribed = false; testState.viewMode = 'compact'; useThreadLiveUpdatesStore.getState().resetState(); + clearStableLastVisitTimeFilterName(); + localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); Object.defineProperty(globalThis, 'alert', { configurable: true, value: vi.fn(), @@ -264,6 +272,8 @@ describe('BoardButtons', () => { afterEach(() => { act(() => root.unmount()); 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 () => { @@ -300,7 +310,7 @@ describe('BoardButtons', () => { it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => { 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('4'); @@ -309,21 +319,55 @@ describe('BoardButtons', () => { expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search'); const selects = Array.from(container.querySelectorAll('select')); - expect(selects).toHaveLength(4); + expect(selects).toHaveLength(5); await changeSelect(selects[0]!, 'replyCount'); await changeSelect(selects[1]!, 'Large'); await changeSelect(selects[2]!, 'On'); await changeSelect(selects[3]!, 'nsfw'); + await changeSelect(selects[4]!, '1w'); await clickButton('refresh'); expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount'); expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large'); expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true); expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw'); + expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=1w' }); 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('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('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 () => { testState.commentsByCid = { 'comment-1': { diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 04ed533b..666ff5c8 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -17,11 +17,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store' import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useIsMobile from '../../hooks/use-is-mobile'; +import useTimeFilter from '../../hooks/use-time-filter'; import CatalogFilters from '../catalog-filters'; import CatalogSearch from '../catalog-search'; import Tooltip from '../tooltip'; import { ModQueueButton } from '../../views/mod-queue/mod-queue'; 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 capitalize from 'lodash/capitalize'; @@ -35,14 +37,39 @@ interface BoardButtonsProps { isTopbar?: boolean; } +const getMultiboardPath = ({ + isInAllView, + isInCatalogView, + isInSubscriptionsView, + isInModView, +}: Pick) => { + 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) => { const { t } = useTranslation(); + const location = useLocation(); const directories = useDirectories(); + const { timeFilterValue } = useTimeFilter(); const createCatalogLink = () => { - if (isInAllView) return `/all/catalog`; - if (isInSubscriptionsView) return `/subs/catalog`; - if (isInModView) return `/mod/catalog`; + const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: true, isInSubscriptionsView, isInModView }); + if (multiboardPath) { + return { + pathname: multiboardPath, + search: getSearchWithTimeFilter(location.search, timeFilterValue), + }; + } let boardPath = ''; if (address) { boardPath = getBoardPath(address, directories); @@ -94,12 +121,12 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => { export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => { const { t } = useTranslation(); + const location = useLocation(); const params = useParams(); const directories = useDirectories(); + const { timeFilterValue } = useTimeFilter(); const createReturnLink = () => { - if (isInAllView) return `/all`; - if (isInSubscriptionsView) return `/subs`; if (isInModQueueView) { // If in mod queue view, return to /mod or /:boardIdentifier if (params?.boardIdentifier) { @@ -107,7 +134,13 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn } 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 = ''; if (address) { 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) => { + navigate({ + pathname: multiboardPath, + search: getSearchWithTimeFilter(location.search, event.target.value), + }); + }; + + return ( + <> + {!isTopbar && ( + <> + {t('filter')}:  + + )} + + + ); +}; + const AllFeedFilter = () => { const { t } = useTranslation(); const { filter, setFilter } = useAllFeedFilterStore(); @@ -395,6 +464,7 @@ export const MobileBoardButtons = () => { const { filteredCount, searchText } = useCatalogFiltersStore(); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const isMultiboard = isInAllView || isInSubscriptionsView || isInModView; + const showTimeFilter = isMultiboard; const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll; const showBottomButton = !effectiveInfiniteScroll; @@ -447,24 +517,24 @@ export const MobileBoardButtons = () => { ) )} - {isInAllView && ( + {(isInAllView || showTimeFilter || isInCatalogView) && ( <>
- -
- - )} - {isInCatalogView && ( - <> -
-
-
- -
-
- -
+ {(isInAllView || showTimeFilter) && ( +
+ {isInAllView && }{' '} + {showTimeFilter && ( + + )} +
+ )} + {isInCatalogView && ( +
+ + +
+ )}
)} @@ -479,6 +549,16 @@ export const MobileBoardButtons = () => { {!(isInAllView || isInSubscriptionsView || isInModView) && } {!(isInAllView || isInSubscriptionsView) && } + {showTimeFilter && ( + <> +
+
+
+ +
+
+ + )} )} @@ -550,6 +630,7 @@ export const DesktopBoardButtons = () => { const { filteredCount, searchText } = useCatalogFiltersStore(); const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); const isMultiboard = isInAllView || isInSubscriptionsView || isInModView; + const showTimeFilter = isMultiboard; const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll; const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll; @@ -653,6 +734,9 @@ export const DesktopBoardButtons = () => { )} {isInAllView && } + {showTimeFilter && ( + + )} {showVoteButton && ( <> [] @@ -693,19 +777,19 @@ const SearchOPsBar = () => { if (event.key === 'Enter') { const searchQuery = (event.target as HTMLInputElement).value.trim(); if (searchQuery) { - let catalogUrl = ''; + const params = new URLSearchParams(location.search); + params.set('q', searchQuery); + const search = `?${params.toString()}`; if (isInAllView) { - catalogUrl = `/all/catalog?q=${encodeURIComponent(searchQuery)}`; + navigate({ pathname: '/all/catalog', search }); } else if (isInSubscriptionsView) { - catalogUrl = `/subs/catalog?q=${encodeURIComponent(searchQuery)}`; + navigate({ pathname: '/subs/catalog', search }); } else if (isInModView) { - catalogUrl = `/mod/catalog?q=${encodeURIComponent(searchQuery)}`; + navigate({ pathname: '/mod/catalog', search }); } else { - catalogUrl = `/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`; + navigate(`/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`); } - - navigate(catalogUrl); } } }; diff --git a/src/components/feed-cache-container/feed-cache-container.tsx b/src/components/feed-cache-container/feed-cache-container.tsx index ee8ede66..8d6ab7c5 100644 --- a/src/components/feed-cache-container/feed-cache-container.tsx +++ b/src/components/feed-cache-container/feed-cache-container.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store'; import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils'; +import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils'; import Board from '../../views/board'; import Catalog from '../../views/catalog'; import styles from './feed-cache-container.module.css'; @@ -9,20 +10,23 @@ import styles from './feed-cache-container.module.css'; interface FeedContextFromKey { viewType: 'all' | 'subs' | 'mod' | 'board'; boardIdentifier?: string; + timeFilterName?: string; } 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'); if (filteredSegments[0] === 'all') { - return { viewType: 'all' }; + return { viewType: 'all', timeFilterName }; } if (filteredSegments[0] === 'subs') { - return { viewType: 'subs' }; + return { viewType: 'subs', timeFilterName }; } if (filteredSegments[0] === 'mod') { - return { viewType: 'mod' }; + return { viewType: 'mod', timeFilterName }; } return { @@ -42,9 +46,21 @@ const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => { return (
{feed.type === 'catalog' ? ( - + ) : ( - + )}
); @@ -54,7 +70,7 @@ const FeedCacheContainer = () => { const location = useLocation(); const { cachedFeeds, accessFeed } = useFeedCacheStore(); - const currentFeedKey = getFeedCacheKey(location.pathname); + const currentFeedKey = getFeedCacheKey(location.pathname, location.search); const isOnFeedRoute = isFeedRoute(location.pathname); const feedType = getFeedType(location.pathname); diff --git a/src/hooks/use-suggestion-feed-loader.ts b/src/hooks/use-suggestion-feed-loader.ts new file mode 100644 index 00000000..a1f08213 --- /dev/null +++ b/src/hooks/use-suggestion-feed-loader.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; + +interface UseSuggestionFeedLoaderOptions { + currentFeedLength: number; + feedLength: number; + hasMore: boolean; + loadMore: () => Promise; + 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]); +}; diff --git a/src/hooks/use-time-filter.ts b/src/hooks/use-time-filter.ts new file mode 100644 index 00000000..db27d936 --- /dev/null +++ b/src/hooks/use-time-filter.ts @@ -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; diff --git a/src/lib/utils/__tests__/route-utils.test.ts b/src/lib/utils/__tests__/route-utils.test.ts index 5df20727..211c38f9 100644 --- a/src/lib/utils/__tests__/route-utils.test.ts +++ b/src/lib/utils/__tests__/route-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { areSameBoardAddress, extractDirectoryFromTitle, @@ -20,6 +20,7 @@ import { normalizeMultiboardFeedPath, stripPageFromFeedPath, } from '../route-utils'; +import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY, touchLastVisitTimestamp } from '../time-filter-utils'; const communities = [ { address: 'business.eth', title: '/biz/ - Business & Finance' }, @@ -32,6 +33,11 @@ const communities = [ { address: 'random.eth', directoryCode: 'b', title: 'Random' }, ]; +beforeEach(() => { + clearStableLastVisitTimeFilterName(); + localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); +}); + describe('directory mapping helpers', () => { it('extracts short codes from titled directories', () => { 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/catalog/4')).toBe('/biz/catalog'); 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(); }); + 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', () => { expect(getFeedCacheKey('/pending/3')).toBeNull(); expect(getFeedCacheKey('/biz/mod/queue')).toBeNull(); diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index e203e71b..c36a9f76 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -1,4 +1,5 @@ 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") @@ -256,7 +257,7 @@ export const getPageFromFeedPath = (pathname: string): number => { 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; normalizedPath = normalizedPath.replace(/\/settings$/, ''); @@ -274,7 +275,12 @@ export const getFeedCacheKey = (pathname: string): string | null => { } 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; diff --git a/src/lib/utils/time-filter-utils.ts b/src/lib/utils/time-filter-utils.ts new file mode 100644 index 00000000..61534c19 --- /dev/null +++ b/src/lib/utils/time-filter-utils.ts @@ -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 = { + '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(); + + 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; +}; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index a5aaf657..06b18b7a 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -32,9 +33,11 @@ const testState = vi.hoisted(() => ({ }, } as Record }>, feed: [] as TestComment[], + feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>, feedStateString: 'syncing', filteredDirectoryAddresses: ['music-posting.eth'] as string[], hasMore: false, + respectPostsPerPageForNewerThan: new Set(), lastVirtuosoDefaultItemHeight: undefined as number | undefined, lastVirtuosoIncreaseViewportBy: 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', () => ({ + Trans: ({ components, i18nKey }: { components?: Record; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]), useTranslation: () => ({ t: (key: string) => key, }), @@ -90,18 +94,51 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi 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', () => ({ useAccount: () => testState.account, useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { testState.accountCommentsCalls.push(options); return { accountComments: getScopedAccountComments(options) }; }, - useFeed: () => ({ - feed: testState.feed, - hasMore: testState.hasMore, - loadMore: testState.loadMoreMock, - reset: testState.resetMock, - }), + useFeed: (options?: { + 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 { + feed: getScopedFeed(options), + hasMore: testState.hasMore, + loadMore: testState.loadMoreMock, + reset: testState.resetMock, + }; + }, useCommunity: () => testState.community, })); @@ -286,9 +323,11 @@ describe('Board', () => { }, }; testState.feed = []; + testState.feedOptionsCalls = []; testState.feedStateString = 'syncing'; testState.filteredDirectoryAddresses = ['music-posting.eth']; testState.hasMore = false; + testState.respectPostsPerPageForNewerThan = new Set(); testState.lastVirtuosoDefaultItemHeight = undefined; testState.lastVirtuosoIncreaseViewportBy = undefined; testState.lastVirtuosoMinOverscanItemCount = undefined; @@ -315,6 +354,8 @@ describe('Board', () => { testState.setEnableInfiniteScrollMock.mockReset(); testState.setResetFunctionMock.mockReset(); document.title = 'before'; + clearStableLastVisitTimeFilterName(); + localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn(), @@ -329,6 +370,8 @@ describe('Board', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + clearStableLastVisitTimeFilterName(); + localStorage.clear(); }); 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'); }); + 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 () => { testState.community = { error: new Error('board failed'), diff --git a/src/views/board/board.module.css b/src/views/board/board.module.css index 2d5d60a8..b42b279a 100644 --- a/src/views/board/board.module.css +++ b/src/views/board/board.module.css @@ -12,6 +12,11 @@ padding: 15px 0 20px 5px; } +.morePostsSuggestion { + color: var(--body-font-color); + padding-bottom: 5px; +} + .footer a { cursor: pointer; text-decoration: var(--button-text-decoration); @@ -47,4 +52,4 @@ border-image-source: url('/assets/garland.png'); border-style: solid; padding-top: 50px; -} \ No newline at end of file +} diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 38e2337d..f2abf70b 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate, useNavigationType, useParams } from 're import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks'; import { useCommunityField } from '../../hooks/use-stable-community'; 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 mobileFooterStyles from '../../components/footer/footer.module.css'; 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 { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; 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 { getPageFromFeedPath, getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-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 ErrorDisplay from '../../components/error-display/error-display'; import LoadingEllipsis from '../../components/loading-ellipsis'; @@ -32,6 +35,9 @@ import { Post } from '../post'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; 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. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; @@ -42,9 +48,12 @@ interface BoardFooterProps { communityAddresses: string[]; hasMore: boolean; combinedFeedLength: number; - isInAllView: boolean; isInSubscriptionsView: boolean; isInModView: boolean; + currentTimeFilterName: string; + moreThreadsSuggestion: TimeFilterSuggestion | null; + moreThreadsSuggestionPathname: string | null; + moreThreadsSuggestionSearch: string; communityState: string | undefined; subscriptionsLength: number; accountCommunityAddressesLength: number; @@ -59,9 +68,12 @@ const BoardFooter = ({ communityAddresses, hasMore, combinedFeedLength, - isInAllView, isInSubscriptionsView, isInModView, + currentTimeFilterName, + moreThreadsSuggestion, + moreThreadsSuggestionPathname, + moreThreadsSuggestionSearch, communityState, subscriptionsLength, accountCommunityAddressesLength, @@ -72,10 +84,26 @@ const BoardFooter = ({ const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); let footerContent; - if (combinedFeedLength === 0) { + if (moreThreadsSuggestion && moreThreadsSuggestionPathname) { + footerContent = ( +
+ + ), + }} + /> +
+ ); + } else if (combinedFeedLength === 0) { footerContent = t('no_threads'); } - if (hasMore || (communityAddresses && communityAddresses.length === 0)) { + if (communityAddresses && communityAddresses.length === 0) { footerContent = null; } return ( @@ -100,16 +128,20 @@ export interface BoardProps { feedCacheKey?: string; viewType?: 'all' | 'subs' | 'mod' | 'board'; boardIdentifier?: string; + timeFilterNameFromCache?: string; 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 location = useLocation(); const params = useParams(); const isInAllView = viewType ? viewType === 'all' : false; const isInSubscriptionsView = viewType ? viewType === 'subs' : 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 resolvedAddressFromUrl = useResolvedCommunityAddress(); @@ -164,11 +196,76 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i sortType: BOARD_SORT_TYPE, postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage, filter: excludeArchivedFilter, + newerThan: multiboardTimeFilterSeconds, }), - [communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter], + [communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter, multiboardTimeFilterSeconds], ); 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( () => communityAddress @@ -230,6 +327,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i () => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, 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 totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]); const currentPageFeed = useMemo( @@ -238,7 +340,6 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i ); const navigate = useNavigate(); - const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView; const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size'; const feedVirtualizationMode = useMemo( () => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode), @@ -305,9 +406,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={combinedFeed.length} - isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} + currentTimeFilterName={timeFilterName} + moreThreadsSuggestion={moreThreadsSuggestion} + moreThreadsSuggestionPathname={moreThreadsSuggestionPathname} + moreThreadsSuggestionSearch={location.search} communityState={communityState} subscriptionsLength={subscriptions?.length || 0} accountCommunityAddressesLength={accountCommunityAddresses?.length || 0} @@ -380,9 +484,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i communityAddresses, hasMore, combinedFeed.length, - isInAllView, isInSubscriptionsView, isInModView, + timeFilterName, + moreThreadsSuggestion, + moreThreadsSuggestionPathname, communityState, communityAddress, subscriptions?.length, @@ -400,7 +506,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i ); const virtuosoRef = useRef(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 boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 }; const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined; diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index ebc221d1..2f0a2641 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -35,6 +36,7 @@ const testState = vi.hoisted(() => ({ account: { subscriptions: [] as string[] }, accountComments: [] as TestComment[], accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>, + accountCommunityAddresses: [] as string[], directoryByAddress: { 'music-posting.eth': { address: 'music-posting.eth', @@ -43,7 +45,7 @@ const testState = vi.hoisted(() => ({ } as Record }>, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, feed: [] as TestComment[], - feedOptionsCalls: [] as Array<{ postsPerPage?: number }>, + feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>, filterItems: [] as FilterItem[], filteredDirectoryAddresses: ['music-posting.eth'] as string[], hasMore: false, @@ -55,6 +57,7 @@ const testState = vi.hoisted(() => ({ maxGuiPages: 3, paginationFeedPostsPerPage: 6, }, + respectPostsPerPageForNewerThan: new Set(), resetMock: vi.fn(), resolvedCommunityAddress: 'music-posting.eth' as string | undefined, searchText: '', @@ -89,6 +92,7 @@ function useCatalogFiltersStoreMock(selector?: (state: ReturnType ({ + Trans: ({ components, i18nKey }: { components?: Record; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]), useTranslation: () => ({ t: (key: string) => key, }), @@ -116,16 +120,46 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi 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', () => ({ useAccount: () => testState.account, useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { testState.accountCommentsCalls.push(options); return { accountComments: getScopedAccountComments(options) }; }, - useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean }; postsPerPage?: number }) => { - testState.feedOptionsCalls.push({ postsPerPage: options.postsPerPage }); + useFeed: (options: { + 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 { - feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed, + feed: getScopedFeed(options), hasMore: testState.hasMore, loadMore: testState.loadMoreMock, reset: testState.resetMock, @@ -179,6 +213,10 @@ vi.mock('../../../hooks/use-board-feed-page-size', () => ({ useBoardFeedPageSize: () => testState.pageSizes, })); +vi.mock('../../../hooks/use-account-community-addresses', () => ({ + useAccountCommunityAddresses: () => testState.accountCommunityAddresses, +})); + vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, })); @@ -264,8 +302,8 @@ let root: Root; const LocationProbe = () => { const location = useLocation(); React.useLayoutEffect(() => { - latestLocation = location.pathname; - }, [location.pathname]); + latestLocation = `${location.pathname}${location.search}`; + }, [location.pathname, location.search]); return null; }; @@ -305,6 +343,7 @@ describe('Catalog', () => { testState.account = { subscriptions: [] }; testState.accountComments = []; testState.accountCommentsCalls = []; + testState.accountCommunityAddresses = []; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.directoryByAddress = { 'music-posting.eth': { @@ -323,6 +362,7 @@ describe('Catalog', () => { maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; + testState.respectPostsPerPageForNewerThan = new Set(); testState.resolvedCommunityAddress = 'music-posting.eth'; testState.searchText = ''; testState.showOPComment = true; @@ -341,6 +381,8 @@ describe('Catalog', () => { testState.setCurrentCommunityAddressMock.mockReset(); testState.setResetFunctionMock.mockReset(); document.title = 'before'; + clearStableLastVisitTimeFilterName(); + localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); container = document.createElement('div'); document.body.appendChild(container); @@ -350,6 +392,8 @@ describe('Catalog', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + clearStableLastVisitTimeFilterName(); + localStorage.clear(); }); it('applies catalog filters and promotes top matches', async () => { @@ -408,13 +452,109 @@ describe('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 () => { container.querySelector('[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 () => { diff --git a/src/views/catalog/catalog.module.css b/src/views/catalog/catalog.module.css index 301b8703..98a43348 100644 --- a/src/views/catalog/catalog.module.css +++ b/src/views/catalog/catalog.module.css @@ -3,6 +3,10 @@ color: var(--post-mobile-abbr-text-color); } +.morePostsSuggestion { + padding-bottom: 5px; +} + .footer a { text-decoration: var(--button-text-decoration); color: var(--button-desktop-text-color); @@ -16,4 +20,4 @@ .footer { padding-left: 5px; } -} \ No newline at end of file +} diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 7204cff6..ba453028 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -1,14 +1,17 @@ import { useDeferredValue, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; -import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; +import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; +import { Trans, useTranslation } from 'react-i18next'; import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers'; 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 { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; 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 useWindowWidth from '../../hooks/use-window-width'; 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 { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; +import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils'; import { getCatalogRowHeightEstimates, getPretextItemSizeFromElement, @@ -37,6 +41,9 @@ import { const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; 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. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; export const getCatalogRenderFeed = (processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] => @@ -46,6 +53,10 @@ interface CatalogFooterProps { communityAddresses: string[]; hasMore: boolean; combinedFeedLength: number; + currentTimeFilterName: string; + moreThreadsSuggestion: TimeFilterSuggestion | null; + moreThreadsSuggestionPathname: string | null; + moreThreadsSuggestionSearch: string; /** When false, suppress the loading ellipsis (e.g. non-infinite mode) */ showLoadingEllipsis?: boolean; } @@ -53,18 +64,44 @@ interface CatalogFooterProps { // 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 // 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 loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts')); let footerContent; - if (combinedFeedLength === 0) { + if (moreThreadsSuggestion && moreThreadsSuggestionPathname) { + footerContent = ( +
+ + ), + }} + /> +
+ ); + } else if (combinedFeedLength === 0) { footerContent = t('no_threads'); } if (hasMore || (communityAddresses && communityAddresses.length === 0)) { footerContent = ( <> + {footerContent} {showLoadingEllipsis && (
@@ -113,6 +150,7 @@ const createContentFilter = ( filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean; color?: string }[], communityAddress: string, onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void, + trackMatches = true, ) => { // Create a unique key based on the enabled filter items const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== ''); @@ -135,7 +173,7 @@ const createContentFilter = ( if (commentMatchesPattern(comment, pattern)) { // Find the original filter index to increment count const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); - if (filterIndex !== -1) { + if (trackMatches && filterIndex !== -1) { if (onFilterMatch) { onFilterMatch(filterIndex, comment.cid, communityAddress); } else { @@ -165,8 +203,9 @@ const createCombinedFilter = ( searchText: string, communityAddress: string, 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 = { filter: (comment: Comment) => { @@ -192,10 +231,11 @@ export interface CatalogProps { feedCacheKey?: string; viewType?: 'all' | 'subs' | 'mod' | 'board'; boardIdentifier?: string; + timeFilterNameFromCache?: string; 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 location = useLocation(); const navigate = useNavigate(); @@ -206,6 +246,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const isInModView = viewType ? viewType === 'mod' : false; 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) const effectiveInfiniteScroll = isMultiboard; @@ -222,6 +264,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const account = useAccount(); const subscriptions = account?.subscriptions; + const accountCommunityAddresses = useAccountCommunityAddresses(); const filteredDirectoryAddresses = useFilteredDirectoryAddresses(); const communityAddresses = useMemo(() => { @@ -231,9 +274,12 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, if (isInSubscriptionsView) { return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values } + if (isInModView) { + return accountCommunityAddresses; + } // Only include communityAddress if it's defined return communityAddress ? [communityAddress] : []; - }, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]); + }, [accountCommunityAddresses, isInAllView, isInSubscriptionsView, isInModView, communityAddress, filteredDirectoryAddresses, subscriptions]); const communities = useCommunityIdentifiers(communityAddresses); const communityIdentifier = useCommunityIdentifier(communityAddress); @@ -252,9 +298,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, if (!(isInAllView || isInSubscriptionsView || isInModView)) return; const canonical = normalizeMultiboardFeedPath(location.pathname); 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 feedSortType = sortType === 'new' ? 'new' : 'active'; @@ -280,10 +326,90 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, sortType: feedSortType, postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage, 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 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( () => communityAddress @@ -346,6 +472,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, () => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, 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]); @@ -373,7 +504,16 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, () => ({ Footer: () => ( <> - + ), }), - [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( () => ( <> - + ), - [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'; @@ -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 virtuosoRef = useRef(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 hasBeenVisibleRef = useRef(false);