@@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@capacitor/app": "6.0.1",
|
||||
"@floating-ui/react": "0.26.1",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7",
|
||||
"@react-spring/web": "9.7.5",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "13.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 150 B After Width: | Height: | Size: 150 B |
|
Before Width: | Height: | Size: 138 B After Width: | Height: | Size: 138 B |
|
Before Width: | Height: | Size: 147 B After Width: | Height: | Size: 147 B |
|
Before Width: | Height: | Size: 157 B After Width: | Height: | Size: 157 B |
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "تم إزالة هذا الموضوع بواسطة مشرفي المنتدى",
|
||||
"this_reply_was_deleted": "تم حذف هذه الرد من قبل مؤلفه",
|
||||
"this_reply_was_removed": "تمت إزالة هذه الرد من قبل مشرفي المنتدى",
|
||||
"not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة."
|
||||
"not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة.",
|
||||
"filters_and_highlights": "الفلاتر والإبرازات",
|
||||
"search_results_for": "نتائج البحث عن",
|
||||
"help": "مساعدة",
|
||||
"filter_and_highlights_help": "مساعدة الفلاتر والإبرازات",
|
||||
"filter_and_highlights": "التصفية والتسليط الضوء"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "এই থ্রেডটি বোর্ড moderators দ্বারা মুছে ফেলা হয়েছে",
|
||||
"this_reply_was_deleted": "এই প্রতিক্রিয়া তার লেখক দ্বারা মুছে ফেলা হয়েছে",
|
||||
"this_reply_was_removed": "এই প্রতিক্রিয়া বোর্ড পরিচালকদের দ্বারা সরানো হয়েছে",
|
||||
"not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।"
|
||||
"not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।",
|
||||
"filters_and_highlights": "ফিল্টার এবং হাইলাইট",
|
||||
"search_results_for": "অনুসন্ধান ফলাফল",
|
||||
"help": "সাহায্য",
|
||||
"filter_and_highlights_help": "ফিল্টার এবং হাইলাইট সাহায্য",
|
||||
"filter_and_highlights": "ফিল্টার এবং হাইলাইট"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Čeká na schválení",
|
||||
"failed": "Nepodařilo se",
|
||||
"add": "Přidat",
|
||||
"pattern": "Vzor",
|
||||
"pattern": "vzor",
|
||||
"enable": "Povolit",
|
||||
"order": "Pořadí",
|
||||
"text": "Text",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Toto vlákno bylo odstraněno moderátory fóra",
|
||||
"this_reply_was_deleted": "Tato odpověď byla smazána autorem",
|
||||
"this_reply_was_removed": "Tato odpověď byla odstraněna moderátory fóra",
|
||||
"not_mod_of_any_board": "Nejste moderátor žádné desky."
|
||||
"not_mod_of_any_board": "Nejste moderátor žádné desky.",
|
||||
"filters_and_highlights": "Filtry a zvýraznění",
|
||||
"search_results_for": "Výsledky hledání pro",
|
||||
"help": "Pomoc",
|
||||
"filter_and_highlights_help": "Pomoc s filtry a zvýrazněním",
|
||||
"filter_and_highlights": "Filtry a zvýraznění"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Afventer",
|
||||
"failed": "Mislykkedes",
|
||||
"add": "Tilføj",
|
||||
"pattern": "Mønster",
|
||||
"pattern": "mønster",
|
||||
"enable": "Aktiver",
|
||||
"order": "Rækkefølge",
|
||||
"text": "Tekst",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Denne tråd blev fjernet af boardmoderatorer",
|
||||
"this_reply_was_deleted": "Dette svar blev slettet af forfatteren",
|
||||
"this_reply_was_removed": "Dette svar blev fjernet af forummoderatorer",
|
||||
"not_mod_of_any_board": "Du er ikke moderator for nogen tavler."
|
||||
"not_mod_of_any_board": "Du er ikke moderator for nogen tavler.",
|
||||
"filters_and_highlights": "Filtre og fremhævninger",
|
||||
"search_results_for": "Søgeresultater for",
|
||||
"help": "Hjælp",
|
||||
"filter_and_highlights_help": "Filtre og fremhævelser Hjælp",
|
||||
"filter_and_highlights": "Filtre og fremhævninger"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Dieser Thread wurde von den Moderatoren des Boards entfernt",
|
||||
"this_reply_was_deleted": "Diese Antwort wurde vom Autor gelöscht",
|
||||
"this_reply_was_removed": "Diese Antwort wurde von den Forenmoderatoren entfernt",
|
||||
"not_mod_of_any_board": "Du bist kein Moderator eines Boards."
|
||||
"not_mod_of_any_board": "Du bist kein Moderator eines Boards.",
|
||||
"filters_and_highlights": "Filter & Highlights",
|
||||
"search_results_for": "Suchergebnisse für",
|
||||
"help": "Hilfe",
|
||||
"filter_and_highlights_help": "Filter- und Markierungs-Hilfe",
|
||||
"filter_and_highlights": "Filter und Hervorhebungen"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Σε εκκρεμότητα",
|
||||
"failed": "Απέτυχε",
|
||||
"add": "Προσθήκη",
|
||||
"pattern": "Μοτίβο",
|
||||
"pattern": "μοτίβο",
|
||||
"enable": "Ενεργοποιήστε",
|
||||
"order": "Σειρά",
|
||||
"text": "Κείμενο",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Αυτή η συζήτηση αφαιρέθηκε από τους διαχειριστές του φόρουμ",
|
||||
"this_reply_was_deleted": "Αυτή η απάντηση διαγράφηκε από τον συγγραφέα της",
|
||||
"this_reply_was_removed": "Η απάντηση αυτή αφαιρέθηκε από τους διαχειριστές του φόρουμ",
|
||||
"not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα."
|
||||
"not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα.",
|
||||
"filters_and_highlights": "Φίλτρα και επισημάνσεις",
|
||||
"search_results_for": "Αποτελέσματα αναζήτησης για",
|
||||
"help": "Βοήθεια",
|
||||
"filter_and_highlights_help": "Βοήθεια φίλτρων και επισήμανσης",
|
||||
"filter_and_highlights": "Φίλτρα και Επισημάνσεις"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Pending",
|
||||
"failed": "Failed",
|
||||
"add": "Add",
|
||||
"pattern": "Pattern",
|
||||
"pattern": "pattern",
|
||||
"enable": "Enable",
|
||||
"order": "Order",
|
||||
"text": "Text",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "This thread was removed by board moderators",
|
||||
"this_reply_was_deleted": "This reply was deleted by its author",
|
||||
"this_reply_was_removed": "This reply was removed by board moderators",
|
||||
"not_mod_of_any_board": "You are not a mod of any board."
|
||||
"not_mod_of_any_board": "You are not a mod of any board.",
|
||||
"filters_and_highlights": "Filters & Highlights",
|
||||
"search_results_for": "Search results for",
|
||||
"help": "Help",
|
||||
"filter_and_highlights_help": "Filters & Highlights Help",
|
||||
"filter_and_highlights": "Filters & Highlights"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Pendiente",
|
||||
"failed": "Fallido",
|
||||
"add": "Añadir",
|
||||
"pattern": "Patrón",
|
||||
"pattern": "patrón",
|
||||
"enable": "Habilitar",
|
||||
"order": "Orden",
|
||||
"text": "Texto",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Este hilo fue eliminado por los moderadores del foro",
|
||||
"this_reply_was_deleted": "Esta respuesta fue eliminada por su autor",
|
||||
"this_reply_was_removed": "Esta respuesta fue eliminada por los moderadores del foro",
|
||||
"not_mod_of_any_board": "No eres moderador de ningún tablero."
|
||||
"not_mod_of_any_board": "No eres moderador de ningún tablero.",
|
||||
"filters_and_highlights": "Filtros y resaltar",
|
||||
"search_results_for": "Resultados de búsqueda para",
|
||||
"help": "Ayuda",
|
||||
"filter_and_highlights_help": "Ayuda de filtros y resaltes",
|
||||
"filter_and_highlights": "Filtros y resaltados"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "این تاپیک توسط مدیران انجمن حذف شد",
|
||||
"this_reply_was_deleted": "این پاسخ توسط نویسندهاش حذف شد",
|
||||
"this_reply_was_removed": "این پاسخ توسط مدیران انجمن حذف شد",
|
||||
"not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید."
|
||||
"not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید.",
|
||||
"filters_and_highlights": "فیلترها و هایلایتها",
|
||||
"search_results_for": "نتایج جستجو برای",
|
||||
"help": "کمک",
|
||||
"filter_and_highlights_help": "کمک فیلترها و برجستهها",
|
||||
"filter_and_highlights": "فیلترها و برجستهها"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Odottaa",
|
||||
"failed": "Epäonnistui",
|
||||
"add": "Lisää",
|
||||
"pattern": "Malli",
|
||||
"pattern": "malli",
|
||||
"enable": "Ota käyttöön",
|
||||
"order": "Järjestys",
|
||||
"text": "Teksti",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Tämä ketju poistettiin foorumin moderaattoreiden toimesta",
|
||||
"this_reply_was_deleted": "Tämä vastaus poistettiin sen kirjoittajalta",
|
||||
"this_reply_was_removed": "Tämä vastaus poistettiin foorumin moderaattoreiden toimesta",
|
||||
"not_mod_of_any_board": "Et ole moderaattori missään taulussa."
|
||||
"not_mod_of_any_board": "Et ole moderaattori missään taulussa.",
|
||||
"filters_and_highlights": "Suodattimet ja korostukset",
|
||||
"search_results_for": "Hakutulokset hakusanalle",
|
||||
"help": "Apua",
|
||||
"filter_and_highlights_help": "Suodattimet ja korostukset -apu",
|
||||
"filter_and_highlights": "Suodattimet ja korostukset"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Naghihintay",
|
||||
"failed": "Nabigo",
|
||||
"add": "Idagdag",
|
||||
"pattern": "Pattern",
|
||||
"pattern": "pattern",
|
||||
"enable": "I-enable",
|
||||
"order": "Kaayusan",
|
||||
"text": "Teksto",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Inalis ang thread na ito ng mga moderator ng board",
|
||||
"this_reply_was_deleted": "Ang tugon na ito ay tinanggal ng may-akda nito",
|
||||
"this_reply_was_removed": "Ang sagot na ito ay tinanggal ng mga moderator ng board",
|
||||
"not_mod_of_any_board": "Hindi ka mod ng anumang board."
|
||||
"not_mod_of_any_board": "Hindi ka mod ng anumang board.",
|
||||
"filters_and_highlights": "Mga filter at highlight",
|
||||
"search_results_for": "Mga resulta ng paghahanap para sa",
|
||||
"help": "Tulong",
|
||||
"filter_and_highlights_help": "Tulong sa mga filter at highlight",
|
||||
"filter_and_highlights": "Mga filter at highlight"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "En attente",
|
||||
"failed": "Échoué",
|
||||
"add": "Ajouter",
|
||||
"pattern": "Modèle",
|
||||
"pattern": "motif",
|
||||
"enable": "Activer",
|
||||
"order": "Ordre",
|
||||
"text": "Texte",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Ce sujet a été supprimé par les modérateurs du forum",
|
||||
"this_reply_was_deleted": "Cette réponse a été supprimée par son auteur",
|
||||
"this_reply_was_removed": "Cette réponse a été supprimée par les modérateurs du forum",
|
||||
"not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum."
|
||||
"not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum.",
|
||||
"filters_and_highlights": "Filtres et mises en évidence",
|
||||
"search_results_for": "Résultats de recherche pour",
|
||||
"help": "Aide",
|
||||
"filter_and_highlights_help": "Aide sur les filtres et surlignages",
|
||||
"filter_and_highlights": "Filtres et surlignages"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "ממתין",
|
||||
"failed": "נכשל",
|
||||
"add": "הוסף",
|
||||
"pattern": "תבנית",
|
||||
"pattern": "דפוס",
|
||||
"enable": "הפעל",
|
||||
"order": "סדר",
|
||||
"text": "טקסט",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "השרשור הזה הוסר על ידי מנהלי הפורום",
|
||||
"this_reply_was_deleted": "תשובה זו נמחקה על ידי המחבר שלה",
|
||||
"this_reply_was_removed": "תשובה זו הוסרה על ידי מנהלי הפורום",
|
||||
"not_mod_of_any_board": "אתה לא מוד בכל לוח."
|
||||
"not_mod_of_any_board": "אתה לא מוד בכל לוח.",
|
||||
"filters_and_highlights": "סינונים והדגשות",
|
||||
"search_results_for": "תוצאות חיפוש עבור",
|
||||
"help": "עזרה",
|
||||
"filter_and_highlights_help": "עזרה במסננים והדגשות",
|
||||
"filter_and_highlights": "מסננים והדגשות"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "यह थ्रेड बोर्ड मॉडरेटर्स द्वारा हटा दिया गया था",
|
||||
"this_reply_was_deleted": "इस उत्तर को इसके लेखक द्वारा हटा दिया गया था",
|
||||
"this_reply_was_removed": "यह उत्तर बोर्ड मॉडरेटर द्वारा हटा दिया गया था",
|
||||
"not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।"
|
||||
"not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।",
|
||||
"filters_and_highlights": "फ़िल्टर और हाइलाइट्स",
|
||||
"search_results_for": "के लिए खोज परिणाम",
|
||||
"help": "मदद",
|
||||
"filter_and_highlights_help": "फ़िल्टर और हाइलाइट्स सहायता",
|
||||
"filter_and_highlights": "फ़िल्टर और हाइलाइट्स"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Függőben",
|
||||
"failed": "Sikertelen",
|
||||
"add": "Hozzáad",
|
||||
"pattern": "Minta",
|
||||
"pattern": "minta",
|
||||
"enable": "Engedélyez",
|
||||
"order": "Rendelés",
|
||||
"text": "Szöveg",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Ezt a témát a fórum moderátorai eltávolították",
|
||||
"this_reply_was_deleted": "Ez a válasz törölve lett a szerzője által",
|
||||
"this_reply_was_removed": "Ez a válasz törlésre került a fórum moderátorai által",
|
||||
"not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem."
|
||||
"not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem.",
|
||||
"filters_and_highlights": "Szűrők és kiemelések",
|
||||
"search_results_for": "Keresési eredmények",
|
||||
"help": "Segítség",
|
||||
"filter_and_highlights_help": "Szűrők és kiemelések súgó",
|
||||
"filter_and_highlights": "Szűrők és kiemelések"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Tertunda",
|
||||
"failed": "Gagal",
|
||||
"add": "Tambahkan",
|
||||
"pattern": "Pola",
|
||||
"pattern": "pola",
|
||||
"enable": "Aktifkan",
|
||||
"order": "Urutan",
|
||||
"text": "Teks",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Thread ini dihapus oleh moderator papan",
|
||||
"this_reply_was_deleted": "Balasan ini dihapus oleh penulisnya",
|
||||
"this_reply_was_removed": "Balasan ini dihapus oleh moderator papan",
|
||||
"not_mod_of_any_board": "Anda bukan mod di papan manapun."
|
||||
"not_mod_of_any_board": "Anda bukan mod di papan manapun.",
|
||||
"filters_and_highlights": "Filter dan Sorotan",
|
||||
"search_results_for": "Hasil pencarian untuk",
|
||||
"help": "Bantuan",
|
||||
"filter_and_highlights_help": "Bantuan Filter dan Sorotan",
|
||||
"filter_and_highlights": "Filter dan Sorotan"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "In attesa",
|
||||
"failed": "Fallito",
|
||||
"add": "Aggiungi",
|
||||
"pattern": "Modello",
|
||||
"pattern": "modello",
|
||||
"enable": "Abilita",
|
||||
"order": "Ordine",
|
||||
"text": "Testo",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Questo thread è stato rimosso dai moderatori del board",
|
||||
"this_reply_was_deleted": "Questa risposta è stata eliminata dal suo autore",
|
||||
"this_reply_was_removed": "Questa risposta è stata rimossa dai moderatori del forum",
|
||||
"not_mod_of_any_board": "Non sei un mod di nessuna board."
|
||||
"not_mod_of_any_board": "Non sei un mod di nessuna board.",
|
||||
"filters_and_highlights": "Filtri e Evidenziazioni",
|
||||
"search_results_for": "Risultati di ricerca per",
|
||||
"help": "Aiuto",
|
||||
"filter_and_highlights_help": "Aiuto su Filtri e Evidenziazioni",
|
||||
"filter_and_highlights": "Filtri e Evidenziazioni"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "このスレッドはボードのモデレーターによって削除されました",
|
||||
"this_reply_was_deleted": "この返信はその著者によって削除されました",
|
||||
"this_reply_was_removed": "この返信は掲示板のモデレーターによって削除されました",
|
||||
"not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。"
|
||||
"not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。",
|
||||
"filters_and_highlights": "フィルターとハイライト",
|
||||
"search_results_for": "検索結果",
|
||||
"help": "ヘルプ",
|
||||
"filter_and_highlights_help": "フィルターとハイライトのヘルプ",
|
||||
"filter_and_highlights": "フィルターとハイライト"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "이 스레드는 보드 관리자에 의해 제거되었습니다",
|
||||
"this_reply_was_deleted": "이 답글은 작성자에 의해 삭제되었습니다",
|
||||
"this_reply_was_removed": "이 답글은 게시판 관리자에 의해 삭제되었습니다",
|
||||
"not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다."
|
||||
"not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다.",
|
||||
"filters_and_highlights": "필터 및 하이라이트",
|
||||
"search_results_for": "검색 결과",
|
||||
"help": "도움말",
|
||||
"filter_and_highlights_help": "필터 및 하이라이트 도움말",
|
||||
"filter_and_highlights": "필터 및 하이라이트"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "हे थ्रेड बोर्ड मध्यमाने काढले गेले आहे",
|
||||
"this_reply_was_deleted": "हा प्रतिसाद त्याच्या लेखकाने हटवला आहे",
|
||||
"this_reply_was_removed": "या उत्तराला बोर्ड मॉडरेटरने काढले आहे",
|
||||
"not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत."
|
||||
"not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत.",
|
||||
"filters_and_highlights": "फिल्टर आणि हायलाइट्स",
|
||||
"search_results_for": "सर्च परिणाम",
|
||||
"help": "मदत",
|
||||
"filter_and_highlights_help": "फिल्टर आणि हायलाइट्स सहाय्य",
|
||||
"filter_and_highlights": "फिल्टर आणि हायलाइट्स"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "In afwachting",
|
||||
"failed": "Mislukt",
|
||||
"add": "Toevoegen",
|
||||
"pattern": "Patroon",
|
||||
"pattern": "patroon",
|
||||
"enable": "Inschakelen",
|
||||
"order": "Volgorde",
|
||||
"text": "Tekst",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Deze thread werd verwijderd door boardmoderators",
|
||||
"this_reply_was_deleted": "Dit antwoord werd verwijderd door de auteur",
|
||||
"this_reply_was_removed": "Dit antwoord is verwijderd door de forum moderators",
|
||||
"not_mod_of_any_board": "Je bent geen mod van een bord."
|
||||
"not_mod_of_any_board": "Je bent geen mod van een bord.",
|
||||
"filters_and_highlights": "Filters en markeringen",
|
||||
"search_results_for": "Zoekresultaten voor",
|
||||
"help": "Help",
|
||||
"filter_and_highlights_help": "Filters en Markeringen Help",
|
||||
"filter_and_highlights": "Filters en markeringen"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Venter",
|
||||
"failed": "Feilet",
|
||||
"add": "Legg til",
|
||||
"pattern": "Mønster",
|
||||
"pattern": "mønster",
|
||||
"enable": "Aktiver",
|
||||
"order": "Rekkefølge",
|
||||
"text": "Tekst",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Denne tråden ble fjernet av boardmoderatorer",
|
||||
"this_reply_was_deleted": "Dette svaret ble slettet av forfatteren",
|
||||
"this_reply_was_removed": "Dette svaret ble fjernet av forummoderatorer",
|
||||
"not_mod_of_any_board": "Du er ikke moderator på noen brett."
|
||||
"not_mod_of_any_board": "Du er ikke moderator på noen brett.",
|
||||
"filters_and_highlights": "Filtre og fremhevinger",
|
||||
"search_results_for": "Søkeresultater for",
|
||||
"help": "Hjelp",
|
||||
"filter_and_highlights_help": "Filtre og fremhevinger Hjelp",
|
||||
"filter_and_highlights": "Filtre og uthevinger"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Oczekujące",
|
||||
"failed": "Nieudane",
|
||||
"add": "Dodaj",
|
||||
"pattern": "Wzór",
|
||||
"pattern": "wzór",
|
||||
"enable": "Włącz",
|
||||
"order": "Kolejność",
|
||||
"text": "Tekst",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Wątek został usunięty przez moderatorów forum",
|
||||
"this_reply_was_deleted": "Ta odpowiedź została usunięta przez autora",
|
||||
"this_reply_was_removed": "Ta odpowiedź została usunięta przez moderatorów forum",
|
||||
"not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy."
|
||||
"not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy.",
|
||||
"filters_and_highlights": "Filtry i wyróżnienia",
|
||||
"search_results_for": "Wyniki wyszukiwania dla",
|
||||
"help": "Pomoc",
|
||||
"filter_and_highlights_help": "Pomoc dotycząca filtrów i wyróżnień",
|
||||
"filter_and_highlights": "Filtry i wyróżnienia"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Pendente",
|
||||
"failed": "Falhou",
|
||||
"add": "Adicionar",
|
||||
"pattern": "Padrão",
|
||||
"pattern": "padrão",
|
||||
"enable": "Ativar",
|
||||
"order": "Ordem",
|
||||
"text": "Texto",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Este tópico foi removido pelos moderadores do fórum",
|
||||
"this_reply_was_deleted": "Esta resposta foi excluída pelo autor",
|
||||
"this_reply_was_removed": "Esta resposta foi removida pelos moderadores do fórum",
|
||||
"not_mod_of_any_board": "Você não é um moderador de nenhum quadro."
|
||||
"not_mod_of_any_board": "Você não é um moderador de nenhum quadro.",
|
||||
"filters_and_highlights": "Filtros e Destaques",
|
||||
"search_results_for": "Resultados de pesquisa para",
|
||||
"help": "Ajuda",
|
||||
"filter_and_highlights_help": "Ajuda de Filtros e Realces",
|
||||
"filter_and_highlights": "Filtros e Destaques"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "În așteptare",
|
||||
"failed": "Eșuat",
|
||||
"add": "Adaugă",
|
||||
"pattern": "Model",
|
||||
"pattern": "model",
|
||||
"enable": "Activare",
|
||||
"order": "Ordine",
|
||||
"text": "Text",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Acest thread a fost eliminat de moderatorii board-ului",
|
||||
"this_reply_was_deleted": "Acest răspuns a fost șters de autorul său",
|
||||
"this_reply_was_removed": "Acest răspuns a fost eliminat de moderatorii forumului",
|
||||
"not_mod_of_any_board": "Nu ești moderator pe niciun tablou."
|
||||
"not_mod_of_any_board": "Nu ești moderator pe niciun tablou.",
|
||||
"filters_and_highlights": "Filtre și evidențiere",
|
||||
"search_results_for": "Rezultatele căutării pentru",
|
||||
"help": "Ajutor",
|
||||
"filter_and_highlights_help": "Ajutor pentru filtre și evidențieri",
|
||||
"filter_and_highlights": "Filtre și evidențieri"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "В ожидании",
|
||||
"failed": "Не удалось",
|
||||
"add": "Добавить",
|
||||
"pattern": "Шаблон",
|
||||
"pattern": "узор",
|
||||
"enable": "Включить",
|
||||
"order": "Порядок",
|
||||
"text": "Текст",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Эта тема была удалена модераторами форума",
|
||||
"this_reply_was_deleted": "Этот ответ был удален автором",
|
||||
"this_reply_was_removed": "Этот ответ был удален модераторами форума",
|
||||
"not_mod_of_any_board": "Вы не модератор ни одной доски."
|
||||
"not_mod_of_any_board": "Вы не модератор ни одной доски.",
|
||||
"filters_and_highlights": "Фильтры и выделения",
|
||||
"search_results_for": "Результаты поиска для",
|
||||
"help": "Помощь",
|
||||
"filter_and_highlights_help": "Помощь с фильтрами и выделениями",
|
||||
"filter_and_highlights": "Фильтры и выделения"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Në pritje",
|
||||
"failed": "Dështuar",
|
||||
"add": "Shto",
|
||||
"pattern": "Model",
|
||||
"pattern": "model",
|
||||
"enable": "Aktivizo",
|
||||
"order": "Rendit",
|
||||
"text": "Tekst",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Ky thread u hoq nga moderatorët e bordit",
|
||||
"this_reply_was_deleted": "Ky përgjigje u fshi nga autori i saj",
|
||||
"this_reply_was_removed": "Ky përgjigje u hoq nga moderatorët e forumit",
|
||||
"not_mod_of_any_board": "Nuk jeni mod në asnjë bord."
|
||||
"not_mod_of_any_board": "Nuk jeni mod në asnjë bord.",
|
||||
"filters_and_highlights": "Filtra dhe theksime",
|
||||
"search_results_for": "Rezultatet e kërkimit për",
|
||||
"help": "Ndihmë",
|
||||
"filter_and_highlights_help": "Ndihma për Filtro & Thekso",
|
||||
"filter_and_highlights": "Filtra dhe thekson"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Väntande",
|
||||
"failed": "Misslyckades",
|
||||
"add": "Lägg till",
|
||||
"pattern": "Mönster",
|
||||
"pattern": "mönster",
|
||||
"enable": "Aktivera",
|
||||
"order": "Ordning",
|
||||
"text": "Text",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Denna tråd togs bort av forumets moderatorer",
|
||||
"this_reply_was_deleted": "Detta svar togs bort av författaren",
|
||||
"this_reply_was_removed": "Detta svar togs bort av forumets moderatorer",
|
||||
"not_mod_of_any_board": "Du är inte moderator på någon tavla."
|
||||
"not_mod_of_any_board": "Du är inte moderator på någon tavla.",
|
||||
"filters_and_highlights": "Filter och markeringar",
|
||||
"search_results_for": "Sökresultat för",
|
||||
"help": "Hjälp",
|
||||
"filter_and_highlights_help": "Filter och markeringar Hjälp",
|
||||
"filter_and_highlights": "Filter och markeringar"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "అనుమతించబడాలి",
|
||||
"failed": "విజయవంతం కాలేదు",
|
||||
"add": "చేర్చండి",
|
||||
"pattern": "ప్యాటర్న్",
|
||||
"pattern": "పాటర్న్",
|
||||
"enable": "చేర్చు",
|
||||
"order": "ఆర్డర్",
|
||||
"text": "పాఠం",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "ఈ థ్రెడ్ బోర్డ్ మోడరేటర్ల ద్వారా తీసివేయబడింది",
|
||||
"this_reply_was_deleted": "ఈ స్పందనను దాని రచయిత తొలగించారు",
|
||||
"this_reply_was_removed": "ఈ స్పందన బోర్డ్ మోడరేటర్లచే తొలగించబడింది",
|
||||
"not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు."
|
||||
"not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు.",
|
||||
"filters_and_highlights": "ఫిల్టర్లు మరియు హైలైట్లు",
|
||||
"search_results_for": "శోధన ఫలితాలు",
|
||||
"help": "సహాయం",
|
||||
"filter_and_highlights_help": "ఫిల్టర్లు మరియు హైలైట్ సహాయం",
|
||||
"filter_and_highlights": "ఫిల్టర్లు మరియు హైలైట్స్"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "กระทู้นี้ถูกลบโดยผู้ดูแลบอร์ด",
|
||||
"this_reply_was_deleted": "การตอบกลับนี้ถูกลบโดยผู้แต่ง",
|
||||
"this_reply_was_removed": "คำตอบนี้ถูกลบโดยผู้ดูแลบอร์ด",
|
||||
"not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ"
|
||||
"not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ",
|
||||
"filters_and_highlights": "ตัวกรองและไฮไลท์",
|
||||
"search_results_for": "ผลการค้นหาสำหรับ",
|
||||
"help": "ช่วยเหลือ",
|
||||
"filter_and_highlights_help": "ตัวกรองและการเน้นความช่วยเหลือ",
|
||||
"filter_and_highlights": "ตัวกรองและไฮไลท์"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Beklemede",
|
||||
"failed": "Başarısız",
|
||||
"add": "Ekle",
|
||||
"pattern": "Desen",
|
||||
"pattern": "desen",
|
||||
"enable": "Etkinleştir",
|
||||
"order": "Sıra",
|
||||
"text": "Metin",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Bu konu, board moderatörleri tarafından kaldırıldı",
|
||||
"this_reply_was_deleted": "Bu yanıt, yazar tarafından silindi",
|
||||
"this_reply_was_removed": "Bu yanıt, forum moderatörleri tarafından kaldırıldı",
|
||||
"not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz."
|
||||
"not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz.",
|
||||
"filters_and_highlights": "Filtreler ve Vurgular",
|
||||
"search_results_for": "Arama sonuçları için",
|
||||
"help": "Yardım",
|
||||
"filter_and_highlights_help": "Filtreler ve Vurgular Yardımı",
|
||||
"filter_and_highlights": "Filtreler ve Vurgular"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Очікує",
|
||||
"failed": "Не вдалося",
|
||||
"add": "Додати",
|
||||
"pattern": "Шаблон",
|
||||
"pattern": "шаблон",
|
||||
"enable": "Увімкнути",
|
||||
"order": "Порядок",
|
||||
"text": "Текст",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Цю тему було видалено модераторами форуму",
|
||||
"this_reply_was_deleted": "Цю відповідь було видалено її автором",
|
||||
"this_reply_was_removed": "Цю відповідь було видалено модераторами форуму",
|
||||
"not_mod_of_any_board": "Ви не модератор жодної дошки."
|
||||
"not_mod_of_any_board": "Ви не модератор жодної дошки.",
|
||||
"filters_and_highlights": "Фільтри та виділення",
|
||||
"search_results_for": "Результати пошуку для",
|
||||
"help": "Допомога",
|
||||
"filter_and_highlights_help": "Допомога з фільтрами та підсвічуваннями",
|
||||
"filter_and_highlights": "Фільтри та виділення"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "منتظر",
|
||||
"failed": "ناکام",
|
||||
"add": "شامل کریں",
|
||||
"pattern": "نمونہ",
|
||||
"pattern": "پیٹرن",
|
||||
"enable": "فعال کریں",
|
||||
"order": "ترتیب",
|
||||
"text": "متن",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "اس تھریڈ کو بورڈ کے موڈریٹرز نے ہٹا دیا ہے",
|
||||
"this_reply_was_deleted": "اس جواب کو اس کے مصنف نے حذف کر دیا تھا",
|
||||
"this_reply_was_removed": "اس جواب کو بورڈ کے منتظمین نے حذف کر دیا ہے",
|
||||
"not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔"
|
||||
"not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔",
|
||||
"filters_and_highlights": "فلٹرز اور ہائیلائٹس",
|
||||
"search_results_for": "تلاش کے نتائج",
|
||||
"help": "مدد",
|
||||
"filter_and_highlights_help": "فلٹرز اور ہائی لائٹس کی مدد",
|
||||
"filter_and_highlights": "فلٹرز اور ہائلाइटس"
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
"pending": "Đang chờ",
|
||||
"failed": "Thất bại",
|
||||
"add": "Thêm",
|
||||
"pattern": "Mẫu",
|
||||
"pattern": "mẫu",
|
||||
"enable": "Kích hoạt",
|
||||
"order": "Thứ tự",
|
||||
"text": "Văn bản",
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "Chủ đề này đã bị các quản trị viên diễn đàn xóa",
|
||||
"this_reply_was_deleted": "Câu trả lời này đã bị tác giả của nó xóa",
|
||||
"this_reply_was_removed": "Phản hồi này đã bị xóa bởi các quản trị viên diễn đàn",
|
||||
"not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào."
|
||||
"not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào.",
|
||||
"filters_and_highlights": "Bộ lọc và Nổi bật",
|
||||
"search_results_for": "Kết quả tìm kiếm cho",
|
||||
"help": "Trợ giúp",
|
||||
"filter_and_highlights_help": "Trợ giúp bộ lọc và đánh dấu",
|
||||
"filter_and_highlights": "Bộ lọc và Nổi bật"
|
||||
}
|
||||
@@ -291,5 +291,10 @@
|
||||
"this_thread_was_removed": "此帖子已被论坛版主删除",
|
||||
"this_reply_was_deleted": "该回复已被其作者删除",
|
||||
"this_reply_was_removed": "该回复已被版主删除",
|
||||
"not_mod_of_any_board": "您不是任何板块的管理员。"
|
||||
"not_mod_of_any_board": "您不是任何板块的管理员。",
|
||||
"filters_and_highlights": "筛选器和高亮",
|
||||
"search_results_for": "搜索结果",
|
||||
"help": "帮助",
|
||||
"filter_and_highlights_help": "过滤器和高亮帮助",
|
||||
"filter_and_highlights": "筛选器与高亮"
|
||||
}
|
||||
@@ -55,8 +55,9 @@ const BoardLayout = () => {
|
||||
{isMobile
|
||||
? (subplebbitAddress || isInAllView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && (
|
||||
<>
|
||||
<MobileBoardButtons />
|
||||
<PostForm key={key} />
|
||||
<hr />
|
||||
<MobileBoardButtons />
|
||||
</>
|
||||
)
|
||||
: (subplebbitAddress || isInAllView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && (
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
}
|
||||
|
||||
.desktopBoardButtons {
|
||||
padding-right: 5px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useComment, useSubplebbit, useSubscribe } from '@plebbit/plebbit-react-hooks';
|
||||
import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { isAllView, isCatalogView, isDescriptionView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useSortingStore from '../../stores/use-sorting-store';
|
||||
import useTimeFilter from '../../hooks/use-time-filter';
|
||||
import CatalogFilters from '../../views/catalog/catalog-filters/';
|
||||
import styles from './board-buttons.module.css';
|
||||
import Tooltip from '../tooltip';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import _ from 'lodash';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import { useEffect } from 'react';
|
||||
import useTimeFilter from '../../hooks/use-time-filter';
|
||||
import CatalogFilters from '../catalog-filters';
|
||||
import CatalogSearch from '../catalog-search';
|
||||
import Tooltip from '../tooltip';
|
||||
import styles from './board-buttons.module.css';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface BoardButtonsProps {
|
||||
address?: string | undefined;
|
||||
@@ -244,13 +246,7 @@ export const MobileBoardButtons = () => {
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress;
|
||||
|
||||
const { filteredCount, resetFilteredCount } = useCatalogFiltersStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (subplebbitAddress) {
|
||||
resetFilteredCount();
|
||||
}
|
||||
}, [subplebbitAddress, resetFilteredCount]);
|
||||
const { filteredCount, searchText } = useCatalogFiltersStore();
|
||||
|
||||
return (
|
||||
<div className={`${styles.mobileBoardButtons} ${!isInCatalogView ? styles.addMargin : ''}`}>
|
||||
@@ -273,11 +269,19 @@ export const MobileBoardButtons = () => {
|
||||
)}
|
||||
{!(isInAllView || isInSubscriptionsView) && <SubscribeButton address={subplebbitAddress} />}
|
||||
<RefreshButton />
|
||||
{isInCatalogView && filteredCount > 0 && (
|
||||
{isInCatalogView && searchText ? (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
— {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
</span>
|
||||
) : (
|
||||
isInCatalogView &&
|
||||
filteredCount > 0 && (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{isInCatalogView && (
|
||||
<>
|
||||
@@ -287,7 +291,7 @@ export const MobileBoardButtons = () => {
|
||||
<SortOptions /> <ImageSizeOptions />
|
||||
</div>
|
||||
<div className={styles.mobileCatalogOptionsPadding}>
|
||||
<ShowOPCommentOption /> <CatalogFilters />
|
||||
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -304,8 +308,9 @@ const PostPageStats = () => {
|
||||
const location = useLocation();
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
|
||||
const comment = useComment({ commentCid: params?.commentCid });
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress: params?.subplebbitAddress });
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]);
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[params?.subplebbitAddress as string]);
|
||||
|
||||
const descriptionReplyCount = location?.pathname.startsWith('/p/all/') ? 0 : subplebbit?.rules?.length > 0 ? 1 : 0;
|
||||
const { closed, pinned, replyCount } = comment || {};
|
||||
const linkCount = useCountLinksInReplies(comment);
|
||||
@@ -334,13 +339,7 @@ export const DesktopBoardButtons = () => {
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
|
||||
const { filteredCount, resetFilteredCount } = useCatalogFiltersStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (subplebbitAddress) {
|
||||
resetFilteredCount();
|
||||
}
|
||||
}, [subplebbitAddress, resetFilteredCount]);
|
||||
const { filteredCount, searchText } = useCatalogFiltersStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -373,11 +372,19 @@ export const DesktopBoardButtons = () => {
|
||||
</>
|
||||
)}
|
||||
[<RefreshButton />]
|
||||
{isInCatalogView && filteredCount > 0 && (
|
||||
{isInCatalogView && searchText ? (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
— {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
</span>
|
||||
) : (
|
||||
isInCatalogView &&
|
||||
filteredCount > 0 && (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span className={styles.rightSideButtons}>
|
||||
{isInCatalogView && (
|
||||
@@ -390,17 +397,16 @@ export const DesktopBoardButtons = () => {
|
||||
{(isInAllView || isInSubscriptionsView) && (
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} />
|
||||
)}
|
||||
{isInCatalogView && (
|
||||
<>
|
||||
[
|
||||
<CatalogFilters />]
|
||||
</>
|
||||
)}{' '}
|
||||
{!(isInAllView || isInSubscriptionsView) && (
|
||||
<>
|
||||
[
|
||||
<SubscribeButton address={subplebbitAddress} />]
|
||||
</>
|
||||
)}{' '}
|
||||
{isInCatalogView && (
|
||||
<>
|
||||
[<CatalogFilters />] <CatalogSearch />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import { useAccountComment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import styles from './board-header.module.css';
|
||||
import { useMultisubMetadata } from '../../hooks/use-default-subplebbits';
|
||||
@@ -9,7 +10,7 @@ import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import Tooltip from '../tooltip';
|
||||
|
||||
const totalBanners = 62;
|
||||
const totalBanners = 63;
|
||||
|
||||
const ImageBanner = () => {
|
||||
const [imagePath] = useState(() => {
|
||||
@@ -29,7 +30,8 @@ const BoardHeader = () => {
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress;
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
|
||||
const { address, shortAddress } = subplebbit || {};
|
||||
|
||||
const multisubMetadata = useMultisubMetadata();
|
||||
|
||||
@@ -33,22 +33,43 @@
|
||||
|
||||
.modal {
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
left: calc(50% + 5px);
|
||||
transform: translateX(-50%);
|
||||
width: 340px;
|
||||
width: 380px;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
padding: 2px;
|
||||
padding: 2px 5px 3px 5px;
|
||||
font-size: var(--settings-modal-font-size);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
background-color: var(--settings-modal-background-color);
|
||||
border-top: var(--settings-modal-border-top);
|
||||
border-right: var(--settings-modal-border-right);
|
||||
border-bottom: var(--settings-modal-border-bottom);
|
||||
border-left: var(--settings-modal-border-left);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filtersProtipModal {
|
||||
top: 50px;
|
||||
width: 570px;
|
||||
left: 50%;
|
||||
padding: 2px 10px 5px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 614px) {
|
||||
.filtersProtipModal {
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.modal {
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -76,6 +97,20 @@
|
||||
top: 5px;
|
||||
image-rendering: pixelated;
|
||||
background-image: var(--settings-modal-close-button-background-image);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openHelpButton {
|
||||
right: 23px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
background-size: 100%;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
image-rendering: pixelated;
|
||||
background-image: var(--open-help-button-background-image);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -102,16 +137,12 @@
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.filtersTable {
|
||||
width: 100%;
|
||||
border-spacing: 1px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 10px 2px 2px 2px;
|
||||
padding: 0px 2px 2px 2px;
|
||||
}
|
||||
|
||||
.filtersTable th {
|
||||
@@ -142,7 +173,13 @@
|
||||
outline: none;
|
||||
border: var(--reply-modal-field-input-border, revert);
|
||||
font-size: 11px;
|
||||
width: 200px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.filtersTable input[type='text'] {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filtersTable input[type='text']:focus {
|
||||
@@ -162,5 +199,16 @@
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
float: right;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.addButton, .saveButton {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filterHits {
|
||||
font-size: 12px !important;
|
||||
text-transform: none;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import FiltersProtip from './filters-protip';
|
||||
import HighlightColorPicker from './highlight-color-picker';
|
||||
import styles from './catalog-filters.module.css';
|
||||
|
||||
const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { filterItems, saveAndApplyFilters, currentSubplebbitAddress } = useCatalogFiltersStore();
|
||||
const resetFeed = useFeedResetStore((state) => state.reset);
|
||||
|
||||
const [localFilterItems, setLocalFilterItems] = useState(
|
||||
filterItems.map((item) => ({
|
||||
...item,
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color ?? '',
|
||||
})),
|
||||
);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
// Update local items when store changes
|
||||
useEffect(() => {
|
||||
setLocalFilterItems(
|
||||
filterItems.map((item) => ({
|
||||
...item,
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color ?? '',
|
||||
})),
|
||||
);
|
||||
}, [filterItems]);
|
||||
|
||||
const handleAddFilter = useCallback(() => {
|
||||
setLocalFilterItems((prev) => {
|
||||
const newIndex = prev.length;
|
||||
setTimeout(() => {
|
||||
inputRefs.current[newIndex]?.focus();
|
||||
}, 0);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: '',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
subplebbitCounts: new Map<string, number>(),
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
hide: true,
|
||||
top: false,
|
||||
color: '',
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '');
|
||||
|
||||
saveAndApplyFilters(nonEmptyFilters);
|
||||
|
||||
useCatalogFiltersStore.getState().resetCountsForCurrentSubplebbit();
|
||||
|
||||
if (resetFeed) {
|
||||
resetFeed();
|
||||
}
|
||||
|
||||
onSave();
|
||||
}, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const updateLocalFilterItem = useCallback((index: number, item: any) => {
|
||||
setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f)));
|
||||
}, []);
|
||||
|
||||
const removeLocalFilterItem = useCallback((index: number) => {
|
||||
setLocalFilterItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const moveLocalFilterItemUp = useCallback((index: number) => {
|
||||
if (index === 0) return;
|
||||
setLocalFilterItems((prev) => {
|
||||
const newItems = [...prev];
|
||||
[newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
|
||||
return newItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<table className={styles.filtersTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>order</th>
|
||||
<th>on</th>
|
||||
<th>pattern</th>
|
||||
<th>color</th>
|
||||
<th>hide</th>
|
||||
<th>top</th>
|
||||
<th>del</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<span className={styles.orderButton} onClick={() => moveLocalFilterItemUp(index)}>
|
||||
↑
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={styles.onCheckbox}
|
||||
checked={item.enabled}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
value={item.text}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })}
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.deleteButton} onClick={() => removeLocalFilterItem(index)}>
|
||||
×
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.filterHits}>
|
||||
{currentSubplebbitAddress && item.subplebbitFilteredCids?.has(currentSubplebbitAddress) && `x${item.subplebbitCounts?.get(currentSubplebbitAddress) ?? 0}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<button className={styles.addButton} onClick={handleAddFilter}>
|
||||
{t('add')}
|
||||
</button>
|
||||
<button className={styles.saveButton} onClick={handleSave}>
|
||||
{t('save')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const openHelp = () => setShowHelp(true);
|
||||
const closeHelp = () => setShowHelp(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showHelp) {
|
||||
closeHelp();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscapeKey);
|
||||
return () => document.removeEventListener('keydown', onEscapeKey);
|
||||
}, [closeModal, showHelp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={showHelp ? closeHelp : closeModal} />
|
||||
<div className={`${styles.modal} ${showHelp ? styles.filtersProtipModal : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span>
|
||||
{!showHelp && <span className={styles.openHelpButton} title={t('help')} onClick={openHelp} />}
|
||||
<span className={styles.closeButton} title={t('close')} onClick={closeModal} />
|
||||
</div>
|
||||
{showHelp ? <FiltersProtip /> : <FiltersTable onSave={closeModal} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CatalogFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${styles.filtersButton} button`} onClick={() => setShowModal(true)}>
|
||||
{t('filters')}
|
||||
</span>
|
||||
{showModal && <FiltersModal closeModal={closeModal} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CatalogFilters;
|
||||
@@ -0,0 +1,34 @@
|
||||
.filtersProtip {
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filtersProtip code {
|
||||
padding: 1px 5px 1px 5px;
|
||||
background-color: #EEE;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.filtersProtip li {
|
||||
list-style: none;
|
||||
margin: 5px 0px;
|
||||
}
|
||||
|
||||
.filtersProtip ul {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4 {
|
||||
font-size: 15px;
|
||||
margin: 10px 0px 5px 0px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4:first-child {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.filtersProtip h4::before {
|
||||
content: "»";
|
||||
margin-right: 3px;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import styles from './filters-protip.module.css';
|
||||
|
||||
const FiltersProtip = () => {
|
||||
return (
|
||||
<div className={styles.filtersProtip}>
|
||||
<h4>Patterns</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Matching whole words:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel</code> — will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>AND operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel girlfriend</code> — will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>OR operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel|girlfriend</code> — will match <em>"feel"</em> OR <em>"girlfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Mixing both operators:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>girlfriend|boyfriend feel</code> — matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Exact match search:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>"that feel when"</code> — place double quotes around the pattern to search for an exact string.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Wildcards:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel*</code> — matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc…
|
||||
</li>
|
||||
<li>
|
||||
<code>idolm*ster</code> — this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc…
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<strong>Filtering by display name or user ID</strong>
|
||||
<li>
|
||||
Prefix the pattern with <code>#</code> to search by <em>user ID</em>: <code>#GPPr4CQSRCt8</code>
|
||||
</li>
|
||||
<li>
|
||||
Prefix the pattern with <code>##</code> to search by <em>name</em>: <code>##Anonymous</code>
|
||||
</li>
|
||||
<li>
|
||||
To filter by <em>role</em>: <code>#!#owner</code>, <code>#!#admin</code>, <code>#!#moderator</code> (or <code>#!#mod</code>)
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<strong>It is also possible to filter by regular expression:</strong>
|
||||
<li>
|
||||
<code>/^(?=.*detachable)(?=.*hats).*$/i</code> — AND operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>/^(?!.*touhou).*$/i</code> — NOT operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>{'/^>/'}</code> — threads starting with a quote (<em>{'">"'}</em> character as an html entity).
|
||||
</li>
|
||||
<li>
|
||||
<code>/^$/</code> — threads with no text.
|
||||
</li>
|
||||
</ul>
|
||||
<h4>Controls</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>On</strong> — enables or disables the filter.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Color</strong> — highlights matched threads with the specified color.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hide</strong> — hides matched threads.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Top</strong> — moves the filter to the top of the feed.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FiltersProtip;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './filters-protip';
|
||||
@@ -0,0 +1,110 @@
|
||||
.colorClickbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: var(--reply-modal-field-input-border, revert);
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colorPickerModal {
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
background-color: var(--settings-modal-background-color);
|
||||
}
|
||||
|
||||
.colorPickerModal table {
|
||||
border-spacing: 1px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.colorPickerModal table td {
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.colorOption {
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
border: 1px solid #aaa;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.middleTxt input[type="text"] {
|
||||
width: 45px;
|
||||
margin: 0 2px;
|
||||
margin-right: 5px;
|
||||
padding: 1px;
|
||||
outline: none;
|
||||
border: var(--reply-modal-field-input-border, revert);
|
||||
font-size: 11px;
|
||||
background-color: default;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.middleTxt input[type="text"]:focus {
|
||||
border: var(--reply-modal-field-input-border-focus, revert);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.middleTxt input[type="text"] {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.middleTxt .colorClickbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border: 1px solid #aaa;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button {
|
||||
color: var(--button-desktop-text-color);
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button:hover {
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button::before{
|
||||
content: '[';
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button::after{
|
||||
content: ']';
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import styles from './highlight-color-picker.module.css';
|
||||
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
||||
|
||||
interface ColorPickerProps {
|
||||
item: any;
|
||||
index: number;
|
||||
updateLocalFilterItem: (index: number, item: any) => void;
|
||||
localFilterItems: any[];
|
||||
}
|
||||
|
||||
const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterItems }: ColorPickerProps) => {
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState<boolean>(false);
|
||||
const [customColor, setCustomColor] = useState<string>('');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: 'left-start',
|
||||
open: isColorPickerOpen,
|
||||
onOpenChange: setIsColorPickerOpen,
|
||||
middleware: [offset(10), shift()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
||||
|
||||
const headingId = useId();
|
||||
|
||||
const colorOptions = ['#E0B0FF', '#F2F3F4', '#7DF9FF', '#FFFF00', '#FBCEB1', '#FFBF00', '#ADFF2F', '#0047AB', '#00A550', '#007FFF', '#AF0A0F', '#B5BD68'];
|
||||
|
||||
const colorRows = [colorOptions.slice(0, 4), colorOptions.slice(4, 8), colorOptions.slice(8, 12)];
|
||||
|
||||
const setItemColor = (color: string) => {
|
||||
updateLocalFilterItem(index, { ...localFilterItems[index], color });
|
||||
setIsColorPickerOpen(false);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value;
|
||||
setCustomColor(newColor);
|
||||
};
|
||||
|
||||
const applyCustomColor = () => {
|
||||
if (customColor) {
|
||||
setItemColor(customColor);
|
||||
}
|
||||
};
|
||||
|
||||
const clearColor = () => {
|
||||
setItemColor('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.colorClickbox} style={{ backgroundColor: item.color || '#fff' }} ref={refs.setReference} {...getReferenceProps()}>
|
||||
{!item.color && '∕'}
|
||||
</span>
|
||||
|
||||
{isColorPickerOpen && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsColorPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<FloatingFocusManager context={context} modal={false} initialFocus={-1}>
|
||||
<div
|
||||
className={styles.colorPickerModal}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
aria-labelledby={headingId}
|
||||
{...getFloatingProps()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<table className={styles.colorGrid}>
|
||||
<tbody>
|
||||
{colorRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((color, colorIndex) => (
|
||||
<td key={colorIndex}>
|
||||
<span className={styles.colorOption} style={{ backgroundColor: color }} onClick={() => setItemColor(color)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>Custom</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.middleTxt} colSpan={4}>
|
||||
<input type='text' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
|
||||
<span
|
||||
className={styles.colorPreview}
|
||||
style={{
|
||||
backgroundColor: customColor || '#fff',
|
||||
display: 'inline-block',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '1px solid #aaa',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={applyCustomColor}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<span className={styles.button} onClick={() => setIsColorPickerOpen(false)}>
|
||||
<span className={styles.buttonText}>Close</span>
|
||||
</span>
|
||||
<span className={styles.button} onClick={clearColor}>
|
||||
<span className={styles.buttonText}>Clear</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightColorPicker;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './highlight-color-picker';
|
||||
@@ -13,6 +13,8 @@
|
||||
padding: 10px 0 3px 0;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.postContent {
|
||||
@@ -69,14 +71,14 @@
|
||||
background-color: var(--media-thumbnail-background-color);
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
clip-path: inset(-5px); /* direct media could change its dimensions after being published, so we need to clip the overflow. We can't use overflow: hidden because it will hide the box-shadow */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post img, .post video, .post audio {
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
max-width: var(--maxWidth);
|
||||
max-height: var(--maxHeight);
|
||||
}
|
||||
|
||||
@@ -2,34 +2,36 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import { useFloating, offset, size, autoUpdate, Placement } from '@floating-ui/react';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import { getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useEditCommentPrivileges from '../../hooks/use-author-privileges';
|
||||
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useHide from '../../hooks/use-hide';
|
||||
import useWindowWidth from '../../hooks/use-window-width';
|
||||
import useReplies from '../../hooks/use-replies';
|
||||
import { ContentPreview } from '../../views/home/popular-threads-box';
|
||||
import PostMenuDesktop from '../post-desktop/post-menu-desktop';
|
||||
import styles from './catalog-row.module.css';
|
||||
import _ from 'lodash';
|
||||
import { ContentPreview } from '../../views/home/popular-threads-box';
|
||||
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import useReplies from '../../hooks/use-replies';
|
||||
|
||||
interface CatalogPostMediaProps {
|
||||
cid: string;
|
||||
commentMediaInfo: any;
|
||||
isOutOfFeed?: boolean;
|
||||
linkWidth?: number;
|
||||
linkHeight?: number;
|
||||
}
|
||||
|
||||
export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||
@@ -89,8 +91,16 @@ export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, lin
|
||||
thumbnailComponent = <audio src={url} controls />;
|
||||
}
|
||||
|
||||
const matchedFilterColor = useCatalogFiltersStore((state) => state.matchedFilters.get(cid || ''));
|
||||
|
||||
return (
|
||||
<div className={hasError ? '' : styles.mediaWrapper} style={CSSProperties}>
|
||||
<div
|
||||
className={hasError ? '' : styles.mediaWrapper}
|
||||
style={{
|
||||
...CSSProperties,
|
||||
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
{!isLoaded && !hasError && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
||||
{hasError ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
||||
</div>
|
||||
@@ -250,7 +260,13 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
{spoiler ? (
|
||||
<img src='assets/spoiler.png' alt='' />
|
||||
) : (
|
||||
<CatalogPostMedia commentMediaInfo={commentMediaInfo} isOutOfFeed={isDescription || isRules} linkWidth={linkWidth} linkHeight={linkHeight} />
|
||||
<CatalogPostMedia
|
||||
cid={cid}
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
isOutOfFeed={isDescription || isRules}
|
||||
linkWidth={linkWidth}
|
||||
linkHeight={linkHeight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.button {
|
||||
text-transform: capitalize;
|
||||
color: var(--button-desktop-text-color);
|
||||
text-decoration: var(--button-text-decoration);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filtersButton {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.searchContainer input {
|
||||
width: 75px;
|
||||
margin: 0px 5px;
|
||||
padding: 1px;
|
||||
outline: none;
|
||||
border: 1px solid #AAA;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.searchContainer input:focus {
|
||||
border: var(--post-form-field-input-focus-border);
|
||||
}
|
||||
|
||||
.closeSearch {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
color: var(--button-desktop-text-color);
|
||||
}
|
||||
|
||||
.closeSearch:hover {
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.searchContainer input {
|
||||
width: 75px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './catalog-search.module.css';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import _ from 'lodash';
|
||||
|
||||
const CatalogSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const [openSearch, setOpenSearch] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
|
||||
|
||||
// Create a debounced version of setSearchFilter
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedSetSearchFilter = useCallback(
|
||||
_.debounce((text: string) => {
|
||||
if (text.trim()) {
|
||||
setSearchFilter(text);
|
||||
} else {
|
||||
clearSearchFilter();
|
||||
}
|
||||
}, 300),
|
||||
[setSearchFilter, clearSearchFilter],
|
||||
);
|
||||
|
||||
const handleToggleSearch = useCallback(() => {
|
||||
setOpenSearch((prev) => !prev);
|
||||
|
||||
if (openSearch) {
|
||||
setInputValue('');
|
||||
clearSearchFilter();
|
||||
}
|
||||
}, [openSearch, clearSearchFilter]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setOpenSearch(false);
|
||||
setInputValue('');
|
||||
clearSearchFilter();
|
||||
}, [clearSearchFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openSearch) {
|
||||
const input = document.querySelector('input');
|
||||
input?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCloseSearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [openSearch, handleCloseSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSetSearchFilter(inputValue);
|
||||
|
||||
return () => {
|
||||
debouncedSetSearchFilter.cancel();
|
||||
};
|
||||
}, [inputValue, debouncedSetSearchFilter]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMobile && '['}
|
||||
<span className={`${styles.filtersButton} button`} onClick={handleToggleSearch}>
|
||||
{t('search')}
|
||||
</span>
|
||||
{!isMobile && ']'}
|
||||
{openSearch && (
|
||||
<div className={styles.searchContainer}>
|
||||
<input type='text' value={inputValue} onChange={handleSearchChange} />
|
||||
<span className={styles.closeSearch} onClick={handleCloseSearch}>
|
||||
✖
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CatalogSearch;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './catalog-search';
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import { isPostPageView } from '../../lib/utils/view-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
@@ -13,7 +14,7 @@ import Tooltip from '../../components/tooltip';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import _ from 'lodash';
|
||||
|
||||
const CommentContent = ({ comment: post, replies }: { comment: Comment; replies: Comment[] }) => {
|
||||
const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
@@ -34,7 +35,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies:
|
||||
? content.slice(0, 2000)
|
||||
: content);
|
||||
|
||||
const quotelinkReply = parentCid && replies?.find((reply) => reply.cid === parentCid);
|
||||
const quotelinkReply = useSubplebbitsPagesStore((state) => state.comments[parentCid]);
|
||||
|
||||
const isReply = !!parentCid;
|
||||
const isReplyingToReply = isReply && parentCid !== postCid;
|
||||
@@ -45,9 +46,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies:
|
||||
|
||||
return (
|
||||
<blockquote className={`${styles.postMessage} ${!isReply && isMobile && styles.clampLines} ${isRules && styles.rulesMessage}`}>
|
||||
{isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) && (
|
||||
<ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotelinkReply} replies={replies} />
|
||||
)}
|
||||
{isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) && <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotelinkReply} />}
|
||||
{removed ? (
|
||||
reason ? (
|
||||
<>
|
||||
@@ -75,7 +74,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies:
|
||||
<span className={styles.abbr}>
|
||||
<br />
|
||||
<br />
|
||||
<Trans i18nKey={'comment_too_long'} shouldUnescape={true} components={{ 1: <span onClick={() => setShowFullComment(true)} /> }} />
|
||||
<Trans i18nKey={'comment_too_long'} shouldUnescape={true} components={{ 1: <span key={cid} onClick={() => setShowFullComment(true)} /> }} />
|
||||
</span>
|
||||
)}
|
||||
{edit && original?.content !== content && (
|
||||
@@ -87,20 +86,22 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies:
|
||||
i18nKey={'comment_edited_at_timestamp'}
|
||||
values={{ timestamp: getFormattedDate(edit?.timestamp) }}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <Tooltip content={getFormattedTimeAgo(edit?.timestamp)} children={<Fragment key={edit?.timestamp}></Fragment>} /> }}
|
||||
components={{
|
||||
1: <Tooltip key={edit?.timestamp} content={getFormattedTimeAgo(edit?.timestamp)} children={<Fragment key={edit?.timestamp}></Fragment>} />,
|
||||
}}
|
||||
/>{' '}
|
||||
{reason && <>{t('reason_reason', { reason: reason, interpolation: { escapeValue: false } })} </>}
|
||||
{showOriginal ? (
|
||||
<Trans
|
||||
i18nKey={'click_here_to_hide_original'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <span className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
components={{ 1: <span key={cid} className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey={'click_here_to_show_original'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <span className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
components={{ 1: <span key={cid} className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -48,7 +48,7 @@ const useShowOmittedReplies = create<ShowOmittedRepliesState>((set) => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, replies: threadReplies }: PostProps) => {
|
||||
const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, shortCid, state, subplebbitAddress, timestamp } = post || {};
|
||||
const title = post?.title?.trim();
|
||||
@@ -219,7 +219,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, replies: threadRe
|
||||
(reply: Comment, index: number) =>
|
||||
reply?.parentCid === cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} replies={threadReplies} />,
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -306,7 +306,7 @@ const PostMedia = ({ commentMediaInfo, hasThumbnail, isDescription, isRules, spo
|
||||
);
|
||||
};
|
||||
|
||||
const Reply = ({ postReplyCount, reply, roles, replies }: PostProps) => {
|
||||
const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
let post = reply;
|
||||
// handle pending mod or author edit
|
||||
const { editedComment } = useEditedComment({ comment: reply });
|
||||
@@ -327,7 +327,7 @@ const Reply = ({ postReplyCount, reply, roles, replies }: PostProps) => {
|
||||
<div className={styles.replyDesktop}>
|
||||
<div className={styles.sideArrows}>{'>>'}</div>
|
||||
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
|
||||
<PostInfo post={post} postReplyCount={postReplyCount} roles={roles} isHidden={hidden} replies={replies} />
|
||||
<PostInfo post={post} postReplyCount={postReplyCount} roles={roles} isHidden={hidden} />
|
||||
{link && !hidden && !(deleted || removed) && isValidURL(link) && (
|
||||
<PostMedia
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
@@ -342,13 +342,13 @@ const Reply = ({ postReplyCount, reply, roles, replies }: PostProps) => {
|
||||
parentCid={parentCid}
|
||||
/>
|
||||
)}
|
||||
{!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && <CommentContent comment={post} replies={replies || []} />}
|
||||
{!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && <CommentContent comment={post} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: threadReplies }: PostProps) => {
|
||||
const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {};
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
@@ -360,8 +360,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
const { hidden, unhide, hide } = useHide({ cid });
|
||||
const isHidden = hidden && !isInPostPageView;
|
||||
|
||||
const commentReplies = useReplies(post);
|
||||
const replies = threadReplies || commentReplies;
|
||||
const replies = useReplies(post);
|
||||
const visiblelinksCount = useCountLinksInReplies(post, 5);
|
||||
const totalLinksCount = useCountLinksInReplies(post);
|
||||
const replyCount = replies?.length;
|
||||
@@ -417,9 +416,9 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
parentCid={parentCid}
|
||||
/>
|
||||
)}
|
||||
<PostInfo isHidden={hidden} post={post} postReplyCount={replyCount} roles={roles} replies={replies} />
|
||||
<PostInfo isHidden={hidden} post={post} postReplyCount={replyCount} roles={roles} />
|
||||
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
|
||||
{!isHidden && <CommentContent comment={post} replies={replies} />}
|
||||
{!isHidden && <CommentContent comment={post} />}
|
||||
</div>
|
||||
{!isHidden && !isDescription && !isRules && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && (
|
||||
<span className={styles.summary}>
|
||||
@@ -433,11 +432,16 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
<Trans
|
||||
i18nKey={'replies_and_links_omitted'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <Link to={`/p/${subplebbitAddress}/c/${cid}`} /> }}
|
||||
components={{ 1: <Link key={cid} to={`/p/${subplebbitAddress}/c/${cid}`} /> }}
|
||||
values={{ repliesCount, linksCount }}
|
||||
/>
|
||||
) : (
|
||||
<Trans i18nKey={'replies_omitted'} shouldUnescape={true} components={{ 1: <Link to={`/p/${subplebbitAddress}/c/${cid}`} /> }} values={{ repliesCount }} />
|
||||
<Trans
|
||||
i18nKey={'replies_omitted'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <Link key={cid} to={`/p/${subplebbitAddress}/c/${cid}`} /> }}
|
||||
values={{ repliesCount }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -448,7 +452,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
showReplies &&
|
||||
(showAllReplies || showOmittedReplies[cid] ? replies : replies.slice(-5)).map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} replies={replies} />
|
||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
|
||||
</div>
|
||||
))}
|
||||
{isDescription && subplebbit?.rules && subplebbit?.rules.length > 0 && (
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
|
||||
.postFormMobile {
|
||||
text-align: center;
|
||||
margin-top: -2px;
|
||||
padding-bottom: 7px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.closed {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Comment, setAccount, useAccount, useAccountComment, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||
import { isValidURL } from '../../lib/utils/url-utils';
|
||||
@@ -56,7 +58,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
|
||||
|
||||
const { anonMode, getNewSigner, getExistingSigner } = useAnonMode(postCid);
|
||||
const comment = useComment({ commentCid: postCid });
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[postCid]);
|
||||
const address = comment?.author?.address;
|
||||
|
||||
const [lengthError, setLengthError] = useState<string | null>(null);
|
||||
@@ -427,9 +429,10 @@ const PostForm = () => {
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
|
||||
const post = useComment({ commentCid: useParams().commentCid });
|
||||
const commentCid = params?.commentCid;
|
||||
const post = useSubplebbitsPagesStore((state) => state.comments[commentCid as string]);
|
||||
let comment: Comment = post;
|
||||
// handle pending mod or author edit
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
@@ -444,7 +447,7 @@ const PostForm = () => {
|
||||
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress;
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
|
||||
|
||||
return (
|
||||
|
||||
@@ -233,7 +233,7 @@ const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ReplyBacklinks = ({ post, replies: threadReplies }: PostProps) => {
|
||||
const ReplyBacklinks = ({ post }: PostProps) => {
|
||||
const { cid, parentCid } = post || {};
|
||||
const replies = useReplies(post);
|
||||
|
||||
@@ -246,14 +246,14 @@ const ReplyBacklinks = ({ post, replies: threadReplies }: PostProps) => {
|
||||
(reply: Comment, index: number) =>
|
||||
reply?.parentCid === cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} replies={threadReplies} />,
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => {
|
||||
const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
let post = reply;
|
||||
// handle pending mod or author edit
|
||||
const { editedComment } = useEditedComment({ comment: reply });
|
||||
@@ -274,7 +274,7 @@ const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => {
|
||||
data-post-cid={postCid}
|
||||
>
|
||||
<PostInfoAndMedia post={post} postReplyCount={postReplyCount} roles={roles} />
|
||||
{!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && <CommentContent comment={post} replies={replies || []} />}
|
||||
{!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && <CommentContent comment={post} />}
|
||||
<ReplyBacklinks post={reply} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,7 +282,7 @@ const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies: threadReplies }: PostProps) => {
|
||||
const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {};
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
@@ -292,8 +292,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
const isInPendingPostView = isPendingPostView(location.pathname, params);
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const linksCount = useCountLinksInReplies(post);
|
||||
const commentReplies = useReplies(post);
|
||||
const replies = threadReplies || commentReplies;
|
||||
const replies = useReplies(post);
|
||||
|
||||
const isInPostPageView = isPostPageView(location.pathname, params);
|
||||
const { hidden, unhide } = useHide({ cid });
|
||||
@@ -339,7 +338,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
>
|
||||
{shouldShowSnow() && <img src={`${process.env.PUBLIC_URL}/assets/xmashat.gif`} className={styles.xmasHat} alt='' />}
|
||||
<PostInfoAndMedia post={post} postReplyCount={replyCount} roles={roles} />
|
||||
<CommentContent comment={post} replies={replies} />
|
||||
<CommentContent comment={post} />
|
||||
</div>
|
||||
{!isInPostView && !isInPendingPostView && showReplies && (
|
||||
<div className={styles.postLink}>
|
||||
@@ -362,7 +361,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies:
|
||||
showReplies &&
|
||||
(showAllReplies ? replies : replies.slice(-5)).map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
<Reply postReplyCount={replyCount} reply={reply} roles={roles} replies={replies} />
|
||||
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
|
||||
</div>
|
||||
))}
|
||||
{showRules && (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { setAccount, useAccount, useComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import { setAccount, useAccount } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
@@ -45,7 +47,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s
|
||||
const { selectedText } = useSelectedTextStore();
|
||||
|
||||
const { anonMode, getNewSigner, getExistingSigner } = useAnonMode(postCid);
|
||||
const comment = useComment({ commentCid: postCid });
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[postCid]);
|
||||
const address = comment?.author?.address;
|
||||
|
||||
const getAnonAddressForReply = useCallback(async () => {
|
||||
@@ -189,7 +191,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
const { updatedAt } = subplebbit || {};
|
||||
const isBoardOffline = subplebbit?.updatedAt && subplebbit.updatedAt < Date.now() / 1000 - 60 * 60;
|
||||
const offlineAlert = updatedAt
|
||||
|
||||
@@ -13,7 +13,6 @@ interface ReplyQuotePreviewProps {
|
||||
backlinkReply?: Comment;
|
||||
isQuotelinkReply?: boolean;
|
||||
quotelinkReply?: Comment;
|
||||
replies?: Comment[];
|
||||
}
|
||||
|
||||
const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => {
|
||||
@@ -54,7 +53,7 @@ const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => {
|
||||
}
|
||||
};
|
||||
|
||||
const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => {
|
||||
const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => {
|
||||
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
|
||||
const [outOfViewCid, setOutOfViewCid] = useState<string | null>(null);
|
||||
const placementRef = useRef<Placement>('right');
|
||||
@@ -145,7 +144,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
|
||||
outOfViewCid === backlinkReply?.cid &&
|
||||
createPortal(
|
||||
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
|
||||
<Post post={backlinkReply} showReplies={false} replies={replies} />
|
||||
<Post post={backlinkReply} showReplies={false} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -174,7 +173,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
|
||||
outOfViewCid === quotelinkReply?.cid &&
|
||||
createPortal(
|
||||
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
|
||||
<Post post={quotelinkReply} showReplies={false} replies={replies} />
|
||||
<Post post={quotelinkReply} showReplies={false} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -184,7 +183,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
|
||||
return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink;
|
||||
};
|
||||
|
||||
const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => {
|
||||
const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => {
|
||||
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
|
||||
const [outOfViewCid, setOutOfViewCid] = useState<string | null>(null);
|
||||
|
||||
@@ -257,7 +256,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
|
||||
outOfViewCid === backlinkReply?.cid &&
|
||||
createPortal(
|
||||
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
|
||||
<Post post={backlinkReply} showReplies={false} replies={replies} />
|
||||
<Post post={backlinkReply} showReplies={false} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -293,7 +292,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
|
||||
outOfViewCid === quotelinkReply?.cid &&
|
||||
createPortal(
|
||||
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
|
||||
<Post post={quotelinkReply} showReplies={false} replies={replies} />
|
||||
<Post post={quotelinkReply} showReplies={false} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -303,25 +302,13 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
|
||||
return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink;
|
||||
};
|
||||
|
||||
const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => {
|
||||
const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile ? (
|
||||
<MobileQuotePreview
|
||||
backlinkReply={backlinkReply}
|
||||
quotelinkReply={quotelinkReply}
|
||||
isBacklinkReply={isBacklinkReply}
|
||||
isQuotelinkReply={isQuotelinkReply}
|
||||
replies={replies}
|
||||
/>
|
||||
<MobileQuotePreview backlinkReply={backlinkReply} quotelinkReply={quotelinkReply} isBacklinkReply={isBacklinkReply} isQuotelinkReply={isQuotelinkReply} />
|
||||
) : (
|
||||
<DesktopQuotePreview
|
||||
backlinkReply={backlinkReply}
|
||||
quotelinkReply={quotelinkReply}
|
||||
isBacklinkReply={isBacklinkReply}
|
||||
isQuotelinkReply={isQuotelinkReply}
|
||||
replies={replies}
|
||||
/>
|
||||
<DesktopQuotePreview backlinkReply={backlinkReply} quotelinkReply={quotelinkReply} isBacklinkReply={isBacklinkReply} isQuotelinkReply={isQuotelinkReply} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ const AccountSettings = () => {
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.json';
|
||||
|
||||
// Handle file selection
|
||||
fileInput.onchange = async (event) => {
|
||||
try {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
@@ -141,7 +140,6 @@ const AccountSettings = () => {
|
||||
}
|
||||
const file = files[0];
|
||||
|
||||
// Read the file content
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
@@ -149,20 +147,40 @@ const AccountSettings = () => {
|
||||
if (typeof fileContent !== 'string') {
|
||||
throw new Error('File content is not a string.');
|
||||
}
|
||||
const newAccount = JSON.parse(fileContent);
|
||||
await importAccount(fileContent);
|
||||
|
||||
// Store the imported account's address
|
||||
if (newAccount.account?.author?.address) {
|
||||
localStorage.setItem('importedAccountAddress', newAccount.account.author.address);
|
||||
const accountData = JSON.parse(fileContent);
|
||||
|
||||
// Add subplebbit addresses to subscriptions if they exist
|
||||
if (accountData.account?.subplebbits) {
|
||||
const subplebbitAddresses = Object.keys(accountData.account.subplebbits);
|
||||
|
||||
if (!accountData.account.subscriptions) {
|
||||
accountData.account.subscriptions = [];
|
||||
}
|
||||
|
||||
const uniqueSubscriptions = [...accountData.account.subscriptions];
|
||||
|
||||
for (const address of subplebbitAddresses) {
|
||||
if (!uniqueSubscriptions.includes(address)) {
|
||||
uniqueSubscriptions.push(address);
|
||||
}
|
||||
}
|
||||
|
||||
accountData.account.subscriptions = uniqueSubscriptions;
|
||||
}
|
||||
|
||||
// Set the new account as active before reloading
|
||||
if (newAccount.account?.name) {
|
||||
await setActiveAccount(newAccount.account.name);
|
||||
const modifiedAccountJson = JSON.stringify(accountData);
|
||||
await importAccount(modifiedAccountJson);
|
||||
|
||||
if (accountData.account?.author?.address) {
|
||||
localStorage.setItem('importedAccountAddress', accountData.account.author.address);
|
||||
}
|
||||
|
||||
alert(`Imported ${newAccount.account?.name}`);
|
||||
if (accountData.account?.name) {
|
||||
await setActiveAccount(accountData.account.name);
|
||||
}
|
||||
|
||||
alert(`Imported ${accountData.account?.name}`);
|
||||
|
||||
const currentPath = location.pathname;
|
||||
if (!currentPath.includes('/settings#account-settings')) {
|
||||
@@ -189,7 +207,6 @@ const AccountSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger file selection dialog
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useAccountComment, useComment, useSubplebbit, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
|
||||
import { useAccountComment, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import useSubplebbitStatsVisibilityStore from '../../stores/use-subplebbit-stats-visibility-store';
|
||||
import { isDescriptionView, isRulesView } from '../../lib/utils/view-utils';
|
||||
import styles from './subplebbit-stats.module.css';
|
||||
@@ -12,7 +14,7 @@ const SubplebbitStats = () => {
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress;
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
const { address, createdAt } = subplebbit || {};
|
||||
|
||||
let stats = useSubplebbitStats({ subplebbitAddress: address });
|
||||
@@ -23,7 +25,7 @@ const SubplebbitStats = () => {
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
|
||||
const comment = useComment({ commentCid: params?.commentCid });
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]);
|
||||
const { deleted, locked, removed } = comment || {};
|
||||
const hideStats = deleted || locked || removed || isInDescriptionView || isInRulesView;
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ const TopBarMobile = ({ subplebbitAddress }: { subplebbitAddress: string }) => {
|
||||
const params = useParams();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
const selectValue = isInAllView ? 'all' : isInSubscriptionsView ? 'subscriptions' : subplebbitAddress;
|
||||
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
@@ -160,7 +160,7 @@ const TopBarMobile = ({ subplebbitAddress }: { subplebbitAddress: string }) => {
|
||||
const subplebbitAddress = address?.includes('.') ? address : Plebbit.getShortAddress(address);
|
||||
return (
|
||||
<option key={index} value={address}>
|
||||
{subplebbitAddress.endsWith('.eth') || subplebbitAddress.endsWith('.sol') ? subplebbitAddress.slice(0, -4) : subplebbitAddress}
|
||||
{Plebbit.getShortAddress(subplebbitAddress)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getAutoSubscribeAddresses, useDefaultSubplebbits } from './use-default-
|
||||
|
||||
const AUTO_SUBSCRIBE_KEY_PREFIX = 'seedit-auto-subscribe-done-';
|
||||
|
||||
// Keep track of which accounts have been processed globally
|
||||
const processedAccounts = new Set<string>();
|
||||
|
||||
export const useAutoSubscribe = () => {
|
||||
@@ -31,7 +30,7 @@ export const useAutoSubscribe = () => {
|
||||
const storageKey = AUTO_SUBSCRIBE_KEY_PREFIX + accountAddress;
|
||||
const hasAutoSubscribed = localStorage.getItem(storageKey);
|
||||
|
||||
if (account.subscriptions?.length > 0 || hasAutoSubscribed) {
|
||||
if (hasAutoSubscribed) {
|
||||
processedAccounts.add(accountAddress);
|
||||
removeCheckingAccount(accountAddress);
|
||||
return;
|
||||
@@ -40,9 +39,19 @@ export const useAutoSubscribe = () => {
|
||||
const autoSubscribeAddresses = getAutoSubscribeAddresses();
|
||||
if (autoSubscribeAddresses.length) {
|
||||
try {
|
||||
const currentSubscriptions = account.subscriptions || [];
|
||||
|
||||
const mergedSubscriptions = [...currentSubscriptions];
|
||||
|
||||
for (const address of autoSubscribeAddresses) {
|
||||
if (!mergedSubscriptions.includes(address)) {
|
||||
mergedSubscriptions.push(address);
|
||||
}
|
||||
}
|
||||
|
||||
await setAccount({
|
||||
...account,
|
||||
subscriptions: autoSubscribeAddresses,
|
||||
subscriptions: mergedSubscriptions,
|
||||
});
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
processedAccounts.add(accountAddress);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||
import { isAllView } from '../lib/utils/view-utils';
|
||||
import { useMultisubMetadata } from './use-default-subplebbits';
|
||||
import _ from 'lodash';
|
||||
import useCatalogFiltersStore from '../stores/use-catalog-filters-store';
|
||||
|
||||
const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +20,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
|
||||
const multisub = useMultisubMetadata();
|
||||
|
||||
const { accountComments } = useAccountComments();
|
||||
const { searchText } = useCatalogFiltersStore();
|
||||
|
||||
const feedWithFakePostsOnTop = useMemo(() => {
|
||||
if (!isFeedLoaded) {
|
||||
@@ -64,7 +66,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
|
||||
}
|
||||
|
||||
// add subplebbit description and rules as fake posts at the top of the feed
|
||||
if (description && description.length > 0) {
|
||||
if (description && description.length > 0 && !searchText) {
|
||||
_feed.unshift({
|
||||
isDescription: true,
|
||||
subplebbitAddress: address,
|
||||
@@ -79,7 +81,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
|
||||
}
|
||||
|
||||
// rules are shown in description thread if both are set
|
||||
if (rules && rules.length > 0 && !description) {
|
||||
if (rules && rules.length > 0 && !description && !searchText) {
|
||||
_feed.unshift({
|
||||
isRules: true,
|
||||
subplebbitAddress: address,
|
||||
@@ -93,7 +95,23 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
|
||||
}
|
||||
|
||||
return _feed;
|
||||
}, [accountComments, feed, description, rules, address, isFeedLoaded, createdAt, title, shortAddress, avatarUrl, t, isInAllView, multisub, hideThreadsWithoutImages]);
|
||||
}, [
|
||||
accountComments,
|
||||
feed,
|
||||
description,
|
||||
rules,
|
||||
address,
|
||||
isFeedLoaded,
|
||||
createdAt,
|
||||
title,
|
||||
shortAddress,
|
||||
avatarUrl,
|
||||
t,
|
||||
isInAllView,
|
||||
multisub,
|
||||
hideThreadsWithoutImages,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const rows = [];
|
||||
|
||||
@@ -52,6 +52,10 @@ hr {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-floating-ui-portal] {
|
||||
color-scheme: var(--color-scheme) !important;
|
||||
}
|
||||
|
||||
.yellowOfflineIcon {
|
||||
background-image: url('/assets/icons/offline-blue.png');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
|
||||
/**
|
||||
* Checks if a text matches a pattern according to various pattern matching rules:
|
||||
* - Whole word matching: 'feel' matches 'feel' but not 'feeling'
|
||||
* - AND operator: 'feel girlfriend' matches both words in any order
|
||||
* - OR operator: 'feel|girlfriend' matches either word
|
||||
* - Mixed operators: 'girlfriend|boyfriend feel' matches complex logic
|
||||
* - Exact match: "that feel when" matches the exact phrase
|
||||
* - Wildcards: 'feel*' matches 'feel', 'feels', 'feeling', etc.
|
||||
* - Regex: /pattern/flags for regular expressions
|
||||
*
|
||||
* @param text The text to check against the pattern
|
||||
* @param pattern The pattern to match
|
||||
* @returns True if the text matches the pattern, false otherwise
|
||||
*/
|
||||
export const matchesPattern = (text: string, pattern: string): boolean => {
|
||||
if (!pattern || !text) return false;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
try {
|
||||
// Check if it's a regex pattern (starts and ends with /)
|
||||
if (pattern.startsWith('/') && pattern.length > 2 && /\/[gimsuy]*$/.test(pattern)) {
|
||||
const lastSlashIndex = pattern.lastIndexOf('/');
|
||||
const regexPattern = pattern.substring(1, lastSlashIndex);
|
||||
const flags = pattern.substring(lastSlashIndex + 1);
|
||||
const regex = new RegExp(regexPattern, flags);
|
||||
return regex.test(textLower);
|
||||
}
|
||||
// Check if it's an exact match pattern (surrounded by quotes)
|
||||
else if (pattern.startsWith('"') && pattern.endsWith('"') && pattern.length > 2) {
|
||||
const exactPattern = pattern.substring(1, pattern.length - 1).toLowerCase();
|
||||
return textLower.includes(exactPattern);
|
||||
}
|
||||
// Check for OR patterns (contains |)
|
||||
else if (pattern.includes('|')) {
|
||||
const orGroups = pattern.split(' ').filter(Boolean);
|
||||
|
||||
// Process each group (which might contain OR operators)
|
||||
return orGroups.every((group) => {
|
||||
const orTerms = group.split('|').map((term) => term.toLowerCase());
|
||||
return orTerms.some((term) => {
|
||||
// Handle wildcards in OR terms
|
||||
if (term.includes('*')) {
|
||||
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
} else {
|
||||
// Match whole word only
|
||||
const regex = new RegExp(`\\b${term}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Check for AND patterns (space-separated words)
|
||||
else if (pattern.includes(' ')) {
|
||||
const andTerms = pattern
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((term) => term.toLowerCase());
|
||||
return andTerms.every((term) => {
|
||||
// Handle wildcards in AND terms
|
||||
if (term.includes('*')) {
|
||||
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
} else {
|
||||
// Match whole word only
|
||||
const regex = new RegExp(`\\b${term}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Handle wildcard patterns
|
||||
else if (pattern.includes('*')) {
|
||||
const regexPattern = pattern.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
}
|
||||
// Simple whole word match
|
||||
else {
|
||||
const regex = new RegExp(`\\b${pattern.toLowerCase()}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
}
|
||||
} catch (error) {
|
||||
// If regex parsing fails, fall back to simple includes
|
||||
console.error('Pattern matching error:', error);
|
||||
const simplifiedPattern = pattern.toLowerCase();
|
||||
return textLower.includes(simplifiedPattern);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a user ID matches a pattern
|
||||
*
|
||||
* @param comment The comment to check
|
||||
* @param pattern The pattern to match (without the # prefix)
|
||||
* @returns True if the user ID matches the pattern, false otherwise
|
||||
*/
|
||||
export const userIdMatchesPattern = (comment: Comment, pattern: string): boolean => {
|
||||
if (!pattern || !comment?.author) return false;
|
||||
|
||||
// Check both address and shortAddress if available
|
||||
const address = comment.author.address || '';
|
||||
const shortAddress = comment.author.shortAddress || '';
|
||||
|
||||
return address.includes(pattern) || shortAddress.includes(pattern);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a display name matches a pattern
|
||||
*
|
||||
* @param comment The comment to check
|
||||
* @param pattern The pattern to match (without the ## prefix)
|
||||
* @returns True if the display name matches the pattern, false otherwise
|
||||
*/
|
||||
export const displayNameMatchesPattern = (comment: Comment, pattern: string): boolean => {
|
||||
if (!pattern) return false;
|
||||
|
||||
// Special case for "Anonymous" to match undefined display names
|
||||
if (pattern.toLowerCase() === 'anonymous') {
|
||||
return comment?.author?.displayName === undefined;
|
||||
}
|
||||
|
||||
if (!comment?.author?.displayName) return false;
|
||||
|
||||
return comment.author.displayName.toLowerCase().includes(pattern.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a user has a specific role
|
||||
*
|
||||
* @param comment The comment to check
|
||||
* @param role The role to check for (without the #!# prefix)
|
||||
* @returns True if the user has the specified role, false otherwise
|
||||
*/
|
||||
export const userHasRole = (comment: Comment, role: string): boolean => {
|
||||
if (!role || !comment?.author?.address || !comment?.subplebbitAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subplebbits = useSubplebbitsStore.getState().subplebbits;
|
||||
const subplebbit = subplebbits[comment.subplebbitAddress];
|
||||
|
||||
if (!subplebbit?.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRole = subplebbit.roles[comment.author.address]?.role;
|
||||
|
||||
// Handle different role names (moderator/mod)
|
||||
if ((role.toLowerCase() === 'moderator' || role.toLowerCase() === 'mod') && userRole === 'moderator') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRole?.toLowerCase() === role.toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a pattern that may contain special filters and content filters
|
||||
*
|
||||
* @param pattern The pattern to parse
|
||||
* @returns An object containing the parsed special filters and content filters
|
||||
*/
|
||||
export const parsePattern = (
|
||||
pattern: string,
|
||||
): {
|
||||
specialFilters: { type: 'userId' | 'displayName' | 'role'; value: string }[];
|
||||
contentFilter: string;
|
||||
} => {
|
||||
if (!pattern) return { specialFilters: [], contentFilter: '' };
|
||||
|
||||
const parts = pattern.split(' ');
|
||||
const specialFilters: { type: 'userId' | 'displayName' | 'role'; value: string }[] = [];
|
||||
const contentParts: string[] = [];
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (part.startsWith('#') && !part.startsWith('##') && !part.startsWith('#!#')) {
|
||||
specialFilters.push({ type: 'userId', value: part.substring(1) });
|
||||
} else if (part.startsWith('##')) {
|
||||
specialFilters.push({ type: 'displayName', value: part.substring(2) });
|
||||
} else if (part.startsWith('#!#')) {
|
||||
specialFilters.push({ type: 'role', value: part.substring(3) });
|
||||
} else {
|
||||
contentParts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
specialFilters,
|
||||
contentFilter: contentParts.join(' '),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a comment matches a pattern in its title or content,
|
||||
* or if it matches special filters for user ID, display name, or role
|
||||
*
|
||||
* @param comment The comment to check
|
||||
* @param pattern The pattern to match
|
||||
* @returns True if the comment matches the pattern, false otherwise
|
||||
*/
|
||||
export const commentMatchesPattern = (comment: Comment, pattern: string): boolean => {
|
||||
if (!pattern || !comment) return false;
|
||||
|
||||
// Check if the pattern contains spaces, which might indicate combined filters
|
||||
if (pattern.includes(' ') && (pattern.includes('#') || pattern.includes('##') || pattern.includes('#!#'))) {
|
||||
// Parse the pattern into special filters and content filter
|
||||
const { specialFilters, contentFilter } = parsePattern(pattern);
|
||||
|
||||
// If there are special filters, check if the comment matches all of them
|
||||
if (specialFilters.length > 0) {
|
||||
const allSpecialFiltersMatch = specialFilters.every((filter) => {
|
||||
if (filter.type === 'userId') {
|
||||
return userIdMatchesPattern(comment, filter.value);
|
||||
} else if (filter.type === 'displayName') {
|
||||
return displayNameMatchesPattern(comment, filter.value);
|
||||
} else if (filter.type === 'role') {
|
||||
return userHasRole(comment, filter.value);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// If there's also a content filter, check if the comment matches it as well
|
||||
if (contentFilter) {
|
||||
return allSpecialFiltersMatch && matchesPattern((comment?.title || '') + ' ' + (comment?.content || ''), contentFilter);
|
||||
}
|
||||
|
||||
return allSpecialFiltersMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple cases for single filters
|
||||
// Check for user ID filter (starts with #)
|
||||
if (pattern.startsWith('#') && !pattern.startsWith('##') && !pattern.startsWith('#!#')) {
|
||||
const userIdPattern = pattern.substring(1);
|
||||
return userIdMatchesPattern(comment, userIdPattern);
|
||||
}
|
||||
|
||||
// Check for display name filter (starts with ##)
|
||||
if (pattern.startsWith('##')) {
|
||||
const displayNamePattern = pattern.substring(2);
|
||||
return displayNameMatchesPattern(comment, displayNamePattern);
|
||||
}
|
||||
|
||||
// Check for role filter (starts with #!#)
|
||||
if (pattern.startsWith('#!#')) {
|
||||
const rolePattern = pattern.substring(3);
|
||||
return userHasRole(comment, rolePattern);
|
||||
}
|
||||
|
||||
// Regular content matching
|
||||
const titleLower = comment?.title?.toLowerCase() || '';
|
||||
const contentLower = comment?.content?.toLowerCase() || '';
|
||||
const textToMatch = titleLower + ' ' + contentLower;
|
||||
|
||||
return matchesPattern(textToMatch, pattern);
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||
import { commentMatchesPattern } from '../lib/utils/pattern-utils';
|
||||
|
||||
interface FilterItem {
|
||||
text: string;
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
filteredCids: Set<string>;
|
||||
subplebbitCounts: Map<string, number>;
|
||||
subplebbitFilteredCids: Map<string, Set<string>>;
|
||||
hide: boolean;
|
||||
top: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CatalogFiltersStore {
|
||||
@@ -20,7 +27,19 @@ interface CatalogFiltersStore {
|
||||
updateFilter: () => void;
|
||||
initializeFilter: () => void;
|
||||
filteredCount: number;
|
||||
resetFilteredCount: () => void;
|
||||
filteredCids: Set<string>;
|
||||
incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => void;
|
||||
recalcFilteredCount: () => void;
|
||||
currentSubplebbitAddress: string | null;
|
||||
setCurrentSubplebbitAddress: (address: string | null) => void;
|
||||
getFilteredCountForCurrentSubplebbit: () => number;
|
||||
searchText: string;
|
||||
setSearchFilter: (text: string) => void;
|
||||
clearSearchFilter: () => void;
|
||||
resetCountsForCurrentSubplebbit: () => void;
|
||||
matchedFilters: Map<string, string>;
|
||||
setMatchedFilter: (cid: string, color: string) => void;
|
||||
clearMatchedFilters: () => void;
|
||||
}
|
||||
|
||||
const useCatalogFiltersStore = create(
|
||||
@@ -28,76 +47,346 @@ const useCatalogFiltersStore = create(
|
||||
(set, get) => ({
|
||||
showTextOnlyThreads: false,
|
||||
setShowTextOnlyThreads: (value: boolean) => {
|
||||
set({ showTextOnlyThreads: value, filteredCount: 0 });
|
||||
set({ showTextOnlyThreads: value });
|
||||
get().updateFilter();
|
||||
},
|
||||
filterText: '',
|
||||
setFilterText: (value: string) => set({ filterText: value }),
|
||||
filterItems: [],
|
||||
filteredCount: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
currentSubplebbitAddress: null,
|
||||
matchedFilters: new Map<string, string>(),
|
||||
setMatchedFilter: (cid: string, color: string) => {
|
||||
set((state) => {
|
||||
const newMatchedFilters = new Map(state.matchedFilters);
|
||||
if (color) {
|
||||
newMatchedFilters.set(cid, color);
|
||||
} else {
|
||||
newMatchedFilters.delete(cid);
|
||||
}
|
||||
return { matchedFilters: newMatchedFilters };
|
||||
});
|
||||
},
|
||||
clearMatchedFilters: () => {
|
||||
set({ matchedFilters: new Map<string, string>() });
|
||||
},
|
||||
setCurrentSubplebbitAddress: (address: string | null) => {
|
||||
const prevAddress = get().currentSubplebbitAddress;
|
||||
|
||||
if (address !== prevAddress) {
|
||||
set((state) => {
|
||||
if (address) {
|
||||
const updatedFilterItems = state.filterItems.map((item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
// Reset count and filteredCids (global counters)
|
||||
newItem.count = 0;
|
||||
newItem.filteredCids = new Set();
|
||||
|
||||
// Ensure subplebbitCounts is a Map
|
||||
if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) {
|
||||
newItem.subplebbitCounts = new Map();
|
||||
}
|
||||
|
||||
// Ensure subplebbitFilteredCids is a Map
|
||||
if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) {
|
||||
newItem.subplebbitFilteredCids = new Map();
|
||||
}
|
||||
|
||||
// Only initialize for the new address if it doesn't already exist
|
||||
if (!newItem.subplebbitFilteredCids.has(address)) {
|
||||
newItem.subplebbitFilteredCids.set(address, new Set<string>());
|
||||
}
|
||||
if (!newItem.subplebbitCounts.has(address)) {
|
||||
newItem.subplebbitCounts.set(address, 0);
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return {
|
||||
currentSubplebbitAddress: address,
|
||||
filterItems: updatedFilterItems,
|
||||
filteredCount: 0, // This will be recalculated below
|
||||
};
|
||||
}
|
||||
|
||||
return { currentSubplebbitAddress: address };
|
||||
});
|
||||
|
||||
// Recalculate the filtered count for the current subplebbit
|
||||
get().recalcFilteredCount();
|
||||
get().updateFilter();
|
||||
} else {
|
||||
set({ currentSubplebbitAddress: address });
|
||||
}
|
||||
},
|
||||
searchText: '',
|
||||
setSearchFilter: (text: string) => {
|
||||
set({ searchText: text });
|
||||
get().updateFilter();
|
||||
},
|
||||
clearSearchFilter: () => {
|
||||
set({ searchText: '' });
|
||||
get().updateFilter();
|
||||
},
|
||||
setFilterItems: (items: FilterItem[]) => {
|
||||
const nonEmptyItems = items.filter((item) => item.text.trim() !== '');
|
||||
const nonEmptyItems = items
|
||||
.filter((item) => item.text.trim() !== '')
|
||||
.map((item) => ({
|
||||
...item,
|
||||
count: item.count || 0,
|
||||
filteredCids: item.filteredCids || new Set(),
|
||||
subplebbitCounts: item.subplebbitCounts || new Map(),
|
||||
subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(),
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color || '',
|
||||
}));
|
||||
set({ filterItems: nonEmptyItems });
|
||||
get().recalcFilteredCount();
|
||||
},
|
||||
saveAndApplyFilters: (items: FilterItem[]) => {
|
||||
const nonEmptyItems = items.filter((item) => item.text.trim() !== '');
|
||||
set({ filterItems: nonEmptyItems, filteredCount: 0 });
|
||||
const nonEmptyItems = items
|
||||
.filter((item) => item.text.trim() !== '')
|
||||
.map((item) => ({
|
||||
...item,
|
||||
count: item.count || 0,
|
||||
filteredCids: item.filteredCids || new Set(),
|
||||
subplebbitCounts: item.subplebbitCounts || new Map(),
|
||||
subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(),
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color || '',
|
||||
}));
|
||||
|
||||
// Compare new filter items with existing ones to detect pattern changes
|
||||
const existingItems = get().filterItems;
|
||||
const updatedItems = nonEmptyItems.map((newItem) => {
|
||||
// Try to find matching existing item by index or text
|
||||
const existingItem = existingItems.find((item) => item.text === newItem.text);
|
||||
|
||||
// If we found a matching item, keep its counts
|
||||
if (existingItem) {
|
||||
return {
|
||||
...newItem,
|
||||
count: existingItem.count,
|
||||
filteredCids: existingItem.filteredCids,
|
||||
subplebbitCounts: existingItem.subplebbitCounts,
|
||||
subplebbitFilteredCids: existingItem.subplebbitFilteredCids,
|
||||
color: newItem.color || '',
|
||||
};
|
||||
}
|
||||
|
||||
// If pattern has changed or it's a new filter, reset all counts
|
||||
return {
|
||||
...newItem,
|
||||
count: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
subplebbitCounts: new Map<string, number>(),
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
color: newItem.color || '',
|
||||
};
|
||||
});
|
||||
|
||||
// Clear matched filters when saving new filters
|
||||
get().clearMatchedFilters();
|
||||
|
||||
set({
|
||||
filterItems: updatedItems,
|
||||
filteredCids: new Set<string>(),
|
||||
});
|
||||
|
||||
get().recalcFilteredCount();
|
||||
get().updateFilter();
|
||||
},
|
||||
filter: undefined,
|
||||
updateFilter: () => {
|
||||
// const filteredCids = new Set<string>();
|
||||
set((state) => ({
|
||||
filter: (comment: Comment) => {
|
||||
const {
|
||||
showTextOnlyThreads,
|
||||
// filterItems
|
||||
} = state;
|
||||
if (!comment?.cid) return true;
|
||||
|
||||
const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {};
|
||||
const currentSubplebbit = state.currentSubplebbitAddress;
|
||||
|
||||
const hasThumbnail = getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link);
|
||||
// const title = comment?.title?.toLowerCase() || '';
|
||||
// const content = comment?.content?.toLowerCase() || '';
|
||||
|
||||
// const matchesFilterItems = filterItems
|
||||
// .filter((item) => item.enabled)
|
||||
// .some((item) => {
|
||||
// const text = item.text.toLowerCase();
|
||||
// return title.includes(text) || content.includes(text);
|
||||
// });
|
||||
|
||||
const shouldShow = showTextOnlyThreads || hasThumbnail;
|
||||
|
||||
if (
|
||||
!shouldShow
|
||||
// || matchesFilterItems
|
||||
) {
|
||||
// if (
|
||||
// matchesFilterItems &&
|
||||
// !filteredCids.has(comment.cid)) {
|
||||
// filteredCids.add(comment.cid);
|
||||
// set((state) => ({ filteredCount: state.filteredCount + 1 }));
|
||||
// }
|
||||
return false;
|
||||
// Apply search filter
|
||||
if (state.searchText.trim() !== '') {
|
||||
if (!commentMatchesPattern(comment, state.searchText)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
// Apply content filters
|
||||
const { filterItems } = state;
|
||||
let shouldHide = false;
|
||||
|
||||
for (let i = 0; i < filterItems.length; i++) {
|
||||
const item = filterItems[i];
|
||||
if (item.enabled && item.text.trim() !== '') {
|
||||
if (commentMatchesPattern(comment, item.text)) {
|
||||
// If we have a current subplebbit and this is a match, increment the count
|
||||
if (currentSubplebbit && comment.subplebbitAddress === currentSubplebbit) {
|
||||
// We need to use a timeout to avoid modifying state during a state update
|
||||
setTimeout(() => {
|
||||
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
|
||||
if (filterIndex !== -1) {
|
||||
get().incrementFilterCount(filterIndex, comment.cid, comment.subplebbitAddress);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (item.hide) {
|
||||
shouldHide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !shouldHide;
|
||||
},
|
||||
}));
|
||||
},
|
||||
initializeFilter: () => {
|
||||
const { updateFilter } = get();
|
||||
updateFilter();
|
||||
get().updateFilter();
|
||||
},
|
||||
incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => {
|
||||
set((state) => {
|
||||
const newFilterItems = [...state.filterItems];
|
||||
if (newFilterItems[filterIndex]) {
|
||||
const item = newFilterItems[filterIndex];
|
||||
|
||||
// Ensure subplebbitFilteredCids is a Map
|
||||
const subplebbitFilteredCids = new Map(item.subplebbitFilteredCids);
|
||||
if (!subplebbitFilteredCids.has(subplebbitAddress)) {
|
||||
subplebbitFilteredCids.set(subplebbitAddress, new Set());
|
||||
}
|
||||
const cidSet = subplebbitFilteredCids.get(subplebbitAddress)!;
|
||||
|
||||
// Only increment the count if this CID hasn't been counted for this subplebbit yet
|
||||
if (!cidSet.has(cid)) {
|
||||
const newItemFilteredCids = new Set(item.filteredCids);
|
||||
newItemFilteredCids.add(cid);
|
||||
|
||||
cidSet.add(cid);
|
||||
subplebbitFilteredCids.set(subplebbitAddress, cidSet);
|
||||
|
||||
const subplebbitCounts = new Map(item.subplebbitCounts);
|
||||
const currentCount = subplebbitCounts.get(subplebbitAddress) || 0;
|
||||
subplebbitCounts.set(subplebbitAddress, currentCount + 1);
|
||||
|
||||
newFilterItems[filterIndex] = {
|
||||
...item,
|
||||
count: item.count + 1,
|
||||
filteredCids: newItemFilteredCids,
|
||||
subplebbitCounts,
|
||||
subplebbitFilteredCids,
|
||||
};
|
||||
|
||||
return { filterItems: newFilterItems };
|
||||
}
|
||||
}
|
||||
return state;
|
||||
});
|
||||
|
||||
get().recalcFilteredCount();
|
||||
},
|
||||
recalcFilteredCount: () => {
|
||||
set((state) => {
|
||||
const currentSubplebbit = state.currentSubplebbitAddress;
|
||||
if (!currentSubplebbit) return { filteredCount: 0 };
|
||||
|
||||
let filteredCount = 0;
|
||||
for (const item of state.filterItems) {
|
||||
if (item.enabled && item.hide) {
|
||||
const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0;
|
||||
filteredCount += subCount;
|
||||
}
|
||||
}
|
||||
|
||||
return { filteredCount };
|
||||
});
|
||||
},
|
||||
getFilteredCountForCurrentSubplebbit: () => {
|
||||
const state = get();
|
||||
const currentSubplebbit = state.currentSubplebbitAddress;
|
||||
if (!currentSubplebbit) return 0;
|
||||
|
||||
let filteredCount = 0;
|
||||
for (const item of state.filterItems) {
|
||||
if (item.enabled && item.hide) {
|
||||
const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0;
|
||||
filteredCount += subCount;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredCount;
|
||||
},
|
||||
resetCountsForCurrentSubplebbit: () => {
|
||||
const currentSubplebbit = get().currentSubplebbitAddress;
|
||||
if (!currentSubplebbit) return;
|
||||
|
||||
set((state) => {
|
||||
const updatedFilterItems = state.filterItems.map((item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
// Ensure maps are properly initialized
|
||||
if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) {
|
||||
newItem.subplebbitCounts = new Map();
|
||||
}
|
||||
if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) {
|
||||
newItem.subplebbitFilteredCids = new Map();
|
||||
}
|
||||
|
||||
// Reset counts for current subplebbit
|
||||
newItem.subplebbitCounts.set(currentSubplebbit, 0);
|
||||
newItem.subplebbitFilteredCids.set(currentSubplebbit, new Set<string>());
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return {
|
||||
filterItems: updatedFilterItems,
|
||||
filteredCount: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Trigger filter reapplication to start counting again
|
||||
get().updateFilter();
|
||||
},
|
||||
filteredCount: 0,
|
||||
resetFilteredCount: () => set({ filteredCount: 0 }),
|
||||
}),
|
||||
{
|
||||
name: 'catalog-filters-storage',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.updateFilter();
|
||||
partialize: (state) => {
|
||||
return {
|
||||
showTextOnlyThreads: state.showTextOnlyThreads,
|
||||
filterItems: state.filterItems.map((item) => ({
|
||||
text: item.text,
|
||||
enabled: item.enabled,
|
||||
hide: item.hide,
|
||||
top: item.top,
|
||||
})),
|
||||
} as any;
|
||||
},
|
||||
deserialize: (persisted) => {
|
||||
const persistedObj = typeof persisted === 'string' ? JSON.parse(persisted) : persisted;
|
||||
|
||||
if (persistedObj && persistedObj.filterItems) {
|
||||
return {
|
||||
...persistedObj,
|
||||
filterItems: persistedObj.filterItems.map((item: any) => ({
|
||||
text: item.text,
|
||||
enabled: item.enabled,
|
||||
hide: item.hide,
|
||||
top: item.top,
|
||||
count: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
subplebbitCounts: new Map<string, number>(),
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
})),
|
||||
filteredCount: 0,
|
||||
};
|
||||
}
|
||||
return persistedObj || persisted;
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
--button-desktop-text-color: #00e;
|
||||
--button-desktop-text-color-hover: red;
|
||||
--close-button-background-image: url("/assets/buttons/cross-red.png");
|
||||
--open-help-button-background-image: url("/assets/buttons/help-red.png");
|
||||
|
||||
/* horizontal rule */
|
||||
--hr-border: none;
|
||||
@@ -263,6 +264,7 @@
|
||||
--button-desktop-text-color: #34345c;
|
||||
--button-desktop-text-color-hover: #d00;
|
||||
--close-button-background-image: url("/assets/buttons/cross-blue.png");
|
||||
--open-help-button-background-image: url("/assets/buttons/help-blue.png");
|
||||
|
||||
/* footer */
|
||||
--footer-item-background-color-desktop: #eef2ff;
|
||||
@@ -479,6 +481,7 @@
|
||||
--button-desktop-text-color-hover: red;
|
||||
--button-text-decoration: underline;
|
||||
--close-button-background-image: url("/assets/buttons/cross-red.png");
|
||||
--open-help-button-background-image: url("/assets/buttons/help-red.png");
|
||||
|
||||
/* footer */
|
||||
--footer-item-background-color-desktop: #fed;
|
||||
@@ -657,7 +660,7 @@
|
||||
--button-text-decoration: underline;
|
||||
--button-desktop-text-color-hover: #d00;
|
||||
--close-button-background-image: url("/assets/buttons/cross-blue.png");
|
||||
|
||||
--open-help-button-background-image: url("/assets/buttons/help-blue.png");
|
||||
/* footer */
|
||||
--footer-item-background-color-desktop: #eef2ff;
|
||||
|
||||
@@ -848,7 +851,7 @@
|
||||
--button-desktop-text-color: #81a2be;
|
||||
--button-desktop-text-color-hover: #5f89ac;
|
||||
--close-button-background-image: url("/assets/buttons/cross-dark.png");
|
||||
|
||||
--open-help-button-background-image: url("/assets/buttons/help-dark.png");
|
||||
/* filter for inputs */
|
||||
--filter80: brightness(85%);
|
||||
|
||||
@@ -1067,7 +1070,7 @@
|
||||
--button-desktop-text-color: #f60;
|
||||
--button-desktop-text-color-hover: #f30;
|
||||
--close-button-background-image: url("/assets/buttons/cross-photon.png");
|
||||
|
||||
--open-help-button-background-image: url("/assets/buttons/help-photon.png");
|
||||
/* horizontal rule */
|
||||
--hr-border: none;
|
||||
--hr-border-top: 1px solid #ddd;
|
||||
|
||||
@@ -20,13 +20,16 @@ import { Post } from '../post';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
|
||||
const threadsWithoutImagesFilter = (comment: Comment) => {
|
||||
const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {};
|
||||
if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const createThreadsWithoutImagesFilter = () => ({
|
||||
filter: (comment: Comment) => {
|
||||
const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {};
|
||||
if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
key: 'threads-with-images-only',
|
||||
});
|
||||
|
||||
const Board = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -66,7 +69,7 @@ const Board = () => {
|
||||
sortType,
|
||||
postsPerPage: isInAllView || isInSubscriptionsView || isInModView ? 5 : 25,
|
||||
...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}),
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined,
|
||||
};
|
||||
|
||||
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
|
||||
@@ -134,13 +137,13 @@ const Board = () => {
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
newerThan: 60 * 60 * 24 * 7,
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined,
|
||||
});
|
||||
const { feed: monthlyFeed } = useFeed({
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
newerThan: 60 * 60 * 24 * 30,
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined,
|
||||
});
|
||||
|
||||
const feedLength = feed.length;
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useCatalogFiltersStore from '../../../stores/use-catalog-filters-store';
|
||||
import styles from './catalog-filters.module.css';
|
||||
|
||||
const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { filterItems, saveAndApplyFilters } = useCatalogFiltersStore();
|
||||
|
||||
const [localFilterItems, setLocalFilterItems] = useState(filterItems);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const handleAddFilter = useCallback(() => {
|
||||
setLocalFilterItems((prev) => {
|
||||
const newIndex = prev.length;
|
||||
setTimeout(() => {
|
||||
inputRefs.current[newIndex]?.focus();
|
||||
}, 0);
|
||||
return [...prev, { text: '', enabled: true }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '');
|
||||
saveAndApplyFilters(nonEmptyFilters);
|
||||
onSave();
|
||||
}, [saveAndApplyFilters, localFilterItems, onSave]);
|
||||
|
||||
const updateLocalFilterItem = useCallback((index: number, item: any) => {
|
||||
setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f)));
|
||||
}, []);
|
||||
|
||||
const removeLocalFilterItem = useCallback((index: number) => {
|
||||
setLocalFilterItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const moveLocalFilterItemUp = useCallback((index: number) => {
|
||||
if (index === 0) return;
|
||||
setLocalFilterItems((prev) => {
|
||||
const newItems = [...prev];
|
||||
[newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
|
||||
return newItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<table className={styles.filtersTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('order')}</th>
|
||||
<th>{t('enable')}</th>
|
||||
<th>{t('text')}</th>
|
||||
<th>{t('delete')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<span className={styles.orderButton} onClick={() => moveLocalFilterItemUp(index)}>
|
||||
↑
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={styles.onCheckbox}
|
||||
checked={item.enabled}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
value={item.text}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })}
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.deleteButton} onClick={() => removeLocalFilterItem(index)}>
|
||||
×
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<button className={styles.addButton} onClick={handleAddFilter}>
|
||||
{t('add')}
|
||||
</button>
|
||||
<button className={styles.saveButton} onClick={handleSave}>
|
||||
{t('save')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={closeModal} />
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{t('filters')}</span>
|
||||
<span className={styles.closeButton} title='close' onClick={closeModal} />
|
||||
</div>
|
||||
<FiltersTable onSave={closeModal} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CatalogFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${styles.filtersButton} button`} onClick={() => setShowModal(false)} style={{ cursor: 'not-allowed' }}>
|
||||
{t('filters')}
|
||||
</span>
|
||||
{showModal && <FiltersModal closeModal={closeModal} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CatalogFilters;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
|
||||
@@ -14,18 +14,112 @@ import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||
import useSortingStore from '../../stores/use-sorting-store';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import CatalogRow from '../../components/catalog-row';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import styles from './catalog.module.css';
|
||||
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
|
||||
const threadsWithoutImagesFilter = (comment: Comment) => {
|
||||
const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {};
|
||||
if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const createContentFilter = (
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
|
||||
subplebbitAddress: string,
|
||||
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
|
||||
) => {
|
||||
// Create a unique key based on the enabled filter items
|
||||
const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '');
|
||||
const filterKey =
|
||||
enabledFilters.length > 0
|
||||
? `content-filter-${enabledFilters.map((item) => `${item.text}-${item.hide ? 'hide' : ''}-${item.top ? 'top' : ''}`).join('-')}`
|
||||
: 'no-content-filter';
|
||||
|
||||
return {
|
||||
filter: (comment: Comment) => {
|
||||
if (!comment?.cid) return true;
|
||||
|
||||
if (enabledFilters.length === 0) return true;
|
||||
|
||||
// Check if any enabled filter matches the content
|
||||
for (let i = 0; i < enabledFilters.length; i++) {
|
||||
const item = enabledFilters[i];
|
||||
const pattern = item.text;
|
||||
|
||||
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 (onFilterMatch) {
|
||||
onFilterMatch(filterIndex, comment.cid, subplebbitAddress);
|
||||
} else {
|
||||
// Fallback to the store method if no callback provided
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, subplebbitAddress);
|
||||
}
|
||||
|
||||
// If the filter has a color, track it in the matchedFilters map
|
||||
if (item.color) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
}
|
||||
}
|
||||
|
||||
// If this filter is set to hide, filter out the comment
|
||||
if (item.hide) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this filter is set to top, we'll handle it separately in the component
|
||||
// (we don't filter it out here)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
key: filterKey,
|
||||
};
|
||||
};
|
||||
|
||||
const createImageFilter = (showTextOnlyThreads: boolean) => {
|
||||
return {
|
||||
filter: (comment: Comment) => {
|
||||
if (showTextOnlyThreads) return true;
|
||||
|
||||
const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {};
|
||||
const hasThumbnail = getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link);
|
||||
|
||||
return hasThumbnail;
|
||||
},
|
||||
key: showTextOnlyThreads ? 'no-image-filter' : 'threads-with-images-only',
|
||||
};
|
||||
};
|
||||
|
||||
const createCombinedFilter = (
|
||||
showTextOnlyThreads: boolean,
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
|
||||
searchText: string,
|
||||
subplebbitAddress: string,
|
||||
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
|
||||
) => {
|
||||
const imageFilter = createImageFilter(showTextOnlyThreads);
|
||||
const contentFilter = createContentFilter(filterItems, subplebbitAddress, onFilterMatch);
|
||||
|
||||
const searchFilter = {
|
||||
filter: (comment: Comment) => {
|
||||
if (!searchText.trim()) return true;
|
||||
return commentMatchesPattern(comment, searchText);
|
||||
},
|
||||
key: searchText ? `search-filter-${searchText}` : 'no-search-filter',
|
||||
};
|
||||
|
||||
return {
|
||||
filter: (comment: Comment) => {
|
||||
if (!imageFilter.filter(comment)) return false;
|
||||
if (!contentFilter.filter(comment)) return false;
|
||||
if (!searchFilter.filter(comment)) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
key: `${imageFilter.key}-${contentFilter.key}-${searchFilter.key}`,
|
||||
};
|
||||
};
|
||||
|
||||
const Catalog = () => {
|
||||
@@ -36,7 +130,7 @@ const Catalog = () => {
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const { hideAdultBoards, hideGoreBoards } = useInterfaceSettingsStore();
|
||||
const { hideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
@@ -74,12 +168,25 @@ const Catalog = () => {
|
||||
const { timeFilterSeconds, timeFilterName } = useTimeFilter();
|
||||
const { sortType } = useSortingStore();
|
||||
|
||||
// Create a stable callback for filter matching
|
||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, subplebbitAddress);
|
||||
}, []);
|
||||
|
||||
// Set the current subplebbit address
|
||||
useEffect(() => {
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(subplebbitAddress || null);
|
||||
return () => {
|
||||
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(null);
|
||||
};
|
||||
}, [subplebbitAddress]);
|
||||
|
||||
const feedOptions = useMemo(() => {
|
||||
const options: any = {
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
postsPerPage: isInAllView || isInSubscriptionsView ? 10 : postsPerPage,
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
|
||||
};
|
||||
|
||||
if (isInAllView || isInSubscriptionsView) {
|
||||
@@ -87,7 +194,19 @@ const Catalog = () => {
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [subplebbitAddresses, sortType, isInAllView, isInSubscriptionsView, postsPerPage, timeFilterSeconds, hideThreadsWithoutImages]);
|
||||
}, [
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
isInAllView,
|
||||
isInSubscriptionsView,
|
||||
postsPerPage,
|
||||
timeFilterSeconds,
|
||||
showTextOnlyThreads,
|
||||
filterItems,
|
||||
searchText,
|
||||
subplebbitAddress,
|
||||
handleFilterMatch,
|
||||
]);
|
||||
|
||||
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
|
||||
const { accountComments } = useAccountComments();
|
||||
@@ -99,19 +218,31 @@ const Catalog = () => {
|
||||
() =>
|
||||
accountComments.filter((comment) => {
|
||||
const { cid, deleted, link, linkHeight, linkWidth, postCid, removed, state, thumbnailUrl, timestamp } = comment || {};
|
||||
return (
|
||||
|
||||
// Basic filtering conditions
|
||||
const basicConditions =
|
||||
!deleted &&
|
||||
!removed &&
|
||||
timestamp > Date.now() / 1000 - 60 * 60 &&
|
||||
state === 'succeeded' &&
|
||||
cid &&
|
||||
(hideThreadsWithoutImages ? getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), comment?.link) : true) &&
|
||||
(showTextOnlyThreads ? getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), comment?.link) : true) &&
|
||||
cid === postCid &&
|
||||
comment?.subplebbitAddress === subplebbitAddress &&
|
||||
!feed.some((post) => post.cid === cid)
|
||||
);
|
||||
!feed.some((post) => post.cid === cid);
|
||||
|
||||
// If search is active, also check search conditions
|
||||
if (basicConditions && searchText.trim()) {
|
||||
const titleLower = comment?.title?.toLowerCase() || '';
|
||||
const contentLower = comment?.content?.toLowerCase() || '';
|
||||
const searchPattern = searchText.toLowerCase();
|
||||
|
||||
return titleLower.includes(searchPattern) || contentLower.includes(searchPattern);
|
||||
}
|
||||
|
||||
return basicConditions;
|
||||
}),
|
||||
[accountComments, subplebbitAddress, feed, hideThreadsWithoutImages],
|
||||
[accountComments, subplebbitAddress, feed, showTextOnlyThreads, searchText],
|
||||
);
|
||||
|
||||
// show newest account comment at the top of the feed but after pinned posts
|
||||
@@ -143,13 +274,14 @@ const Catalog = () => {
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
newerThan: 60 * 60 * 24 * 7,
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
|
||||
});
|
||||
|
||||
const { feed: monthlyFeed } = useFeed({
|
||||
subplebbitAddresses,
|
||||
sortType,
|
||||
newerThan: 60 * 60 * 24 * 30,
|
||||
filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined,
|
||||
filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
|
||||
});
|
||||
|
||||
const [showMorePostsSuggestion, setShowMorePostsSuggestion] = useState(false);
|
||||
@@ -262,7 +394,40 @@ const Catalog = () => {
|
||||
|
||||
const isFeedLoaded = feed.length > 0 || state === 'failed';
|
||||
|
||||
const rows = useCatalogFeedRows(columnCount, combinedFeed, isFeedLoaded, subplebbit);
|
||||
// Process the feed to move "top" posts to the top
|
||||
const processedFeed = useMemo(() => {
|
||||
if (!combinedFeed || combinedFeed.length === 0) return combinedFeed;
|
||||
|
||||
const enabledTopFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.top);
|
||||
if (enabledTopFilters.length === 0) return combinedFeed;
|
||||
|
||||
// Separate posts that match "top" filters
|
||||
const topPosts: Comment[] = [];
|
||||
const regularPosts: Comment[] = [];
|
||||
|
||||
combinedFeed.forEach((comment) => {
|
||||
if (!comment) return;
|
||||
|
||||
let isTop = false;
|
||||
for (const filter of enabledTopFilters) {
|
||||
if (commentMatchesPattern(comment, filter.text)) {
|
||||
isTop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTop) {
|
||||
topPosts.push(comment);
|
||||
} else {
|
||||
regularPosts.push(comment);
|
||||
}
|
||||
});
|
||||
|
||||
// Return top posts followed by regular posts
|
||||
return [...topPosts, ...regularPosts];
|
||||
}, [combinedFeed, filterItems]);
|
||||
|
||||
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
||||
|
||||
// save the last Virtuoso state to restore it when navigating back
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
@@ -286,11 +451,42 @@ const Catalog = () => {
|
||||
document.title = documentTitle + ` - ${t('catalog')} - plebchan`;
|
||||
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t]);
|
||||
|
||||
// Clear matched filters when component mounts or when subplebbit changes
|
||||
useEffect(() => {
|
||||
clearMatchedFilters();
|
||||
return () => {
|
||||
clearMatchedFilters();
|
||||
};
|
||||
}, [clearMatchedFilters, subplebbitAddress]);
|
||||
|
||||
// Apply filter colors to posts when feed changes
|
||||
useEffect(() => {
|
||||
if (combinedFeed.length > 0 && filterItems.length > 0) {
|
||||
// Clear existing matched filters
|
||||
clearMatchedFilters();
|
||||
|
||||
// Apply colors to posts that match filters
|
||||
combinedFeed.forEach((comment) => {
|
||||
if (!comment?.cid) return;
|
||||
|
||||
// Check each filter
|
||||
for (const item of filterItems) {
|
||||
if (item.enabled && item.text.trim() !== '' && item.color) {
|
||||
if (commentMatchesPattern(comment, item.text)) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
break; // Use the first matching filter's color
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [combinedFeed, filterItems, clearMatchedFilters]);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<hr />
|
||||
<div className={styles.catalog}>
|
||||
{combinedFeed.length !== 0 ? (
|
||||
{processedFeed?.length !== 0 ? (
|
||||
<>
|
||||
<Virtuoso
|
||||
increaseViewportBy={{ bottom: 1200, top: 1200 }}
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
width: 24px;
|
||||
max-height: 24px;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
bottom: -7px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import PostMobile from '../../components/post-mobile';
|
||||
import SubplebbitDescription from '../../components/subplebbit-description';
|
||||
import SubplebbitRules from '../../components/subplebbit-rules';
|
||||
import styles from './post.module.css';
|
||||
|
||||
export interface PostProps {
|
||||
index?: number;
|
||||
isHidden?: boolean;
|
||||
@@ -20,10 +21,9 @@ export interface PostProps {
|
||||
roles?: Role[];
|
||||
showAllReplies?: boolean;
|
||||
showReplies?: boolean;
|
||||
replies?: Comment[];
|
||||
}
|
||||
|
||||
export const Post = ({ post, showAllReplies = false, showReplies = true, replies }: PostProps) => {
|
||||
export const Post = ({ post, showAllReplies = false, showReplies = true }: PostProps) => {
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[post?.subplebbitAddress]);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -39,9 +39,9 @@ export const Post = ({ post, showAllReplies = false, showReplies = true, replies
|
||||
<div className={styles.thread}>
|
||||
<div className={styles.postContainer}>
|
||||
{isMobile ? (
|
||||
<PostMobile post={comment} roles={subplebbit?.roles} showAllReplies={showAllReplies} showReplies={showReplies} replies={replies} />
|
||||
<PostMobile post={comment} roles={subplebbit?.roles} showAllReplies={showAllReplies} showReplies={showReplies} />
|
||||
) : (
|
||||
<PostDesktop post={comment} roles={subplebbit?.roles} showAllReplies={showAllReplies} showReplies={showReplies} replies={replies} />
|
||||
<PostDesktop post={comment} roles={subplebbit?.roles} showAllReplies={showAllReplies} showReplies={showReplies} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,10 +57,9 @@ const PostPage = () => {
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit;
|
||||
|
||||
const comment = useComment({ commentCid });
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit || {};
|
||||
|
||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||
const postComment = useComment({ commentCid: comment?.postCid });
|
||||
|
||||
@@ -3069,9 +3069,9 @@
|
||||
dependencies:
|
||||
debug "4.3.4"
|
||||
|
||||
"@plebbit/plebbit-react-hooks@https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f":
|
||||
"@plebbit/plebbit-react-hooks@https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7":
|
||||
version "0.0.1"
|
||||
resolved "https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f"
|
||||
resolved "https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7"
|
||||
dependencies:
|
||||
"@plebbit/plebbit-js" "https://github.com/plebbit/plebbit-js.git#5ff48e9b9a906a098a51b474a52692eebfb6561d"
|
||||
"@plebbit/plebbit-logger" "https://github.com/plebbit/plebbit-logger.git"
|
||||
@@ -3976,6 +3976,21 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz#7e880faf91f89471c30c141951e15f0eb3a0599e"
|
||||
integrity sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/type-utils" "8.26.0"
|
||||
"@typescript-eslint/utils" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^5.3.1"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^5.5.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
|
||||
@@ -3992,21 +4007,6 @@
|
||||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz#7e880faf91f89471c30c141951e15f0eb3a0599e"
|
||||
integrity sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/type-utils" "8.26.0"
|
||||
"@typescript-eslint/utils" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^5.3.1"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
"@typescript-eslint/experimental-utils@^5.0.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz#14559bf73383a308026b427a4a6129bae2146741"
|
||||
@@ -4014,6 +4014,17 @@
|
||||
dependencies:
|
||||
"@typescript-eslint/utils" "5.62.0"
|
||||
|
||||
"@typescript-eslint/parser@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.0.tgz#9b4d2198e89f64fb81e83167eedd89a827d843a9"
|
||||
integrity sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/typescript-estree" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/parser@^5.5.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7"
|
||||
@@ -4024,17 +4035,6 @@
|
||||
"@typescript-eslint/typescript-estree" "5.62.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/parser@^8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.0.tgz#9b4d2198e89f64fb81e83167eedd89a827d843a9"
|
||||
integrity sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/typescript-estree" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@5.62.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
|
||||
|
||||