From 60d2f6804d6f045292c18cba5573a69ac569f291 Mon Sep 17 00:00:00 2001 From: plebeius Date: Fri, 20 Feb 2026 20:16:11 +0800 Subject: [PATCH] feat(board): default pagination with optional infinite scroll and URL-based page routing --- 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/app.tsx | 4 + .../board-pagination.module.css | 36 +++++ .../board-pagination/board-pagination.tsx | 61 ++++++++ src/components/board-pagination/index.ts | 2 + .../__tests__/interface-settings.test.tsx | 102 +++++++++++++ .../interface-settings/interface-settings.tsx | 8 + src/data/5chan-directories.json | 60 +++++--- src/hooks/use-board-feed-page-size.ts | 20 +++ src/hooks/use-directories.ts | 1 + src/hooks/use-time-filter.ts | 6 + .../__tests__/board-feed-pagination.test.ts | 105 +++++++++++++ src/lib/utils/board-feed-pagination.ts | 49 ++++++ src/lib/utils/route-utils.ts | 32 +++- .../use-feed-view-settings-store.test.ts | 36 +++++ src/stores/use-feed-view-settings-store.ts | 21 +++ src/views/board/board.tsx | 139 ++++++++++++++---- 51 files changed, 775 insertions(+), 82 deletions(-) create mode 100644 src/components/board-pagination/board-pagination.module.css create mode 100644 src/components/board-pagination/board-pagination.tsx create mode 100644 src/components/board-pagination/index.ts create mode 100644 src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx create mode 100644 src/hooks/use-board-feed-page-size.ts create mode 100644 src/lib/utils/__tests__/board-feed-pagination.test.ts create mode 100644 src/lib/utils/board-feed-pagination.ts create mode 100644 src/stores/__tests__/use-feed-view-settings-store.test.ts create mode 100644 src/stores/use-feed-view-settings-store.ts diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 559dc6a8..3ededc42 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "رفع الوسائط غير مدعوم من متصفحك بسبب قيود تقنية (CORS). إنه مدعوم في تطبيق 5chan لـ Android وتطبيق سطح المكتب لـ Mac/Linux/Windows.", "upload_not_supported_web_link_text": "تحميل من صفحة الإصدار الرسمية", "board_subscriptions": "اشتراكات اللوحات", - "chain_ticker": "رمز السلسلة" + "chain_ticker": "رمز السلسلة", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index e14ddb2a..367dbc0f 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "প্রযুক্তিগত সীমাবদ্ধতার কারণে (CORS) আপনার ব্রাউজার মিডিয়া আপলোড সমর্থন করে না। এটি 5chan অ্যান্ড্রয়েড অ্যাপ এবং Mac/Linux/Windows ডেস্কটপ অ্যাপে সমর্থিত।", "upload_not_supported_web_link_text": "অফিসিয়াল রিলিজ পৃষ্ঠা থেকে ডাউনলোড করুন", "board_subscriptions": "বোর্ড সাবস্ক্রিপশন", - "chain_ticker": "চেইন টিকার" + "chain_ticker": "চেইন টিকার", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index f90ba2b6..952eac78 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Váš prohlížeč z důvodu technických omezení (CORS) nepodporuje nahrávání médií. Je podporováno v aplikaci 5chan pro Android a desktopové aplikaci pro Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Stáhnout z oficiální stránky vydání", "board_subscriptions": "Odběry desek", - "chain_ticker": "ticker řetězce" + "chain_ticker": "ticker řetězce", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 586311ad..3811a6b6 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Din browser understøtter ikke upload af medier på grund af tekniske begrænsninger (CORS). Det understøttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.", "upload_not_supported_web_link_text": "Download fra den officielle udgivelsesside", "board_subscriptions": "Bordabonnementer", - "chain_ticker": "kæde-ticker" + "chain_ticker": "kæde-ticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 1c372dba..69b8a163 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Das Hochladen von Medien wird von Ihrem Browser aufgrund technischer Einschränkungen (CORS) nicht unterstützt. Es wird in der 5chan-Android-App und der Mac/Linux/Windows-Desktop-App unterstützt.", "upload_not_supported_web_link_text": "Von der offiziellen Release-Seite herunterladen", "board_subscriptions": "Board-Abonnements", - "chain_ticker": "Chain-Ticker" + "chain_ticker": "Chain-Ticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 8b7f9192..b17de5c5 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Η μεταφόρτωση πολυμέσων δεν υποστηρίζεται από το πρόγραμμα περιήγησής σας λόγω τεχνικών περιορισμών (CORS). Υποστηρίζεται στην εφαρμογή 5chan για Android και την εφαρμογή επιφάνειας εργασίας Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Λήψη από τη σελίδα επίσημης έκδοσης", "board_subscriptions": "Συνδρομές σε πίνακες", - "chain_ticker": "σύμβολο αλυσίδας" + "chain_ticker": "σύμβολο αλυσίδας", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index f46e9769..200d5870 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -280,5 +280,8 @@ "media_hosting_preferred": "Preferred provider", "media_hosting_preferred_provider_label": "Choose provider", "media_hosting_preferred_failure_guidance": "If your preferred provider fails, the app will try other providers (Random mode) or show an error (Preferred mode).", - "media_hosting_all_providers_failed_guidance": "All upload providers failed. Try again or paste a link manually." + "media_hosting_all_providers_failed_guidance": "All upload providers failed. Try again or paste a link manually.", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index 8f58f122..f9ca40a2 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "La subida de medios no está soportada por tu navegador debido a limitaciones técnicas (CORS). Sí está soportada en la app 5chan para Android y la app de escritorio para Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Descargar desde la página de release oficial", "board_subscriptions": "Suscripciones a tableros", - "chain_ticker": "ticker de cadena" + "chain_ticker": "ticker de cadena", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index e12ad2c8..cc53259d 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "آپلود رسانه به دلیل محدودیت‌های فنی (CORS) توسط مرورگر شما پشتیبانی نمی‌شود. در اپلیکیشن 5chan اندروید و اپلیکیشن دسکتاپ Mac/Linux/Windows پشتیبانی می‌شود.", "upload_not_supported_web_link_text": "دانلود از صفحه انتشار رسمی", "board_subscriptions": "اشتراک‌های تابلو", - "chain_ticker": "نماد زنجیره" + "chain_ticker": "نماد زنجیره", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 7bac009b..751edfbd 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Medioiden lataus ei ole tuettu selaimessasi teknisten rajoitusten vuoksi (CORS). Se on tuettu 5chan Android-sovelluksessa ja Mac/Linux/Windows-työpöytäsovelluksessa.", "upload_not_supported_web_link_text": "Lataa viralliselta julkaisusivulta", "board_subscriptions": "Alustojen tilaukset", - "chain_ticker": "ketjumerkki" + "chain_ticker": "ketjumerkki", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index b0d59c8c..4ce66f5d 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Ang pag-upload ng media ay hindi sinusuportahan ng iyong browser dahil sa mga teknikal na limitasyon (CORS). Ito ay sinusuportahan sa 5chan Android app at Mac/Linux/Windows desktop app.", "upload_not_supported_web_link_text": "I-download mula sa opisyal na release page", "board_subscriptions": "Mga subscription sa board", - "chain_ticker": "chain ticker" + "chain_ticker": "chain ticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 91621b1e..b823c651 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Le téléchargement de médias n'est pas pris en charge par votre navigateur en raison de limitations techniques (CORS). Il est pris en charge dans l'application 5chan Android et l'application de bureau Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Télécharger depuis la page de version officielle", "board_subscriptions": "Abonnements aux tableaux", - "chain_ticker": "ticker de chaîne" + "chain_ticker": "ticker de chaîne", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 6f0c048a..ec0e3e95 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "העלאת מדיה אינה נתמכת על ידי הדפדפן שלך בשל מגבלות טכניות (CORS). זה נתמך באפליקציית 5chan לאנדרואיד ובאפליקציית הדסקטופ ל-Mac/Linux/Windows.", "upload_not_supported_web_link_text": "הורד מהדף השחרור הרשמי", "board_subscriptions": "מנויים ללוחות", - "chain_ticker": "טיקר שרשרת" + "chain_ticker": "טיקר שרשרת", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 210fda63..e5e9ab87 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "तकनीकी सीमाओं (CORS) के कारण आपका ब्राउज़र मीडिया अपलोड का समर्थन नहीं करता। यह 5chan एंड्रॉइड ऐप और Mac/Linux/Windows डेस्कटॉप ऐप में समर्थित है।", "upload_not_supported_web_link_text": "आधिकारिक रिलीज़ पेज से डाउनलोड करें", "board_subscriptions": "बोर्ड सदस्यता", - "chain_ticker": "श्रृंखला टिकर" + "chain_ticker": "श्रृंखला टिकर", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 1e651a78..462ef96f 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "A böngésző nem támogatja a média feltöltését technikai korlátozások miatt (CORS). A 5chan Android alkalmazásban és a Mac/Linux/Windows asztali alkalmazásban támogatott.", "upload_not_supported_web_link_text": "Letöltés a hivatalos kiadási oldalról", "board_subscriptions": "Fórum feliratkozások", - "chain_ticker": "lánc ticker" + "chain_ticker": "lánc ticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index a9595bc7..6d3e060d 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Mengunggah media tidak didukung oleh browser Anda karena batasan teknis (CORS). Didukung di aplikasi 5chan Android dan aplikasi desktop Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Unduh dari halaman rilis resmi", "board_subscriptions": "Langganan papan", - "chain_ticker": "ticker rantai" + "chain_ticker": "ticker rantai", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index fae8f4c5..da1a60f7 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Il caricamento di file multimediali non è supportato dal tuo browser a causa di limitazioni tecniche (CORS). È supportato nell'app 5chan per Android e nell'app desktop per Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Scarica dalla pagina di release ufficiale", "board_subscriptions": "Iscrizioni alle bacheche", - "chain_ticker": "ticker di catena" + "chain_ticker": "ticker di catena", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 99f62f64..c836c45d 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "技術的な制限(CORS)のため、お使いのブラウザではメディアのアップロードがサポートされていません。5chan AndroidアプリとMac/Linux/Windowsデスクトップアプリではサポートされています。", "upload_not_supported_web_link_text": "公式リリースページからダウンロード", "board_subscriptions": "掲示板の購読", - "chain_ticker": "チェーンチッカー" + "chain_ticker": "チェーンチッカー", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 5d059ec3..2f58b29d 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "기술적 제한(CORS)으로 인해 브라우저에서는 미디어 업로드를 지원하지 않습니다. 5chan Android 앱과 Mac/Linux/Windows 데스크톱 앱에서는 지원됩니다.", "upload_not_supported_web_link_text": "공식 출시 페이지에서 다운로드", "board_subscriptions": "보드 구독", - "chain_ticker": "체인 티커" + "chain_ticker": "체인 티커", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 5a583544..a7bafe28 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "तांत्रिक मर्यादांमुळे (CORS) तुमचा ब्राउझर मीडिया अपलोड समर्थित नाही. ते 5chan Android अॅप आणि Mac/Linux/Windows डेस्कटॉप अॅपमध्ये समर्थित आहे.", "upload_not_supported_web_link_text": "अधिकृत रिलीझ पेजवरून डाउनलोड करा", "board_subscriptions": "बोर्ड सदस्यता", - "chain_ticker": "चेन टिकर" + "chain_ticker": "चेन टिकर", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 7051fc82..7e287e0d 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Mediabestanden uploaden wordt niet ondersteund door uw browser vanwege technische beperkingen (CORS). Het wordt ondersteund in de 5chan Android-app en de Mac/Linux/Windows desktop-app.", "upload_not_supported_web_link_text": "Downloaden van de officiële releasepagina", "board_subscriptions": "Boardabonnementen", - "chain_ticker": "kettingticker" + "chain_ticker": "kettingticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 3e5c5232..29568210 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Opplasting av medier støttes ikke av nettleseren din på grunn av tekniske begrensninger (CORS). Det støttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.", "upload_not_supported_web_link_text": "Last ned fra den offisielle utgivelsessiden", "board_subscriptions": "Brettabonnementer", - "chain_ticker": "kjede-ticker" + "chain_ticker": "kjede-ticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index cb39098a..04575057 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Przesyłanie multimediów nie jest obsługiwane przez Twoją przeglądarkę z powodu ograniczeń technicznych (CORS). Jest obsługiwane w aplikacji 5chan na Androida i aplikacji komputerowej Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Pobierz ze strony oficjalnego wydania", "board_subscriptions": "Subskrypcje tablic", - "chain_ticker": "ticker łańcucha" + "chain_ticker": "ticker łańcucha", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 1e0d893f..82af7413 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "O envio de mídia não é suportado pelo seu navegador devido a limitações técnicas (CORS). É suportado no aplicativo 5chan para Android e no aplicativo de desktop Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Baixar da página de lançamento oficial", "board_subscriptions": "Assinaturas de quadros", - "chain_ticker": "ticker da cadeia" + "chain_ticker": "ticker da cadeia", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 8741e85c..f89cba0f 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Încărcarea mediilor nu este acceptată de browserul dvs. din cauza limitărilor tehnice (CORS). Este acceptată în aplicația 5chan pentru Android și aplicația de desktop Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Descărcare de pe pagina de lansare oficială", "board_subscriptions": "Abonamente la panouri", - "chain_ticker": "ticker lanț" + "chain_ticker": "ticker lanț", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 8cd7aa69..01654a67 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Загрузка медиа не поддерживается вашим браузером из-за технических ограничений (CORS). Поддерживается в приложении 5chan для Android и десктопном приложении для Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Скачать со страницы официального релиза", "board_subscriptions": "Подписки на доски", - "chain_ticker": "тикер блокчейна" + "chain_ticker": "тикер блокчейна", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 91210760..5a790081 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Ngarkimi i mediave nuk mbështetet nga shfletuesi juaj për shkak të kufizimeve teknike (CORS). Mbështetet në aplikacionin 5chan për Android dhe aplikacionin e desktopit Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Shkarkoni nga faqja e zyrës së publikimit", "board_subscriptions": "Abonime në tabela", - "chain_ticker": "simboli i zinxhirit" + "chain_ticker": "simboli i zinxhirit", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 5e9c6150..49af9457 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Uppladdning av media stöds inte av din webbläsare på grund av tekniska begränsningar (CORS). Det stöds i 5chan Android-appen och Mac/Linux/Windows skrivbordsappen.", "upload_not_supported_web_link_text": "Ladda ner från den officiella utgivningssidan", "board_subscriptions": "Brädeabonnemang", - "chain_ticker": "kedjeticker" + "chain_ticker": "kedjeticker", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index b3c83b56..0ce46b9f 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "సాంకేతిక పరిమితుల కారణంగా (CORS) మీ బ్రౌజర్ మీడియా అప్‌లోడ్‌ను మద్దతు ఇవ్వదు. ఇది 5chan Android యాప్ మరియు Mac/Linux/Windows డెస్క్‌టాప్ యాప్‌లో మద్దతు ఇస్తుంది.", "upload_not_supported_web_link_text": "అధికారిక రిలీజ్ పేజీ నుండి డౌన్‌లోడ్ చేయండి", "board_subscriptions": "బోర్డ్ సబ్‌స్క్రిప్షన్లు", - "chain_ticker": "చైన్ టికర్" + "chain_ticker": "చైన్ టికర్", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index e10c4be3..fcb74709 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "การอัปโหลดสื่อไม่รองรับโดยเบราว์เซอร์ของคุณเนื่องจากข้อจำกัดทางเทคนิค (CORS) รองรับในแอป 5chan สำหรับ Android และแอปเดสก์ท็อป Mac/Linux/Windows", "upload_not_supported_web_link_text": "ดาวน์โหลดจากหน้าปล่อยรุ่นอย่างเป็นทางการ", "board_subscriptions": "การสมัครสมาชิกบอร์ด", - "chain_ticker": "ไทเกอร์เชน" + "chain_ticker": "ไทเกอร์เชน", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 4d52c6bf..2c3d1a2d 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Teknik kısıtlamalar (CORS) nedeniyle medya yükleme tarayıcınız tarafından desteklenmiyor. 5chan Android uygulaması ve Mac/Linux/Windows masaüstü uygulamasında desteklenmektedir.", "upload_not_supported_web_link_text": "Resmi sürüm sayfasından indir", "board_subscriptions": "Pano abonelikleri", - "chain_ticker": "zincir ticker'ı" + "chain_ticker": "zincir ticker'ı", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 7bb3f2f9..6f5ad7a8 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Завантаження медіа не підтримується вашим браузером через технічні обмеження (CORS). Підтримується в додатку 5chan для Android та десктопному додатку для Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Завантажити з офіційної сторінки випуску", "board_subscriptions": "Підписки на дошки", - "chain_ticker": "тикер ланцюга" + "chain_ticker": "тикер ланцюга", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index c8fc18dc..2000348a 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "تکنیکی پابندیوں (CORS) کی وجہ سے آپ کے براؤزر میڈیا اپ لوڈ کی حمایت نہیں کرتا۔ یہ 5chan Android ایپ اور Mac/Linux/Windows ڈیسک ٹاپ ایپ میں تعاون یافتہ ہے۔", "upload_not_supported_web_link_text": "سرکاری ریلیز صفحے سے ڈاؤن لوڈ کریں", "board_subscriptions": "بورڈ سبسکرپشنز", - "chain_ticker": "چین ٹکر" + "chain_ticker": "چین ٹکر", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 37d4c523..04e79c40 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "Tải lên phương tiện không được trình duyệt của bạn hỗ trợ do hạn chế kỹ thuật (CORS). Được hỗ trợ trong ứng dụng 5chan Android và ứng dụng desktop Mac/Linux/Windows.", "upload_not_supported_web_link_text": "Tải xuống từ trang phát hành chính thức", "board_subscriptions": "Đăng ký bảng", - "chain_ticker": "mã chuỗi" + "chain_ticker": "mã chuỗi", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 063ea0e0..cfd5485c 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -280,5 +280,8 @@ "upload_not_supported_web_before_link": "由于技术限制(CORS),您的浏览器不支持上传媒体。在 5chan Android 应用和 Mac/Linux/Windows 桌面应用中支持。", "upload_not_supported_web_link_text": "从官方发布页面下载", "board_subscriptions": "板块订阅", - "chain_ticker": "链代币符号" + "chain_ticker": "链代币符号", + "enable_infinite_scroll": "Enable infinite scroll", + "prev": "Prev", + "next": "Next" } diff --git a/src/app.tsx b/src/app.tsx index 0dffccaf..8a56bf2b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -196,16 +196,19 @@ const App = () => ( } /> } /> }> + + + @@ -214,6 +217,7 @@ const App = () => ( } /> } /> + diff --git a/src/components/board-pagination/board-pagination.module.css b/src/components/board-pagination/board-pagination.module.css new file mode 100644 index 00000000..24b35289 --- /dev/null +++ b/src/components/board-pagination/board-pagination.module.css @@ -0,0 +1,36 @@ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 0 16px; + flex-wrap: wrap; +} + +.paginationButton { + min-width: 32px; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + text-decoration: var(--button-text-decoration); + color: var(--button-desktop-text-color); + background: transparent; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; +} + +.paginationButton:hover:not(:disabled) { + color: var(--button-desktop-text-color-hover); + cursor: pointer; +} + +.paginationButton:disabled, +.paginationButton.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pageButtonActive { + font-weight: bold; + color: var(--button-desktop-text-color-hover); +} diff --git a/src/components/board-pagination/board-pagination.tsx b/src/components/board-pagination/board-pagination.tsx new file mode 100644 index 00000000..89c33a4a --- /dev/null +++ b/src/components/board-pagination/board-pagination.tsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import styles from './board-pagination.module.css'; + +export interface BoardPaginationProps { + basePath: string; + currentPage: number; + totalPages: number; +} + +const BoardPagination = ({ basePath, currentPage, totalPages }: BoardPaginationProps) => { + const { t } = useTranslation(); + + const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`); + const prevHref = currentPage > 1 ? pageHref(currentPage - 1) : undefined; + const nextHref = currentPage < totalPages ? pageHref(currentPage + 1) : undefined; + + if (totalPages <= 1) { + return null; + } + + const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + + return ( +
+ {prevHref ? ( + + {t('prev')} + + ) : ( + + {t('prev')} + + )} + {pageNumbers.map((page) => { + const href = pageHref(page); + const isCurrent = page === currentPage; + return isCurrent ? ( + + {page} + + ) : ( + + {page} + + ); + })} + {nextHref ? ( + + {t('next')} + + ) : ( + + {t('next')} + + )} +
+ ); +}; + +export default BoardPagination; diff --git a/src/components/board-pagination/index.ts b/src/components/board-pagination/index.ts new file mode 100644 index 00000000..8d2e13ba --- /dev/null +++ b/src/components/board-pagination/index.ts @@ -0,0 +1,2 @@ +export { default } from './board-pagination'; +export type { BoardPaginationProps } from './board-pagination'; diff --git a/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx b/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx new file mode 100644 index 00000000..ecb84f45 --- /dev/null +++ b/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import InterfaceSettings from '../interface-settings'; +import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn(), language: 'en' }, + }), +})); + +vi.mock('../../../../hooks/use-theme', () => ({ + default: () => ['yotsuba', vi.fn()], +})); + +vi.mock('../../../../stores/use-expanded-media-store', () => ({ + default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }), +})); + +vi.mock('../../../../stores/use-special-theme-store', () => ({ + default: () => ({ isEnabled: false, setIsEnabled: vi.fn() }), +})); + +vi.mock('../../../../lib/utils/time-utils', () => ({ + isChristmas: () => false, +})); + +vi.mock('../../version', () => ({ + default: () => null, +})); + +/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */ +const BoardModeIndicator = () => { + const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); + return {enableInfiniteScroll ? 'infinite' : 'pagination'}; +}; + +const STORAGE_KEY = 'feed-view-settings-store'; + +let root: Root; +let container: HTMLDivElement; + +const render = (children: React.ReactNode) => { + act(() => { + root.render(createElement(MemoryRouter, {}, children)); + }); +}; + +describe('InterfaceSettings', () => { + let setItemSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.removeItem(STORAGE_KEY); + useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false); + setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + setItemSpy.mockRestore(); + }); + + it('renders enable_infinite_scroll checkbox unchecked by default', () => { + render(createElement(InterfaceSettings)); + const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll')); + expect(label).toBeTruthy(); + const checkbox = label?.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeTruthy(); + expect(checkbox?.checked).toBe(false); + }); + + it('toggling checkbox updates persisted state and re-renders board mode', async () => { + render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator))); + + const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll')); + const checkbox = label?.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeTruthy(); + + expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('pagination'); + + await act(async () => { + checkbox?.click(); + }); + + expect(useFeedViewSettingsStore.getState().enableInfiniteScroll).toBe(true); + expect(checkbox?.checked).toBe(true); + expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite'); + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true')); + }); +}); diff --git a/src/components/settings-modal/interface-settings/interface-settings.tsx b/src/components/settings-modal/interface-settings/interface-settings.tsx index c163aad2..4afdc400 100644 --- a/src/components/settings-modal/interface-settings/interface-settings.tsx +++ b/src/components/settings-modal/interface-settings/interface-settings.tsx @@ -5,6 +5,7 @@ import packageJson from '../../../../package.json'; import styles from './interface-settings.module.css'; import capitalize from 'lodash/capitalize'; import useExpandedMediaStore from '../../../stores/use-expanded-media-store'; +import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-store'; import useSpecialThemeStore from '../../../stores/use-special-theme-store'; import { isChristmas } from '../../../lib/utils/time-utils'; import Version from '../../version'; @@ -124,6 +125,7 @@ const InterfaceLanguage = () => { const InterfaceSettings = () => { const { t } = useTranslation(); const { fitExpandedImagesToScreen, setFitExpandedImagesToScreen } = useExpandedMediaStore(); + const { enableInfiniteScroll, setEnableInfiniteScroll } = useFeedViewSettingsStore(); return (
@@ -146,6 +148,12 @@ const InterfaceSettings = () => {
{capitalize(t('fit_expanded_images_to_screen_tip'))}
+
+ +
); }; diff --git a/src/data/5chan-directories.json b/src/data/5chan-directories.json index 5adf1641..e80a0df1 100644 --- a/src/data/5chan-directories.json +++ b/src/data/5chan-directories.json @@ -16,7 +16,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": false, - "noSpoilerReplies": false + "noSpoilerReplies": false, + "postsPerPage": 15 } }, { @@ -31,7 +32,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 20 } }, { @@ -46,7 +48,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 20 } }, { @@ -61,7 +64,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -76,7 +80,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -91,7 +96,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": false, - "noSpoilerReplies": false + "noSpoilerReplies": false, + "postsPerPage": 20 } }, { @@ -106,7 +112,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -121,7 +128,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -136,7 +144,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -151,7 +160,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -166,7 +176,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -181,7 +192,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -196,7 +208,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -211,7 +224,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -226,7 +240,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": false, - "noSpoilerReplies": false + "noSpoilerReplies": false, + "postsPerPage": 15 } }, { @@ -241,7 +256,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": false, - "noSpoilerReplies": false + "noSpoilerReplies": false, + "postsPerPage": 15 } }, { @@ -256,7 +272,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -271,7 +288,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -286,7 +304,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } }, { @@ -301,7 +320,8 @@ "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": true, - "noSpoilerReplies": true + "noSpoilerReplies": true, + "postsPerPage": 15 } } ] diff --git a/src/hooks/use-board-feed-page-size.ts b/src/hooks/use-board-feed-page-size.ts new file mode 100644 index 00000000..355eb846 --- /dev/null +++ b/src/hooks/use-board-feed-page-size.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import type { DirectoryCommunity } from './use-directories'; +import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, type CommunityWithPostsPerPage } from '../lib/utils/board-feed-pagination'; + +export type BoardFeedPageSize = ReturnType; + +/** + * Compute board feed page-size values from directory community. + * + * - Single directory board: uses community.features.postsPerPage when valid numeric > 0 + * - Missing metadata, non-directory board, /all, /subs, /mod: fallback 15 + * + * Returns: guiPostsPerPage, maxGuiPages (10), paginationFeedPostsPerPage, infiniteFeedPostsPerPage + */ +export const useBoardFeedPageSize = (community?: DirectoryCommunity | null): BoardFeedPageSize => { + return useMemo(() => { + const guiPostsPerPage = computeGuiPostsPerPage(community as CommunityWithPostsPerPage | null | undefined); + return getBoardFeedPageSizeConstants(guiPostsPerPage); + }, [community]); +}; diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts index 0e01d103..80b68df4 100644 --- a/src/hooks/use-directories.ts +++ b/src/hooks/use-directories.ts @@ -9,6 +9,7 @@ export interface DirectoriesMetadata { } export interface DirectoryFeatures { + postsPerPage?: number; pseudonymityMode?: string; nsfw?: boolean; noSpoilers?: boolean; diff --git a/src/hooks/use-time-filter.ts b/src/hooks/use-time-filter.ts index 6709d27e..befb3021 100644 --- a/src/hooks/use-time-filter.ts +++ b/src/hooks/use-time-filter.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import { useParams } from 'react-router-dom'; +import { isBoardFeedPageNumber } from '../lib/utils/route-utils'; // the timestamp the last time the user visited const lastVisitTimestamp = localStorage.getItem('5chanLastVisitTimestamp'); @@ -110,6 +111,11 @@ const useTimeFilter = () => { const params = useParams(); let timeFilterName = params.timeFilterName; + // Ignore when param is a board feed page number (e.g. /all/3, /biz/2) + if (timeFilterName && isBoardFeedPageNumber(timeFilterName)) { + timeFilterName = undefined; + } + // the default time filter is the last visit time filter if (!timeFilterName) { timeFilterName = lastVisitTimeFilterName; diff --git a/src/lib/utils/__tests__/board-feed-pagination.test.ts b/src/lib/utils/__tests__/board-feed-pagination.test.ts new file mode 100644 index 00000000..cde3d1c1 --- /dev/null +++ b/src/lib/utils/__tests__/board-feed-pagination.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, getPageSlice } from '../board-feed-pagination'; + +describe('computeGuiPostsPerPage', () => { + it('uses community.features.postsPerPage when valid numeric > 0', () => { + expect(computeGuiPostsPerPage({ features: { postsPerPage: 15 } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: 20 } })).toBe(20); + expect(computeGuiPostsPerPage({ features: { postsPerPage: 1 } })).toBe(1); + expect(computeGuiPostsPerPage({ features: { postsPerPage: 100 } })).toBe(100); + }); + + it('falls back to 15 when postsPerPage is missing, invalid, or non-positive', () => { + expect(computeGuiPostsPerPage(undefined)).toBe(15); + expect(computeGuiPostsPerPage(null)).toBe(15); + expect(computeGuiPostsPerPage({})).toBe(15); + expect(computeGuiPostsPerPage({ features: {} })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: 0 } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: -1 } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: NaN } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: Infinity } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: '15' as unknown as number } })).toBe(15); + expect(computeGuiPostsPerPage({ features: { postsPerPage: 15.7 } })).toBe(15); + }); +}); + +describe('getBoardFeedPageSizeConstants', () => { + it('directory page size 15 -> pagination feed size 150, 10-page cap', () => { + const c = getBoardFeedPageSizeConstants(15); + expect(c.guiPostsPerPage).toBe(15); + expect(c.maxGuiPages).toBe(10); + expect(c.paginationFeedPostsPerPage).toBe(150); + expect(c.infiniteFeedPostsPerPage).toBe(15); + }); + + it('directory page size 20 -> pagination feed size 200', () => { + const c = getBoardFeedPageSizeConstants(20); + expect(c.guiPostsPerPage).toBe(20); + expect(c.maxGuiPages).toBe(10); + expect(c.paginationFeedPostsPerPage).toBe(200); + expect(c.infiniteFeedPostsPerPage).toBe(20); + }); +}); + +describe('getPageSlice', () => { + const guiPostsPerPage = 15; + const maxGuiPages = 10; + + it('returns correct slice for page 1', () => { + const items = Array.from({ length: 200 }, (_, i) => i); + const slice = getPageSlice(items, 1, guiPostsPerPage, maxGuiPages); + expect(slice).toHaveLength(15); + expect(slice[0]).toBe(0); + expect(slice[14]).toBe(14); + }); + + it('returns correct slice for middle page', () => { + const items = Array.from({ length: 200 }, (_, i) => i); + const slice = getPageSlice(items, 5, guiPostsPerPage, maxGuiPages); + expect(slice).toHaveLength(15); + expect(slice[0]).toBe(60); + expect(slice[14]).toBe(74); + }); + + it('returns correct slice for last page', () => { + const items = Array.from({ length: 200 }, (_, i) => i); + const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages); + expect(slice).toHaveLength(15); + expect(slice[0]).toBe(135); + expect(slice[14]).toBe(149); + }); + + it('returns partial slice when total items do not fill last page', () => { + const items = Array.from({ length: 37 }, (_, i) => i); + const slice = getPageSlice(items, 3, guiPostsPerPage, maxGuiPages); + expect(slice).toHaveLength(7); + expect(slice[0]).toBe(30); + expect(slice[6]).toBe(36); + }); + + it('clamps page when total items shrink (requested page beyond valid range)', () => { + const items = Array.from({ length: 20 }, (_, i) => i); // only 2 pages of 15 + const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages); + expect(slice).toHaveLength(5); // page 2 has 5 items (15-19) + expect(slice[0]).toBe(15); + expect(slice[4]).toBe(19); + }); + + it('clamps page 1 when requesting page 0 or negative', () => { + const items = Array.from({ length: 50 }, (_, i) => i); + const slice0 = getPageSlice(items, 0, guiPostsPerPage, maxGuiPages); + const sliceNeg = getPageSlice(items, -1, guiPostsPerPage, maxGuiPages); + expect(slice0).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages)); + expect(sliceNeg).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages)); + }); + + it('returns empty array when guiPostsPerPage or maxGuiPages is 0', () => { + const items = [1, 2, 3]; + expect(getPageSlice(items, 1, 0, 10)).toEqual([]); + expect(getPageSlice(items, 1, 15, 0)).toEqual([]); + }); + + it('returns empty array when items is empty', () => { + expect(getPageSlice([], 1, guiPostsPerPage, maxGuiPages)).toEqual([]); + }); +}); diff --git a/src/lib/utils/board-feed-pagination.ts b/src/lib/utils/board-feed-pagination.ts new file mode 100644 index 00000000..05b025c4 --- /dev/null +++ b/src/lib/utils/board-feed-pagination.ts @@ -0,0 +1,49 @@ +const GUI_POSTS_PER_PAGE_FALLBACK = 15; +const MAX_GUI_PAGES = 10; + +/** Minimal shape for directory community with optional postsPerPage */ +export interface CommunityWithPostsPerPage { + features?: { postsPerPage?: number }; +} + +/** + * Compute posts-per-page for the GUI from directory features. + * Single directory board: uses community.features.postsPerPage when valid numeric > 0. + * Missing metadata, non-directory board, /all, /subs, /mod: fallback 15. + */ +export const computeGuiPostsPerPage = (community?: CommunityWithPostsPerPage | null): number => { + const value = community?.features?.postsPerPage; + if (typeof value === 'number' && value > 0 && Number.isFinite(value)) { + return Math.floor(value); + } + return GUI_POSTS_PER_PAGE_FALLBACK; +}; + +/** + * Board feed pagination constants. + * Fallback 15 and max 10 GUI pages are fixed. + */ +export const getBoardFeedPageSizeConstants = (guiPostsPerPage: number) => ({ + guiPostsPerPage, + maxGuiPages: MAX_GUI_PAGES, + paginationFeedPostsPerPage: guiPostsPerPage * MAX_GUI_PAGES, + infiniteFeedPostsPerPage: guiPostsPerPage, +}); + +/** + * Slice items for a given page with page clamping. + * When total items shrink, the requested page is clamped to the last valid page. + */ +export const getPageSlice = (items: T[], page: number, guiPostsPerPage: number, maxGuiPages: number): T[] => { + if (guiPostsPerPage <= 0 || maxGuiPages <= 0) { + return []; + } + + const totalItems = items.length; + const totalGuiPages = Math.min(maxGuiPages, Math.ceil(totalItems / guiPostsPerPage) || 1); + const clampedPage = Math.max(1, Math.min(page, totalGuiPages)); + const start = (clampedPage - 1) * guiPostsPerPage; + const end = start + guiPostsPerPage; + + return items.slice(start, end); +}; diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index 57a5fc89..090b224a 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -118,8 +118,10 @@ export const isFeedRoute = (pathname: string): boolean => { if (segments.length >= 1) { if (segments.length === 1) return true; if (segments.length === 2 && segments[1] === 'catalog') return true; - if (segments.length === 2 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1])) return true; + if (segments.length === 2 && (/^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) || /^([1-9]|10)$/.test(segments[1]))) return true; if (segments.length === 3 && segments[1] === 'catalog' && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[2])) return true; + if (segments.length === 3 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) && /^([1-9]|10)$/.test(segments[2])) return true; + if (segments.length === 2 && segments[0] !== 'all' && segments[0] !== 'subs' && segments[0] !== 'mod' && /^([1-9]|10)$/.test(segments[1])) return true; } return false; @@ -143,6 +145,32 @@ export const isModQueueRoute = (pathname: string): boolean => { return normalizedPath.includes('/modqueue'); }; +/** Page numbers 1–10 for board feed pagination */ +export const BOARD_PAGE_REGEX = /^([1-9]|10)$/; + +export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment); + +/** Strip trailing page number (1–10) from path for feed cache key */ +export const stripPageFromFeedPath = (path: string): string => { + const segments = path.split('/').filter(Boolean); + if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) { + return '/' + segments.slice(0, -1).join('/'); + } + return path; +}; + +/** Parse current page number (1–10) from feed pathname; returns 1 if none */ +export const getPageFromFeedPath = (pathname: string): number => { + const normalized = pathname.replace(/\/settings$/, '').replace(/\/$/, ''); + const segments = normalized.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (last && isBoardFeedPageNumber(last)) { + const n = parseInt(last, 10); + return Math.min(10, Math.max(1, n)); + } + return 1; +}; + export const getFeedCacheKey = (pathname: string): string | null => { let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; normalizedPath = normalizedPath.replace(/\/settings$/, ''); @@ -161,7 +189,7 @@ export const getFeedCacheKey = (pathname: string): string | null => { } if (isFeedRoute(pathname)) { - return normalizedPath; + return stripPageFromFeedPath(normalizedPath); } return null; diff --git a/src/stores/__tests__/use-feed-view-settings-store.test.ts b/src/stores/__tests__/use-feed-view-settings-store.test.ts new file mode 100644 index 00000000..3b54b29b --- /dev/null +++ b/src/stores/__tests__/use-feed-view-settings-store.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useFeedViewSettingsStore from '../use-feed-view-settings-store'; + +const STORAGE_KEY = 'feed-view-settings-store'; + +describe('useFeedViewSettingsStore', () => { + let setItemSpy: ReturnType; + + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY); + setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + }); + + afterEach(() => { + setItemSpy.mockRestore(); + useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false); + }); + + it('defaults enableInfiniteScroll to false', () => { + const { enableInfiniteScroll } = useFeedViewSettingsStore.getState(); + expect(enableInfiniteScroll).toBe(false); + }); + + it('sets enableInfiniteScroll to true when setEnableInfiniteScroll is called with true', () => { + useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true); + const { enableInfiniteScroll } = useFeedViewSettingsStore.getState(); + expect(enableInfiniteScroll).toBe(true); + }); + + it('persists state to localStorage when setEnableInfiniteScroll is called', () => { + useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true); + + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"state":')); + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true')); + }); +}); diff --git a/src/stores/use-feed-view-settings-store.ts b/src/stores/use-feed-view-settings-store.ts new file mode 100644 index 00000000..e5170784 --- /dev/null +++ b/src/stores/use-feed-view-settings-store.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface FeedViewSettingsState { + enableInfiniteScroll: boolean; + setEnableInfiniteScroll: (enable: boolean) => void; +} + +const useFeedViewSettingsStore = create()( + persist( + (set) => ({ + enableInfiniteScroll: false, + setEnableInfiniteScroll: (enable) => set({ enableInfiniteScroll: enable }), + }), + { + name: 'feed-view-settings-store', + }, + ), +); + +export default useFeedViewSettingsStore; diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index ee06a435..5bf129ba 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -1,21 +1,25 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Trans, useTranslation } from 'react-i18next'; import styles from './board.module.css'; import { shouldShowSnow } from '../../lib/snow'; -import { useDirectoryAddresses, useDirectories } from '../../hooks/use-directories'; +import { useDirectoryAddresses, useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses'; import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address'; import { useFeedStateString } from '../../hooks/use-state-string'; import useTimeFilter, { timeFilterNameToSeconds } from '../../hooks/use-time-filter'; import useFeedResetStore from '../../stores/use-feed-reset-store'; import useSortingStore from '../../stores/use-sorting-store'; -import { getSubplebbitAddress, isDirectoryBoard } from '../../lib/utils/route-utils'; +import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'; +import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; +import { getPageSlice } from '../../lib/utils/board-feed-pagination'; +import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, stripPageFromFeedPath } from '../../lib/utils/route-utils'; import ErrorDisplay from '../../components/error-display/error-display'; import LoadingEllipsis from '../../components/loading-ellipsis'; +import BoardPagination from '../../components/board-pagination'; import { Post } from '../post'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; @@ -39,6 +43,8 @@ interface BoardFooterProps { subplebbitState: string | undefined; subscriptionsLength: number; accountSubplebbitAddressesLength: number; + /** In pagination mode, suppress infinite-scroll loading cues */ + showLoadingEllipsis?: boolean; } // Defined outside Board to preserve component identity across renders (Virtuoso optimization) @@ -63,6 +69,7 @@ const BoardFooter = ({ subplebbitState, subscriptionsLength, accountSubplebbitAddressesLength, + showLoadingEllipsis = true, }: BoardFooterProps) => { const { t } = useTranslation(); @@ -141,7 +148,7 @@ const BoardFooter = ({ ) : isInModView && accountSubplebbitAddressesLength === 0 ? ( {t('not_mod_of_any_board')} ) : ( - hasMore && + showLoadingEllipsis && hasMore && )} @@ -201,16 +208,38 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook; const timeFilterSeconds = timeFilterNameFromCache ? timeFilterNameToSeconds(timeFilterNameFromCache) : timeFilterSecondsFromHook; - const feedOptions = { - subplebbitAddresses, - sortType, - postsPerPage: isInAllView || isInSubscriptionsView || isInModView ? 5 : 25, - ...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}), - }; + const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll); + const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress); + const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(community); + + const feedOptions = useMemo( + () => ({ + subplebbitAddresses, + sortType, + postsPerPage: enableInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage, + ...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}), + }), + [ + subplebbitAddresses, + sortType, + enableInfiniteScroll, + infiniteFeedPostsPerPage, + paginationFeedPostsPerPage, + isInAllView, + isInSubscriptionsView, + isInModView, + timeFilterSeconds, + ], + ); const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions); const { accountComments } = useAccountComments(); + const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${sortType}-${timeFilterSeconds}-${viewType ?? 'board'}-${enableInfiniteScroll}`; + const pathWithoutSettings = location.pathname.replace(/\/settings$/, ''); + const currentPage = getPageFromFeedPath(pathWithoutSettings); + const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings); + const resetTriggeredRef = useRef(false); const setResetFunction = useFeedResetStore((state) => state.setResetFunction); @@ -249,6 +278,32 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t return newFeed; }, [feed, filteredComments]); + const cappedFeed = useMemo( + () => (enableInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)), + [enableInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages], + ); + const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]); + const currentPageFeed = useMemo( + () => (enableInfiniteScroll ? [] : getPageSlice(cappedFeed, currentPage, guiPostsPerPage, maxGuiPages)), + [enableInfiniteScroll, cappedFeed, currentPage, guiPostsPerPage, maxGuiPages], + ); + + const navigate = useNavigate(); + useEffect(() => { + if (!enableInfiniteScroll && currentPage > totalPages && totalPages > 0) { + const targetPage = totalPages; + const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`; + navigate(targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`, { replace: true }); + } + }, [enableInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]); + + // Scroll to top instantly when page changes in pagination mode + useEffect(() => { + if (!enableInfiniteScroll) { + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + } + }, [enableInfiniteScroll, currentPage]); + useEffect(() => { if (filteredComments.length > 0 && !resetTriggeredRef.current) { reset(); @@ -328,6 +383,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t subplebbitState={subplebbitState} subscriptionsLength={subscriptions?.length || 0} accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0} + showLoadingEllipsis={enableInfiniteScroll} /> ), }), @@ -350,6 +406,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t subplebbitState, subscriptions?.length, accountSubplebbitAddresses?.length, + enableInfiniteScroll, ], ); @@ -415,25 +472,56 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t )} - {/* Use Virtuoso for infinite scroll only when there's more content to paginate */} - {hasMore ? ( - } - useWindowScroll={true} - components={footerComponents} - endReached={loadMore} - ref={virtuosoRef} - restoreStateFrom={lastVirtuosoState} - initialScrollTop={lastVirtuosoState?.scrollTop} - /> + {/* Infinite mode: Virtuoso when hasMore, else plain list */} + {enableInfiniteScroll ? ( + hasMore ? ( + } + useWindowScroll={true} + components={footerComponents} + endReached={loadMore} + ref={virtuosoRef} + restoreStateFrom={lastVirtuosoState} + initialScrollTop={lastVirtuosoState?.scrollTop} + /> + ) : ( + <> + {combinedFeed.map((post, index) => ( + + ))} + + + ) ) : ( + /* Pagination mode: plain list, no Virtuoso, no loadMore */ <> - {combinedFeed.map((post, index) => ( + {currentPageFeed.map((post, index) => ( ))} + )}