Merge branch 'master' of github.com:bitsocialnet/5chan

# Conflicts:
#	public/translations/ar/default.json
#	public/translations/bn/default.json
#	public/translations/cs/default.json
#	public/translations/da/default.json
#	public/translations/de/default.json
#	public/translations/el/default.json
#	public/translations/en/default.json
#	public/translations/es/default.json
#	public/translations/fa/default.json
#	public/translations/fi/default.json
#	public/translations/fil/default.json
#	public/translations/fr/default.json
#	public/translations/he/default.json
#	public/translations/hi/default.json
#	public/translations/hu/default.json
#	public/translations/id/default.json
#	public/translations/it/default.json
#	public/translations/ja/default.json
#	public/translations/ko/default.json
#	public/translations/mr/default.json
#	public/translations/nl/default.json
#	public/translations/no/default.json
#	public/translations/pl/default.json
#	public/translations/pt/default.json
#	public/translations/ro/default.json
#	public/translations/ru/default.json
#	public/translations/sq/default.json
#	public/translations/sv/default.json
#	public/translations/te/default.json
#	public/translations/th/default.json
#	public/translations/tr/default.json
#	public/translations/uk/default.json
#	public/translations/ur/default.json
#	public/translations/vi/default.json
#	public/translations/zh/default.json
This commit is contained in:
Tommaso Casaburi
2026-05-24 23:18:28 +07:00
66 changed files with 1293 additions and 74 deletions
+3 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "لم يتم التحقق من عنوان هذه اللوحة. قد لا يكون مالك اللوحة مالكًا لهذا العنوان.",
"go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات</1> لاستيراد حساب المشرف الخاص بك",
"post_form_rules_faq_prompt": "يُرجى قراءة <rules>القواعد</rules> و<faq>الأسئلة الشائعة</faq> قبل النشر.",
"pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\"."
"pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\".",
"flag": "علم"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "এই বোর্ড ঠিকানাটি যাচাই করা হয়নি। বোর্ডের মালিক এই ঠিকানার মালিক নাও হতে পারেন।",
"go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস</1>-এ যান",
"post_form_rules_faq_prompt": "পোস্ট করার আগে <rules>নিয়ম</rules> এবং <faq>প্রশ্নোত্তর</faq> পড়ুন।",
"pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\"."
"pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\".",
"flag": "পতাকা"
}
+2 -1
View File
@@ -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í</1> a importujte svůj moderátorský účet",
"post_form_rules_faq_prompt": "Před odesláním si přečtěte <rules>Pravidla</rules> a <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> for at importere din mod-konto",
"post_form_rules_faq_prompt": "Læs venligst <rules>Reglerne</rules> og <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1>, um dein Moderatorkonto zu importieren",
"post_form_rules_faq_prompt": "Bitte lesen Sie die <rules>Regeln</rules> und die <faq>FAQ</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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "Αυτή η διεύθυνση board δεν έχει επαληθευτεί. Ο ιδιοκτήτης του board ενδέχεται να μην κατέχει αυτή τη διεύθυνση.",
"go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις</1> για να εισαγάγετε τον λογαριασμό διαχειριστή σας",
"post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους <rules>Κανόνες</rules> και τις <faq>Συχνές ερωτήσεις</faq> πριν δημοσιεύσετε.",
"pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\"."
"pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\".",
"flag": "Σημαία"
}
+2 -1
View File
@@ -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>Rules</rules> and <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> para importar tu cuenta de moderador",
"post_form_rules_faq_prompt": "Por favor, lee las <rules>Reglas</rules> y las <faq>FAQ</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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "این نشانی برد تأیید نشده است. ممکن است مالک برد صاحب این نشانی نباشد.",
"go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات</1> بروید",
"post_form_rules_faq_prompt": "لطفاً قبل از ارسال <rules>قوانین</rules> و <faq>پرسش‌های متداول</faq> را بخوانید.",
"pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\"."
"pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\".",
"flag": "پرچم"
}
+2 -1
View File
@@ -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</1> tuodaksesi moderaattoritilisi",
"post_form_rules_faq_prompt": "Lue <rules>Säännöt</rules> ja <faq>UKK</faq> 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"
}
+2 -1
View File
@@ -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</1> para i-import ang iyong mod account",
"post_form_rules_faq_prompt": "Pakibasa ang <rules>Rules</rules> at <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> pour importer votre compte de modération",
"post_form_rules_faq_prompt": "Veuillez lire le <rules>Règlement</rules> et la <faq>FAQ</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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "כתובת הלוח הזו אינה מאומתת. ייתכן שבעל הלוח אינו הבעלים של הכתובת הזו.",
"go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות</1> כדי לייבא את חשבון המוד שלך",
"post_form_rules_faq_prompt": "אנא קרא את <rules>הכללים</rules> ואת <faq>שאלות נפוצות</faq> לפני פרסום.",
"pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\"."
"pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\".",
"flag": "דגל"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "इस बोर्ड पते की पुष्टि नहीं हुई है। हो सकता है बोर्ड का मालिक इस पते का मालिक न हो।",
"go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स</1> पर जाएं",
"post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया <rules>नियम</rules> और <faq>अक्सर पूछे जाने वाले सवाल</faq> पढ़ें।",
"pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\"."
"pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\".",
"flag": "ध्वज"
}
+2 -1
View File
@@ -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</1> menübe a moderátori fiókod importálásához",
"post_form_rules_faq_prompt": "Kérjük, olvasd el a <rules>Szabályokat</rules> és a <faq>GYIK</faq>-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ó"
}
+2 -1
View File
@@ -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</1> untuk mengimpor akun mod Anda",
"post_form_rules_faq_prompt": "Silakan baca <rules>Aturan</rules> dan <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> per importare il tuo account mod",
"post_form_rules_faq_prompt": "Leggi le <rules>Regole</rules> e le <faq>FAQ</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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "このボードアドレスは検証されていません。ボード所有者がこのアドレスを所有していない可能性があります。",
"go_to_settings_to_import_mod_account": "モデレーターアカウントをインポートするには<1>設定</1>に移動してください",
"post_form_rules_faq_prompt": "投稿する前に<rules>ルール</rules>と<faq>FAQ</faq>をお読みください。",
"pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\"."
"pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\".",
"flag": "旗"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "이 보드 주소는 확인되지 않았습니다. 보드 소유자가 이 주소를 소유하지 않았을 수 있습니다.",
"go_to_settings_to_import_mod_account": "관리자 계정을 가져오려면 <1>설정</1>으로 이동하세요",
"post_form_rules_faq_prompt": "게시하기 전에 <rules>규칙</rules>과 <faq>FAQ</faq>을 읽어 주세요.",
"pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\"."
"pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\".",
"flag": "깃발"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "हा बोर्ड पत्ता सत्यापित केलेला नाही. बोर्डचा मालक हा पत्ता मालक नसू शकतो.",
"go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज</1> वर जा",
"post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी <rules>नियम</rules> आणि <faq>वारंवार विचारले जाणारे प्रश्न</faq> वाचा.",
"pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\"."
"pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\".",
"flag": "ध्वज"
}
+2 -1
View File
@@ -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</1> om je mod-account te importeren",
"post_form_rules_faq_prompt": "Lees de <rules>Regels</rules> en de <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> for å importere modkontoen din",
"post_form_rules_faq_prompt": "Vennligst les <rules>Reglene</rules> og <faq>FAQ</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"
}
+2 -1
View File
@@ -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ń</1>, aby zaimportować swoje konto moderatora",
"post_form_rules_faq_prompt": "Przed opublikowaniem przeczytaj <rules>Zasady</rules> i <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> para importar sua conta de mod",
"post_form_rules_faq_prompt": "Leia as <rules>Regras</rules> e as <faq>FAQ</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"
}
+2 -1
View File
@@ -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</1> pentru a importa contul tău de moderator",
"post_form_rules_faq_prompt": "Citește <rules>Regulile</rules> și <faq>Întrebările frecvente</faq> î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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "Этот адрес доски не проверен. Владелец доски может не владеть этим адресом.",
"go_to_settings_to_import_mod_account": "Перейдите в <1>настройки</1>, чтобы импортировать свой аккаунт модератора",
"post_form_rules_faq_prompt": "Пожалуйста, прочитайте <rules>Правила</rules> и <faq>FAQ</faq> перед публикацией.",
"pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\"."
"pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\".",
"flag": "Флаг"
}
+2 -1
View File
@@ -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</1> për të importuar llogarinë tuaj të modit",
"post_form_rules_faq_prompt": "Ju lutemi lexoni <rules>Rregullat</rules> dhe <faq>Pyetjet e shpeshta</faq> 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"
}
+2 -1
View File
@@ -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</1> för att importera ditt modkonto",
"post_form_rules_faq_prompt": "Läs <rules>Reglerna</rules> och <faq>FAQ</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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "ఈ బోర్డ్ చిరునామా ధృవీకరించబడలేదు. బోర్డ్ యజమాని ఈ చిరునామాకు యజమాని కాకపోవచ్చు.",
"go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలు</1>కి వెళ్లండి",
"post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు <rules>నియమాలు</rules> మరియు <faq>తరచుగా అడిగే ప్రశ్నలు</faq> చదవండి.",
"pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\"."
"pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\".",
"flag": "జెండా"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "ที่อยู่บอร์ดนี้ยังไม่ได้รับการยืนยัน เจ้าของบอร์ดอาจไม่ได้เป็นเจ้าของที่อยู่นี้",
"go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า</1> เพื่อนำเข้าบัญชีผู้ดูแลของคุณ",
"post_form_rules_faq_prompt": "โปรดอ่าน<rules>กฎ</rules>และ<faq>คำถามที่พบบ่อย</faq>ก่อนโพสต์",
"pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\"."
"pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\".",
"flag": "ธง"
}
+2 -1
View File
@@ -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>ayarlar</1>a gidin",
"post_form_rules_faq_prompt": "Lütfen göndermeden önce <rules>Kuralları</rules> ve <faq>SSS</faq>'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"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "Цю адресу дошки не перевірено. Власник дошки може не володіти цією адресою.",
"go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань</1>, щоб імпортувати свій обліковий запис модератора",
"post_form_rules_faq_prompt": "Будь ласка, прочитайте <rules>Правила</rules> і <faq>Часті запитання</faq> перед публікацією.",
"pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\"."
"pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\".",
"flag": "Прапор"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "اس بورڈ کے پتے کی تصدیق نہیں ہوئی۔ ممکن ہے بورڈ کا مالک اس پتے کا مالک نہ ہو۔",
"go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات</1> پر جائیں",
"post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے <rules>اصول</rules> اور <faq>اکثر پوچھے گئے سوالات</faq> پڑھیں۔",
"pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\"."
"pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\".",
"flag": "پرچم"
}
+2 -1
View File
@@ -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</1> để nhập tài khoản mod của bạn",
"post_form_rules_faq_prompt": "Vui lòng đọc <rules>Quy tắc</rules> và <faq>Câu hỏi thường gặp</faq> 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ờ"
}
+2 -1
View File
@@ -392,5 +392,6 @@
"board_address_unverified_warning": "此版面地址尚未验证。版主可能并不拥有此地址。",
"go_to_settings_to_import_mod_account": "前往<1>设置</1>以导入您的版主账户",
"post_form_rules_faq_prompt": "发帖前请阅读<rules>规则</rules>和<faq>常见问题</faq>。",
"pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。"
"pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。",
"flag": "旗帜"
}
@@ -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 = [
@@ -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(() => {
+39
View File
@@ -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 (
<span className={styles.authorFlags}>
{flags.map((flag) => (
<span
key={flag.key}
aria-label={flag.label}
className={styles.authorFlag}
role='img'
style={{
backgroundImage: `url("${flag.spritePath}")`,
backgroundPosition: getBackgroundPosition(flag.x, flag.y),
width: flag.width,
height: flag.height,
}}
title={flag.label}
/>
))}
</span>
);
};
export default PostAuthorFlags;
@@ -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 = ({
</>
)}
</span>
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
<>
@@ -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<string, unknown>; title?: string }>,
] as Array<{ address: string; directoryCode?: string; features?: Record<string, unknown>; 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<HTMLSelectElement>('select[aria-label="flag"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('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<HTMLSelectElement>('select[aria-label="flag"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('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';
@@ -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;
+38 -2
View File
@@ -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<HTMLInputElement>;
optionsRef: React.RefObject<HTMLInputElement>;
flagRef: React.RefObject<HTMLSelectElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
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 && <div className={styles.error}>{lengthError}</div>}
</td>
</tr>
{flagOptions.length > 0 && (
<tr>
<td>{t('flag')}</td>
<td>
<select
key={flagOptions.map((option) => option.value).join('|')}
aria-label={t('flag')}
className={styles.flagSelect}
ref={flagRef}
defaultValue={flagOptions[0]?.value}
>
{flagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
</tr>
)}
<tr>
<td>{requirePostLinkIsMedia ? t('link_to_file') : t('link')}</td>
<td className={styles.linkField}>
@@ -422,6 +447,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const urlRef = useRef<HTMLInputElement>(null);
const subjectRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(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}
@@ -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 = ({
){' '}
</>
)}
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
{pinned && (
<span className={styles.stickyIconWrapper}>
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
@@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({
features: {},
title: '/mu/ - Music',
},
} as Record<string, { address: string; features?: Record<string, unknown>; title?: string }>,
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; 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<HTMLSelectElement>('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<HTMLSelectElement>('select[aria-label="flag"]');
await dispatchInput(container.querySelector<HTMLTextAreaElement>('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;
@@ -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;
}
}
+17 -1
View File
@@ -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<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(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
}}
/>
</div>
{flagOptions.length > 0 && (
<div>
<select key={flagOptions.map((option) => option.value).join('|')} aria-label={t('flag')} ref={flagRef} defaultValue={flagOptions[0]?.value}>
{flagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
<div className={styles.link}>
<input
type='text'
@@ -189,6 +189,7 @@ describe('use-directories', () => {
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();
});
@@ -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);
});
});
@@ -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);
+8 -1
View File
@@ -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<string, unknown>): DirectoryCommunity[] => {
+6 -3
View File
@@ -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<Comment>) => {
@@ -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) {
+6 -3
View File
@@ -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<Comment>) => {
@@ -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) {
+38
View File
@@ -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 });
});
});
@@ -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,
});
});
});
+109
View File
@@ -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']);
});
});
+190
View File
@@ -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<string, BoardFlagDefinition> =>
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<BoardFlagKind, ReadonlyMap<string, BoardFlagDefinition>> = {
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()] : [];
};
+108
View File
@@ -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<DirectoryCommunity, 'directoryCode' | 'title'> | 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<DirectoryCommunity, 'directoryCode' | 'features' | 'title'> | 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,
};
};
+221
View File
@@ -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<string, unknown> => 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<string>();
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;
};
@@ -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);
+11 -1
View File
@@ -11,7 +11,9 @@ type SubmitState = {
title: string | undefined;
content: string | undefined;
link: string | undefined;
flairs: Comment['flairs'] | undefined;
spoiler: boolean | undefined;
challengeRequest: Record<string, unknown> | undefined;
publishCommentOptions: PublishCommentOptions;
setPublishPostStore: (data: Partial<SubmitState>) => void;
resetPublishPostStore: () => void;
@@ -24,11 +26,13 @@ const usePublishPostStore = create<SubmitState>((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<SubmitState>((set) => ({
title,
content,
link,
flairs,
spoiler,
...(challengeRequest ? { challengeRequest } : {}),
onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
@@ -65,7 +71,9 @@ const usePublishPostStore = create<SubmitState>((set) => ({
title,
content,
link,
flairs,
spoiler,
challengeRequest,
publishCommentOptions,
};
}),
@@ -77,7 +85,9 @@ const usePublishPostStore = create<SubmitState>((set) => ({
title: undefined,
content: undefined,
link: undefined,
flairs: undefined,
spoiler: undefined,
challengeRequest: undefined,
publishCommentOptions: {},
}),
}));
+11 -1
View File
@@ -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<string, unknown> | undefined };
publishCommentOptions: { [parentCid: string]: PublishCommentOptions | undefined };
setPublishReplyStore: (comment: Comment) => void;
resetPublishReplyStore: (parentCid: string) => void;
@@ -20,12 +22,14 @@ const usePublishReplyStore = create<ReplyState>((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<ReplyState>((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<ReplyState>((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<ReplyState>((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 },
})),
}));
+15
View File
@@ -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;
+13 -13
View File
@@ -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