diff --git a/package.json b/package.json index 4359dcaf..5ec5a6cf 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@capawesome/capacitor-android-edge-to-edge-support": "7.2.2", "@chenglou/pretext": "0.0.5", "@floating-ui/react": "0.26.1", - "@pkcprotocol/pkc-js": "0.0.35", + "@pkcprotocol/pkc-js": "0.0.37", "@react-spring/web": "10.0.3", "@types/node": "20.19.37", "@types/react": "18.2.25", @@ -183,7 +183,9 @@ "portless": "0.11.1" }, "resolutions": { + "@pkcprotocol/pkc-js": "0.0.37", "follow-redirects": "1.16.0", + "@libp2p/gossipsub": "15.0.23", "@libp2p/kad-dht": "16.2.6", "brace-expansion@npm:^5.0.2": "5.0.6", "protobufjs": "7.5.8", diff --git a/public/assets/icons/flags-2.png b/public/assets/icons/flags-2.png new file mode 100644 index 00000000..882fd917 Binary files /dev/null and b/public/assets/icons/flags-2.png differ diff --git a/public/assets/icons/flags-pony.png b/public/assets/icons/flags-pony.png new file mode 100644 index 00000000..d78f63de Binary files /dev/null and b/public/assets/icons/flags-pony.png differ diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index b7ffbe82..fb9fd630 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "لم يتم التحقق من عنوان هذه اللوحة. قد لا يكون مالك اللوحة مالكًا لهذا العنوان.", "go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات لاستيراد حساب المشرف الخاص بك", "post_form_rules_faq_prompt": "يُرجى قراءة القواعد والأسئلة الشائعة قبل النشر.", - "pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\"." + "pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\".", + "flag": "علم" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index dc3cfc31..fb1f8f11 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "এই বোর্ড ঠিকানাটি যাচাই করা হয়নি। বোর্ডের মালিক এই ঠিকানার মালিক নাও হতে পারেন।", "go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস-এ যান", "post_form_rules_faq_prompt": "পোস্ট করার আগে নিয়ম এবং প্রশ্নোত্তর পড়ুন।", - "pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\"." + "pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\".", + "flag": "পতাকা" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index d846b9cd..ed5e0176 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Tato adresa nástěnky není ověřená. Vlastník nástěnky nemusí tuto adresu vlastnit.", "go_to_settings_to_import_mod_account": "Přejděte do <1>nastavení a importujte svůj moderátorský účet", "post_form_rules_faq_prompt": "Před odesláním si přečtěte Pravidla a FAQ.", - "pending_mod_approval_reason": "AI moderace odeslala tento příspěvek do fronty moderátorů. Důvod: \"{{reason}}\"." + "pending_mod_approval_reason": "AI moderace odeslala tento příspěvek do fronty moderátorů. Důvod: \"{{reason}}\".", + "flag": "Vlajka" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 92461c45..39b15192 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Denne boardadresse er ikke verificeret. Boardets ejer ejer muligvis ikke denne adresse.", "go_to_settings_to_import_mod_account": "Gå til <1>indstillinger for at importere din mod-konto", "post_form_rules_faq_prompt": "Læs venligst Reglerne og FAQ, før du poster.", - "pending_mod_approval_reason": "AI-moderering sendte dette opslag til moderatorkøen. Grund: \"{{reason}}\"." + "pending_mod_approval_reason": "AI-moderering sendte dette opslag til moderatorkøen. Grund: \"{{reason}}\".", + "flag": "Flag" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 6ccd1e29..2427f2cc 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Diese Board-Adresse ist nicht verifiziert. Der Board-Betreiber besitzt diese Adresse möglicherweise nicht.", "go_to_settings_to_import_mod_account": "Gehe zu <1>Einstellungen, um dein Moderatorkonto zu importieren", "post_form_rules_faq_prompt": "Bitte lesen Sie die Regeln und die FAQ, bevor Sie posten.", - "pending_mod_approval_reason": "Die KI-Moderation hat diesen Beitrag in die Moderationswarteschlange verschoben. Grund: \"{{reason}}\"." + "pending_mod_approval_reason": "Die KI-Moderation hat diesen Beitrag in die Moderationswarteschlange verschoben. Grund: \"{{reason}}\".", + "flag": "Flagge" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index b2c6ba14..b25136c9 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Αυτή η διεύθυνση board δεν έχει επαληθευτεί. Ο ιδιοκτήτης του board ενδέχεται να μην κατέχει αυτή τη διεύθυνση.", "go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις για να εισαγάγετε τον λογαριασμό διαχειριστή σας", "post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους Κανόνες και τις Συχνές ερωτήσεις πριν δημοσιεύσετε.", - "pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\"." + "pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\".", + "flag": "Σημαία" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index a6c27ade..0be4c552 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -411,5 +411,6 @@ "modQueue.dismiss": "Dismiss", "board_address_unverified_warning": "This board address is not verified. The board owner might not own this address.", "post_form_rules_faq_prompt": "Please read the Rules and FAQ before posting.", - "pending_mod_approval_reason": "AI moderation sent this post to the mod queue. Reason: \"{{reason}}\"." + "pending_mod_approval_reason": "AI moderation sent this post to the mod queue. Reason: \"{{reason}}\".", + "flag": "Flag" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index b0fc2e1b..d5ea20ac 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Esta dirección del tablón no está verificada. Es posible que el propietario del tablón no sea dueño de esta dirección.", "go_to_settings_to_import_mod_account": "Ve a <1>configuración para importar tu cuenta de moderador", "post_form_rules_faq_prompt": "Por favor, lee las Reglas y las FAQ antes de publicar.", - "pending_mod_approval_reason": "La moderación con IA envió esta publicación a la cola de moderación. Motivo: \"{{reason}}\"." + "pending_mod_approval_reason": "La moderación con IA envió esta publicación a la cola de moderación. Motivo: \"{{reason}}\".", + "flag": "Bandera" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index cedb1352..986f8f46 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "این نشانی برد تأیید نشده است. ممکن است مالک برد صاحب این نشانی نباشد.", "go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات بروید", "post_form_rules_faq_prompt": "لطفاً قبل از ارسال قوانین و پرسش‌های متداول را بخوانید.", - "pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\"." + "pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\".", + "flag": "پرچم" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 4c44e77c..fc7688b3 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Tätä laudan osoitetta ei ole vahvistettu. Laudan omistaja ei välttämättä omista tätä osoitetta.", "go_to_settings_to_import_mod_account": "Siirry <1>asetuksiin tuodaksesi moderaattoritilisi", "post_form_rules_faq_prompt": "Lue Säännöt ja UKK ennen julkaisemista.", - "pending_mod_approval_reason": "Tekoälymoderointi lähetti tämän viestin moderointijonoon. Syy: \"{{reason}}\"." + "pending_mod_approval_reason": "Tekoälymoderointi lähetti tämän viestin moderointijonoon. Syy: \"{{reason}}\".", + "flag": "Lippu" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 33524aaf..735dc7a1 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Hindi beripikado ang address ng board na ito. Maaaring hindi pag-aari ng may-ari ng board ang address na ito.", "go_to_settings_to_import_mod_account": "Pumunta sa <1>mga setting para i-import ang iyong mod account", "post_form_rules_faq_prompt": "Pakibasa ang Rules at FAQ bago mag-post.", - "pending_mod_approval_reason": "Ipinadala ng AI moderation ang post na ito sa mod queue. Dahilan: \"{{reason}}\"." + "pending_mod_approval_reason": "Ipinadala ng AI moderation ang post na ito sa mod queue. Dahilan: \"{{reason}}\".", + "flag": "Bandila" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 63f6bf26..ed8bf97b 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Cette adresse de board n'est pas vérifiée. Le propriétaire du board pourrait ne pas posséder cette adresse.", "go_to_settings_to_import_mod_account": "Allez dans les <1>paramètres pour importer votre compte de modération", "post_form_rules_faq_prompt": "Veuillez lire le Règlement et la FAQ avant de publier.", - "pending_mod_approval_reason": "La modération par IA a envoyé ce post dans la file de modération. Raison: \"{{reason}}\"." + "pending_mod_approval_reason": "La modération par IA a envoyé ce post dans la file de modération. Raison: \"{{reason}}\".", + "flag": "Drapeau" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 4cb8492f..39c82ec7 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "כתובת הלוח הזו אינה מאומתת. ייתכן שבעל הלוח אינו הבעלים של הכתובת הזו.", "go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות כדי לייבא את חשבון המוד שלך", "post_form_rules_faq_prompt": "אנא קרא את הכללים ואת שאלות נפוצות לפני פרסום.", - "pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\"." + "pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\".", + "flag": "דגל" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 838e1263..28945024 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "इस बोर्ड पते की पुष्टि नहीं हुई है। हो सकता है बोर्ड का मालिक इस पते का मालिक न हो।", "go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स पर जाएं", "post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया नियम और अक्सर पूछे जाने वाले सवाल पढ़ें।", - "pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\"." + "pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\".", + "flag": "ध्वज" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 81914607..a28e015a 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Ez a board-cím nincs ellenőrizve. Lehet, hogy a board tulajdonosa nem birtokolja ezt a címet.", "go_to_settings_to_import_mod_account": "Lépj a <1>beállítások menübe a moderátori fiókod importálásához", "post_form_rules_faq_prompt": "Kérjük, olvasd el a Szabályokat és a GYIK-et posztolás előtt.", - "pending_mod_approval_reason": "Az AI-moderáció ezt a bejegyzést a moderátori várólistára küldte. Ok: \"{{reason}}\"." + "pending_mod_approval_reason": "Az AI-moderáció ezt a bejegyzést a moderátori várólistára küldte. Ok: \"{{reason}}\".", + "flag": "Zászló" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index cbea3aeb..b9a130b4 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Alamat board ini belum diverifikasi. Pemilik board mungkin tidak memiliki alamat ini.", "go_to_settings_to_import_mod_account": "Buka <1>pengaturan untuk mengimpor akun mod Anda", "post_form_rules_faq_prompt": "Silakan baca Aturan dan FAQ sebelum memposting.", - "pending_mod_approval_reason": "Moderasi AI mengirim postingan ini ke antrean moderator. Alasan: \"{{reason}}\"." + "pending_mod_approval_reason": "Moderasi AI mengirim postingan ini ke antrean moderator. Alasan: \"{{reason}}\".", + "flag": "Bendera" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 955c44a3..28845597 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Questo indirizzo della board non è verificato. Il proprietario della board potrebbe non possedere questo indirizzo.", "go_to_settings_to_import_mod_account": "Vai alle <1>impostazioni per importare il tuo account mod", "post_form_rules_faq_prompt": "Leggi le Regole e le FAQ prima di pubblicare.", - "pending_mod_approval_reason": "La moderazione AI ha inviato questo post alla coda di moderazione. Motivo: \"{{reason}}\"." + "pending_mod_approval_reason": "La moderazione AI ha inviato questo post alla coda di moderazione. Motivo: \"{{reason}}\".", + "flag": "Bandiera" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index f2b4d50a..906fd3a3 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "このボードアドレスは検証されていません。ボード所有者がこのアドレスを所有していない可能性があります。", "go_to_settings_to_import_mod_account": "モデレーターアカウントをインポートするには<1>設定に移動してください", "post_form_rules_faq_prompt": "投稿する前にルールFAQをお読みください。", - "pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\"." + "pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\".", + "flag": "旗" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index f0ef67ee..c841af09 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "이 보드 주소는 확인되지 않았습니다. 보드 소유자가 이 주소를 소유하지 않았을 수 있습니다.", "go_to_settings_to_import_mod_account": "관리자 계정을 가져오려면 <1>설정으로 이동하세요", "post_form_rules_faq_prompt": "게시하기 전에 규칙FAQ을 읽어 주세요.", - "pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\"." + "pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\".", + "flag": "깃발" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 595fb124..ffefbfaa 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "हा बोर्ड पत्ता सत्यापित केलेला नाही. बोर्डचा मालक हा पत्ता मालक नसू शकतो.", "go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज वर जा", "post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी नियम आणि वारंवार विचारले जाणारे प्रश्न वाचा.", - "pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\"." + "pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\".", + "flag": "ध्वज" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 24462d5f..8483d981 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Dit boardadres is niet geverifieerd. De eigenaar van het board is mogelijk niet de eigenaar van dit adres.", "go_to_settings_to_import_mod_account": "Ga naar <1>instellingen om je mod-account te importeren", "post_form_rules_faq_prompt": "Lees de Regels en de FAQ voordat je plaatst.", - "pending_mod_approval_reason": "AI-moderatie heeft dit bericht naar de moderatiewachtrij gestuurd. Reden: \"{{reason}}\"." + "pending_mod_approval_reason": "AI-moderatie heeft dit bericht naar de moderatiewachtrij gestuurd. Reden: \"{{reason}}\".", + "flag": "Vlag" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index ab7f9c90..fe6ec26b 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Denne board-adressen er ikke bekreftet. Board-eieren eier kanskje ikke denne adressen.", "go_to_settings_to_import_mod_account": "Gå til <1>innstillinger for å importere modkontoen din", "post_form_rules_faq_prompt": "Vennligst les Reglene og FAQ før du poster.", - "pending_mod_approval_reason": "AI-moderering sendte dette innlegget til moderatorkøen. Årsak: \"{{reason}}\"." + "pending_mod_approval_reason": "AI-moderering sendte dette innlegget til moderatorkøen. Årsak: \"{{reason}}\".", + "flag": "Flagg" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index d3bca265..4932bb70 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Ten adres boarda nie jest zweryfikowany. Właściciel boarda może nie być właścicielem tego adresu.", "go_to_settings_to_import_mod_account": "Przejdź do <1>ustawień, aby zaimportować swoje konto moderatora", "post_form_rules_faq_prompt": "Przed opublikowaniem przeczytaj Zasady i FAQ.", - "pending_mod_approval_reason": "Moderacja AI wysłała ten post do kolejki moderatorów. Powód: \"{{reason}}\"." + "pending_mod_approval_reason": "Moderacja AI wysłała ten post do kolejki moderatorów. Powód: \"{{reason}}\".", + "flag": "Flaga" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index b2116e8d..91bde7cc 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Este endereço do board não foi verificado. O proprietário do board pode não ser dono deste endereço.", "go_to_settings_to_import_mod_account": "Vá para <1>configurações para importar sua conta de mod", "post_form_rules_faq_prompt": "Leia as Regras e as FAQ antes de publicar.", - "pending_mod_approval_reason": "A moderação por IA enviou esta postagem para a fila de moderação. Razão: \"{{reason}}\"." + "pending_mod_approval_reason": "A moderação por IA enviou esta postagem para a fila de moderação. Razão: \"{{reason}}\".", + "flag": "Bandeira" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 420c8851..9fec568a 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Această adresă de board nu este verificată. Proprietarul boardului s-ar putea să nu dețină această adresă.", "go_to_settings_to_import_mod_account": "Accesează <1>setările pentru a importa contul tău de moderator", "post_form_rules_faq_prompt": "Citește Regulile și Întrebările frecvente înainte de a publica.", - "pending_mod_approval_reason": "Moderarea AI a trimis această postare în coada moderatorilor. Motiv: \"{{reason}}\"." + "pending_mod_approval_reason": "Moderarea AI a trimis această postare în coada moderatorilor. Motiv: \"{{reason}}\".", + "flag": "Steag" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index dbe9f655..a993ba38 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Этот адрес доски не проверен. Владелец доски может не владеть этим адресом.", "go_to_settings_to_import_mod_account": "Перейдите в <1>настройки, чтобы импортировать свой аккаунт модератора", "post_form_rules_faq_prompt": "Пожалуйста, прочитайте Правила и FAQ перед публикацией.", - "pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\"." + "pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\".", + "flag": "Флаг" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 81a31be1..c85fab2b 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Kjo adresë e board-it nuk është verifikuar. Pronari i board-it mund të mos e zotërojë këtë adresë.", "go_to_settings_to_import_mod_account": "Shkoni te <1>parametrat për të importuar llogarinë tuaj të modit", "post_form_rules_faq_prompt": "Ju lutemi lexoni Rregullat dhe Pyetjet e shpeshta para se të postoni.", - "pending_mod_approval_reason": "Moderimi me AI e dërgoi këtë postim në radhën e moderatorëve. Arsye: \"{{reason}}\"." + "pending_mod_approval_reason": "Moderimi me AI e dërgoi këtë postim në radhën e moderatorëve. Arsye: \"{{reason}}\".", + "flag": "Flamur" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 9034a439..df997b79 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Den här board-adressen är inte verifierad. Boardets ägare kanske inte äger den här adressen.", "go_to_settings_to_import_mod_account": "Gå till <1>inställningar för att importera ditt modkonto", "post_form_rules_faq_prompt": "Läs Reglerna och FAQ innan du postar.", - "pending_mod_approval_reason": "AI-moderering skickade det här inlägget till moderatorkön. Anledning: \"{{reason}}\"." + "pending_mod_approval_reason": "AI-moderering skickade det här inlägget till moderatorkön. Anledning: \"{{reason}}\".", + "flag": "Flagga" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 34c54d64..615b32de 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "ఈ బోర్డ్ చిరునామా ధృవీకరించబడలేదు. బోర్డ్ యజమాని ఈ చిరునామాకు యజమాని కాకపోవచ్చు.", "go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలుకి వెళ్లండి", "post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు నియమాలు మరియు తరచుగా అడిగే ప్రశ్నలు చదవండి.", - "pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\"." + "pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\".", + "flag": "జెండా" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index ce1c2dba..626e3b84 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "ที่อยู่บอร์ดนี้ยังไม่ได้รับการยืนยัน เจ้าของบอร์ดอาจไม่ได้เป็นเจ้าของที่อยู่นี้", "go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า เพื่อนำเข้าบัญชีผู้ดูแลของคุณ", "post_form_rules_faq_prompt": "โปรดอ่านกฎและคำถามที่พบบ่อยก่อนโพสต์", - "pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\"." + "pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\".", + "flag": "ธง" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 7a8bab4f..01ed819d 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Bu board adresi doğrulanmamış. Board sahibi bu adresin sahibi olmayabilir.", "go_to_settings_to_import_mod_account": "Mod hesabınızı içe aktarmak için <1>ayarlara gidin", "post_form_rules_faq_prompt": "Lütfen göndermeden önce Kuralları ve SSS'yi okuyun.", - "pending_mod_approval_reason": "AI moderasyonu bu gönderiyi moderatör kuyruğuna gönderdi. Neden: \"{{reason}}\"." + "pending_mod_approval_reason": "AI moderasyonu bu gönderiyi moderatör kuyruğuna gönderdi. Neden: \"{{reason}}\".", + "flag": "Bayrak" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 9585168d..ab06bbad 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Цю адресу дошки не перевірено. Власник дошки може не володіти цією адресою.", "go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань, щоб імпортувати свій обліковий запис модератора", "post_form_rules_faq_prompt": "Будь ласка, прочитайте Правила і Часті запитання перед публікацією.", - "pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\"." + "pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\".", + "flag": "Прапор" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 2b21e380..c7ed77e5 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "اس بورڈ کے پتے کی تصدیق نہیں ہوئی۔ ممکن ہے بورڈ کا مالک اس پتے کا مالک نہ ہو۔", "go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات پر جائیں", "post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے اصول اور اکثر پوچھے گئے سوالات پڑھیں۔", - "pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\"." + "pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\".", + "flag": "پرچم" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index da9f8a8a..67425543 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "Địa chỉ bảng này chưa được xác minh. Chủ bảng có thể không sở hữu địa chỉ này.", "go_to_settings_to_import_mod_account": "Vào <1>cài đặt để nhập tài khoản mod của bạn", "post_form_rules_faq_prompt": "Vui lòng đọc Quy tắcCâu hỏi thường gặp trước khi đăng.", - "pending_mod_approval_reason": "Kiểm duyệt AI đã gửi bài viết này vào hàng đợi kiểm duyệt. Lý do: \"{{reason}}\"." + "pending_mod_approval_reason": "Kiểm duyệt AI đã gửi bài viết này vào hàng đợi kiểm duyệt. Lý do: \"{{reason}}\".", + "flag": "Cờ" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 5bbfd152..c38b6473 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -392,5 +392,6 @@ "board_address_unverified_warning": "此版面地址尚未验证。版主可能并不拥有此地址。", "go_to_settings_to_import_mod_account": "前往<1>设置以导入您的版主账户", "post_form_rules_faq_prompt": "发帖前请阅读规则常见问题。", - "pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。" + "pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。", + "flag": "旗帜" } diff --git a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx index 9fc3245e..d38b03bb 100644 --- a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx +++ b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx @@ -438,6 +438,63 @@ describe('ChallengeModal', () => { expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); + it('advances through multiple iframe challenges before publishing answers', async () => { + const publication = createPublication(); + testState.challenges = [ + createStoredChallenge( + [ + { + challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', + type: 'url/iframe', + }, + { + challenge: 'https://flags.5chan.app/iframe/session-flag', + type: 'url/iframe', + }, + ], + publication, + ), + ]; + + await renderModal(); + await clickButton('Open'); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'challengeAnswer', + challengeAnswers: [''], + sessionId: 'session-123', + }, + origin: 'https://spamblocker.bitsocial.net', + }), + ); + }); + + expect(publication.publishChallengeAnswers).not.toHaveBeenCalled(); + expect(testState.removeChallengeMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain('mu wants to open flags.5chan.app.'); + + await clickButton('Open'); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'challengeAnswer', + challengeAnswers: [''], + sessionId: 'session-flag', + }, + origin: 'https://flags.5chan.app', + }), + ); + }); + + expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['', '']); + expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); + }); + it('ignores iframe completion messages with the wrong session id', async () => { const publication = createPublication(); testState.challenges = [ diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx index a135991e..6ae626d6 100644 --- a/src/components/challenge-modal/challenge-modal.tsx +++ b/src/components/challenge-modal/challenge-modal.tsx @@ -373,19 +373,41 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => { closeModal(); }; + const completeCurrentChallenge = useCallback( + (challengeAnswers: string[]) => { + if (!publication) return; + const resolvedAnswers = challengeAnswers.length ? challengeAnswers : ['']; + const updatedAnswers = [...answers]; + resolvedAnswers.forEach((answer, index) => { + updatedAnswers[currentChallengeIndex + index] = answer; + }); + + const nextChallengeIndex = currentChallengeIndex + resolvedAnswers.length; + if (challenges?.[nextChallengeIndex]) { + setAnswers(updatedAnswers); + setCurrentChallengeIndex(nextChallengeIndex); + setReadyIframeChallengeKey(''); + return; + } + + publication.publishChallengeAnswers(updatedAnswers); + setAnswers([]); + closeModal(); + }, + [answers, challenges, closeModal, currentChallengeIndex, publication], + ); + const onIframeDone = useCallback(() => { if (!publication) return; - publication.publishChallengeAnswers(['']); - closeModal(); - }, [closeModal, publication]); + completeCurrentChallenge(['']); + }, [completeCurrentChallenge, publication]); const onIframeAutoComplete = useCallback( (challengeAnswers: string[]) => { if (!publication) return; - publication.publishChallengeAnswers(challengeAnswers); - closeModal(); + completeCurrentChallenge(challengeAnswers); }, - [closeModal, publication], + [completeCurrentChallenge, publication], ); const onIframeReady = useCallback(() => { diff --git a/src/components/post-author-flags.tsx b/src/components/post-author-flags.tsx new file mode 100644 index 00000000..8f78442f --- /dev/null +++ b/src/components/post-author-flags.tsx @@ -0,0 +1,39 @@ +import { getCommentFlagFlairs, getAuthorFlagViewModels } from '../lib/comment-flags'; +import styles from '../views/post/post.module.css'; + +interface PostAuthorFlagsProps { + author: unknown; + comment: unknown; + enabled: boolean; +} + +const getBackgroundPosition = (x: number, y: number) => `${x === 0 ? 0 : -x}px ${y === 0 ? 0 : -y}px`; + +const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => { + if (!enabled) return null; + + const flags = getAuthorFlagViewModels(getCommentFlagFlairs(comment, author)); + if (flags.length === 0) return null; + + return ( + + {flags.map((flag) => ( + + ))} + + ); +}; + +export default PostAuthorFlags; diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index e7dbcb8c..a884c2ca 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -31,6 +31,7 @@ import EditMenu from '../edit-menu/edit-menu'; import FailedPublishNotice from '../failed-publish-notice'; import { canEmbed } from '../embed'; import LoadingEllipsis from '../loading-ellipsis'; +import PostAuthorFlags from '../post-author-flags'; import PostMenuDesktop from './post-menu-desktop'; import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; @@ -239,7 +240,9 @@ const PostInfo = ({ const isReply = parentCid; const { showOmittedReplies } = useShowOmittedReplies(); const directories = useDirectories(); + const directoryEntry = communityAddress ? findDirectoryByAddress(directories, communityAddress) : undefined; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; + const showAuthorFlags = directoryEntry?.features?.hasFlags === true && !(deleted || removed || purged); const postMenuProps = selectPostMenuProps(post); const params = useParams(); @@ -415,6 +418,7 @@ const PostInfo = ({ )} + {isInModQueueView && isOverThreshold ? ( <> diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 864b35b0..6c14258b 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -20,7 +20,7 @@ const testState = vi.hoisted(() => ({ directories: [ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, { address: 'mod.eth', features: {}, title: '/mod/ - Moderation' }, - ] as Array<{ address: string; features?: Record; title?: string }>, + ] as Array<{ address: string; directoryCode?: string; features?: Record; title?: string }>, editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined, gifFrameStatus: 'idle' as 'idle' | 'ready', handleUploadMock: vi.fn(), @@ -426,6 +426,7 @@ describe('PostForm', () => { testState.comments = {}; testState.directories = [ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, + { address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' }, { address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' }, { address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' }, { address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' }, @@ -606,6 +607,59 @@ describe('PostForm', () => { expect(testState.publishedPostOptions?.content).toBeUndefined(); }); + it('shows a 4chan-style flag field on flag boards and publishes the default geographic request', async () => { + testState.resolvedCommunityAddress = 'politically-incorrect.bso'; + + await renderPostForm('/pol'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table'); + const flagSelect = table?.querySelector('select[aria-label="flag"]'); + const textarea = table?.querySelector('textarea'); + + expect(flagSelect).toBeTruthy(); + expect(flagSelect?.value).toBe('country:auto'); + expect( + Array.from(flagSelect?.options || []) + .slice(0, 4) + .map((option) => option.textContent), + ).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist', 'Black Nationalist']); + + await dispatchInput(textarea as HTMLTextAreaElement, 'flagged post'); + await clickByText(table as HTMLTableElement, 'post'); + + expect(testState.publishPostMock).toHaveBeenCalledWith({ + content: 'flagged post', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }], + }); + }); + + it('publishes selected political flags from the post form', async () => { + testState.resolvedCommunityAddress = 'politically-incorrect.bso'; + + await renderPostForm('/pol'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table'); + const flagSelect = table?.querySelector('select[aria-label="flag"]'); + const textarea = table?.querySelector('textarea'); + + await dispatchChange(flagSelect as HTMLSelectElement, 'pol:AC'); + await dispatchInput(textarea as HTMLTextAreaElement, 'memeflag post'); + await clickByText(table as HTMLTableElement, 'post'); + + expect(testState.publishPostMock).toHaveBeenCalledWith({ + content: 'memeflag post', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'], + }, + flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }], + }); + }); + it('validates unsupported options and stores fortune output in post content', async () => { const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25); testState.resolvedCommunityAddress = 'random-nsfw.bso'; diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index 64dea8e0..8bffca87 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -69,7 +69,7 @@ position: relative; } -.postFormTable input[type="text"], .postFormTable textarea { +.postFormTable input[type="text"], .postFormTable textarea, .postFormTable select { border: var(--post-form-field-input-border, revert); font-family: var(--post-form-field-font-family, revert); appearance: var(--post-form-field-input-appearance, revert); @@ -80,7 +80,7 @@ font-size: 10pt; } -.postFormTable input[type="text"]:focus, .postFormTable textarea:focus { +.postFormTable input[type="text"]:focus, .postFormTable textarea:focus, .postFormTable select:focus { border: var(--post-form-field-input-focus-border, revert); } @@ -113,6 +113,10 @@ width: 190px !important; } +.flagSelect { + width: 254px; +} + .spoilerButton input[type="checkbox"] { margin: 0 3px; vertical-align: middle; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index bb05b769..d29f8231 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -24,6 +24,7 @@ import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/ import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { getBoardPath } from '../../lib/utils/route-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection, type CommentFlagSelectOption } from '../../lib/comment-flag-selection'; import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useCommunityField } from '../../hooks/use-stable-community'; @@ -126,6 +127,7 @@ interface PostFormFieldsProps { postCid: string; subjectRef: React.Ref; optionsRef: React.RefObject; + flagRef: React.RefObject; textRef: React.RefObject; urlRef: React.Ref; url: string; @@ -150,6 +152,7 @@ interface PostFormFieldsProps { communityAddress: string | undefined; rulesPath: string; requirePostLinkIsMedia: boolean; + flagOptions: CommentFlagSelectOption[]; showBbcodeToolbar: boolean; onBbcodePreviewToggle: () => void; onPublishReply: () => void; @@ -168,6 +171,7 @@ const PostFormFields = ({ postCid, subjectRef, optionsRef, + flagRef, textRef, urlRef, url, @@ -192,6 +196,7 @@ const PostFormFields = ({ communityAddress, rulesPath, requirePostLinkIsMedia, + flagOptions, showBbcodeToolbar, onBbcodePreviewToggle, onPublishReply, @@ -294,6 +299,26 @@ const PostFormFields = ({ {lengthError &&
{lengthError}
} + {flagOptions.length > 0 && ( + + {t('flag')} + + + + + )} {requirePostLinkIsMedia ? t('link_to_file') : t('link')} @@ -422,6 +447,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const urlRef = useRef(null); const subjectRef = useRef(null); const optionsRef = useRef(null); + const flagRef = useRef(null); const fortuneEntryRef = useRef(null); const diceRollRef = useRef(null); const nonokoRedirectPathRef = useRef(null); @@ -440,6 +466,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); + const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry); const accountCommunityAddresses = useAccountCommunityAddresses(); const accountAddress = account?.author?.address; @@ -485,6 +512,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: if (optionsRef.current) { optionsRef.current.value = ''; } + if (flagRef.current) { + flagRef.current.value = flagRef.current.options[0]?.value ?? ''; + } checkContentLength.cancel(); checkPostOptions.cancel(); fortuneEntryRef.current = null; @@ -545,8 +575,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } + const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value); + nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null; - publishPost({ content: publishContent }); + publishPost({ content: publishContent, ...flagPublishOptions }); }; // redirect to pending page when pending comment is created @@ -655,8 +687,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } + const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value); + nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null; - publishReply({ content: publishContent }); + publishReply({ content: publishContent, ...flagPublishOptions }); }; useEffect(() => { @@ -715,6 +749,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: postCid={postCid} subjectRef={subjectRef} optionsRef={optionsRef} + flagRef={flagRef} textRef={textRef} urlRef={urlRef} url={url} @@ -739,6 +774,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: communityAddress={communityAddress} rulesPath={rulesPath} requirePostLinkIsMedia={requirePostLinkIsMedia} + flagOptions={flagOptions} showBbcodeToolbar={showBbcodeToolbar} onBbcodePreviewToggle={handleBbcodePreviewToggle} onPublishReply={onPublishReply} diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index dfb145c3..2f087651 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -28,6 +28,7 @@ import CommentContent from '../comment-content'; import CommentMedia, { MediaLoadFailureInfo } from '../comment-media'; import FailedPublishNotice from '../failed-publish-notice'; import LoadingEllipsis from '../loading-ellipsis'; +import PostAuthorFlags from '../post-author-flags'; import PostMenuMobile from './post-menu-mobile'; import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; @@ -87,6 +88,8 @@ const PostInfoAndMedia = ({ const archived = isCommentArchived(resolvedPost); const purged = resolvedPost?.commentModeration?.purged; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; + const directoryEntry = communityAddress ? findDirectoryByAddress(directories, communityAddress) : undefined; + const showAuthorFlags = directoryEntry?.features?.hasFlags === true && !(deleted || removed || purged); const displayBoardPath = boardPath && communityAddress ? boardPath !== communityAddress @@ -348,6 +351,7 @@ const PostInfoAndMedia = ({ ){' '} )} + {pinned && ( diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 984d72d0..1210de8a 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({ features: {}, title: '/mu/ - Music', }, - } as Record; title?: string }>, + } as Record; title?: string }>, handleUploadMock: vi.fn(), isMobile: false, isResolvingExternalQuotes: false, @@ -323,6 +323,12 @@ describe('ReplyModal', () => { features: {}, title: '/mu/ - Music', }, + 'politically-incorrect.bso': { + address: 'politically-incorrect.bso', + directoryCode: 'pol', + features: { hasFlags: true }, + title: '/pol/ - Politically Incorrect', + }, 'random-nsfw.bso': { address: 'random-nsfw.bso', features: {}, @@ -425,6 +431,52 @@ describe('ReplyModal', () => { expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' }); }); + it('shows a flag selector on flag boards and publishes the default geographic request', async () => { + await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso'); + + const flagSelect = container.querySelector('select[aria-label="flag"]'); + + expect(flagSelect).toBeTruthy(); + expect(flagSelect?.value).toBe('country:auto'); + expect( + Array.from(flagSelect?.options || []) + .slice(0, 3) + .map((option) => option.textContent), + ).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist']); + + await clickButtonByText('post'); + + expect(testState.publishReplyMock).toHaveBeenCalledWith({ + content: '>>42\nselected text', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }], + }); + }); + + it('publishes selected political flags from the reply modal', async () => { + await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso'); + + const flagSelect = container.querySelector('select[aria-label="flag"]'); + await dispatchInput(container.querySelector('textarea') as HTMLTextAreaElement, 'reply body'); + await act(async () => { + if (flagSelect) { + flagSelect.value = 'pol:AC'; + flagSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await clickButtonByText('post'); + + expect(testState.publishReplyMock).toHaveBeenCalledWith({ + content: 'reply body', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'], + }, + flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }], + }); + }); + it('uses the shared loading ellipsis while a reply upload is running', async () => { testState.isUploading = true; diff --git a/src/components/reply-modal/reply-modal.module.css b/src/components/reply-modal/reply-modal.module.css index b9d8939a..fd87d6f3 100644 --- a/src/components/reply-modal/reply-modal.module.css +++ b/src/components/reply-modal/reply-modal.module.css @@ -39,7 +39,7 @@ background-image: var(--close-button-background-image); } -.container input[type="text"], .container textarea { +.container input[type="text"], .container textarea, .container select { border: var(--reply-modal-field-input-border, revert); font-family: var(--post-form-field-font-family, revert); appearance: var(--post-form-field-input-appearance, revert); @@ -53,7 +53,7 @@ margin-bottom: 1px; } -.container input[type="text"]:focus, .container textarea:focus { +.container input[type="text"]:focus, .container textarea:focus, .container select:focus { border: var(--reply-modal-field-input-border-focus, revert); } @@ -139,7 +139,7 @@ cursor: default; } - .container input[type="text"], .container textarea { + .container input[type="text"], .container textarea, .container select { font-size: 16px; } } diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 5861ab10..ce662166 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; +import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection } from '../../lib/comment-flag-selection'; import { type DiceRoll, type FortuneEntry, @@ -64,6 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia; const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView)); + const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry); const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = usePublishReply({ cid: parentCid, @@ -89,6 +91,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }); const urlRef = useRef(null); const optionsRef = useRef(null); + const flagRef = useRef(null); const fortuneEntryRef = useRef(null); const diceRollRef = useRef(null); const nonokoRedirectPathRef = useRef(null); @@ -166,9 +169,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa return; } + const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value); + setError(null); nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null; - publishReply({ content: publishContent }); + publishReply({ content: publishContent, ...flagPublishOptions }); }; useEffect(() => { @@ -515,6 +520,17 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }} /> + {flagOptions.length > 0 && ( +
+ +
+ )}
{ expect(findDirectoryByAddress(communities, 'music-posting.bso')?.address).toBe('music-posting.bso'); expect(findDirectoryByAddress(communities, 'music-posting.eth')?.address).toBe('music-posting.bso'); expect(findDirectoryByAddress(communities, '12D3KooWQdQ6TkVA1Xe9zzaFP6vXBgsLeMAewpLpLwbsAYKivnQy')?.address).toBe('music-posting.bso'); + expect(findDirectoryByAddress([], '12D3KooWNFgjQWX2EUEs7pixdjkWSLh21EZ9NeYnV8iMaCyYhLGJ')).toBeUndefined(); expect(findDirectoryByAddress(communities, undefined)).toBeUndefined(); }); diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx index 24f086f2..640703b8 100644 --- a/src/hooks/__tests__/use-publish-post.test.tsx +++ b/src/hooks/__tests__/use-publish-post.test.tsx @@ -155,4 +155,30 @@ describe('usePublishPost', () => { expect(testState.lastPublishOptions?.content).toBe('Fresh body'); expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); }); + + it('clears stale flag data from one-shot publish options', async () => { + await act(async () => { + latestValue.setPublishPostOptions({ + content: 'Flag body', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ text: 'flag:country:auto', type: 'country', code: 'auto' }], + } as never); + }); + + await act(async () => { + latestValue.publishPost({ + content: 'Plain body', + challengeRequest: undefined, + flairs: undefined, + } as never); + await Promise.resolve(); + }); + + expect(testState.lastPublishOptions?.content).toBe('Plain body'); + expect(testState.lastPublishOptions?.challengeRequest).toBeUndefined(); + expect(testState.lastPublishOptions?.flairs).toBeUndefined(); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx index 92b5d08c..729c221d 100644 --- a/src/hooks/__tests__/use-publish-reply.test.tsx +++ b/src/hooks/__tests__/use-publish-reply.test.tsx @@ -83,8 +83,10 @@ describe('usePublishReply', () => { usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } }); usePublishReplyStore.setState({ author: {}, + challengeRequest: {}, content: {}, displayName: {}, + flairs: {}, link: {}, publishCommentOptions: {}, spoiler: {}, @@ -186,6 +188,33 @@ describe('usePublishReply', () => { expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); }); + it('clears stale flag data from one-shot reply options', async () => { + await act(async () => { + latestValue.setPublishReplyOptions({ + content: 'Flag reply', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'], + }, + flairs: [{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }], + } as never); + }); + + await act(async () => { + await latestValue.publishReply({ + content: 'Plain reply', + challengeRequest: undefined, + flairs: undefined, + } as never); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(testState.lastPublishOptions?.content).toBe('Plain reply'); + expect(testState.lastPublishOptions?.challengeRequest).toBeUndefined(); + expect(testState.lastPublishOptions?.flairs).toBeUndefined(); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + }); + it('blocks publish when a same-board external quote cannot be resolved', async () => { testState.resolveExternalQuoteTargetMock.mockResolvedValue(null); diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts index 148d2267..9f8e33dd 100644 --- a/src/hooks/use-directories.ts +++ b/src/hooks/use-directories.ts @@ -136,7 +136,14 @@ export const findDirectoryByAddress = (directories: DirectoryCommunity[], addres } const normalizedAddress = normalizeBoardAddress(address); - return directories.find((community) => getDirectoryIdentifiers(community).some((identifier) => normalizeBoardAddress(identifier) === normalizedAddress)); + const normalizedMatch = directories.find((community) => + getDirectoryIdentifiers(community).some((identifier) => normalizeBoardAddress(identifier) === normalizedAddress), + ); + if (normalizedMatch) { + return normalizedMatch; + } + + return undefined; }; const adaptV1Communities = (value: Record): DirectoryCommunity[] => { diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts index 31506a14..73dd4330 100644 --- a/src/hooks/use-publish-post.ts +++ b/src/hooks/use-publish-post.ts @@ -10,12 +10,13 @@ type UsePublishPostOptions = { }; const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { - const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore( + const { author, title, content, link, flairs, spoiler, publishCommentOptions } = usePublishPostStore( useShallow((state) => ({ author: state.author, title: state.title || undefined, content: state.content || undefined, link: state.link || undefined, + flairs: state.flairs, spoiler: state.spoiler || false, publishCommentOptions: state.publishCommentOptions, })), @@ -39,7 +40,9 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { title, content, link, + flairs, spoiler, + ...(publishCommentOptions.challengeRequest ? { challengeRequest: publishCommentOptions.challengeRequest } : {}), }; const displayName = author?.displayName; @@ -48,7 +51,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { } return baseOptions; - }, [author, content, link, spoiler, communityAddress, title]); + }, [author, content, flairs, link, spoiler, communityAddress, title, publishCommentOptions.challengeRequest]); const setPublishPostOptions = useCallback( (options: Partial) => { @@ -90,7 +93,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { useEffect(() => { setPublishPostError(null); - }, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]); + }, [author?.displayName, blockedReason, communityAddress, content, flairs, link, spoiler, title]); const startPublishPost = useCallback(() => { if (blockedReason) { diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index 92c3c33d..46061232 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -23,11 +23,12 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti const account = useAccount(); const directories = useDirectories(); - const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore( + const { author, content, link, flairs, spoiler, publishCommentOptions } = usePublishReplyStore( useShallow((state) => ({ author: state.author[parentCid], content: state.content[parentCid] || undefined, link: state.link[parentCid] || undefined, + flairs: state.flairs[parentCid], spoiler: state.spoiler[parentCid] || false, publishCommentOptions: state.publishCommentOptions[parentCid], })), @@ -57,7 +58,9 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti postCid: postCid ?? parentCid, content, link, + flairs, spoiler, + ...(publishCommentOptions?.challengeRequest ? { challengeRequest: publishCommentOptions.challengeRequest } : {}), }; const displayName = author?.displayName; @@ -66,7 +69,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti } return baseOptions; - }, [author, content, link, parentCid, postCid, spoiler, communityAddress]); + }, [author, content, flairs, link, parentCid, postCid, spoiler, communityAddress, publishCommentOptions?.challengeRequest]); const setPublishReplyOptions = useCallback( (options: Partial) => { @@ -142,7 +145,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti setPublishReplyError(null); setPublishReplyStateMessage(null); setIsResolvingExternalQuotes(false); - }, [blockedReason, content, communityAddress]); + }, [blockedReason, content, communityAddress, flairs]); useEffect(() => { if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) { diff --git a/src/lib/__tests__/board-flags.test.ts b/src/lib/__tests__/board-flags.test.ts new file mode 100644 index 00000000..83a39c2e --- /dev/null +++ b/src/lib/__tests__/board-flags.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { getBoardFlagDefinition, normalizeBoardFlagKind } from '../board-flags'; + +describe('board-flags', () => { + it('normalizes board flag kind aliases', () => { + expect(normalizeBoardFlagKind('pol')).toBe('pol'); + expect(normalizeBoardFlagKind('memeflags')).toBe('pol'); + expect(normalizeBoardFlagKind('mlp')).toBe('pony'); + expect(normalizeBoardFlagKind('unknown')).toBeUndefined(); + }); + + it('maps political flags to the flags-2 sprite grid', () => { + expect(getBoardFlagDefinition('pol', 'AC')).toMatchObject({ + code: 'AC', + label: 'Anarcho-Capitalist', + spritePath: 'assets/icons/flags-2.png', + width: 16, + height: 12, + x: 0, + y: 0, + }); + expect(getBoardFlagDefinition('pol', 'WP')).toMatchObject({ code: 'WP', x: 64, y: 48 }); + }); + + it('maps pony flags to the flags-pony sprite grid', () => { + expect(getBoardFlagDefinition('pony', '4CC')).toMatchObject({ + code: '4CC', + label: '4cc /mlp/', + spritePath: 'assets/icons/flags-pony.png', + width: 16, + height: 16, + x: 0, + y: 0, + }); + expect(getBoardFlagDefinition('pony', 'AJ')).toMatchObject({ code: 'AJ', label: 'Applejack', x: 80, y: 0 }); + expect(getBoardFlagDefinition('pony', 'ZS')).toMatchObject({ code: 'ZS', label: 'Zipp Storm', x: 16, y: 144 }); + }); +}); diff --git a/src/lib/__tests__/comment-flag-selection.test.ts b/src/lib/__tests__/comment-flag-selection.test.ts new file mode 100644 index 00000000..fd58c4ed --- /dev/null +++ b/src/lib/__tests__/comment-flag-selection.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection, getCommentFlagRequestFromSelection } from '../comment-flag-selection'; + +describe('comment-flag-selection', () => { + it('does not expose options for boards without flags', () => { + expect(getCommentFlagOptionsForDirectory({ features: {}, title: '/mu/ - Music' })).toEqual([]); + }); + + it('uses geographic location as the default for country flag boards', () => { + expect( + getCommentFlagOptionsForDirectory({ + directoryCode: 'int', + features: { hasFlags: true }, + title: '/int/ - International', + }), + ).toEqual([{ label: 'Geographic Location', value: 'country:auto' }]); + }); + + it('matches the 4chan /pol/ flag order', () => { + const options = getCommentFlagOptionsForDirectory({ + directoryCode: 'pol', + features: { hasFlags: true }, + title: '/pol/ - Politically Incorrect', + }); + + expect(options.slice(0, 6)).toEqual([ + { label: 'Geographic Location', value: 'country:auto' }, + { label: 'Anarcho-Capitalist', value: 'pol:AC' }, + { label: 'Anarchist', value: 'pol:AN' }, + { label: 'Black Nationalist', value: 'pol:BL' }, + { label: 'Confederate', value: 'pol:CF' }, + { label: 'Communist', value: 'pol:CM' }, + ]); + }); + + it('matches the 4chan /mlp/ flag default', () => { + const options = getCommentFlagOptionsForDirectory({ + directoryCode: 'mlp', + features: { hasFlags: true }, + title: '/mlp/ - Pony', + }); + + expect(options.slice(0, 4)).toEqual([ + { label: 'None', value: 'none' }, + { label: '4cc /mlp/', value: 'pony:4CC' }, + { label: 'Adagio Dazzle', value: 'pony:ADA' }, + { label: 'Anon', value: 'pony:AN' }, + ]); + }); + + it('falls back to the board title when directoryCode is blank', () => { + const options = getCommentFlagOptionsForDirectory({ + directoryCode: ' ', + features: { hasFlags: true }, + title: '/mlp/ - Pony', + }); + + expect(options.slice(0, 2)).toEqual([ + { label: 'None', value: 'none' }, + { label: '4cc /mlp/', value: 'pony:4CC' }, + ]); + }); + + it('serializes selected flags as challenge-readable flag requests', () => { + expect(getCommentFlagRequestFromSelection('country:auto')).toEqual({ + type: 'country', + code: 'auto', + text: 'flag:country:auto', + }); + expect(getCommentFlagRequestFromSelection('pol:AC')).toEqual({ + type: 'pol', + code: 'AC', + text: 'flag:pol:AC', + }); + expect(getCommentFlagRequestFromSelection('pony:AJ')).toEqual({ + type: 'pony', + code: 'AJ', + text: 'flag:pony:AJ', + }); + expect(getCommentFlagRequestFromSelection('none')).toBeUndefined(); + }); + + it('publishes selected flags as signed comment flairs and challenge answers', () => { + expect(getCommentFlagPublishOptionsFromSelection('country:auto')).toEqual({ + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }], + }); + expect(getCommentFlagPublishOptionsFromSelection('pony:AJ')).toEqual({ + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:pony:AJ'], + }, + flairs: [{ type: 'pony', code: 'AJ', text: 'flag:pony:AJ' }], + }); + }); + + it('clears flag publish data when no flag is selected', () => { + expect(getCommentFlagPublishOptionsFromSelection('none')).toEqual({ + challengeRequest: undefined, + flairs: undefined, + }); + expect(getCommentFlagPublishOptionsFromSelection(undefined)).toEqual({ + challengeRequest: undefined, + flairs: undefined, + }); + }); +}); diff --git a/src/lib/__tests__/comment-flags.test.ts b/src/lib/__tests__/comment-flags.test.ts new file mode 100644 index 00000000..7c467d3c --- /dev/null +++ b/src/lib/__tests__/comment-flags.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { getCommentFlagFlairs, getAuthorFlagFlairs, getAuthorFlagViewModels } from '../comment-flags'; + +describe('comment-flags', () => { + it('parses the pkc-js challenge flair example for country flags', () => { + const [flag] = getAuthorFlagViewModels([{ text: 'US-emoji' }], 1000); + + expect(flag).toMatchObject({ + key: 'country:us', + type: 'country', + code: 'us', + spritePath: 'assets/icons/flags-1.png', + width: 16, + height: 11, + x: 240, + y: 154, + }); + }); + + it('parses namespaced country, political, and pony flag flairs', () => { + const flags = getAuthorFlagViewModels([{ text: 'flag:country:DE' }, { text: 'flag:pol:AC' }, { text: 'flag:pony:AJ' }], 1000); + + expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']); + expect(flags[1]).toMatchObject({ label: 'Anarcho-Capitalist', x: 0, y: 0 }); + expect(flags[2]).toMatchObject({ label: 'Applejack', x: 80, y: 0 }); + }); + + it('normalizes text flag kinds before parsing', () => { + const flags = getAuthorFlagViewModels([{ text: 'FLAG:COUNTRY:DE' }, { text: 'POL:AC' }, { text: 'flag:MLP:AJ' }], 1000); + + expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']); + }); + + it('parses structured flag flair fields', () => { + const flags = getAuthorFlagViewModels( + [ + { text: 'ignored', type: 'Country', country: 'GB' }, + { text: 'ignored', kind: 'MLP', code: 'RD' }, + ], + 1000, + ); + + expect(flags.map((flag) => flag.key)).toEqual(['country:gb', 'pony:RD']); + }); + + it('skips expired, duplicate, and unknown flags', () => { + const flags = getAuthorFlagViewModels( + [{ text: 'flag:country:DE', expiresAt: 999 }, { text: 'flag:country:DE' }, { text: 'DE-emoji' }, { text: 'flag:pony:NOPE' }], + 1000, + ); + + expect(flags.map((flag) => flag.key)).toEqual(['country:de']); + }); + + it('prefers trusted challenge-written author community flairs', () => { + const flairs = getAuthorFlagFlairs({ + flairs: [{ text: 'flag:pol:AC' }], + community: { + flairs: [{ text: 'US-emoji' }], + }, + }); + + expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:us']); + }); + + it('reads the signed 5chan flag assertion from the comment', () => { + const flairs = getCommentFlagFlairs({ + '5chan': { + country: 'VN', + flag: { + code: 'VN', + text: 'flag:country:vn', + type: 'country', + }, + }, + flairs: [{ code: 'auto', text: 'flag:country:auto', type: 'country' }], + author: { + community: { + flairs: [{ text: 'US-emoji' }], + }, + }, + }); + + expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:vn']); + }); + + it('falls back to author or post flairs when there is no signed flag assertion', () => { + expect( + getAuthorFlagViewModels( + getCommentFlagFlairs({ + author: { + subplebbit: { + flairs: [{ text: 'flag:country:DE' }], + }, + }, + flairs: [{ text: 'flag:pony:AJ' }], + }), + ).map((flag) => flag.key), + ).toEqual(['country:de']); + + expect( + getAuthorFlagViewModels( + getCommentFlagFlairs({ + flairs: [{ text: 'flag:pony:AJ' }], + }), + ).map((flag) => flag.key), + ).toEqual(['pony:AJ']); + }); +}); diff --git a/src/lib/board-flags.ts b/src/lib/board-flags.ts new file mode 100644 index 00000000..9ae003a1 --- /dev/null +++ b/src/lib/board-flags.ts @@ -0,0 +1,190 @@ +export type BoardFlagKind = 'pol' | 'pony'; + +export interface BoardFlagDefinition { + kind: BoardFlagKind; + code: string; + label: string; + spritePath: string; + width: number; + height: number; + x: number; + y: number; +} + +const POLITICAL_FLAG_WIDTH = 16; +const POLITICAL_FLAG_HEIGHT = 12; +const POLITICAL_FLAG_COLUMNS = 5; +const POLITICAL_FLAG_SPRITE_PATH = 'assets/icons/flags-2.png'; + +const PONY_FLAG_WIDTH = 16; +const PONY_FLAG_HEIGHT = 16; +const PONY_FLAG_COLUMNS = 9; +const PONY_FLAG_SPRITE_PATH = 'assets/icons/flags-pony.png'; + +const POLITICAL_FLAG_ENTRIES = [ + ['AC', 'Anarcho-Capitalist'], + ['AN', 'Anarchist'], + ['BL', 'Black Nationalist'], + ['CF', 'Confederate'], + ['CM', 'Communist'], + ['CT', 'Catalonia'], + ['DM', 'Democrat'], + ['EU', 'European'], + ['FC', 'Fascist'], + ['GN', 'Gadsden'], + ['GY', 'Gay'], + ['JH', 'Jihadi'], + ['KN', 'Kekistani'], + ['MF', 'Muslim'], + ['NB', 'National Bolshevik'], + ['NT', 'NATO'], + ['NZ', 'Nazi'], + ['PC', 'Hippie'], + ['PR', 'Pirate'], + ['RE', 'Republican'], + ['TM', 'Templar'], + ['MZ', 'Task Force Z'], + ['TR', 'Tree Hugger'], + ['UN', 'United Nations'], + ['WP', 'White Supremacist'], +] as const; + +const PONY_FLAG_ENTRIES = [ + ['4CC', '4cc /mlp/'], + ['ADA', 'Adagio Dazzle'], + ['AN', 'Anon'], + ['ANF', 'Anonfilly'], + ['APB', 'Apple Bloom'], + ['AJ', 'Applejack'], + ['AB', 'Aria Blaze'], + ['AU', 'Autumn Blaze'], + ['BB', 'Bon Bon'], + ['BM', 'Big Mac'], + ['BP', 'Berry Punch'], + ['BS', 'Babs Seed'], + ['CL', 'Changeling'], + ['CO', 'Coco Pommel'], + ['CG', 'Cozy Glow'], + ['CHE', 'Cheerilee'], + ['CB', 'Cherry Berry'], + ['DAY', 'Daybreaker'], + ['DD', 'Daring Do'], + ['DER', 'Derpy Hooves'], + ['DT', 'Diamond Tiara'], + ['DIS', 'Discord'], + ['EQA', 'EqG Applejack'], + ['EQF', 'EqG Fluttershy'], + ['EQP', 'EqG Pinkie Pie'], + ['EQR', 'EqG Rainbow Dash'], + ['EQT', 'EqG Trixie'], + ['EQI', 'EqG Twilight Sparkle'], + ['EQS', 'EqG Sunset Shimmer'], + ['ERA', 'EqG Rarity'], + ['FAU', 'Fausticorn'], + ['FLE', 'Fleur de lis'], + ['FL', 'Fluttershy'], + ['GI', 'Gilda'], + ['HT', 'Hitch Trailblazer'], + ['IZ', 'Izzy Moonbow'], + ['LI', 'Limestone'], + ['LT', 'Lord Tirek'], + ['LY', 'Lyra Heartstrings'], + ['MA', 'Marble'], + ['MAU', 'Maud'], + ['MIN', 'Minuette'], + ['NI', 'Nightmare Moon'], + ['NUR', 'Nurse Redheart'], + ['OCT', 'Octavia'], + ['PAR', 'Parasprite'], + ['PC', 'Princess Cadance'], + ['PCE', 'Princess Celestia'], + ['PI', 'Pinkie Pie'], + ['PLU', 'Princess Luna'], + ['PM', 'Pinkamena'], + ['PP', 'Pipp Petals'], + ['QC', 'Queen Chrysalis'], + ['RAR', 'Rarity'], + ['RD', 'Rainbow Dash'], + ['RLU', 'Roseluck'], + ['S1L', 'S1 Luna'], + ['SCO', 'Scootaloo'], + ['SHI', 'Shining Armor'], + ['SIL', 'Silver Spoon'], + ['SON', 'Sonata Dusk'], + ['SP', 'Spike'], + ['SPI', 'Spitfire'], + ['SS', 'Sunny Starscout'], + ['STA', 'Star Dancer'], + ['STL', 'Starlight Glimmer'], + ['SPT', 'Sprout'], + ['SUN', 'Sunburst'], + ['SUS', 'Sunset Shimmer'], + ['SWB', 'Sweetie Belle'], + ['TFA', 'TFH Arizona'], + ['TFO', 'TFH Oleander'], + ['TFP', 'TFH Paprika'], + ['TFS', 'TFH Shanty'], + ['TFT', 'TFH Tianhuo'], + ['TFV', 'TFH Velvet'], + ['TP', 'TFH Pom'], + ['TS', 'Tempest Shadow'], + ['TWI', 'Twilight Sparkle'], + ['TX', 'Trixie'], + ['VS', 'Vinyl Scratch'], + ['ZE', 'Zecora'], + ['ZS', 'Zipp Storm'], +] as const; + +const normalizeCode = (value: string | undefined) => value?.trim().toUpperCase(); + +const buildFlagDefinitions = ( + entries: readonly (readonly [string, string])[], + kind: BoardFlagKind, + spritePath: string, + width: number, + height: number, + columns: number, +): ReadonlyMap => + new Map( + entries.map(([code, label], index) => [ + code, + { + kind, + code, + label, + spritePath, + width, + height, + x: (index % columns) * width, + y: Math.floor(index / columns) * height, + }, + ]), + ); + +const BOARD_FLAGS_BY_KIND: Record> = { + pol: buildFlagDefinitions(POLITICAL_FLAG_ENTRIES, 'pol', POLITICAL_FLAG_SPRITE_PATH, POLITICAL_FLAG_WIDTH, POLITICAL_FLAG_HEIGHT, POLITICAL_FLAG_COLUMNS), + pony: buildFlagDefinitions(PONY_FLAG_ENTRIES, 'pony', PONY_FLAG_SPRITE_PATH, PONY_FLAG_WIDTH, PONY_FLAG_HEIGHT, PONY_FLAG_COLUMNS), +}; + +export const normalizeBoardFlagKind = (value: string | undefined): BoardFlagKind | undefined => { + const kind = value?.trim().toLowerCase(); + if (kind === 'pol' || kind === 'political' || kind === 'meme' || kind === 'memeflag' || kind === 'memeflags') { + return 'pol'; + } + if (kind === 'pony' || kind === 'mlp') { + return 'pony'; + } + return undefined; +}; + +export const getBoardFlagDefinition = (kindValue: string | undefined, codeValue: string | undefined): BoardFlagDefinition | undefined => { + const kind = normalizeBoardFlagKind(kindValue); + const code = normalizeCode(codeValue); + if (!kind || !code) return undefined; + return BOARD_FLAGS_BY_KIND[kind].get(code); +}; + +export const getBoardFlagDefinitions = (kindValue: string | undefined): BoardFlagDefinition[] => { + const kind = normalizeBoardFlagKind(kindValue); + return kind ? [...BOARD_FLAGS_BY_KIND[kind].values()] : []; +}; diff --git a/src/lib/comment-flag-selection.ts b/src/lib/comment-flag-selection.ts new file mode 100644 index 00000000..e0891395 --- /dev/null +++ b/src/lib/comment-flag-selection.ts @@ -0,0 +1,108 @@ +import { getBoardFlagDefinition, getBoardFlagDefinitions, type BoardFlagKind } from './board-flags'; +import type { DirectoryCommunity } from './utils/directory-list-utils'; + +const GEOGRAPHIC_LOCATION_FLAG_VALUE = 'country:auto'; +const NO_FLAG_VALUE = 'none'; +const FLAG_CHALLENGE_ANSWER_PREFIX = 'bitsocial-flags:5chan:'; + +export interface CommentFlagSelectOption { + value: string; + label: string; +} + +export interface CommentFlagRequest { + type: 'country' | BoardFlagKind; + code: string; + text: string; +} + +interface CommentFlagChallengeRequest { + challengeAnswers: string[]; +} + +export interface CommentFlagPublishOptions { + challengeRequest: CommentFlagChallengeRequest | undefined; + flairs: CommentFlagRequest[] | undefined; +} + +const GEOGRAPHIC_LOCATION_OPTION: CommentFlagSelectOption = { + value: GEOGRAPHIC_LOCATION_FLAG_VALUE, + label: 'Geographic Location', +}; + +const NO_FLAG_OPTION: CommentFlagSelectOption = { + value: NO_FLAG_VALUE, + label: 'None', +}; + +const getDirectoryCode = (directory: Pick | undefined): string | undefined => { + const directoryCode = directory?.directoryCode?.trim().toLowerCase(); + return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase(); +}; + +const getBoardFlagOptions = (kind: BoardFlagKind): CommentFlagSelectOption[] => + getBoardFlagDefinitions(kind).map((flag) => ({ + value: `${flag.kind}:${flag.code}`, + label: flag.label, + })); + +export const getCommentFlagOptionsForDirectory = (directory: Pick | undefined): CommentFlagSelectOption[] => { + if (directory?.features?.hasFlags !== true) { + return []; + } + + const directoryCode = getDirectoryCode(directory); + if (directoryCode === 'mlp') { + return [NO_FLAG_OPTION, ...getBoardFlagOptions('pony')]; + } + + if (directoryCode === 'pol') { + return [GEOGRAPHIC_LOCATION_OPTION, ...getBoardFlagOptions('pol')]; + } + + return [GEOGRAPHIC_LOCATION_OPTION]; +}; + +export const getCommentFlagRequestFromSelection = (value: string | undefined): CommentFlagRequest | undefined => { + if (!value || value === NO_FLAG_VALUE) { + return undefined; + } + + if (value === GEOGRAPHIC_LOCATION_FLAG_VALUE) { + return { + type: 'country', + code: 'auto', + text: 'flag:country:auto', + }; + } + + const match = value.match(/^(pol|pony):([A-Z0-9]+)$/); + if (!match) { + return undefined; + } + + const [, kind, code] = match; + const flag = getBoardFlagDefinition(kind, code); + return flag + ? { + type: flag.kind, + code: flag.code, + text: `flag:${flag.kind}:${flag.code}`, + } + : undefined; +}; + +export const getCommentFlagPublishOptionsFromSelection = (value: string | undefined): CommentFlagPublishOptions => { + const flag = getCommentFlagRequestFromSelection(value); + return flag + ? { + challengeRequest: { + challengeAnswers: [`${FLAG_CHALLENGE_ANSWER_PREFIX}${flag.text}`], + }, + flairs: [flag], + } + : { + challengeRequest: undefined, + flairs: undefined, + }; +}; diff --git a/src/lib/comment-flags.ts b/src/lib/comment-flags.ts new file mode 100644 index 00000000..288a29b4 --- /dev/null +++ b/src/lib/comment-flags.ts @@ -0,0 +1,221 @@ +import { COUNTRY_FLAG_HEIGHT, COUNTRY_FLAG_WIDTH, getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from './country-flags'; +import { getBoardFlagDefinition, normalizeBoardFlagKind, type BoardFlagKind } from './board-flags'; + +const COUNTRY_FLAG_SPRITE_PATH = 'assets/icons/flags-1.png'; +const FLAG_TEXT_PATTERN = /^flag:([a-z-]+):([a-z0-9-]+)$/i; +const SHORT_FLAG_TEXT_PATTERN = /^(country|geo|pol|political|meme|memeflag|memeflags|pony|mlp):([a-z0-9-]+)$/i; +const COUNTRY_EMOJI_TEXT_PATTERN = /^([a-z]{2}|catalonia|eu|fam|xe|xs|xw|xk|xx)-emoji$/i; + +type FlagSource = + | { type: 'country'; code: string } + | { + type: BoardFlagKind; + code: string; + }; + +interface AuthorFlairLike { + text?: unknown; + code?: unknown; + country?: unknown; + type?: unknown; + kind?: unknown; + expiresAt?: unknown; +} + +interface AuthorLike { + flairs?: unknown; + community?: { + flairs?: unknown; + }; + subplebbit?: { + flairs?: unknown; + }; +} + +interface CommentLike { + '5chan'?: unknown; + author?: unknown; + flairs?: unknown; + raw?: { + comment?: unknown; + }; +} + +export interface AuthorFlagViewModel { + key: string; + type: 'country' | BoardFlagKind; + code: string; + label: string; + spritePath: string; + width: number; + height: number; + x: number; + y: number; +} + +const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeFlairsContainer = (flairs: unknown): unknown => { + if (Array.isArray(flairs)) return flairs; + return isRecord(flairs) && Array.isArray(flairs.author) ? flairs.author : undefined; +}; + +export const getAuthorFlagFlairs = (author: unknown): unknown => { + if (!isRecord(author)) return undefined; + + const authorLike = author as AuthorLike; + return normalizeFlairsContainer(authorLike.community?.flairs) ?? normalizeFlairsContainer(authorLike.subplebbit?.flairs) ?? normalizeFlairsContainer(authorLike.flairs); +}; + +const getFiveChanFlagFlair = (comment: unknown): AuthorFlairLike | undefined => { + if (!isRecord(comment)) return undefined; + + const fiveChan = (comment as CommentLike)['5chan']; + if (!isRecord(fiveChan) || !isRecord(fiveChan.flag)) return undefined; + + const flag = fiveChan.flag; + const text = toString(flag.text); + const type = toString(flag.type); + const code = toString(flag.code) ?? toString(fiveChan.country); + const country = toString(fiveChan.country); + + if (!text && (!type || !code)) { + return undefined; + } + + return { + text, + type, + code, + country, + }; +}; + +export const getCommentFlagFlairs = (comment: unknown, author?: unknown): unknown => { + if (!isRecord(comment)) return getAuthorFlagFlairs(author); + + const commentLike = comment as CommentLike; + const fiveChanFlagFlair = getFiveChanFlagFlair(comment) ?? getFiveChanFlagFlair(commentLike.raw?.comment); + if (fiveChanFlagFlair) { + return [fiveChanFlagFlair]; + } + + return getAuthorFlagFlairs(author ?? commentLike.author) ?? normalizeFlairsContainer(commentLike.flairs); +}; + +const isFreshFlair = (flair: AuthorFlairLike, nowSeconds: number) => + typeof flair.expiresAt !== 'number' || !Number.isFinite(flair.expiresAt) || flair.expiresAt > nowSeconds; + +const parseTextFlagSource = (text: string | undefined): FlagSource | undefined => { + if (!text) return undefined; + + const flagMatch = text.match(FLAG_TEXT_PATTERN); + if (flagMatch) { + const [, kindValue, code] = flagMatch; + const normalizedKindValue = kindValue.toLowerCase(); + if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') { + return { type: 'country', code }; + } + const kind = normalizeBoardFlagKind(normalizedKindValue); + return kind ? { type: kind, code } : undefined; + } + + const shortMatch = text.match(SHORT_FLAG_TEXT_PATTERN); + if (shortMatch) { + const [, kindValue, code] = shortMatch; + const normalizedKindValue = kindValue.toLowerCase(); + if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') { + return { type: 'country', code }; + } + const kind = normalizeBoardFlagKind(normalizedKindValue); + return kind ? { type: kind, code } : undefined; + } + + const countryEmojiMatch = text.match(COUNTRY_EMOJI_TEXT_PATTERN); + return countryEmojiMatch ? { type: 'country', code: countryEmojiMatch[1] } : undefined; +}; + +const parseStructuredFlagSource = (flair: AuthorFlairLike): FlagSource | undefined => { + const typeValue = toString(flair.type) ?? toString(flair.kind); + if (!typeValue) return undefined; + + const normalizedTypeValue = typeValue.toLowerCase(); + if (normalizedTypeValue === 'country' || normalizedTypeValue === 'geo') { + const code = toString(flair.country) ?? toString(flair.code); + return code ? { type: 'country', code } : undefined; + } + + const kind = normalizeBoardFlagKind(normalizedTypeValue); + const code = toString(flair.code); + return kind && code ? { type: kind, code } : undefined; +}; + +const toCountryFlagViewModel = (codeValue: string): AuthorFlagViewModel | undefined => { + const code = normalizeCountryCode(codeValue); + const position = getCountryFlagPosition(code); + if (!code || !position) return undefined; + + return { + key: `country:${code}`, + type: 'country', + code, + label: getCountryLabel(code) ?? code.toUpperCase(), + spritePath: COUNTRY_FLAG_SPRITE_PATH, + width: COUNTRY_FLAG_WIDTH, + height: COUNTRY_FLAG_HEIGHT, + x: position.x, + y: position.y, + }; +}; + +const toBoardFlagViewModel = (type: BoardFlagKind, code: string): AuthorFlagViewModel | undefined => { + const flag = getBoardFlagDefinition(type, code); + return flag + ? { + key: `${flag.kind}:${flag.code}`, + type: flag.kind, + code: flag.code, + label: flag.label, + spritePath: flag.spritePath, + width: flag.width, + height: flag.height, + x: flag.x, + y: flag.y, + } + : undefined; +}; + +const toFlagViewModel = (source: FlagSource | undefined): AuthorFlagViewModel | undefined => { + if (!source) return undefined; + return source.type === 'country' ? toCountryFlagViewModel(source.code) : toBoardFlagViewModel(source.type, source.code); +}; + +export const getAuthorFlagViewModels = (flairs: unknown, nowSeconds = Date.now() / 1000): AuthorFlagViewModel[] => { + if (!Array.isArray(flairs)) return []; + + const seenKeys = new Set(); + const flags: AuthorFlagViewModel[] = []; + + for (const flair of flairs) { + if (typeof flair !== 'object' || flair === null) { + continue; + } + + const flairRecord = flair as AuthorFlairLike; + if (!isFreshFlair(flairRecord, nowSeconds)) { + continue; + } + + const flag = toFlagViewModel(parseStructuredFlagSource(flairRecord) ?? parseTextFlagSource(toString(flairRecord.text))); + if (!flag || seenKeys.has(flag.key)) { + continue; + } + + seenKeys.add(flag.key); + flags.push(flag); + } + + return flags; +}; diff --git a/src/stores/__tests__/publish-stores.test.ts b/src/stores/__tests__/publish-stores.test.ts index 268cb7d9..2456dfde 100644 --- a/src/stores/__tests__/publish-stores.test.ts +++ b/src/stores/__tests__/publish-stores.test.ts @@ -43,6 +43,10 @@ describe('publish stores', () => { spoiler: true, communityAddress: 'music-posting.eth', title: 'Hello', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ text: 'flag:country:auto', type: 'country', code: 'auto' }], }; usePublishPostStore.getState().setPublishPostStore(comment); @@ -53,6 +57,10 @@ describe('publish stores', () => { expect(state.publishCommentOptions.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' }); expect(state.publishCommentOptions.communityAddress).toBe('music-posting.eth'); expect(state.publishCommentOptions.title).toBe('Hello'); + expect(state.publishCommentOptions.challengeRequest).toEqual({ + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }); + expect(state.publishCommentOptions.flairs).toEqual([{ text: 'flag:country:auto', type: 'country', code: 'auto' }]); state.publishCommentOptions.onChallengeVerification?.({} as never, comment); expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({}, comment); @@ -88,6 +96,10 @@ describe('publish stores', () => { parentCid: 'parent-1', spoiler: false, communityAddress: 'music-posting.eth', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'], + }, + flairs: [{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }], }; usePublishReplyStore.getState().setPublishReplyStore(comment); @@ -98,6 +110,10 @@ describe('publish stores', () => { expect(state.publishCommentOptions['parent-1']?.communityAddress).toBe('music-posting.eth'); expect(state.publishCommentOptions['parent-1']?.parentCid).toBe('parent-1'); expect(state.publishCommentOptions['parent-1']?.postCid).toBe('parent-1'); + expect(state.publishCommentOptions['parent-1']?.challengeRequest).toEqual({ + challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'], + }); + expect(state.publishCommentOptions['parent-1']?.flairs).toEqual([{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }]); state.publishCommentOptions['parent-1']?.onChallengeVerification?.({ token: 'challenge' } as never, comment); expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({ token: 'challenge' }, comment); diff --git a/src/stores/use-publish-post-store.ts b/src/stores/use-publish-post-store.ts index 8f732de3..da88e5fd 100644 --- a/src/stores/use-publish-post-store.ts +++ b/src/stores/use-publish-post-store.ts @@ -11,7 +11,9 @@ type SubmitState = { title: string | undefined; content: string | undefined; link: string | undefined; + flairs: Comment['flairs'] | undefined; spoiler: boolean | undefined; + challengeRequest: Record | undefined; publishCommentOptions: PublishCommentOptions; setPublishPostStore: (data: Partial) => void; resetPublishPostStore: () => void; @@ -24,11 +26,13 @@ const usePublishPostStore = create((set) => ({ title: undefined, content: undefined, link: undefined, + flairs: undefined, spoiler: undefined, + challengeRequest: undefined, publishCommentOptions: {}, setPublishPostStore: (comment: Comment) => set(() => { - const { author, content, spoiler, title } = comment; + const { author, challengeRequest, content, flairs, spoiler, title } = comment; const link = comment.link ? normalizePublishURL(comment.link) : undefined; const communityAddress = getCommentCommunityAddress(comment); @@ -44,7 +48,9 @@ const usePublishPostStore = create((set) => ({ title, content, link, + flairs, spoiler, + ...(challengeRequest ? { challengeRequest } : {}), onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => { alertChallengeVerificationFailed(challengeVerification, comment); }, @@ -65,7 +71,9 @@ const usePublishPostStore = create((set) => ({ title, content, link, + flairs, spoiler, + challengeRequest, publishCommentOptions, }; }), @@ -77,7 +85,9 @@ const usePublishPostStore = create((set) => ({ title: undefined, content: undefined, link: undefined, + flairs: undefined, spoiler: undefined, + challengeRequest: undefined, publishCommentOptions: {}, }), })); diff --git a/src/stores/use-publish-reply-store.ts b/src/stores/use-publish-reply-store.ts index b85e4df8..9e273d21 100644 --- a/src/stores/use-publish-reply-store.ts +++ b/src/stores/use-publish-reply-store.ts @@ -9,7 +9,9 @@ type ReplyState = { displayName: { [parentCid: string]: string | undefined }; content: { [parentCid: string]: string | undefined }; link: { [parentCid: string]: string | undefined }; + flairs: { [parentCid: string]: Comment['flairs'] | undefined }; spoiler: { [parentCid: string]: boolean | undefined }; + challengeRequest: { [parentCid: string]: Record | undefined }; publishCommentOptions: { [parentCid: string]: PublishCommentOptions | undefined }; setPublishReplyStore: (comment: Comment) => void; resetPublishReplyStore: (parentCid: string) => void; @@ -20,12 +22,14 @@ const usePublishReplyStore = create((set) => ({ displayName: {}, content: {}, link: {}, + flairs: {}, spoiler: {}, + challengeRequest: {}, publishCommentOptions: {}, setPublishReplyStore: (comment: Comment) => set((state) => { - const { parentCid, author, content, spoiler } = comment; + const { parentCid, author, challengeRequest, content, flairs, spoiler } = comment; const link = comment.link ? normalizePublishURL(comment.link) : undefined; const communityAddress = getCommentCommunityAddress(comment); @@ -42,7 +46,9 @@ const usePublishReplyStore = create((set) => ({ postCid: comment?.postCid || parentCid, content, link, + flairs, spoiler, + ...(challengeRequest ? { challengeRequest } : {}), onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => { alertChallengeVerificationFailed(challengeVerification, comment); }, @@ -61,7 +67,9 @@ const usePublishReplyStore = create((set) => ({ displayName: { ...state.displayName, [parentCid]: displayName }, content: { ...state.content, [parentCid]: content }, link: { ...state.link, [parentCid]: link }, + flairs: { ...state.flairs, [parentCid]: flairs }, spoiler: { ...state.spoiler, [parentCid]: spoiler }, + challengeRequest: { ...state.challengeRequest, [parentCid]: challengeRequest }, publishCommentOptions: { ...state.publishCommentOptions, [parentCid]: publishCommentOptions }, }; }), @@ -72,7 +80,9 @@ const usePublishReplyStore = create((set) => ({ displayName: { ...state.displayName, [parentCid]: undefined }, content: { ...state.content, [parentCid]: undefined }, link: { ...state.link, [parentCid]: undefined }, + flairs: { ...state.flairs, [parentCid]: undefined }, spoiler: { ...state.spoiler, [parentCid]: undefined }, + challengeRequest: { ...state.challengeRequest, [parentCid]: undefined }, publishCommentOptions: { ...state.publishCommentOptions, [parentCid]: undefined }, })), })); diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css index 5d5e2879..0684acdf 100644 --- a/src/views/post/post.module.css +++ b/src/views/post/post.module.css @@ -129,6 +129,21 @@ font-weight: var(--post-name-font-weight); } +.authorFlags { + display: inline-flex; + align-items: center; + gap: 3px; + margin-right: 3px; + vertical-align: -1px; +} + +.authorFlag { + display: inline-block; + flex: 0 0 auto; + background-repeat: no-repeat; + image-rendering: pixelated; +} + .postDesktop .postNumLink a, .postDesktop .postNumLink span, .replyDesktop .postNumLink a, .replyDesktop .postNumLink span { color: unset; text-decoration: unset; diff --git a/yarn.lock b/yarn.lock index c789194a..5ed2f0c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,7 +25,7 @@ __metadata: "@electron-forge/maker-zip": "npm:7.8.0" "@electron/rebuild": "npm:3.7.2" "@floating-ui/react": "npm:0.26.1" - "@pkcprotocol/pkc-js": "npm:0.0.35" + "@pkcprotocol/pkc-js": "npm:0.0.37" "@react-spring/web": "npm:10.0.3" "@reforged/maker-appimage": "npm:5.1.1" "@types/lodash": "npm:4.17.24" @@ -3958,15 +3958,15 @@ __metadata: languageName: node linkType: hard -"@libp2p/gossipsub@npm:15.0.21": - version: 15.0.21 - resolution: "@libp2p/gossipsub@npm:15.0.21" +"@libp2p/gossipsub@npm:15.0.23": + version: 15.0.23 + resolution: "@libp2p/gossipsub@npm:15.0.23" dependencies: - "@libp2p/crypto": "npm:^5.1.17" + "@libp2p/crypto": "npm:^5.1.18" "@libp2p/interface": "npm:^3.2.2" - "@libp2p/interface-internal": "npm:^3.1.3" - "@libp2p/peer-id": "npm:^6.0.8" - "@libp2p/utils": "npm:^7.1.0" + "@libp2p/interface-internal": "npm:^3.1.5" + "@libp2p/peer-id": "npm:^6.0.9" + "@libp2p/utils": "npm:^7.2.1" "@multiformats/multiaddr": "npm:^13.0.1" denque: "npm:^2.1.0" it-length-prefixed: "npm:^10.0.1" @@ -3976,7 +3976,7 @@ __metadata: protons-runtime: "npm:^6.0.1" uint8arraylist: "npm:^2.4.8" uint8arrays: "npm:^5.0.1" - checksum: 10c0/d7001866a16020dd0a9cf49df0a798c97102851d991da21528dda91b190bb5e069151d08e36549d5c9ccdce481fe876adadd448121fcafb267049bc9ba5264d2 + checksum: 10c0/1bf9caeea0ae27813e6c22ce855d372d96bea2ac8599dca40722b727a40f6dc1625b07a26de82a62e1bfdc6de820fb13ba8c392d8a06aa93f7d6669bf192bbd3 languageName: node linkType: hard @@ -5829,9 +5829,9 @@ __metadata: languageName: node linkType: hard -"@pkcprotocol/pkc-js@npm:0.0.35": - version: 0.0.35 - resolution: "@pkcprotocol/pkc-js@npm:0.0.35" +"@pkcprotocol/pkc-js@npm:0.0.37": + version: 0.0.37 + resolution: "@pkcprotocol/pkc-js@npm:0.0.37" dependencies: "@enhances/with-resolvers": "npm:0.0.5" "@helia/block-brokers": "npm:5.2.4" @@ -5880,7 +5880,7 @@ __metadata: typestub-ipfs-only-hash: "npm:4.0.0" uuid: "npm:13.0.0" zod: "npm:4.3.6" - checksum: 10c0/b218f19f8f01c422f68069f9f49e08dd3f57cfa082cb1213cc30c624e2ee6e7608480ae34152e23ba5d6b78ba21b0841f29e623632f7b97561cb1314a748d265 + checksum: 10c0/4a76a0a64685414ce3eee004189b96beb6ddfeee654eb191c104c8a38654b0b15de591966f39de67f5961ba07fe2cf0bedc744c280382b93e52ece2887618c58 languageName: node linkType: hard