merge: codex/fix/all-active-sort

# Conflicts:
#	public/translations/ar/default.json
#	public/translations/bn/default.json
#	public/translations/cs/default.json
#	public/translations/da/default.json
#	public/translations/de/default.json
#	public/translations/el/default.json
#	public/translations/en/default.json
#	public/translations/es/default.json
#	public/translations/fa/default.json
#	public/translations/fi/default.json
#	public/translations/fil/default.json
#	public/translations/fr/default.json
#	public/translations/he/default.json
#	public/translations/hi/default.json
#	public/translations/hu/default.json
#	public/translations/id/default.json
#	public/translations/it/default.json
#	public/translations/ja/default.json
#	public/translations/ko/default.json
#	public/translations/mr/default.json
#	public/translations/nl/default.json
#	public/translations/no/default.json
#	public/translations/pl/default.json
#	public/translations/pt/default.json
#	public/translations/ro/default.json
#	public/translations/ru/default.json
#	public/translations/sq/default.json
#	public/translations/sv/default.json
#	public/translations/te/default.json
#	public/translations/th/default.json
#	public/translations/tr/default.json
#	public/translations/uk/default.json
#	public/translations/ur/default.json
#	public/translations/vi/default.json
#	public/translations/zh/default.json
This commit is contained in:
Tommaso Casaburi
2026-04-18 16:01:00 +07:00
51 changed files with 1296 additions and 119 deletions
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "لا. لأن 5chan Pass مُخطط له كاعتماد قائم على NFT، فهو غير مُصمم للاعتماد على عنوان IP ثابت. لا توجد أي قيود مقصودة بناءً على استخدام VPN أو الوكلاء أو تغيّر الشبكات.",
"pass_answer_fiat": "لا. عند إطلاق 5chan Pass، سيكون الدفع بالعملات المشفرة فقط.",
"pass_answer_bypass": "لا. لا يُعد Pass وسيلة لتجاوز الحظر أو قيود النشر أو إشراف اللوحات. فكل board ما يزال يضع قواعده الخاصة ويطبقها.",
"pass_answer_privileges": "لا. شراء pass لن يجعل شخصًا ما moderator أو admin أو مالكًا لأي board. إنه شراء داعم، وليس تجاوزًا للحوكمة."
"pass_answer_privileges": "لا. شراء pass لن يجعل شخصًا ما moderator أو admin أو مالكًا لأي board. إنه شراء داعم، وليس تجاوزًا للحوكمة.",
"more_threads_last_week": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الأسبوع الماضي</1>]",
"more_threads_last_month": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الشهر الماضي</1>]",
"more_threads_last_year": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من السنة الماضية</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "না। কারণ 5chan Pass-কে NFT-ভিত্তিক credential হিসেবে পরিকল্পনা করা হয়েছে, এটি কোনো নির্দিষ্ট IP address-এর উপর নির্ভর করার জন্য তৈরি নয়। VPN ব্যবহার, proxy, বা নেটওয়ার্ক পরিবর্তনের ভিত্তিতে কোনো নির্ধারিত সীমাবদ্ধতা নেই।",
"pass_answer_fiat": "না। 5chan Pass চালু হলে, পেমেন্ট শুধুমাত্র ক্রিপ্টোতে হবে।",
"pass_answer_bypass": "না। Pass ব্যান, পোস্ট করার সীমাবদ্ধতা, বা board moderation এড়িয়ে যাওয়ার উপায় নয়। প্রতিটি board এখনও নিজস্ব নিয়ম ঠিক করে এবং প্রয়োগ করে।",
"pass_answer_privileges": "না। একটি pass কিনলে কেউ moderator, admin, বা কোনো board-এর মালিক হবে না। এটি সমর্থকদের জন্য একটি ক্রয়, শাসনব্যবস্থাকে অগ্রাহ্য করার উপায় নয়।"
"pass_answer_privileges": "না। একটি pass কিনলে কেউ moderator, admin, বা কোনো board-এর মালিক হবে না। এটি সমর্থকদের জন্য একটি ক্রয়, শাসনব্যবস্থাকে অগ্রাহ্য করার উপায় নয়।",
"more_threads_last_week": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত সপ্তাহের আরও থ্রেড দেখান</1>]",
"more_threads_last_month": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত মাসের আরও থ্রেড দেখান</1>]",
"more_threads_last_year": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত বছরের আরও থ্রেড দেখান</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Ne. Protože je 5chan Pass plánován jako přihlašovací údaj založený na NFT, nemá být závislý na pevné IP adrese. Není zamýšleno žádné omezení na základě používání VPN, proxy serverů nebo změn sítě.",
"pass_answer_fiat": "Ne. Až bude 5chan Pass spuštěn, platba bude pouze kryptem.",
"pass_answer_bypass": "Ne. Pass není způsob, jak obejít bany, omezení přispívání ani moderaci boardu. Každý board si stále stanovuje a vymáhá vlastní pravidla.",
"pass_answer_privileges": "Ne. Koupě pass z nikoho neudělá moderator, admin ani vlastníka board. Je to podpůrný nákup, ne obejití governance."
"pass_answer_privileges": "Ne. Koupě pass z nikoho neudělá moderator, admin ani vlastníka board. Je to podpůrný nákup, ne obejití governance.",
"more_threads_last_week": "{{count}} vláken od {{currentTimeFilterName}} — [<1>Ukázat více vláken z minulého týdne</1>]",
"more_threads_last_month": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého měsíce</1>]",
"more_threads_last_year": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého roku</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nej. Fordi 5chan Pass er planlagt som en NFT-baseret legitimation, er den ikke tænkt til at afhænge af en fast IP-adresse. Der er ingen tilsigtet begrænsning baseret på brug af VPN, proxyer eller skiftende netværk.",
"pass_answer_fiat": "Nej. Når 5chan Pass lanceres, vil betaling kun være med krypto.",
"pass_answer_bypass": "Nej. Et pass er ikke en måde at omgå bans, opslagbegrænsninger eller boardmoderation på. Hvert board fastsætter og håndhæver stadig sine egne regler.",
"pass_answer_privileges": "Nej. At købe et pass gør ikke nogen til moderator, admin eller ejer af et board. Det er et støttekøb, ikke en tilsidesættelse af governance."
"pass_answer_privileges": "Nej. At købe et pass gør ikke nogen til moderator, admin eller ejer af et board. Det er et støttekøb, ikke en tilsidesættelse af governance.",
"more_threads_last_week": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste uge</1>]",
"more_threads_last_month": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste måned</1>]",
"more_threads_last_year": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste år</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nein. Da 5chan Pass als NFT-basierter Berechtigungsnachweis geplant ist, soll er nicht von einer festen IP-Adresse abhängen. Es gibt keine beabsichtigte Einschränkung aufgrund der Nutzung von VPN, Proxys oder wechselnden Netzwerken.",
"pass_answer_fiat": "Nein. Wenn 5chan Pass startet, wird die Zahlung nur mit Krypto möglich sein.",
"pass_answer_bypass": "Nein. Ein Pass ist kein Weg, Sperren, Posting-Beschränkungen oder die Moderation eines Boards zu umgehen. Jedes Board legt weiterhin seine eigenen Regeln fest und setzt sie durch.",
"pass_answer_privileges": "Nein. Der Kauf eines Passes macht niemanden zu einem Moderator, Admin oder Eigentümer eines Boards. Es ist ein Unterstützerkauf, keine Umgehung der Governance."
"pass_answer_privileges": "Nein. Der Kauf eines Passes macht niemanden zu einem Moderator, Admin oder Eigentümer eines Boards. Es ist ein Unterstützerkauf, keine Umgehung der Governance.",
"more_threads_last_week": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads aus der letzten Woche anzeigen</1>]",
"more_threads_last_month": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Monat anzeigen</1>]",
"more_threads_last_year": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Jahr anzeigen</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Όχι. Επειδή το 5chan Pass σχεδιάζεται ως διαπιστευτήριο βασισμένο σε NFT, δεν προορίζεται να εξαρτάται από σταθερή διεύθυνση IP. Δεν υπάρχει προβλεπόμενος περιορισμός με βάση τη χρήση VPN, proxy ή την αλλαγή δικτύων.",
"pass_answer_fiat": "Όχι. Όταν κυκλοφορήσει το 5chan Pass, η πληρωμή θα γίνεται μόνο με κρυπτονομίσματα.",
"pass_answer_bypass": "Όχι. Ένα pass δεν είναι τρόπος παράκαμψης αποκλεισμών, περιορισμών ανάρτησης ή συντονισμού ενός board. Κάθε board εξακολουθεί να θέτει και να επιβάλλει τους δικούς του κανόνες.",
"pass_answer_privileges": "Όχι. Η αγορά ενός pass δεν θα κάνει κάποιον συντονιστή, διαχειριστή ή ιδιοκτήτη ενός board. Είναι μια αγορά υποστήριξης, όχι παράκαμψη της διακυβέρνησης."
"pass_answer_privileges": "Όχι. Η αγορά ενός pass δεν θα κάνει κάποιον συντονιστή, διαχειριστή ή ιδιοκτήτη ενός board. Είναι μια αγορά υποστήριξης, όχι παράκαμψη της διακυβέρνησης.",
"more_threads_last_week": "{{count}} θέματα από {{currentTimeFilterName}} — [<1>Εμφάνιση περισσότερων θεμάτων από την περασμένη εβδομάδα</1>]",
"more_threads_last_month": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από τον τελευταίο μήνα</1>]",
"more_threads_last_year": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από το περασμένο έτος</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "No. Because 5chan Pass is planned as an NFT-based credential, it is not meant to depend on a fixed IP address. There is no intended restriction based on VPN use, proxies, or changing networks.",
"pass_answer_fiat": "No. When 5chan Pass launches, payment will be crypto only.",
"pass_answer_bypass": "No. A pass is not a way around bans, posting restrictions, or board moderation. Every board still sets and enforces its own rules.",
"pass_answer_privileges": "No. Buying a pass will not make someone a moderator, an admin, or the owner of a board. It is a supporter purchase, not a governance override."
"pass_answer_privileges": "No. Buying a pass will not make someone a moderator, an admin, or the owner of a board. It is a supporter purchase, not a governance override.",
"more_threads_last_week": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Week</1>]",
"more_threads_last_month": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Month</1>]",
"more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "No. Como 5chan Pass está previsto como una credencial basada en NFT, no está pensado para depender de una dirección IP fija. No hay ninguna restricción prevista en función del uso de VPN, proxies o cambios de red.",
"pass_answer_fiat": "No. Cuando se lance 5chan Pass, el pago será solo con cripto.",
"pass_answer_bypass": "No. Un pass no es una forma de eludir bloqueos, restricciones para publicar ni la moderación de un board. Cada board sigue estableciendo y haciendo cumplir sus propias reglas.",
"pass_answer_privileges": "No. Comprar un pass no convertirá a alguien en moderator, admin ni propietario de un board. Es una compra de apoyo, no una anulación de la gobernanza."
"pass_answer_privileges": "No. Comprar un pass no convertirá a alguien en moderator, admin ni propietario de un board. Es una compra de apoyo, no una anulación de la gobernanza.",
"more_threads_last_week": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Mostrar más hilos de la semana pasada</1>]",
"more_threads_last_month": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del mes pasado</1>]",
"more_threads_last_year": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del año pasado</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "خیر. چون 5chan Pass به عنوان یک credential مبتنی بر NFT برنامه‌ریزی شده است، قرار نیست به یک آدرس IP ثابت وابسته باشد. هیچ محدودیت مورد نظری بر اساس استفاده از VPN، proxy یا تغییر شبکه‌ها وجود ندارد.",
"pass_answer_fiat": "خیر. وقتی 5chan Pass راه‌اندازی شود، پرداخت فقط با کریپتو خواهد بود.",
"pass_answer_bypass": "خیر. Pass راهی برای دور زدن بن‌ها، محدودیت‌های ارسال یا نظارت بردها نیست. هر board همچنان قوانین خودش را تعیین و اجرا می‌کند.",
"pass_answer_privileges": "خیر. خرید یک pass کسی را به moderator، admin یا مالک یک board تبدیل نمی‌کند. این یک خرید حمایتی است، نه راهی برای دور زدن حاکمیت."
"pass_answer_privileges": "خیر. خرید یک pass کسی را به moderator، admin یا مالک یک board تبدیل نمی‌کند. این یک خرید حمایتی است، نه راهی برای دور زدن حاکمیت.",
"more_threads_last_week": "{{count}} موضوع از {{currentTimeFilterName}} — [<1>نمایش موضوعات بیشتر از هفته گذشته</1>]",
"more_threads_last_month": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های ماه گذشته</1>]",
"more_threads_last_year": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های سال گذشته</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Ei. Koska 5chan Pass on suunniteltu NFT-pohjaiseksi tunnisteeksi, sen ei ole tarkoitus riippua kiinteästä IP-osoitteesta. VPN:n käytön, välityspalvelinten tai verkon vaihtumisen perusteella ei ole tarkoitus rajoittaa käyttöä.",
"pass_answer_fiat": "Ei. Kun 5chan Pass julkaistaan, maksaminen onnistuu vain kryptolla.",
"pass_answer_bypass": "Ei. Pass ei ole keino kiertää porttikieltoja, julkaisemisrajoituksia tai laudan moderointia. Jokainen board asettaa ja valvoo edelleen omia sääntöjään.",
"pass_answer_privileges": "Ei. pass ostaminen ei tee kenestäkään moderator, admin tai board omistajaa. Se on tukijaosto, ei governance-mallin ohitus."
"pass_answer_privileges": "Ei. pass ostaminen ei tee kenestäkään moderator, admin tai board omistajaa. Se on tukijaosto, ei governance-mallin ohitus.",
"more_threads_last_week": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää ketjuja viime viikolta</1>]",
"more_threads_last_month": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime kuun ketjuja</1>]",
"more_threads_last_year": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime vuoden ketjuja</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Hindi. Dahil nakaplanong maging NFT-based credential ang 5chan Pass, hindi ito nilalayong umasa sa isang fixed na IP address. Walang planong paghihigpit batay sa paggamit ng VPN, mga proxy, o pagpapalit ng network.",
"pass_answer_fiat": "Hindi. Kapag inilunsad ang 5chan Pass, crypto only ang pagbabayad.",
"pass_answer_bypass": "Hindi. Ang pass ay hindi paraan para malusutan ang mga ban, mga limitasyon sa pag-post, o moderation ng board. Ang bawat board ay nagtatakda at nagpapatupad pa rin ng sarili nitong mga patakaran.",
"pass_answer_privileges": "Hindi. Ang pagbili ng pass ay hindi gagawing moderator, admin, o may-ari ng isang board ang sinuman. Isa itong supporter purchase, hindi governance override."
"pass_answer_privileges": "Hindi. Ang pagbili ng pass ay hindi gagawing moderator, admin, o may-ari ng isang board ang sinuman. Isa itong supporter purchase, hindi governance override.",
"more_threads_last_week": "{{count}} mga thread mula sa {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula sa nakaraang linggo</1>]",
"more_threads_last_month": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang buwan</1>]",
"more_threads_last_year": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang taon</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Non. Comme 5chan Pass est prévu comme un identifiant basé sur un NFT, il n'est pas destiné à dépendre d'une adresse IP fixe. Aucune restriction n'est prévue en fonction de l'utilisation d'un VPN, de proxys ou d'un changement de réseau.",
"pass_answer_fiat": "Non. Lorsque 5chan Pass sera lancé, le paiement sera crypto uniquement.",
"pass_answer_bypass": "Non. Un pass n'est pas un moyen de contourner les bans, les restrictions de publication ou la modération d'un board. Chaque board continue de définir et d'appliquer ses propres règles.",
"pass_answer_privileges": "Non. Acheter un pass ne fera de personne un moderator, un admin ou le propriétaire d'un board. C'est un achat de soutien, pas un contournement de la gouvernance."
"pass_answer_privileges": "Non. Acheter un pass ne fera de personne un moderator, un admin ou le propriétaire d'un board. C'est un achat de soutien, pas un contournement de la gouvernance.",
"more_threads_last_week": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de la semaine dernière</1>]",
"more_threads_last_month": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils du mois dernier</1>]",
"more_threads_last_year": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de l'année dernière</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "לא. מכיוון ש-5chan Pass מתוכנן כאישור מבוסס NFT, הוא לא נועד להיות תלוי בכתובת IP קבועה. אין הגבלה מתוכננת על סמך שימוש ב-VPN, בפרוקסי או בהחלפת רשתות.",
"pass_answer_fiat": "לא. כש-5chan Pass יושק, התשלום יהיה בקריפטו בלבד.",
"pass_answer_bypass": "לא. Pass אינו דרך לעקוף חסימות, הגבלות פרסום או ניהול של board. כל board עדיין קובע ואוכף את הכללים שלו.",
"pass_answer_privileges": "לא. קניית pass לא תהפוך מישהו ל-moderator, ל-admin או לבעלים של board. זו רכישת תמיכה, לא עקיפה של הממשל."
"pass_answer_privileges": "לא. קניית pass לא תהפוך מישהו ל-moderator, ל-admin או לבעלים של board. זו רכישת תמיכה, לא עקיפה של הממשל.",
"more_threads_last_week": "{{count}} אשכולות מאז {{currentTimeFilterName}} — [<1>הצג עוד אשכולות מהשבוע שעבר</1>]",
"more_threads_last_month": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהחודש שעבר</1>]",
"more_threads_last_year": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהשנה שעברה</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "नहीं। क्योंकि 5chan Pass को NFT-आधारित credential के रूप में योजनाबद्ध किया गया है, इसका उद्देश्य किसी स्थिर IP address पर निर्भर होना नहीं है। VPN के उपयोग, proxy, या नेटवर्क बदलने के आधार पर कोई निर्धारित प्रतिबंध नहीं है।",
"pass_answer_fiat": "नहीं। जब 5chan Pass लॉन्च होगा, भुगतान केवल क्रिप्टो में होगा।",
"pass_answer_bypass": "नहीं। Pass बैन, पोस्टिंग प्रतिबंधों या board moderation से बचने का तरीका नहीं है। हर board अब भी अपने नियम खुद तय करता है और उन्हें लागू करता है।",
"pass_answer_privileges": "नहीं। pass खरीदने से कोई moderator, admin या किसी board का मालिक नहीं बन जाएगा। यह एक समर्थक खरीद है, शासन को दरकिनार करने का तरीका नहीं।"
"pass_answer_privileges": "नहीं। pass खरीदने से कोई moderator, admin या किसी board का मालिक नहीं बन जाएगा। यह एक समर्थक खरीद है, शासन को दरकिनार करने का तरीका नहीं।",
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} से — [<1>पिछले सप्ताह के और थ्रेड दिखाएं</1>]",
"more_threads_last_month": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>अगले महीने के अधिक थ्रेड्स दिखाएं</1>]",
"more_threads_last_year": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>पिछले साल के अधिक थ्रेड्स दिखाएं</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nem. Mivel az 5chan Pass NFT-alapú hitelesítő adatként van tervezve, nem arra szolgál, hogy egy fix IP-címtől függjön. Nincs szándékolt korlátozás a VPN-használat, a proxyk vagy a hálózatváltás alapján.",
"pass_answer_fiat": "Nem. Amikor a 5chan Pass elindul, a fizetés csak kriptóval lesz lehetséges.",
"pass_answer_bypass": "Nem. A Pass nem arra való, hogy megkerüld a kitiltásokat, a posztolási korlátozásokat vagy egy board moderálását. Minden board továbbra is a saját szabályait határozza meg és érvényesíti.",
"pass_answer_privileges": "Nem. A pass megvásárlásától senki sem lesz moderator, admin vagy egy board tulajdonosa. Ez egy támogatói vásárlás, nem a governance felülírása."
"pass_answer_privileges": "Nem. A pass megvásárlásától senki sem lesz moderator, admin vagy egy board tulajdonosa. Ez egy támogatói vásárlás, nem a governance felülírása.",
"more_threads_last_week": "{{count}} szál a {{currentTimeFilterName}} óta — [<1>Mutass több szálat a múlt héten</1>]",
"more_threads_last_month": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt hónapból</1>]",
"more_threads_last_year": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt évből</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Tidak. Karena 5chan Pass direncanakan sebagai kredensial berbasis NFT, ini tidak dimaksudkan untuk bergantung pada alamat IP tetap. Tidak ada pembatasan yang dimaksudkan berdasarkan penggunaan VPN, proxy, atau pergantian jaringan.",
"pass_answer_fiat": "Tidak. Saat 5chan Pass diluncurkan, pembayaran hanya akan tersedia dengan crypto.",
"pass_answer_bypass": "Tidak. Pass bukan cara untuk menghindari ban, pembatasan posting, atau moderasi board. Setiap board tetap menetapkan dan menegakkan aturannya sendiri.",
"pass_answer_privileges": "Tidak. Membeli pass tidak akan menjadikan seseorang moderator, admin, atau pemilik sebuah board. Ini adalah pembelian dukungan, bukan pengesampingan tata kelola."
"pass_answer_privileges": "Tidak. Membeli pass tidak akan menjadikan seseorang moderator, admin, atau pemilik sebuah board. Ini adalah pembelian dukungan, bukan pengesampingan tata kelola.",
"more_threads_last_week": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Show lebih banyak thread dari minggu lalu</1>]",
"more_threads_last_month": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari bulan lalu</1>]",
"more_threads_last_year": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari tahun lalu</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "No. Poiché 5chan Pass è previsto come credenziale basata su NFT, non è pensato per dipendere da un indirizzo IP fisso. Non è prevista alcuna limitazione basata sull'uso di VPN, proxy o sul cambio di rete.",
"pass_answer_fiat": "No. Quando 5chan Pass sarà lanciato, il pagamento sarà solo in crypto.",
"pass_answer_bypass": "No. Un pass non è un modo per aggirare ban, restrizioni di pubblicazione o moderazione di una board. Ogni board stabilisce e fa rispettare comunque le proprie regole.",
"pass_answer_privileges": "No. Acquistare un pass non renderà qualcuno un moderator, un admin o il proprietario di una board. È un acquisto di supporto, non un aggiramento della governance."
"pass_answer_privileges": "No. Acquistare un pass non renderà qualcuno un moderator, un admin o il proprietario di una board. È un acquisto di supporto, non un aggiramento della governance.",
"more_threads_last_week": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra più thread dalla scorsa settimana</1>]",
"more_threads_last_month": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread del mese scorso</1>]",
"more_threads_last_year": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread dell'anno scorso</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "いいえ。5chan Pass は NFT ベースの認証資格として計画されているため、固定の IP アドレスに依存することは想定されていません。VPN の使用、プロキシ、またはネットワークの変更に基づく制限は意図されていません。",
"pass_answer_fiat": "いいえ。5chan Pass の提供開始時、支払いは暗号資産のみになります。",
"pass_answer_bypass": "いいえ。Pass は、BAN、投稿制限、または board のモデレーションを回避する手段ではありません。各 board は引き続き独自のルールを定め、それを適用します。",
"pass_answer_privileges": "いいえ。pass を購入しても、moderator、admin、または board の所有者になることはありません。これは支援のための購入であり、ガバナンスを上書きするものではありません。"
"pass_answer_privileges": "いいえ。pass を購入しても、moderator、admin、または board の所有者になることはありません。これは支援のための購入であり、ガバナンスを上書きするものではありません。",
"more_threads_last_week": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先週のスレッドをさらに表示</1>]",
"more_threads_last_month": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先月のスレッドをさらに表示</1>]",
"more_threads_last_year": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>去年のスレッドをさらに表示</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "아니요. 5chan Pass는 NFT 기반 자격 증명으로 계획되어 있으므로 고정된 IP 주소에 의존하도록 설계되지 않았습니다. VPN 사용, 프록시, 또는 네트워크 변경을 기준으로 한 의도된 제한은 없습니다.",
"pass_answer_fiat": "아니요. 5chan Pass가 출시되면 결제는 암호화폐로만 가능합니다.",
"pass_answer_bypass": "아니요. Pass는 차단, 글쓰기 제한 또는 board 운영을 우회하는 수단이 아닙니다. 각 board는 여전히 자체 규칙을 정하고 집행합니다.",
"pass_answer_privileges": "아니요. pass를 구매해도 누군가가 moderator, admin 또는 board 소유자가 되지는 않습니다. 이것은 후원 구매이지, 거버넌스 무효화 수단이 아닙니다."
"pass_answer_privileges": "아니요. pass를 구매해도 누군가가 moderator, admin 또는 board 소유자가 되지는 않습니다. 이것은 후원 구매이지, 거버넌스 무효화 수단이 아닙니다.",
"more_threads_last_week": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 주의 더 많은 스레드 보기</1>]",
"more_threads_last_month": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 달의 스레드 더 보기</1>]",
"more_threads_last_year": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>작년의 스레드 더 보기</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "नाही. 5chan Pass हे NFT-आधारित credential म्हणून नियोजित असल्यामुळे ते निश्चित IP address वर अवलंबून राहावे असा हेतू नाही. VPN वापर, proxy, किंवा नेटवर्क बदलण्यावर आधारित कोणतीही अपेक्षित मर्यादा नाही.",
"pass_answer_fiat": "नाही. 5chan Pass लॉन्च झाल्यावर पेमेंट फक्त क्रिप्टोमध्येच असेल.",
"pass_answer_bypass": "नाही. Pass हा बंदी, पोस्ट करण्यावरील निर्बंध किंवा board moderation चुकवण्याचा मार्ग नाही. प्रत्येक board अजूनही स्वतःचे नियम ठरवतो आणि त्यांची अंमलबजावणी करतो.",
"pass_answer_privileges": "नाही. pass खरेदी केल्याने कोणी moderator, admin किंवा एखाद्या boardचा मालक होणार नाही. ही समर्थक खरेदी आहे, शासनाला बगल देण्याचा मार्ग नाही."
"pass_answer_privileges": "नाही. pass खरेदी केल्याने कोणी moderator, admin किंवा एखाद्या boardचा मालक होणार नाही. ही समर्थक खरेदी आहे, शासनाला बगल देण्याचा मार्ग नाही.",
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} पासून — [<1>गेल्या आठवड्यातील अधिक थ्रेड्स पहा</1>]",
"more_threads_last_month": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या महिन्यातील आणखी थ्रेड्स दाखवा</1>]",
"more_threads_last_year": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या वर्षातील आणखी थ्रेड्स दाखवा</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nee. Omdat 5chan Pass is gepland als een op NFT gebaseerde credential, is het niet bedoeld om afhankelijk te zijn van een vast IP-adres. Er is geen beoogde beperking op basis van VPN-gebruik, proxy's of wisselende netwerken.",
"pass_answer_fiat": "Nee. Wanneer 5chan Pass wordt gelanceerd, zal betaling alleen met crypto mogelijk zijn.",
"pass_answer_bypass": "Nee. Een pass is geen manier om bans, plaatsingsbeperkingen of boardmoderatie te omzeilen. Elk board stelt nog steeds zijn eigen regels vast en handhaaft die.",
"pass_answer_privileges": "Nee. Door een pass te kopen wordt iemand geen moderator, admin of eigenaar van een board. Het is een ondersteunende aankoop, geen governance-override."
"pass_answer_privileges": "Nee. Door een pass te kopen wordt iemand geen moderator, admin of eigenaar van een board. Het is een ondersteunende aankoop, geen governance-override.",
"more_threads_last_week": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Toon meer threads van vorige week</1>]",
"more_threads_last_month": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen maand tonen</1>]",
"more_threads_last_year": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen jaar tonen</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nei. Fordi 5chan Pass er planlagt som en NFT-basert legitimasjon, er det ikke ment å være avhengig av en fast IP-adresse. Det er ingen tilsiktet begrensning basert på bruk av VPN, proxier eller skiftende nettverk.",
"pass_answer_fiat": "Nei. Når 5chan Pass lanseres, vil betaling kun være mulig med krypto.",
"pass_answer_bypass": "Nei. Et pass er ikke en måte å omgå utestengelser, postebegrensninger eller boardmoderering på. Hvert board fastsetter og håndhever fortsatt sine egne regler.",
"pass_answer_privileges": "Nei. Å kjøpe et pass gjør ikke noen til moderator, admin eller eier av et board. Det er et støttekjøp, ikke en overstyring av styringen."
"pass_answer_privileges": "Nei. Å kjøpe et pass gjør ikke noen til moderator, admin eller eier av et board. Det er et støttekjøp, ikke en overstyring av styringen.",
"more_threads_last_week": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige uke</1>]",
"more_threads_last_month": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige måned</1>]",
"more_threads_last_year": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige år</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nie. Ponieważ 5chan Pass jest planowany jako poświadczenie oparte na NFT, nie ma zależeć od stałego adresu IP. Nie ma zamierzonego ograniczenia opartego na używaniu VPN, proxy ani zmianie sieci.",
"pass_answer_fiat": "Nie. Gdy 5chan Pass wystartuje, płatność będzie możliwa tylko w krypto.",
"pass_answer_bypass": "Nie. Pass nie służy do omijania banów, ograniczeń publikowania ani moderacji boardu. Każdy board nadal ustala i egzekwuje własne zasady.",
"pass_answer_privileges": "Nie. Kupienie pass nie uczyni nikogo moderator, admin ani właścicielem board. To zakup wspierający, a nie obejście zasad governance."
"pass_answer_privileges": "Nie. Kupienie pass nie uczyni nikogo moderator, admin ani właścicielem board. To zakup wspierający, a nie obejście zasad governance.",
"more_threads_last_week": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego tygodnia</1>]",
"more_threads_last_month": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego miesiąca</1>]",
"more_threads_last_year": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego roku</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Não. Como o 5chan Pass está planejado como uma credencial baseada em NFT, ele não foi feito para depender de um endereço IP fixo. Não há nenhuma restrição pretendida com base no uso de VPN, proxies ou mudanças de rede.",
"pass_answer_fiat": "Não. Quando o 5chan Pass for lançado, o pagamento será somente em cripto.",
"pass_answer_bypass": "Não. Um pass não é uma forma de contornar banimentos, restrições de postagem ou a moderação de um board. Cada board continua definindo e aplicando as suas próprias regras.",
"pass_answer_privileges": "Não. Comprar um pass não fará de alguém um moderator, um admin ou o dono de um board. É uma compra de apoio, não uma substituição da governança."
"pass_answer_privileges": "Não. Comprar um pass não fará de alguém um moderator, um admin ou o dono de um board. É uma compra de apoio, não uma substituição da governança.",
"more_threads_last_week": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos da semana passada</1>]",
"more_threads_last_month": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do mês passado</1>]",
"more_threads_last_year": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do ano passado</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nu. Deoarece 5chan Pass este planificat ca un acreditiv bazat pe NFT, nu este menit să depindă de o adresă IP fixă. Nu există nicio restricție intenționată bazată pe utilizarea VPN, a proxy-urilor sau pe schimbarea rețelelor.",
"pass_answer_fiat": "Nu. Când 5chan Pass va fi lansat, plata va fi doar în crypto.",
"pass_answer_bypass": "Nu. Un pass nu este o modalitate de a ocoli banările, restricțiile de postare sau moderarea unui board. Fiecare board își stabilește și își aplică în continuare propriile reguli.",
"pass_answer_privileges": "Nu. Cumpărarea unui pass nu va face pe cineva moderator, admin sau proprietarul unui board. Este o achiziție de susținere, nu o anulare a guvernanței."
"pass_answer_privileges": "Nu. Cumpărarea unui pass nu va face pe cineva moderator, admin sau proprietarul unui board. Este o achiziție de susținere, nu o anulare a guvernanței.",
"more_threads_last_week": "{{count}} fire de discuție de la {{currentTimeFilterName}} — [<1>Arată mai multe fire din săptămâna trecută</1>]",
"more_threads_last_month": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de luna trecută</1>]",
"more_threads_last_year": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de anul trecut</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Нет. Поскольку 5chan Pass планируется как учетные данные на основе NFT, он не должен зависеть от фиксированного IP-адреса. Не предполагается никаких ограничений, основанных на использовании VPN, прокси или смене сети.",
"pass_answer_fiat": "Нет. Когда 5chan Pass запустится, оплата будет только криптовалютой.",
"pass_answer_bypass": "Нет. Pass не предназначен для обхода банов, ограничений на публикацию или модерации борда. Каждый board по-прежнему устанавливает и применяет свои собственные правила.",
"pass_answer_privileges": "Нет. Покупка pass не сделает кого-либо moderator, admin или владельцем board. Это покупка в поддержку проекта, а не обход системы управления."
"pass_answer_privileges": "Нет. Покупка pass не сделает кого-либо moderator, admin или владельцем board. Это покупка в поддержку проекта, а не обход системы управления.",
"more_threads_last_week": "{{count}} тредов с {{currentTimeFilterName}} — [<1>Показать больше тредов за прошлую неделю</1>]",
"more_threads_last_month": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого месяца</1>]",
"more_threads_last_year": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого года</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Jo. Meqenëse 5chan Pass është planifikuar si një kredencial i bazuar në NFT, ai nuk synohet të varet nga një adresë IP fikse. Nuk ka asnjë kufizim të synuar bazuar te përdorimi i VPN, proxy-ve ose ndryshimi i rrjeteve.",
"pass_answer_fiat": "Jo. Kur të lançohet 5chan Pass, pagesa do të bëhet vetëm me kripto.",
"pass_answer_bypass": "Jo. Një pass nuk është mënyrë për të anashkaluar ndalimet, kufizimet e postimit ose moderimin e një board-i. Çdo board vazhdon të vendosë dhe zbatojë rregullat e veta.",
"pass_answer_privileges": "Jo. Blerja e një pass nuk do ta bëjë dikë moderator, admin ose pronar të një board-i. Është një blerje mbështetëse, jo një anashkalim i qeverisjes."
"pass_answer_privileges": "Jo. Blerja e një pass nuk do ta bëjë dikë moderator, admin ose pronar të një board-i. Është një blerje mbështetëse, jo një anashkalim i qeverisjes.",
"more_threads_last_week": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga java e kaluar</1>]",
"more_threads_last_month": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga muaji i kaluar</1>]",
"more_threads_last_year": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga viti i kaluar</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Nej. Eftersom 5chan Pass är planerat som en NFT-baserad behörighet är det inte tänkt att vara beroende av en fast IP-adress. Det finns ingen avsedd begränsning baserad på användning av VPN, proxyservrar eller byte av nätverk.",
"pass_answer_fiat": "Nej. När 5chan Pass lanseras kommer betalning endast att vara möjlig med krypto.",
"pass_answer_bypass": "Nej. Ett pass är inte ett sätt att kringgå avstängningar, postningsbegränsningar eller modereringen av ett board. Varje board sätter fortfarande sina egna regler och upprätthåller dem.",
"pass_answer_privileges": "Nej. Att köpa ett pass gör inte någon till moderator, admin eller ägare av en board. Det är ett stödköp, inte en åsidosättning av governance."
"pass_answer_privileges": "Nej. Att köpa ett pass gör inte någon till moderator, admin eller ägare av en board. Det är ett stödköp, inte en åsidosättning av governance.",
"more_threads_last_week": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra veckan</1>]",
"more_threads_last_month": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra månaden</1>]",
"more_threads_last_year": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra året</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "కాదు. 5chan Pass ను NFT-ఆధారిత credential గా ప్రణాళిక చేస్తున్నందున, అది స్థిరమైన IP address పై ఆధారపడేలా ఉద్దేశించబడలేదు. VPN వినియోగం, proxyలు, లేదా నెట్‌వర్క్‌లు మారడం ఆధారంగా ఎలాంటి ఉద్దేశిత పరిమితి లేదు.",
"pass_answer_fiat": "కాదు. 5chan Pass ప్రారంభమైనప్పుడు, చెల్లింపు క్రిప్టోతో మాత్రమే ఉంటుంది.",
"pass_answer_bypass": "కాదు. Pass అనేది బ్యాన్‌లు, పోస్టింగ్ పరిమితులు లేదా board moderation‌ను తప్పించుకునే మార్గం కాదు. ప్రతి board ఇప్పటికీ తన స్వంత నియమాలను నిర్ణయించి అమలు చేస్తుంది.",
"pass_answer_privileges": "కాదు. ఒక pass కొనడం వల్ల ఎవరూ moderator, admin లేదా ఏదైనా board యజమాని కాలేరు. ఇది మద్దతుదారుల కొనుగోలు మాత్రమే, పాలనను దాటవేయడం కాదు."
"pass_answer_privileges": "కాదు. ఒక pass కొనడం వల్ల ఎవరూ moderator, admin లేదా ఏదైనా board యజమాని కాలేరు. ఇది మద్దతుదారుల కొనుగోలు మాత్రమే, పాలనను దాటవేయడం కాదు.",
"more_threads_last_week": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత వారానికి మరిన్ని థ్రెడ్‌లు చూపించు</1>]",
"more_threads_last_month": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత నెల నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</1>]",
"more_threads_last_year": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత సంవత్సరం నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "ไม่ใช่ เนื่องจากมีแผนให้ 5chan Pass เป็น credential ที่อิงกับ NFT จึงไม่ได้ตั้งใจให้ต้องพึ่งพา IP address แบบคงที่ ไม่มีข้อจำกัดที่ตั้งใจไว้โดยอิงจากการใช้ VPN, proxy หรือการเปลี่ยนเครือข่าย",
"pass_answer_fiat": "ไม่. เมื่อ 5chan Pass เปิดตัว การชำระเงินจะรองรับเฉพาะคริปโตเท่านั้น",
"pass_answer_bypass": "ไม่ใช่ Pass ไม่ใช่วิธีหลีกเลี่ยงการแบน ข้อจำกัดในการโพสต์ หรือการดูแล board แต่ละ board ยังคงตั้งและบังคับใช้กฎของตัวเอง",
"pass_answer_privileges": "ไม่ การซื้อ pass จะไม่ทำให้ใครกลายเป็น moderator, admin หรือเจ้าของ board นี่คือการซื้อเพื่อสนับสนุน ไม่ใช่การลบล้างการกำกับดูแล"
"pass_answer_privileges": "ไม่ การซื้อ pass จะไม่ทำให้ใครกลายเป็น moderator, admin หรือเจ้าของ board นี่คือการซื้อเพื่อสนับสนุน ไม่ใช่การลบล้างการกำกับดูแล",
"more_threads_last_week": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้เพิ่มเติมจากสัปดาห์ที่แล้ว</1>]",
"more_threads_last_month": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากเดือนที่แล้วเพิ่มเติม</1>]",
"more_threads_last_year": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากปีที่แล้วเพิ่มเติม</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Hayır. 5chan Pass, NFT tabanlı bir kimlik bilgisi olarak planlandığı için sabit bir IP adresine bağlı olması amaçlanmamıştır. VPN kullanımı, proxy'ler veya ağ değiştirme temelinde amaçlanan bir kısıtlama yoktur.",
"pass_answer_fiat": "Hayır. 5chan Pass kullanıma sunulduğunda, ödeme yalnızca kripto ile yapılabilecek.",
"pass_answer_bypass": "Hayır. Pass, banları, gönderi kısıtlamalarını veya bir boardun moderasyonunu aşmanın bir yolu değildir. Her board kendi kurallarını belirlemeye ve uygulamaya devam eder.",
"pass_answer_privileges": "Hayır. Bir pass satın almak kimseyi moderator, admin ya da bir boardun sahibi yapmaz. Bu bir destekçi satın alımıdır, governance'i geçersiz kılma yolu değildir."
"pass_answer_privileges": "Hayır. Bir pass satın almak kimseyi moderator, admin ya da bir boardun sahibi yapmaz. Bu bir destekçi satın alımıdır, governance'i geçersiz kılma yolu değildir.",
"more_threads_last_week": "{{count}} başlık {{currentTimeFilterName}}den itibaren — [<1>Geçen haftadan daha fazla başlık göster</1>]",
"more_threads_last_month": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen aydan daha fazla konu göster</1>]",
"more_threads_last_year": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen yıldan daha fazla konu göster</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Ні. Оскільки 5chan Pass планується як облікові дані на основі NFT, він не має залежати від фіксованої IP-адреси. Не передбачається жодних обмежень на основі використання VPN, проксі або зміни мереж.",
"pass_answer_fiat": "Ні. Коли 5chan Pass буде запущено, оплата буде лише криптовалютою.",
"pass_answer_bypass": "Ні. Pass не є способом обійти бани, обмеження на дописи чи модерацію борду. Кожен board і далі встановлює та забезпечує виконання власних правил.",
"pass_answer_privileges": "Ні. Купівля pass не зробить когось moderator, admin або власником board. Це покупка на підтримку, а не спосіб обійти систему врядування."
"pass_answer_privileges": "Ні. Купівля pass не зробить когось moderator, admin або власником board. Це покупка на підтримку, а не спосіб обійти систему врядування.",
"more_threads_last_week": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого тижня</1>]",
"more_threads_last_month": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого місяця</1>]",
"more_threads_last_year": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого року</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "نہیں۔ کیونکہ 5chan Pass کو NFT-based credential کے طور پر منصوبہ بنایا گیا ہے، اس کا مقصد کسی مقررہ IP address پر منحصر ہونا نہیں ہے۔ VPN کے استعمال، proxy، یا نیٹ ورک بدلنے کی بنیاد پر کوئی طے شدہ پابندی نہیں ہے۔",
"pass_answer_fiat": "نہیں۔ جب 5chan Pass لانچ ہوگا، ادائیگی صرف crypto میں ہوگی۔",
"pass_answer_bypass": "نہیں۔ پاس پابندیوں، پوسٹنگ کی قدغنوں، یا board moderation سے بچ نکلنے کا طریقہ نہیں ہے۔ ہر board اب بھی اپنے اصول خود طے کرتا ہے اور انہیں نافذ کرتا ہے۔",
"pass_answer_privileges": "نہیں۔ pass خریدنے سے کوئی شخص moderator، admin، یا کسی board کا مالک نہیں بنے گا۔ یہ حمایتی خرید ہے، گورننس کو نظرانداز کرنے کا طریقہ نہیں۔"
"pass_answer_privileges": "نہیں۔ pass خریدنے سے کوئی شخص moderator، admin، یا کسی board کا مالک نہیں بنے گا۔ یہ حمایتی خرید ہے، گورننس کو نظرانداز کرنے کا طریقہ نہیں۔",
"more_threads_last_week": "{{count}} تھریڈ {{currentTimeFilterName}} سے — [<1>گزشتہ ہفتے کے مزید تھریڈ دیکھیں</1>]",
"more_threads_last_month": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>پچھلے ماہ کے مزید تھریڈز دکھائیں</1>]",
"more_threads_last_year": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>گزشتہ سال کے مزید تھریڈز دکھائیں</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "Không. Vì 5chan Pass được lên kế hoạch như một credential dựa trên NFT, nó không được thiết kế để phụ thuộc vào một địa chỉ IP cố định. Không có hạn chế nào được dự định dựa trên việc sử dụng VPN, proxy hoặc thay đổi mạng.",
"pass_answer_fiat": "Không. Khi 5chan Pass ra mắt, thanh toán sẽ chỉ dùng crypto.",
"pass_answer_bypass": "Không. Pass không phải là cách để lách lệnh cấm, hạn chế đăng bài hoặc việc kiểm duyệt của board. Mỗi board vẫn tự đặt ra và thực thi các quy tắc riêng của mình.",
"pass_answer_privileges": "Không. Mua pass sẽ không biến ai đó thành moderator, admin hoặc chủ sở hữu của một board. Đây là một khoản mua để ủng hộ, không phải cách ghi đè cơ chế quản trị."
"pass_answer_privileges": "Không. Mua pass sẽ không biến ai đó thành moderator, admin hoặc chủ sở hữu của một board. Đây là một khoản mua để ủng hộ, không phải cách ghi đè cơ chế quản trị.",
"more_threads_last_week": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Xem thêm chủ đề từ tuần trước</1>]",
"more_threads_last_month": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ tháng trước</1>]",
"more_threads_last_year": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ năm ngoái</1>]"
}
+4 -1
View File
@@ -360,5 +360,8 @@
"pass_answer_vpn": "不是。由于 5chan Pass 计划作为基于 NFT 的凭证,因此并不打算依赖固定的 IP 地址。并没有基于使用 VPN、代理或更换网络的预期限制。",
"pass_answer_fiat": "不会。5chan Pass 上线后,支付将仅支持加密货币。",
"pass_answer_bypass": "不是。Pass 不是绕过封禁、发帖限制或 board 管理的办法。每个 board 仍然会制定并执行自己的规则。",
"pass_answer_privileges": "不会。购买 pass 并不会让任何人成为版主、管理员或某个 board 的所有者。这是一种支持性购买,不是对治理规则的覆盖。"
"pass_answer_privileges": "不会。购买 pass 并不会让任何人成为版主、管理员或某个 board 的所有者。这是一种支持性购买,不是对治理规则的覆盖。",
"more_threads_last_week": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多来自上周的主题</1>]",
"more_threads_last_month": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多上个月的主题</1>]",
"more_threads_last_year": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多去年的主题</1>]"
}
+1
View File
@@ -111,6 +111,7 @@ function makeNamedComponent(name: string) {
vi.mock('../components/board-buttons', () => ({
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
}));
+1 -1
View File
@@ -298,7 +298,7 @@ const App = () => {
}
/>
<Route element={<BoardLayout />}>
{/* Canonical multiboard routes (no time filter) */}
{/* Canonical multiboard routes (time filter lives in ?t=) */}
<Route path='/all' element={boardFeedElement} />
<Route path='/all/settings' element={boardFeedElement} />
<Route path='/all/catalog' element={catalogFeedElement} />
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { 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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -184,7 +185,12 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string
createElement(
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<HTMLSelectElement>('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<HTMLInputElement>('input[type="text"]');
expect(searchInput).toBeTruthy();
await act(async () => {
if (searchInput) {
searchInput.value = 'cats';
searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
}
});
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last&q=cats' });
});
it('lets multiboard views switch back to the last-visit filter alias', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - (2 * 24 * 60 * 60 + 1) * 1000));
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=1w');
const timeFilterSelect = Array.from(container.querySelectorAll<HTMLSelectElement>('select')).at(-1);
expect(timeFilterSelect).toBeTruthy();
expect(Array.from(timeFilterSelect?.options || []).some((option) => option.value === 'last')).toBe(true);
await changeSelect(timeFilterSelect!, 'last');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last' });
});
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
testState.commentsByCid = {
'comment-1': {
+112 -28
View File
@@ -17,11 +17,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import 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<BoardButtonsProps, 'isInAllView' | 'isInCatalogView' | 'isInSubscriptionsView' | 'isInModView'>) => {
if (isInAllView) {
return isInCatalogView ? '/all/catalog' : '/all';
}
if (isInSubscriptionsView) {
return isInCatalogView ? '/subs/catalog' : '/subs';
}
if (isInModView) {
return isInCatalogView ? '/mod/catalog' : '/mod';
}
return null;
};
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
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<HTMLSelectElement>) => {
navigate({
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, event.target.value),
});
};
return (
<>
{!isTopbar && (
<>
<span>{t('filter')}</span>:&nbsp;
</>
)}
<select onChange={changeTimeFilter} className={[styles.feedName, styles.menuItem, 'capitalize'].join(' ')} value={timeFilterValue}>
{timeFilterValues.map((value) => (
<option key={value} value={value}>
{getTimeFilterOptionLabel(value, lastVisitTimeFilterName)}
</option>
))}
</select>
</>
);
};
const AllFeedFilter = () => {
const { 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 = () => {
</span>
)
)}
{isInAllView && (
{(isInAllView || showTimeFilter || isInCatalogView) && (
<>
<hr />
<div className={styles.options}>
<AllFeedFilter />
</div>
</>
)}
{isInCatalogView && (
<>
<hr />
<div className={styles.options}>
<div>
<SortOptions /> <ImageSizeOptions />
</div>
<div className={styles.mobileCatalogOptionsPadding}>
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
</div>
{(isInAllView || showTimeFilter) && (
<div>
{isInAllView && <AllFeedFilter />}{' '}
{showTimeFilter && (
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
)}
</div>
)}
{isInCatalogView && (
<div className={styles.mobileCatalogOptionsPadding}>
<SortOptions /> <ImageSizeOptions />
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
</div>
)}
</div>
</>
)}
@@ -479,6 +549,16 @@ export const MobileBoardButtons = () => {
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
</div>
{showTimeFilter && (
<>
<hr />
<div className={styles.options}>
<div>
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
</div>
</div>
</>
)}
</>
)}
</div>
@@ -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 && <AllFeedFilter />}
{showTimeFilter && (
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
)}
{showVoteButton && (
<>
[<VoteButton />]
@@ -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);
}
}
};
@@ -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 (
<div className={isVisible ? styles.visible : styles.hidden}>
{feed.type === 'catalog' ? (
<Catalog feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} />
<Catalog
feedCacheKey={feed.key}
viewType={context.viewType}
boardIdentifier={context.boardIdentifier}
timeFilterNameFromCache={context.timeFilterName}
isVisible={isVisible}
/>
) : (
<Board feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} />
<Board
feedCacheKey={feed.key}
viewType={context.viewType}
boardIdentifier={context.boardIdentifier}
timeFilterNameFromCache={context.timeFilterName}
isVisible={isVisible}
/>
)}
</div>
);
@@ -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);
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
interface UseSuggestionFeedLoaderOptions {
currentFeedLength: number;
feedLength: number;
hasMore: boolean;
loadMore: () => Promise<void>;
requestKey: string;
shouldLoad: boolean;
}
const EMPTY_REQUEST_KEY = '';
export const useSuggestionFeedLoader = ({ currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad }: UseSuggestionFeedLoaderOptions) => {
const lastRequestKeyRef = useRef(EMPTY_REQUEST_KEY);
useEffect(() => {
if (!shouldLoad || !requestKey || !hasMore || feedLength > currentFeedLength) {
lastRequestKeyRef.current = EMPTY_REQUEST_KEY;
return;
}
const nextRequestKey = `${requestKey}:${currentFeedLength}:${feedLength}`;
if (lastRequestKeyRef.current === nextRequestKey) {
return;
}
lastRequestKeyRef.current = nextRequestKey;
void loadMore();
}, [currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad]);
};
+56
View File
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
getEffectiveTimeFilterName,
getEffectiveTimeFilterSeconds,
getStableLastVisitTimeFilterName,
getSelectedTimeFilterValue,
getTimeFilterOptionValues,
touchLastVisitTimestamp,
} from '../lib/utils/time-filter-utils';
declare global {
interface Window {
_5chanLastVisitTimestampIntervalId?: number;
_5chanLastVisitTimestampSubscribers?: number;
}
}
const ensureLastVisitTimestampTracking = (): (() => void) => {
if (typeof window === 'undefined') {
return () => {};
}
touchLastVisitTimestamp();
window._5chanLastVisitTimestampSubscribers = (window._5chanLastVisitTimestampSubscribers || 0) + 1;
if (!window._5chanLastVisitTimestampIntervalId) {
window._5chanLastVisitTimestampIntervalId = window.setInterval(() => {
touchLastVisitTimestamp();
}, 60 * 1000);
}
return () => {
window._5chanLastVisitTimestampSubscribers = Math.max((window._5chanLastVisitTimestampSubscribers || 1) - 1, 0);
if (window._5chanLastVisitTimestampSubscribers === 0 && window._5chanLastVisitTimestampIntervalId) {
window.clearInterval(window._5chanLastVisitTimestampIntervalId);
delete window._5chanLastVisitTimestampIntervalId;
}
};
};
const useTimeFilter = (timeFilterNameOverride?: string) => {
const location = useLocation();
useEffect(() => ensureLastVisitTimestampTracking(), []);
const lastVisitTimeFilterName = getStableLastVisitTimeFilterName();
const timeFilterValue = timeFilterNameOverride || getSelectedTimeFilterValue(location.search);
const timeFilterName = timeFilterNameOverride || getEffectiveTimeFilterName(location.search, lastVisitTimeFilterName);
const timeFilterSeconds = getEffectiveTimeFilterSeconds('', timeFilterName);
const timeFilterValues = getTimeFilterOptionValues(lastVisitTimeFilterName);
return { timeFilterSeconds, timeFilterName, timeFilterValue, timeFilterValues, lastVisitTimeFilterName };
};
export default useTimeFilter;
+21 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
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();
+8 -2
View File
@@ -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;
+249
View File
@@ -0,0 +1,249 @@
const DAY_IN_SECONDS = 24 * 60 * 60;
const LAST_VISIT_STORAGE_KEY = '5chanLastVisitTimestamp';
const LAST_VISIT_TIME_FILTER_VALUE = 'last';
const TIME_FILTER_QUERY_PARAM = 't';
const FALLBACK_TIME_FILTER_NAME = '24h';
const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS;
const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS;
const YEAR_IN_SECONDS = 365 * DAY_IN_SECONDS;
declare global {
interface Window {
_5chanLastVisitTimeFilterSnapshot?: string;
}
}
const presetTimeFilterSeconds: Record<string, number | undefined> = {
'1h': 60 * 60,
'12h': 12 * 60 * 60,
'24h': DAY_IN_SECONDS,
'48h': 2 * DAY_IN_SECONDS,
'1w': WEEK_IN_SECONDS,
'1m': MONTH_IN_SECONDS,
'1y': YEAR_IN_SECONDS,
all: undefined,
};
const timeFilterPresetNames = ['1h', '12h', '24h', '48h', '1w', '1m', '1y', 'all'] as const;
export { LAST_VISIT_STORAGE_KEY, TIME_FILTER_QUERY_PARAM };
const convertDynamicTimeFilterNameToSeconds = (timeFilterName: string): number | undefined => {
const match = timeFilterName.match(/^(\d+)([hdwmy])$/);
if (!match) {
return undefined;
}
const [, rawValue, unit] = match;
const value = Number.parseInt(rawValue, 10);
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
switch (unit) {
case 'h':
return value * 60 * 60;
case 'd':
return value * DAY_IN_SECONDS;
case 'w':
return value * 7 * DAY_IN_SECONDS;
case 'm':
return value * MONTH_IN_SECONDS;
case 'y':
return value * YEAR_IN_SECONDS;
default:
return undefined;
}
};
const getTimeFilterSeconds = (timeFilterName: string | null | undefined): number | undefined => {
if (!timeFilterName || timeFilterName === 'all') {
return undefined;
}
if (timeFilterName in presetTimeFilterSeconds) {
return presetTimeFilterSeconds[timeFilterName as keyof typeof presetTimeFilterSeconds];
}
return convertDynamicTimeFilterNameToSeconds(timeFilterName);
};
const isValidTimeFilterName = (timeFilterName: string | null | undefined): timeFilterName is string =>
timeFilterName === 'all' || typeof getTimeFilterSeconds(timeFilterName) === 'number';
const readLastVisitTimestamp = (): number | null => {
if (typeof window === 'undefined') {
return null;
}
const storedValue = window.localStorage.getItem(LAST_VISIT_STORAGE_KEY);
if (!storedValue) {
return null;
}
const parsedValue = Number.parseInt(storedValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : null;
};
export const touchLastVisitTimestamp = (timestamp = Date.now()) => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(timestamp));
};
const getLastVisitTimeFilterName = (now = Date.now(), lastVisitTimestamp = readLastVisitTimestamp()): string => {
const secondsSinceLastVisit = lastVisitTimestamp ? (now - lastVisitTimestamp) / 1000 : Number.POSITIVE_INFINITY;
if (secondsSinceLastVisit > 30 * DAY_IN_SECONDS) {
return '1m';
}
if (secondsSinceLastVisit > 7 * DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / (7 * DAY_IN_SECONDS))}w`;
}
if (secondsSinceLastVisit > DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / DAY_IN_SECONDS)}d`;
}
return FALLBACK_TIME_FILTER_NAME;
};
export const getStableLastVisitTimeFilterName = (): string => {
if (typeof window === 'undefined') {
return getLastVisitTimeFilterName();
}
if (!window._5chanLastVisitTimeFilterSnapshot) {
window._5chanLastVisitTimeFilterSnapshot = getLastVisitTimeFilterName();
}
return window._5chanLastVisitTimeFilterSnapshot;
};
export const clearStableLastVisitTimeFilterName = () => {
if (typeof window === 'undefined') {
return;
}
delete window._5chanLastVisitTimeFilterSnapshot;
};
const getExplicitTimeFilterValueFromSearch = (search: string): string | undefined => {
const timeFilterValue = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM);
if (timeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return timeFilterValue;
}
return isValidTimeFilterName(timeFilterValue) ? timeFilterValue : undefined;
};
export const getEffectiveTimeFilterName = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): string => {
const explicitTimeFilterValue = getExplicitTimeFilterValueFromSearch(search);
if (explicitTimeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return fallbackTimeFilterName;
}
return explicitTimeFilterValue ?? fallbackTimeFilterName;
};
export const getSelectedTimeFilterValue = (search: string): string => getExplicitTimeFilterValueFromSearch(search) ?? LAST_VISIT_TIME_FILTER_VALUE;
export const getTimeFilterOptionValues = (lastVisitTimeFilterName: string): string[] => {
const optionValues = [LAST_VISIT_TIME_FILTER_VALUE, ...timeFilterPresetNames];
const seenLabels = new Set<string>();
return optionValues.filter((value) => {
const label = value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
if (seenLabels.has(label)) {
return false;
}
seenLabels.add(label);
return true;
});
};
export const getTimeFilterOptionLabel = (value: string, lastVisitTimeFilterName: string): string =>
value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
export const getEffectiveTimeFilterSeconds = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): number | undefined => {
const effectiveTimeFilterName = getEffectiveTimeFilterName(search, fallbackTimeFilterName);
const effectiveTimeFilterSeconds = getTimeFilterSeconds(effectiveTimeFilterName);
if (effectiveTimeFilterName === 'all') {
return undefined;
}
return effectiveTimeFilterSeconds ?? presetTimeFilterSeconds[FALLBACK_TIME_FILTER_NAME];
};
export const getSearchWithTimeFilter = (
search: string,
timeFilterName: string | undefined,
options?: {
removeKeys?: string[];
},
): string => {
const params = new URLSearchParams(search);
for (const key of options?.removeKeys || []) {
params.delete(key);
}
if (timeFilterName) {
params.set(TIME_FILTER_QUERY_PARAM, timeFilterName);
} else {
params.delete(TIME_FILTER_QUERY_PARAM);
}
const nextSearch = params.toString();
return nextSearch ? `?${nextSearch}` : '';
};
export type TimeFilterSuggestion =
| {
i18nKey: 'more_threads_last_week';
timeFilterName: '1w';
}
| {
i18nKey: 'more_threads_last_month';
timeFilterName: '1m';
}
| {
i18nKey: 'more_threads_last_year';
timeFilterName: '1y';
};
export const getTimeFilterSuggestion = (
currentFeedLength: number,
weeklyFeedLength: number,
monthlyFeedLength: number,
yearlyFeedLength: number,
currentTimeFilterSeconds?: number,
): TimeFilterSuggestion | null => {
if (typeof currentTimeFilterSeconds !== 'number') {
return null;
}
if (currentTimeFilterSeconds < WEEK_IN_SECONDS && weeklyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_week',
timeFilterName: '1w',
};
}
if (currentTimeFilterSeconds < MONTH_IN_SECONDS && monthlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_month',
timeFilterName: '1m',
};
}
if (currentTimeFilterSeconds < YEAR_IN_SECONDS && yearlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_year',
timeFilterName: '1y',
};
}
return null;
};
+141 -6
View File
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { 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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -32,9 +33,11 @@ const testState = vi.hoisted(() => ({
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
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<number>(),
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<number, React.ReactNode>; 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 () => {
@@ -467,6 +510,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'),
+6 -1
View File
@@ -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;
}
}
+117 -11
View File
@@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate, useNavigationType, useParams } from 're
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks';
import { 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 = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads');
}
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<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}${location.search}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType();
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 1200, top: 2400 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
+148 -8
View File
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { 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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -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<string, { address: string; features?: Record<string, unknown> }>,
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<number>(),
resetMock: vi.fn(),
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
searchText: '',
@@ -89,6 +92,7 @@ function useCatalogFiltersStoreMock<T>(selector?: (state: ReturnType<typeof getC
useCatalogFiltersStoreMock.getState = getCatalogFiltersState;
vi.mock('react-i18next', () => ({
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactNode>; 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<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
expect(testState.loadMoreMock.mock.calls.length).toBe(loadMoreCallCountBeforeEndReached + 1);
});
it('passes multiboard time filters to useFeed and keeps query params during canonical redirects', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all', timeFilterNameFromCache: '48h' },
initialEntry: '/all/catalog/7?t=24h',
routePath: '/all/*',
});
expect(latestLocation).toBe('/all/catalog?t=24h');
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
newerThan: 48 * 60 * 60,
postsPerPage: 24,
sortType: 'new',
}),
]),
);
});
it('shows a wider multiboard catalog time-filter suggestion when older threads exist', async () => {
const now = Math.floor(Date.now() / 1000);
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String((now - 3 * 24 * 60 * 60) * 1000));
testState.feed = [
{ cid: 'recent-post', title: 'recent', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 },
{ cid: 'older-post', title: 'older', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=last',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60 }),
]),
);
expect(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all/catalog?t=1w')).toBe(true);
});
it('keeps broader catalog suggestion feeds on the base page size so their identities stay stable while scrolling', async () => {
const now = Math.floor(Date.now() / 1000);
testState.feed = [
...Array.from({ length: 25 }, (_, index) => ({
cid: `post-${index + 1}`,
title: `thread ${index + 1}`,
communityAddress: 'music-posting.eth',
timestamp: now - (index + 1) * 60 * 60,
})),
{ cid: 'older-post', title: 'older thread', communityAddress: 'music-posting.eth', timestamp: now - 20 * 24 * 60 * 60 },
];
testState.pageSizes = {
guiPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.respectPostsPerPageForNewerThan = new Set([30 * 24 * 60 * 60, 365 * 24 * 60 * 60]);
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=1w',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
]),
);
});
it('disables broader suggestion probe feeds when the multiboard filter is already all', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=all',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }),
]),
);
});
it('captures Virtuoso state on pagehide instead of wiring a scroll hot-path listener', async () => {
+5 -1
View File
@@ -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;
}
}
}
+191 -16
View File
@@ -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 = <T,>(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 = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads');
}
if (hasMore || (communityAddresses && communityAddresses.length === 0)) {
footerContent = (
<>
{footerContent}
{showLoadingEllipsis && (
<div className={styles.stateString}>
<LoadingEllipsis string={loadingStateString} />
@@ -113,6 +150,7 @@ const createContentFilter = (
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
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: () => (
<>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
@@ -395,12 +535,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</>
),
}),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
[
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
);
const catalogFooter = useMemo(
() => (
<>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
@@ -421,7 +583,20 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</PageFooterMobile>
</>
),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
[
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
@@ -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<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}${location.search}-${sortType}-catalog`;
const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false);