fix(react): address Doctor findings (#1143)

Accessibility/semantics pass aligned with React Doctor: role-based spans/divs become real buttons, role=status becomes <output>, modals use native <dialog>, author/peer flags render as <img>, and embed/challenge iframes are sandboxed. Includes review follow-ups: correct button chrome/centering/themed dialog colors and focus rings, the missing aria-label i18n keys (all languages), the Yandex image-search URL, clearer .bso setup steps, a keyboard-accessible image-search dropdown, and removal of the post-edit ('comment edited') display (mod edits unchanged).
This commit is contained in:
Tommaso Casaburi
2026-05-29 17:09:07 +07:00
committed by GitHub
parent ac95e58433
commit e082c7b428
132 changed files with 1753 additions and 1224 deletions
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "عرض لوحات آمنة للعمل فقط",
"show_all_content": "عرض جميع المحتويات",
"link_type": "نوع الرابط",
"comment_edited_at_timestamp": "تم تحرير التعليق في <1>{{timestamp}}</1>",
"reason_reason": "السبب: \"{{reason}}\".",
"click_here_to_show_original": "<1>انقر هنا</1> لعرض الأصل.",
"click_here_to_hide_original": "<1>انقر هنا</1> لإخفاء الأصل.",
"loading": "جار التحميل",
"posted_by": "نشر بواسطة",
"by": "بواسطة",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات</1> لاستيراد حساب المشرف الخاص بك",
"post_form_rules_faq_prompt": "يُرجى قراءة <rules>القواعد</rules> و<faq>الأسئلة الشائعة</faq> قبل النشر.",
"pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\".",
"flag": "علم"
"flag": "علم",
"show_replies": "عرض الردود",
"hide_replies": "إخفاء الردود",
"unhide": "إظهار",
"account_data": "بيانات الحساب",
"ban_duration_days": "مدة الحظر بالأيام",
"challenge_answer": "إجابة التحدي",
"ban": "حظر",
"timestamp": "الطابع الزمني"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "শুধুমাত্র কাজ-সম্পূর্ণ বোর্ড দেখান",
"show_all_content": "সমস্ত কন্টেন্ট দেখান",
"link_type": "লিংক প্রকার",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> এ মন্তব্য সম্পাদিত হয়েছে",
"reason_reason": "কারণ: \"{{reason}}\".",
"click_here_to_show_original": "<1>এখানে ক্লিক করুন</1> অসল দেখানোর জন্য।",
"click_here_to_hide_original": "<1>এখানে ক্লিক করুন</1> অসল লুকাতে।",
"loading": "লোড হচ্ছে",
"posted_by": "পোস্ট করেছেন",
"by": "দ্বারা",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস</1>-এ যান",
"post_form_rules_faq_prompt": "পোস্ট করার আগে <rules>নিয়ম</rules> এবং <faq>প্রশ্নোত্তর</faq> পড়ুন।",
"pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\".",
"flag": "পতাকা"
"flag": "পতাকা",
"show_replies": "উত্তরগুলি দেখান",
"hide_replies": "উত্তরগুলি লুকান",
"unhide": "লুকানো সরান",
"account_data": "অ্যাকাউন্ট ডেটা",
"ban_duration_days": "ব্যান মেয়াদ দিনে",
"challenge_answer": "চ্যালেঞ্জ উত্তর",
"ban": "নিষেধ করুন",
"timestamp": "টাইমস্ট্যাম্প"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Zobrazit pouze bezpečné pracovní desky",
"show_all_content": "Zobrazit veškerý obsah",
"link_type": "Typ odkazu",
"comment_edited_at_timestamp": "Komentář byl upraven dne <1>{{timestamp}}</1>",
"reason_reason": "Důvod: \"{{reason}}\".",
"click_here_to_show_original": "Klikněte <1>zde</1> pro zobrazení původního.",
"click_here_to_hide_original": "Klikněte <1>zde</1> pro skrytí původního.",
"loading": "Načítání",
"posted_by": "Publikoval(a)",
"by": "od",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Vlajka"
"flag": "Vlajka",
"show_replies": "Zobrazit odpovědi",
"hide_replies": "skrýt odpovědi",
"unhide": "odkrýt",
"account_data": "data účtu",
"ban_duration_days": "doba zákazu v dnech",
"challenge_answer": "odpověď na výzvu",
"ban": "zakázat",
"timestamp": "časové razítko"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Vis kun Worksafe boards",
"show_all_content": "Vis alt indhold",
"link_type": "Link type",
"comment_edited_at_timestamp": "Kommentaren blev redigeret den <1>{{timestamp}}</1>",
"reason_reason": "Grund: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klik her</1> for at vise originalen.",
"click_here_to_hide_original": "<1>Klik her</1> for at skjule originalen.",
"loading": "Indlæser",
"posted_by": "Skrevet af",
"by": "af",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flag"
"flag": "Flag",
"show_replies": "Vis svar",
"hide_replies": "skjul svar",
"unhide": "unhide",
"account_data": "kontodata",
"ban_duration_days": "banvarighed i dage",
"challenge_answer": "svar på udfordring",
"ban": "forbyd",
"timestamp": "tidsstempel"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Nur sichere Boards anzeigen",
"show_all_content": "Alle Inhalte anzeigen",
"link_type": "Linktyp",
"comment_edited_at_timestamp": "Kommentar bearbeitet am <1>{{timestamp}}</1>",
"reason_reason": "Grund: \"{{reason}}\".",
"click_here_to_show_original": "<1>Hier klicken</1>, um das Original anzuzeigen.",
"click_here_to_hide_original": "<1>Hier klicken</1>, um das Original zu verstecken.",
"loading": "Laden",
"posted_by": "Geschrieben von",
"by": "von",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flagge"
"flag": "Flagge",
"show_replies": "Antworten anzeigen",
"hide_replies": "Antworten ausblenden",
"unhide": "einblenden",
"account_data": "kontodaten",
"ban_duration_days": "bannierungsdauer in tagen",
"challenge_answer": "herausforderungsantwort",
"ban": "sperren",
"timestamp": "Zeitstempel"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Εμφάνιση μόνο ασφαλών πινάκων εργασίας",
"show_all_content": "Εμφάνιση όλων των περιεχομένων",
"link_type": "Τύπος συνδέσμου",
"comment_edited_at_timestamp": "Σχόλιο επεξεργασμένο στις <1>{{timestamp}}</1>",
"reason_reason": "Λόγος: \"{{reason}}\".",
"click_here_to_show_original": "<1>Κάντε κλικ εδώ</1> για να εμφανιστεί το αρχικό.",
"click_here_to_hide_original": "<1>Κάντε κλικ εδώ</1> για να κρύψετε το αρχικό.",
"loading": "Φόρτωση",
"posted_by": "Δημοσιεύτηκε από",
"by": "από",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις</1> για να εισαγάγετε τον λογαριασμό διαχειριστή σας",
"post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους <rules>Κανόνες</rules> και τις <faq>Συχνές ερωτήσεις</faq> πριν δημοσιεύσετε.",
"pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\".",
"flag": "Σημαία"
"flag": "Σημαία",
"show_replies": "Εμφάνιση απαντήσεων",
"hide_replies": "Απόκρυψη απαντήσεων",
"unhide": "εμφάνιση",
"account_data": "δεδομένα λογαριασμού",
"ban_duration_days": "διάρκεια απαγόρευσης σε ημέρες",
"challenge_answer": "απάντηση πρόκλησης",
"ban": "απαγόρευση",
"timestamp": "χρονοσήμανση"
}
+9 -5
View File
@@ -162,10 +162,6 @@
"nsfw_boards_only": "NSFW Boards Only",
"worksafe_boards_only": "Worksafe Boards Only",
"link_type": "Link type",
"comment_edited_at_timestamp": "Comment edited at <1>{{timestamp}}</1>",
"reason_reason": "Reason: \"{{reason}}\".",
"click_here_to_show_original": "<1>Click here</1> to show the original.",
"click_here_to_hide_original": "<1>Click here</1> to hide the original.",
"loading": "Loading",
"posted_by": "Posted by",
"by": "by",
@@ -412,5 +408,13 @@
"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}}\".",
"flag": "Flag"
"flag": "Flag",
"show_replies": "show replies",
"hide_replies": "hide replies",
"unhide": "unhide",
"account_data": "account data",
"ban_duration_days": "ban duration in days",
"challenge_answer": "challenge answer",
"ban": "ban",
"timestamp": "timestamp"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Mostrar solo tableros seguros para el trabajo",
"show_all_content": "Mostrar todo el contenido",
"link_type": "Tipo de enlace",
"comment_edited_at_timestamp": "Comentario editado el <1>{{timestamp}}</1>",
"reason_reason": "Motivo: \"{{reason}}\".",
"click_here_to_show_original": "<1>Haga clic aquí</1> para mostrar el original.",
"click_here_to_hide_original": "<1>Haga clic aquí</1> para ocultar el original.",
"loading": "Cargando",
"posted_by": "Publicado por",
"by": "por",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bandera"
"flag": "Bandera",
"show_replies": "mostrar respuestas",
"hide_replies": "ocultar respuestas",
"unhide": "mostrar",
"account_data": "datos de cuenta",
"ban_duration_days": "duración del baneo en días",
"challenge_answer": "respuesta del desafío",
"ban": "prohibir",
"timestamp": "marca de tiempo"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "نمایش تنها بوردهای ایمن برای کار",
"show_all_content": "نمایش تمام محتوا",
"link_type": "نوع پیوند",
"comment_edited_at_timestamp": "نظر ویرایش شده در <1>{{timestamp}}</1>",
"reason_reason": "دلیل: \"{{reason}}\".",
"click_here_to_show_original": "<1>برای نمایش اصلی اینجا کلیک کنید</1>.",
"click_here_to_hide_original": "<1>برای پنهان کردن اصل اینجا کلیک کنید</1>.",
"loading": "در حال بارگذاری",
"posted_by": "ارسال شده توسط",
"by": "توسط",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات</1> بروید",
"post_form_rules_faq_prompt": "لطفاً قبل از ارسال <rules>قوانین</rules> و <faq>پرسش‌های متداول</faq> را بخوانید.",
"pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\".",
"flag": "پرچم"
"flag": "پرچم",
"show_replies": "نمایش پاسخ‌ها",
"hide_replies": "پنهان کردن پاسخ‌ها",
"unhide": "نمایش دادن",
"account_data": "داده‌های حساب",
"ban_duration_days": "مدت بن به روز",
"challenge_answer": "پاسخ چالش",
"ban": "مسدود کردن",
"timestamp": "مهر زمان"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Näytä vain työturvalliset hallit",
"show_all_content": "Näytä kaikki sisällöt",
"link_type": "Linkin tyyppi",
"comment_edited_at_timestamp": "Kommentti muokattu <1>{{timestamp}}</1>",
"reason_reason": "Syy: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klikkaa tästä</1> näyttääksesi alkuperäisen.",
"click_here_to_hide_original": "<1>Klikkaa tästä</1> piilottaaksesi alkuperäisen.",
"loading": "Lataa",
"posted_by": "Lähettäjä",
"by": "kirjoittanut",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Lippu"
"flag": "Lippu",
"show_replies": "Näytä vastaukset",
"hide_replies": "piilota vastaukset",
"unhide": "paljasta",
"account_data": "tilin tiedot",
"ban_duration_days": "kiellon kesto päivissä",
"challenge_answer": "haasteen vastaus",
"ban": "kielto",
"timestamp": "aikaleimaustiedosto"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Ipakita lamang ang mga Worksafe boards",
"show_all_content": "Ipakita lahat ng nilalaman",
"link_type": "Uri ng Link",
"comment_edited_at_timestamp": "Na-edit ang komento sa <1>{{timestamp}}</1>",
"reason_reason": "Dahilan: \"{{reason}}\".",
"click_here_to_show_original": "<1>I-click dito</1> upang ipakita ang orihinal.",
"click_here_to_hide_original": "<1>I-click dito</1> upang itago ang orihinal.",
"loading": "Naglo-load",
"posted_by": "Nai-post ni",
"by": "ni",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bandila"
"flag": "Bandila",
"show_replies": "Ipakita ang mga sagot",
"hide_replies": "itago ang mga sagot",
"unhide": "ipakita",
"account_data": "data ng account",
"ban_duration_days": "tagal ng ban sa araw",
"challenge_answer": "sagot sa hamon",
"ban": "ipagbawal",
"timestamp": "timestamp"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Afficher uniquement les tableaux Worksafe",
"show_all_content": "Afficher tout le contenu",
"link_type": "Type de lien",
"comment_edited_at_timestamp": "Commentaire modifié le <1>{{timestamp}}</1>",
"reason_reason": "Raison: \"{{reason}}\".",
"click_here_to_show_original": "<1>Cliquez ici</1> pour afficher l'original.",
"click_here_to_hide_original": "<1>Cliquez ici</1> pour masquer l'original.",
"loading": "Chargement",
"posted_by": "Posté par",
"by": "par",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Drapeau"
"flag": "Drapeau",
"show_replies": "afficher les réponses",
"hide_replies": "masquer les réponses",
"unhide": "afficher",
"account_data": "données du compte",
"ban_duration_days": "durée du bannissement en jours",
"challenge_answer": "réponse au défi",
"ban": "interdire",
"timestamp": "horodatage"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "הצג לוחות עבודה בטוחים בלבד",
"show_all_content": "הצג את כל התוכן",
"link_type": "סוג קישור",
"comment_edited_at_timestamp": "התגובה נערכה ב-<1>{{timestamp}}</1>",
"reason_reason": "סיבה: \"{{reason}}\".",
"click_here_to_show_original": "<1>לחץ כאן</1> כדי להציג את המקור.",
"click_here_to_hide_original": "<1>לחץ כאן</1> כדי להסתיר את המקור.",
"loading": "טוען",
"posted_by": "פורסם על ידי",
"by": "על ידי",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות</1> כדי לייבא את חשבון המוד שלך",
"post_form_rules_faq_prompt": "אנא קרא את <rules>הכללים</rules> ואת <faq>שאלות נפוצות</faq> לפני פרסום.",
"pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\".",
"flag": "דגל"
"flag": "דגל",
"show_replies": "הצג תגובות",
"hide_replies": "הסתרת תגובות",
"unhide": "הצג",
"account_data": "נתוני חשבון",
"ban_duration_days": "משך הבן בימים",
"challenge_answer": "תשובת אתגר",
"ban": "חסימה",
"timestamp": "חותמת זמן"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "केवल कामसुरक्षित बोर्ड्स दिखाएं",
"show_all_content": "सभी सामग्री दिखाएं",
"link_type": "लिंक प्रकार",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> पर टिप्पणी संपादित की गई",
"reason_reason": "कारण: \"{{reason}}\".",
"click_here_to_show_original": "<1>यहां क्लिक करें</1> मूल दिखाने के लिए।",
"click_here_to_hide_original": "<1>यहां क्लिक करें</1> मूल छुपाने के लिए।",
"loading": "लोड हो रहा है",
"posted_by": "द्वारा लिखित",
"by": "द्वारा",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स</1> पर जाएं",
"post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया <rules>नियम</rules> और <faq>अक्सर पूछे जाने वाले सवाल</faq> पढ़ें।",
"pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\".",
"flag": "ध्वज"
"flag": "ध्वज",
"show_replies": "उत्तर दिखाएं",
"hide_replies": "जवाब छुपाएं",
"unhide": "दिखाएं",
"account_data": "खाता डेटा",
"ban_duration_days": "प्रतिबंध की अवधि दिनों में",
"challenge_answer": "चुनौती उत्तर",
"ban": "प्रतिबंध",
"timestamp": "टाइमस्टैंप"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Csak munkabiztos táblák mutatása",
"show_all_content": "Mutasd az összes tartalmat",
"link_type": "Link típusa",
"comment_edited_at_timestamp": "Megjegyzés szerkesztve ekkor: <1>{{timestamp}}</1>",
"reason_reason": "Ok: \"{{reason}}\".",
"click_here_to_show_original": "<1>Kattints ide</1> az eredeti megjelenítéséhez.",
"click_here_to_hide_original": "<1>Kattints ide</1> az eredeti elrejtéséhez.",
"loading": "Betöltés",
"posted_by": "Írta",
"by": "által",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Zászló"
"flag": "Zászló",
"show_replies": "Válaszok megjelenítése",
"hide_replies": "válaszok elrejtése",
"unhide": "mutat",
"account_data": "fiókadat",
"ban_duration_days": "kitiltás időtartama napokban",
"challenge_answer": "kihívás válasza",
"ban": "tiltás",
"timestamp": "időbélyeg"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Tampilkan Hanya Board Worksafe",
"show_all_content": "Tampilkan Semua Konten",
"link_type": "Tipe tautan",
"comment_edited_at_timestamp": "Komentar diedit pada <1>{{timestamp}}</1>",
"reason_reason": "Alasan: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klik di sini</1> untuk menampilkan aslinya.",
"click_here_to_hide_original": "<1>Klik di sini</1> untuk menyembunyikan aslinya.",
"loading": "Memuat",
"posted_by": "Diposting oleh",
"by": "oleh",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bendera"
"flag": "Bendera",
"show_replies": "Tampilkan balasan",
"hide_replies": "sembunyikan balasan",
"unhide": "tampilkan",
"account_data": "data akun",
"ban_duration_days": "durasi larangan dalam hari",
"challenge_answer": "jawaban tantangan",
"ban": "larangan",
"timestamp": "stempel waktu"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Mostra solo le board Worksafe",
"show_all_content": "Mostra tutti i contenuti",
"link_type": "Tipo di link",
"comment_edited_at_timestamp": "Commento modificato il <1>{{timestamp}}</1>",
"reason_reason": "Motivo: \"{{reason}}\".",
"click_here_to_show_original": "<1>Clicca qui</1> per mostrare l'originale.",
"click_here_to_hide_original": "<1>Clicca qui</1> per nascondere l'originale.",
"loading": "Caricamento",
"posted_by": "Pubblicato da",
"by": "da",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bandiera"
"flag": "Bandiera",
"show_replies": "mostra risposte",
"hide_replies": "nascondi risposte",
"unhide": "mostra",
"account_data": "dati account",
"ban_duration_days": "durata del ban in giorni",
"challenge_answer": "risposta alla sfida",
"ban": "vietare",
"timestamp": "marca temporale"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Worksafeボードのみ表示",
"show_all_content": "すべてのコンテンツを表示",
"link_type": "リンクの種類",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> にコメントを編集しました",
"reason_reason": "理由: \"{{reason}}\".",
"click_here_to_show_original": "<1>ここをクリック</1> して元の表示。",
"click_here_to_hide_original": "<1>ここをクリック</1> して元を非表示にする。",
"loading": "読み込み中",
"posted_by": "投稿者",
"by": "によって",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "旗"
"flag": "旗",
"show_replies": "返信を表示",
"hide_replies": "返信を非表示",
"unhide": "表示",
"account_data": "アカウントデータ",
"ban_duration_days": "バン期間(日数)",
"challenge_answer": "チャレンジ回答",
"ban": "ブロック",
"timestamp": "タイムスタンプ"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "작업 안전 보드만 표시",
"show_all_content": "모든 콘텐츠 보기",
"link_type": "링크 유형",
"comment_edited_at_timestamp": "댓글 편집 시간: <1>{{timestamp}}</1>",
"reason_reason": "이유: \"{{reason}}\".",
"click_here_to_show_original": "<1>여기를 클릭</1> 하여 원본 표시.",
"click_here_to_hide_original": "<1>여기를 클릭</1> 하여 원본 숨기기.",
"loading": "로딩 중",
"posted_by": "작성자",
"by": "작성자:",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "깃발"
"flag": "깃발",
"show_replies": "답글 표시",
"hide_replies": "답글 숨기기",
"unhide": "표시",
"account_data": "계정 데이터",
"ban_duration_days": "차단 기간(일)",
"challenge_answer": "챌린지 답변",
"ban": "차단",
"timestamp": "타임스탬프"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "केवळ कामसुरक्षित बोर्ड्स दाखवा",
"show_all_content": "सर्व सामग्री दाखवा",
"link_type": "लिंक प्रकार",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> ला संपादित केला",
"reason_reason": "कारण: \"{{reason}}\".",
"click_here_to_show_original": "<1>येथे क्लिक करा</1> मूळ पाहण्यासाठी।",
"click_here_to_hide_original": "<1>येथे क्लिक करा</1> मूळ लपविण्यासाठी।",
"loading": "लोड होत आहे",
"posted_by": "ने टप्पल केला",
"by": "व्यक्ती",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज</1> वर जा",
"post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी <rules>नियम</rules> आणि <faq>वारंवार विचारले जाणारे प्रश्न</faq> वाचा.",
"pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\".",
"flag": "ध्वज"
"flag": "ध्वज",
"show_replies": "उत्तरे दर्शवा",
"hide_replies": "उत्तरे लपवा",
"unhide": "दाखवा",
"account_data": "खाता डेटा",
"ban_duration_days": "बंद मुदत दिवसांमध्ये",
"challenge_answer": "आव्हान उत्तर",
"ban": "प्रतिबंध",
"timestamp": "टाइमस्टॅम्प"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Toon alleen Worksafe boards",
"show_all_content": "Toon alle inhoud",
"link_type": "Link type",
"comment_edited_at_timestamp": "Reactie bewerkt op <1>{{timestamp}}</1>",
"reason_reason": "Reden: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klik hier</1> om de originele te tonen.",
"click_here_to_hide_original": "<1>Klik hier</1> om de originele te verbergen.",
"loading": "Laden",
"posted_by": "Geplaatst door",
"by": "door",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Vlag"
"flag": "Vlag",
"show_replies": "antwoorden weergeven",
"hide_replies": "reacties verbergen",
"unhide": "weergeven",
"account_data": "accountgegevens",
"ban_duration_days": "banningsduur in dagen",
"challenge_answer": "antwoord op uitdaging",
"ban": "verbannen",
"timestamp": "tijdstempel"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Vis kun Worksafe boards",
"show_all_content": "Vis allt innhold",
"link_type": "Lenketype",
"comment_edited_at_timestamp": "Kommentaren ble redigert på <1>{{timestamp}}</1>",
"reason_reason": "Årsak: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klikk her</1> for å vise originalen.",
"click_here_to_hide_original": "<1>Klikk her</1> for å skjule originalen.",
"loading": "Laster",
"posted_by": "Skrevet av",
"by": "av",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flagg"
"flag": "Flagg",
"show_replies": "vis svar",
"hide_replies": "skjul svar",
"unhide": "vis",
"account_data": "kontodata",
"ban_duration_days": "bannvarighet i dager",
"challenge_answer": "svar på utfordring",
"ban": "forbud",
"timestamp": "tidsstempel"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Pokaż tylko bezpieczne tablice",
"show_all_content": "Pokaż całą zawartość",
"link_type": "Typ łącza",
"comment_edited_at_timestamp": "Komentarz edytowany <1>{{timestamp}}</1>",
"reason_reason": "Powód: \"{{reason}}\".",
"click_here_to_show_original": "<1>Kliknij tutaj</1>, aby pokazać oryginał.",
"click_here_to_hide_original": "<1>Kliknij tutaj</1>, aby ukryć oryginał.",
"loading": "Ładowanie",
"posted_by": "Opublikowane przez",
"by": "przez",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flaga"
"flag": "Flaga",
"show_replies": "Pokaż odpowiedzi",
"hide_replies": "ukryj odpowiedzi",
"unhide": "pokaż",
"account_data": "dane konta",
"ban_duration_days": "czas trwania banu w dniach",
"challenge_answer": "odpowiedź na wyzwanie",
"ban": "zbanować",
"timestamp": "znacznik czasu"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Mostrar apenas boards Worksafe",
"show_all_content": "Mostrar Todo o Conteúdo",
"link_type": "Tipo de link",
"comment_edited_at_timestamp": "Comentário editado em <1>{{timestamp}}</1>",
"reason_reason": "Razão: \"{{reason}}\".",
"click_here_to_show_original": "<1>Clique aqui</1> para mostrar o original.",
"click_here_to_hide_original": "<1>Clique aqui</1> para ocultar o original.",
"loading": "Carregando",
"posted_by": "Postado por",
"by": "por",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bandeira"
"flag": "Bandeira",
"show_replies": "mostrar respostas",
"hide_replies": "ocultar respostas",
"unhide": "mostrar",
"account_data": "dados da conta",
"ban_duration_days": "duração do banimento em dias",
"challenge_answer": "resposta do desafio",
"ban": "banir",
"timestamp": "marca de tempo"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Afișați doar tablourile Worksafe",
"show_all_content": "Afișați tot conținutul",
"link_type": "Tipul linkului",
"comment_edited_at_timestamp": "Comentariu editat la <1>{{timestamp}}</1>",
"reason_reason": "Motiv: \"{{reason}}\".",
"click_here_to_show_original": "<1>Click aici</1> pentru a arăta originalul.",
"click_here_to_hide_original": "<1>Click aici</1> pentru a ascunde originalul.",
"loading": "Încărcare",
"posted_by": "Postat de",
"by": "de către",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Steag"
"flag": "Steag",
"show_replies": "arată răspunsuri",
"hide_replies": "ascunde răspunsuri",
"unhide": "afișează",
"account_data": "date cont",
"ban_duration_days": "durata banului în zile",
"challenge_answer": "răspunsul la provocare",
"ban": "interzice",
"timestamp": "marcă de timp"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Показывать только безопасные доски",
"show_all_content": "Показать весь контент",
"link_type": "Тип ссылки",
"comment_edited_at_timestamp": "Комментарий изменен в <1>{{timestamp}}</1>",
"reason_reason": "Причина: \"{{reason}}\".",
"click_here_to_show_original": "<1>Нажмите здесь</1> для отображения оригинала.",
"click_here_to_hide_original": "<1>Нажмите здесь</1> чтобы скрыть оригинал.",
"loading": "Загрузка",
"posted_by": "Опубликовано",
"by": "от",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Перейдите в <1>настройки</1>, чтобы импортировать свой аккаунт модератора",
"post_form_rules_faq_prompt": "Пожалуйста, прочитайте <rules>Правила</rules> и <faq>FAQ</faq> перед публикацией.",
"pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\".",
"flag": "Флаг"
"flag": "Флаг",
"show_replies": "показать ответы",
"hide_replies": "скрыть ответы",
"unhide": "показать",
"account_data": "данные аккаунта",
"ban_duration_days": "продолжительность бана в днях",
"challenge_answer": "ответ на задачу",
"ban": "блокировка",
"timestamp": "временная метка"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Shfaq vetëm taulat e sigurta të punës",
"show_all_content": "Shfaq të gjitha përmbajtjet",
"link_type": "Lloji i lidhjes",
"comment_edited_at_timestamp": "Komenti u redaktua në <1>{{timestamp}}</1>",
"reason_reason": "Arsye: \"{{reason}}\".",
"click_here_to_show_original": "<1>Kliko këtu</1> për të treguar origjinalin.",
"click_here_to_hide_original": "<1>Kliko këtu</1> për të fshehur origjinalin.",
"loading": "Ngarkimi",
"posted_by": "Postuar nga",
"by": "nga",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flamur"
"flag": "Flamur",
"show_replies": "Shfaq përgjigjet",
"hide_replies": "fsheh përgjigjet",
"unhide": "shfaq",
"account_data": "të dhënat e llogarisë",
"ban_duration_days": "kohëzgjatja e ndalimit në ditë",
"challenge_answer": "përgjigje ndaj sfidës",
"ban": "ndalim",
"timestamp": "vulë kohe"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Visa endast Worksafe-styrelser",
"show_all_content": "Visa allt innehåll",
"link_type": "Länktyp",
"comment_edited_at_timestamp": "Kommentar redigerad vid <1>{{timestamp}}</1>",
"reason_reason": "Anledning: \"{{reason}}\".",
"click_here_to_show_original": "<1>Klicka här</1> för att visa original.",
"click_here_to_hide_original": "<1>Klicka här</1> för att dölja original.",
"loading": "Laddar",
"posted_by": "Postat av",
"by": "av",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Flagga"
"flag": "Flagga",
"show_replies": "visa svar",
"hide_replies": "dölj svar",
"unhide": "visa",
"account_data": "kontoupplysningar",
"ban_duration_days": "bannlängd i dagar",
"challenge_answer": "svar på utmaning",
"ban": "förbjud",
"timestamp": "tidsstämpel"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "పనిమాడు భద్రపరిచిన బోర్డులను మాత్రమే చూపించు",
"show_all_content": "అన్ని కాంటెంట్ చూపించు",
"link_type": "లింక్ రకం",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> లో సమీక్ష సవరించబడింది",
"reason_reason": "కారణం: \"{{reason}}\".",
"click_here_to_show_original": "<1>ఇక్కడ క్లిక్ చేయండి</1> అసలు చూపించడానికి.",
"click_here_to_hide_original": "<1>ఇక్కడ క్లిక్ చేయండి</1> అసలు దాచడానికి.",
"loading": "లోడ్ అవుతోంది",
"posted_by": "పోస్ట్ చేసినవాడు",
"by": "ద్వారా",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలు</1>కి వెళ్లండి",
"post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు <rules>నియమాలు</rules> మరియు <faq>తరచుగా అడిగే ప్రశ్నలు</faq> చదవండి.",
"pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\".",
"flag": "జెండా"
"flag": "జెండా",
"show_replies": "సమాధానాలను చూపించు",
"hide_replies": "సమాధానలను దాచు",
"unhide": "చూపించు",
"account_data": "ఖాతా డేటా",
"ban_duration_days": "నిషేధ వ్యవధి రోజుల్లో",
"challenge_answer": "సవాలు సమాధానం",
"ban": "నిషేధం",
"timestamp": "టైమ్‌స్టాంప్"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "แสดงบอร์ด Worksafe เท่านั้น",
"show_all_content": "แสดงเนื้อหาทั้งหมด",
"link_type": "ประเภทลิงก์",
"comment_edited_at_timestamp": "แก้ไขความคิดเห็นที่ <1>{{timestamp}}</1>",
"reason_reason": "เหตุผล: \"{{reason}}\".",
"click_here_to_show_original": "<1>คลิกที่นี่</1> เพื่อแสดงข้อมูลต้นฉบับ",
"click_here_to_hide_original": "<1>คลิกที่นี่</1> เพื่อซ่อนข้อมูลต้นฉบับ",
"loading": "กำลังโหลด",
"posted_by": "โพสต์โดย",
"by": "โดย",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า</1> เพื่อนำเข้าบัญชีผู้ดูแลของคุณ",
"post_form_rules_faq_prompt": "โปรดอ่าน<rules>กฎ</rules>และ<faq>คำถามที่พบบ่อย</faq>ก่อนโพสต์",
"pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\".",
"flag": "ธง"
"flag": "ธง",
"show_replies": "แสดงคำตอบ",
"hide_replies": "ซ่อนคำตอบ",
"unhide": "แสดง",
"account_data": "ข้อมูลบัญชี",
"ban_duration_days": "ระยะเวลาแบนในวัน",
"challenge_answer": "คำตอบความท้าทาย",
"ban": "แบน",
"timestamp": "แสตมป์เวลา"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Sadece Worksafe Panoları Göster",
"show_all_content": "Tüm İçeriği Göster",
"link_type": "Bağlantı türü",
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> tarihinde yorum düzenlendi",
"reason_reason": "Sebep: \"{{reason}}\".",
"click_here_to_show_original": "<1>Buraya tıklayın</1> orijinali göstermek için.",
"click_here_to_hide_original": "<1>Buraya tıklayın</1> orijinali gizlemek için.",
"loading": "Yükleniyor",
"posted_by": "Tarafından gönderildi",
"by": "tarafından",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Bayrak"
"flag": "Bayrak",
"show_replies": "yanıtları göster",
"hide_replies": "yanıtları gizle",
"unhide": "göster",
"account_data": "hesap verileri",
"ban_duration_days": "ban süresi gün cinsinden",
"challenge_answer": "zorluk yanıtı",
"ban": "yasakla",
"timestamp": "zaman damgası"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Показувати лише безпечні дошки",
"show_all_content": "Показати весь вміст",
"link_type": "Тип посилання",
"comment_edited_at_timestamp": "Коментар відредаговано в <1>{{timestamp}}</1>",
"reason_reason": "Причина: \"{{reason}}\".",
"click_here_to_show_original": "<1>Натисніть тут</1> для відображення оригіналу.",
"click_here_to_hide_original": "<1>Натисніть тут</1> для приховання оригіналу.",
"loading": "Завантаження",
"posted_by": "Опубліковано",
"by": "від",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань</1>, щоб імпортувати свій обліковий запис модератора",
"post_form_rules_faq_prompt": "Будь ласка, прочитайте <rules>Правила</rules> і <faq>Часті запитання</faq> перед публікацією.",
"pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\".",
"flag": "Прапор"
"flag": "Прапор",
"show_replies": "показати відповіді",
"hide_replies": "приховати відповіді",
"unhide": "показати",
"account_data": "дані аккаунту",
"ban_duration_days": "тривалість бану в днях",
"challenge_answer": "відповідь на виклик",
"ban": "заблокувати",
"timestamp": "часова мітка"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "صرف کام سے بچائے گئے بورڈز دکھائیں",
"show_all_content": "تمام مواد دکھائیں",
"link_type": "لنک کی قسم",
"comment_edited_at_timestamp": "تبدیل کیا گیا تبصرہ <1>{{timestamp}}</1> پر",
"reason_reason": "وجہ: \"{{reason}}\".",
"click_here_to_show_original": "<1>یہاں کلک کریں</1> تاکہ اصل دکھائی دے۔",
"click_here_to_hide_original": "<1>یہاں کلک کریں</1> تاکہ اصل چھپ جائے۔",
"loading": "لوڈ ہو رہا ہے",
"posted_by": "پوسٹ کیا گیا",
"by": "کے ذریعے",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات</1> پر جائیں",
"post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے <rules>اصول</rules> اور <faq>اکثر پوچھے گئے سوالات</faq> پڑھیں۔",
"pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\".",
"flag": "پرچم"
"flag": "پرچم",
"show_replies": "جوابات دکھائیں",
"hide_replies": "جوابات چھپائیں",
"unhide": "دکھائیں",
"account_data": "اکاؤنٹ ڈیٹا",
"ban_duration_days": "بین کی مدت دنوں میں",
"challenge_answer": "چ یلنج کا جواب",
"ban": "روک",
"timestamp": "ٹائم سٹیمپ"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "Chỉ hiển thị bảng Worksafe",
"show_all_content": "Hiển thị tất cả nội dung",
"link_type": "Loại liên kết",
"comment_edited_at_timestamp": "Bình luận được chỉnh sửa lúc <1>{{timestamp}}</1>",
"reason_reason": "Lý do: \"{{reason}}\".",
"click_here_to_show_original": "<1>Nhấp vào đây</1> để hiển thị nguyên bản.",
"click_here_to_hide_original": "<1>Nhấp vào đây</1> để ẩn nguyên bản.",
"loading": "Đang tải",
"posted_by": "Được đăng bởi",
"by": "bởi",
@@ -393,5 +389,13 @@
"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}}\".",
"flag": "Cờ"
"flag": "Cờ",
"show_replies": "hiển thị câu trả lời",
"hide_replies": "ẩn các trả lời",
"unhide": "hiển thị",
"account_data": "dữ liệu tài khoản",
"ban_duration_days": "thời gian cấm theo ngày",
"challenge_answer": "câu trả lời thử thách",
"ban": "cấm",
"timestamp": "dấu thời gian"
}
+9 -5
View File
@@ -144,10 +144,6 @@
"show_worksafe_boards_only": "仅显示安全工作板",
"show_all_content": "显示所有内容",
"link_type": "链接类型",
"comment_edited_at_timestamp": "评论于 <1>{{timestamp}}</1> 被编辑",
"reason_reason": "原因: \"{{reason}}\".",
"click_here_to_show_original": "<1>点击这里</1> 显示原文。",
"click_here_to_hide_original": "<1>点击这里</1> 隐藏原文。",
"loading": "加载中",
"posted_by": "由...发布",
"by": "由...发布",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "前往<1>设置</1>以导入您的版主账户",
"post_form_rules_faq_prompt": "发帖前请阅读<rules>规则</rules>和<faq>常见问题</faq>。",
"pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。",
"flag": "旗帜"
"flag": "旗帜",
"show_replies": "显示回复",
"hide_replies": "隐藏回复",
"unhide": "显示",
"account_data": "账户数据",
"ban_duration_days": "封禁时长(天数)",
"challenge_answer": "挑战答案",
"ban": "封禁",
"timestamp": "时间戳"
}
+2 -2
View File
@@ -33,7 +33,7 @@ import {
import styles from './app.module.css';
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
import Blotter from './views/blotter';
import FAQ from './views/faq';
import Faq from './views/faq';
import Home from './views/home';
import Archive from './views/archive/archive';
import Directory from './views/directory/directory';
@@ -282,7 +282,7 @@ const App = () => {
<Routes>
<Route element={<GlobalLayout />}>
<Route path='/' element={<Home />} />
<Route path='/faq' element={<FAQ />} />
<Route path='/faq' element={<Faq />} />
<Route path='/pass' element={<Pass />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route path='/blotter' element={<Blotter />} />
@@ -34,6 +34,22 @@
text-transform: capitalize;
}
.hideButton {
appearance: none;
background: transparent;
border: 0;
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
}
.hideButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.hideButton:hover,
.actionLink:hover {
cursor: pointer;
@@ -19,8 +19,8 @@ const BoardBlotter = () => {
<div className={`${styles.content} ${styles.show}`}>
<table className={styles.blotter}>
<thead>
<tr>
<td>
<tr aria-label='Blotter separator'>
<td aria-label='Blotter divider'>
<hr />
</td>
</tr>
@@ -40,9 +40,9 @@ const BoardBlotter = () => {
<tr>
<td>
[
<span
<button
type='button'
className={styles.hideButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -53,7 +53,7 @@ const BoardBlotter = () => {
onClick={() => toggleVisibility()}
>
{isHidden ? t('show_blotter') : t('hide')}
</span>
</button>
]
{!isHidden && (
<>
+18 -9
View File
@@ -110,7 +110,7 @@ export const ArchiveButton = ({ address, isInAllView, isInSubscriptionsView, isI
}
return (
<button className='button' onClick={() => navigate(archivePath)}>
<button type='button' className='button' onClick={() => navigate(archivePath)}>
{t('archive')}
</button>
);
@@ -121,7 +121,7 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
return (
<button className='button' onClick={subscribed ? unsubscribe : subscribe}>
<button type='button' className='button' onClick={subscribed ? unsubscribe : subscribe}>
{subscribed ? t('unsubscribe') : t('subscribe')}
</button>
);
@@ -186,7 +186,7 @@ export const RefreshButton = () => {
const { t } = useTranslation();
const reset = useFeedResetStore((state) => state.reset);
return (
<button className='button' onClick={() => reset && reset()}>
<button type='button' className='button' onClick={() => reset && reset()}>
{t('refresh')}
</button>
);
@@ -250,7 +250,7 @@ export const UpdateButton = () => {
const { t } = useTranslation();
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
return (
<button className='button' onClick={() => requestUpdate()}>
<button type='button' className='button' onClick={() => requestUpdate()}>
{t('update')}
</button>
);
@@ -281,7 +281,7 @@ export const BottomButton = () => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
};
return (
<button className='button' onClick={scrollToBottom}>
<button type='button' className='button' onClick={scrollToBottom}>
{t('bottom')}
</button>
);
@@ -293,7 +293,7 @@ export const TopButton = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
};
return (
<button className='button' onClick={scrollToTop}>
<button type='button' className='button' onClick={scrollToTop}>
{t('top')}
</button>
);
@@ -381,6 +381,7 @@ const ModQueueAlertThreshold = () => {
{t('alert_threshold')}:
<input
type='number'
aria-label={t('alert_threshold')}
min='1'
step='1'
value={alertThresholdValue}
@@ -670,7 +671,7 @@ export const CatalogSearchResultsLabel = () => {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('search_results_for')}: <strong>{searchText}</strong>
({t('search_results_for')}: <strong>{searchText}</strong>)
</span>
);
}
@@ -679,7 +680,7 @@ export const CatalogSearchResultsLabel = () => {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong>
({t('filtered_threads')}: <strong>{filteredCount}</strong>)
</span>
);
}
@@ -865,5 +866,13 @@ const SearchOPsBar = () => {
}
};
return <input type='text' placeholder={t('search_ops_placeholder', 'Search OPs...')} onKeyDown={handleSearch} className={styles.searchOPsInput} />;
return (
<input
type='text'
aria-label={t('search_ops_placeholder', 'Search OPs...')}
placeholder={t('search_ops_placeholder', 'Search OPs...')}
onKeyDown={handleSearch}
className={styles.searchOPsInput}
/>
);
};
@@ -177,7 +177,7 @@ describe('BoardHeader', () => {
expect(container.textContent).toContain('/subs/ - Subscriptions');
expect(container.textContent).toContain('subscriptions_subtitle:{"count":2}');
const clickableSubtitle = container.querySelector('[role="button"]');
const clickableSubtitle = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('subscriptions_subtitle'));
await act(async () => {
clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
@@ -25,14 +25,27 @@
}
.clickableSubtitle {
appearance: none;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-block;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
}
.clickableSubtitle:hover {
text-decoration: underline;
}
.clickableSubtitle:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.content hr {
width: 90%;
}
+3 -3
View File
@@ -110,9 +110,9 @@ const BoardHeader = () => {
</div>
<div className={styles.boardSubtitle}>
{isInSubscriptionsView ? (
<span
<button
type='button'
className={styles.clickableSubtitle}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -123,7 +123,7 @@ const BoardHeader = () => {
onClick={() => navigate('/subs/settings#subscriptions-settings')}
>
{subtitle}
</span>
</button>
) : isInDirectoryListView ? (
<span>{subtitle}</span>
) : !isInAllView && !isInModView && subtitle ? (
@@ -90,7 +90,7 @@ describe('BoardPagination', () => {
expect(container.textContent).toContain('catalog');
expect(container.textContent).toContain('archive');
const allLink = Array.from(container.querySelectorAll<HTMLElement>('[role="button"]')).find((element) => element.textContent === 'all');
const allLink = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === 'all');
expect(allLink).toBeTruthy();
await act(async () => {
@@ -45,9 +45,9 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
{currentPage === 1 && (
<span className={styles.footerPageItem}>
<span className={styles.footerPageBracket}>[</span>
<span
<button
type='button'
className={styles.footerPageLink}
role='button'
tabIndex={0}
onClick={() => setEnableInfiniteScroll(true)}
onKeyDown={(e) => {
@@ -58,7 +58,7 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
}}
>
{t('all')}
</span>
</button>
<span className={styles.footerPageBracket}>]</span>
</span>
)}
@@ -56,14 +56,14 @@ describe('BoardsBarEditModal', () => {
input?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
expect(container.querySelector('dialog')).toBeTruthy();
expect(useBoardsBarEditModalStore.getState().showModal).toBe(true);
});
it('still closes when the backdrop itself handles keyboard activation', async () => {
await renderModal();
const backdrop = container.querySelector<HTMLElement>('[role="button"]');
const backdrop = container.querySelector<HTMLElement>('button[aria-label="Close board list modal"]');
expect(backdrop).toBeTruthy();
await act(async () => {
@@ -8,6 +8,14 @@
z-index: 999;
}
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.boardsbarEditDialog {
position: absolute;
width: 400px;
@@ -25,9 +33,12 @@
box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 5px 0px;
font-size: var(--body-font-size);
line-height: normal;
z-index: 1000;
z-index: 1;
display: flex;
flex-direction: column;
color: inherit;
margin-top: 0;
margin-bottom: 0;
}
@media (max-width: 600px) {
@@ -71,7 +71,13 @@ const BoardsBarEditModalForm = ({
{subscriptions.length > 0 && (
<div className={styles.section}>
<div className={styles.checkboxItem}>
<input type='checkbox' id='show-subscriptions' checked={showSubscriptions} onChange={(e) => setShowSubscriptions(e.target.checked)} />
<input
type='checkbox'
id='show-subscriptions'
aria-label='show subscriptions'
checked={showSubscriptions}
onChange={(e) => setShowSubscriptions(e.target.checked)}
/>
<label htmlFor='show-subscriptions'>show subscriptions</label>
<span className={styles.editSubscriptionsWrapper}>
(
@@ -91,7 +97,9 @@ const BoardsBarEditModalForm = ({
</div>
)}
<div className={styles.boardsbarEditFooter}>
<button onClick={handleSave}>Save</button>
<button type='button' onClick={handleSave}>
Save
</button>
</div>
</>
);
@@ -108,28 +116,23 @@ const BoardsBarEditModal = () => {
return null;
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeBoardsBarEditModal();
}
};
const formKey = `${directoriesToString(visibleDirectories)}-${showSubscriptionsInBoardsBar}`;
return (
<div
className={styles.backdrop}
role='button'
tabIndex={0}
<div className={styles.backdrop}>
<button
type='button'
className={styles.backdropButton}
aria-label='Close board list modal'
onClick={closeBoardsBarEditModal}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeBoardsBarEditModal();
}
}}
onClick={handleBackdropClick}
>
<div className={styles.boardsbarEditDialog} role='dialog' aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
/>
<dialog open className={styles.boardsbarEditDialog} aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
<div className={styles.hd}>
<h2 id='boards-bar-edit-modal-title'>Custom Board List</h2>
<button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' />
@@ -146,7 +149,7 @@ const BoardsBarEditModal = () => {
location={location}
/>
</div>
</div>
</dialog>
</div>
);
};
@@ -220,7 +220,7 @@ describe('BoardsBar', () => {
await renderBoardsBar('/mu');
await act(async () => {
findExactText('search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
findExactText('Search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
@@ -5,13 +5,30 @@
position: relative;
}
.boardNavDesktop button, .boardNavMobile button {
appearance: none;
background: transparent;
border: 0;
color: inherit;
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
}
.boardNavDesktop button:focus-visible, .boardNavMobile button:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.checkingSubscriptions {
font-size: var(--boardsbar-font-size);
color: var(--boardsbar-desktop-link-text-color);
text-transform: lowercase;
}
.boardNavDesktop a, .navTopRight span {
.boardNavDesktop a, .navTopRight span, .navTopRight button {
color: var(--boardsbar-desktop-link-text-color);
text-decoration: var(--boardsbar-desktop-link-text-decoration);
}
@@ -20,13 +37,13 @@
padding: 0 1px;
}
.boardNavDesktop a:hover, .navTopRight span:hover {
.boardNavDesktop a:hover, .navTopRight span:hover, .navTopRight button:hover {
color: var(--boardsbar-desktop-link-text-color-hover);
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
cursor: pointer;
}
.placeholder {
.boardNavDesktop .placeholder {
color: var(--boardsbar-separator-color);
}
@@ -45,13 +62,17 @@
margin-right: -7px;
}
.temporaryButton {
.boardNavDesktop .temporaryButton {
color: var(--boardsbar-desktop-link-text-color);
text-decoration: var(--boardsbar-desktop-link-text-decoration);
text-transform: capitalize;
}
.temporaryButton:hover {
.navTopRight button {
text-transform: capitalize;
}
.boardNavDesktop .temporaryButton:hover {
color: var(--boardsbar-desktop-link-text-color-hover);
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
cursor: pointer;
@@ -80,7 +101,7 @@
justify-content: space-around;
}
.boardNavMobile .pageJump a, .boardNavMobile .pageJump span {
.boardNavMobile .pageJump a, .boardNavMobile .pageJump span, .boardNavMobile .pageJump button {
color: var(--boardsbar-mobile-button-text-color);
text-decoration: var(--button-text-decoration-mobile);
}
@@ -108,7 +129,7 @@
padding: 3px 5px 0 0;
}
.pageJump a, .pageJump span {
.pageJump a, .pageJump span, .pageJump button {
padding-right: 5px;
text-decoration: var(--homepage-box-link-text-decoration);
cursor: pointer;
+31 -22
View File
@@ -70,7 +70,16 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
return (
<div className={styles.searchBar} ref={searchBarRef}>
<form onSubmit={handleSearchSubmit}>
<input type='text' autoCorrect='off' autoComplete='off' spellCheck='false' autoCapitalize='off' placeholder={placeholder} ref={searchInputRef} />
<input
type='text'
aria-label={placeholder}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
autoCapitalize='off'
placeholder={placeholder}
ref={searchInputRef}
/>
</form>
</div>
);
@@ -154,9 +163,9 @@ const BoardsBarDesktop = () => {
const linkContent = (
<>
{isPlaceholder ? (
<span
<button
type='button'
className={styles.placeholder}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -168,7 +177,7 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }}
>
{code}
</span>
</button>
) : (
<Link to={`/${code}${catalogSuffix}`} onClick={openDirectoryForPlaceholder}>
{code}
@@ -209,19 +218,19 @@ const BoardsBarDesktop = () => {
</>
)}
]{' '}
{BOARD_CODE_GROUPS.map((group, groupIndex) => {
{BOARD_CODE_GROUPS.map((group) => {
const visibleCodes = group.filter((code) => directoriesToShow.has(code));
if (visibleCodes.length === 0) return null;
return <span key={groupIndex}>[{visibleCodes.map((code, codeIndex) => renderBoardCode(code, codeIndex === visibleCodes.length - 1))}] </span>;
return <span key={group.join('|')}>[{visibleCodes.map((code, codeIndex) => renderBoardCode(code, codeIndex === visibleCodes.length - 1))}] </span>;
})}
{hasHiddenDirectories && !showAllTemporarily && (
<>
{' '}
[
<span
<button
type='button'
className={styles.temporaryButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -234,7 +243,7 @@ const BoardsBarDesktop = () => {
title='Show all'
>
...
</span>
</button>
]{' '}
</>
)}
@@ -242,9 +251,9 @@ const BoardsBarDesktop = () => {
<>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] </>
)}
[
<span
<button
type='button'
className={styles.temporaryButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -256,11 +265,11 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }}
>
{capitalize(t('edit'))}
</span>
</button>
] [
<span
<button
type='button'
className={styles.temporaryButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -272,13 +281,13 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }}
>
{t('create_board')}
</span>
</button>
]
</span>
<span className={styles.navTopRight}>
[<Link to={!location.pathname.endsWith('settings') ? location.pathname.replace(/\/$/, '') + '/settings' : location.pathname}>{t('settings')}</Link>] [
<span
role='button'
<button
type='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -288,8 +297,8 @@ const BoardsBarDesktop = () => {
}}
onClick={() => setShowSearchBar(!showSearchBar)}
>
{t('search')}
</span>
{capitalize(t('search'))}
</button>
] [<Link to='/'>{t('home')}</Link>]
</span>
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
@@ -387,8 +396,8 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
</div>
<div className={styles.pageJump}>
<Link to={location.pathname.replace(/\/$/, '') + '/settings'}>{t('settings')}</Link>
<span
role='button'
<button
type='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -399,7 +408,7 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
onClick={() => setShowSearchBar(!showSearchBar)}
>
{capitalize(t('search'))}
</span>
</button>
<Link to='/'>{t('home')}</Link>
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
</div>
@@ -122,7 +122,7 @@ const renderCatalogFilters = () => {
};
const openModal = async () => {
const button = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.textContent === 'filters');
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'filters');
await click(button ?? null);
};
@@ -176,7 +176,7 @@ describe('CatalogFilters', () => {
expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip');
expect(container.textContent).toContain('filter_and_highlights_help');
const overlay = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.tagName === 'DIV');
const overlay = container.querySelector<HTMLButtonElement>('button[class*="overlay"]');
await click(overlay ?? null);
expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull();
@@ -220,14 +220,15 @@ describe('CatalogFilters', () => {
expect(container.textContent).toContain('x4');
const initialRows = getRows();
const moveUpButton = initialRows[1]?.querySelector('[role="button"]');
const moveUpButton = initialRows[1]?.querySelector('button');
await click(moveUpButton ?? null);
const reorderedInputs = getTextInputs();
expect(reorderedInputs[0]?.value).toBe('beta');
expect(reorderedInputs[1]?.value).toBe('alpha');
const deleteButton = getRows()[1]?.querySelectorAll('[role="button"]')[1];
const rowButtons = Array.from(getRows()[1]?.querySelectorAll('button') ?? []);
const deleteButton = rowButtons.at(-1);
await click(deleteButton ?? null);
expect(getTextInputs()).toHaveLength(1);
@@ -1,4 +1,5 @@
.overlay {
all: unset;
position: fixed;
top: 0;
left: 0;
@@ -88,6 +89,11 @@
}
.closeButton {
appearance: none;
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
right: 5px;
width: 18px;
height: 18px;
@@ -101,6 +107,11 @@
}
.openHelpButton {
appearance: none;
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
right: 23px;
width: 18px;
height: 18px;
@@ -113,6 +124,11 @@
cursor: pointer;
}
.closeButton:focus-visible, .openHelpButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.filters {
padding: 5px;
text-align: left;
@@ -190,6 +206,16 @@
cursor: pointer;
}
.filtersTable button {
appearance: none;
background: transparent;
border: 0;
color: inherit;
font: inherit;
margin: 0;
padding: 0;
}
.filtersTable tfoot td {
padding-top: 20px;
}
@@ -152,9 +152,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
{localFilterItems.map((item, index) => (
<tr key={item.id ?? index}>
<td>
<span
<button
type='button'
className={styles.orderButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -165,11 +165,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onClick={() => moveLocalFilterItemUp(index)}
>
</span>
</button>
</td>
<td>
<input
type='checkbox'
aria-label={`Enable filter ${index + 1}`}
className={styles.onCheckbox}
checked={item.enabled}
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
@@ -178,6 +179,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
<td>
<input
type='text'
aria-label={`Filter text ${index + 1}`}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
@@ -192,15 +194,25 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
</td>
<td>
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
<input
type='checkbox'
aria-label={`Hide matches for filter ${index + 1}`}
checked={item.hide}
onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })}
/>
</td>
<td>
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
<input
type='checkbox'
aria-label={`Pin matches for filter ${index + 1}`}
checked={item.top}
onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })}
/>
</td>
<td>
<span
<button
type='button'
className={styles.deleteButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -211,7 +223,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onClick={() => removeLocalFilterItem(index)}
>
×
</span>
</button>
</td>
<td className={styles.filterHits}>
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`}
@@ -259,9 +271,10 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
return (
<>
<div
<button
type='button'
aria-label={t('close')}
className={styles.overlay}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -279,10 +292,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
<div className={styles.header}>
<span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span>
{!showHelp && (
<span
<button
type='button'
className={styles.openHelpButton}
aria-label={t('help')}
title={t('help')}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -293,10 +307,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
onClick={openHelp}
/>
)}
<span
<button
type='button'
className={styles.closeButton}
aria-label={t('close')}
title={t('close')}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -323,9 +338,9 @@ const CatalogFilters = () => {
return (
<>
<span
<button
type='button'
className={`${styles.filtersButton} button`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -336,7 +351,7 @@ const CatalogFilters = () => {
onClick={() => setShowModal(true)}
>
{t('filters')}
</span>
</button>
{showModal && <FiltersModal closeModal={closeModal} />}
</>
);
@@ -1,4 +1,16 @@
.colorPickerBackdrop {
all: unset;
cursor: default;
position: fixed;
inset: 0;
z-index: 30;
}
.colorClickbox {
appearance: none;
padding: 0;
margin: 0;
box-sizing: border-box;
width: 16px;
height: 16px;
line-height: 16px;
@@ -35,6 +47,9 @@
}
.colorOption {
appearance: none;
padding: 0;
box-sizing: border-box;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
margin: auto;
width: 16px;
@@ -52,6 +67,9 @@
}
.colorPreview {
appearance: none;
padding: 0;
box-sizing: border-box;
display: inline-block;
width: 16px;
height: 16px;
@@ -100,6 +118,11 @@
}
.colorPickerModal tfoot .button {
appearance: none;
background: transparent;
border: 0;
font: inherit;
padding: 0;
color: var(--button-desktop-text-color);
cursor: pointer;
margin: 0 2px;
@@ -118,3 +141,11 @@
content: ']';
color: var(--body-font-color);
}
.colorClickbox:focus-visible,
.colorOption:focus-visible,
.colorPreview:focus-visible,
.colorPickerModal tfoot .button:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
@@ -57,10 +57,11 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
return (
<>
<span
<button
type='button'
aria-label={`Select filter color ${index + 1}`}
className={styles.colorClickbox}
style={{ backgroundColor: item.color || '#fff' }}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -72,20 +73,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
{...getReferenceProps()}
>
{!item.color && ''}
</span>
</button>
{isColorPickerOpen && (
<FloatingPortal>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
role='button'
<button
type='button'
aria-label='Close color picker'
className={styles.colorPickerBackdrop}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -111,14 +106,15 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
>
<table className={styles.colorGrid}>
<tbody>
{colorRows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((color, colorIndex) => (
<td key={colorIndex}>
<span
{colorRows.map((row) => (
<tr key={row.join('|')}>
{row.map((color) => (
<td key={color}>
<button
type='button'
aria-label={`Use color ${color}`}
className={styles.colorOption}
style={{ backgroundColor: color }}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -139,13 +135,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
</tr>
<tr>
<td className={styles.middleTxt} colSpan={4}>
<input type='text' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
<span
<input type='text' aria-label='Custom highlight color' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
<button
type='button'
aria-label='Apply custom highlight color'
className={styles.colorPreview}
style={{
backgroundColor: customColor || '#fff',
}}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -159,9 +156,9 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
</tr>
<tr>
<td colSpan={4}>
<span
<button
type='button'
className={styles.button}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -172,10 +169,10 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
onClick={() => setIsColorPickerOpen(false)}
>
<span className={styles.buttonText}>Close</span>
</span>
<span
</button>
<button
type='button'
className={styles.button}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -186,7 +183,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
onClick={clearColor}
>
<span className={styles.buttonText}>Clear</span>
</span>
</button>
</td>
</tr>
</tfoot>
+14 -7
View File
@@ -92,14 +92,14 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
<img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />
) : (
// show first frame of the video, as a workaround for Safari not loading thumbnails
<video src={`${url}#t=0.001`} onError={handleError} />
<video src={`${url}#t=0.001`} aria-label='Video thumbnail' onError={handleError} />
);
} else if (type === 'webpage' && !hasError) {
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'iframe' && iframeThumbnail && !hasError) {
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'audio') {
thumbnailComponent = <audio src={url} controls />;
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
}
return (
@@ -238,7 +238,7 @@ const CatalogPost = memo(
return (
<>
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
<div onMouseOver={() => setHoveredCid(cid)} onFocus={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)} onBlur={() => setHoveredCid(null)}>
{shouldMaskPost ? (
<Link to={postLink}>
<span className={styles.hiddenThumbnail} />
@@ -246,11 +246,10 @@ const CatalogPost = memo(
) : hasThumbnail ? (
<>
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
<Link to={postLink}>
<div
className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`}
ref={refs.setReference}
<Link
to={postLink}
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
onFocus={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
onMouseLeave={() => {
setShowPortal(false);
if (timeoutRef.current) {
@@ -258,7 +257,15 @@ const CatalogPost = memo(
timeoutRef.current = null;
}
}}
onBlur={() => {
setShowPortal(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}}
>
<div className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`} ref={refs.setReference}>
{threadIcons}
{spoiler ? (
<img src='assets/spoiler.png' alt='' />
@@ -72,8 +72,8 @@ const clickElement = async (element: Element | null) => {
});
};
const querySearchButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'search') ?? null;
const queryCloseButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === '✖') ?? null;
const querySearchButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'search') ?? null;
const queryCloseButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '✖') ?? null;
const queryInput = () => container.querySelector('input');
const dispatchInput = async (element: HTMLInputElement, value: string) => {
@@ -31,8 +31,13 @@
}
.closeSearch {
appearance: none;
background: transparent;
cursor: pointer;
border: none;
font: inherit;
margin: 0;
padding: 0;
white-space: nowrap;
color: var(--button-desktop-text-color, revert);
}
@@ -41,6 +46,11 @@
color: var(--button-desktop-text-color-hover, revert);
}
.closeSearch:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
@media (max-width: 640px) {
.searchContainer input {
width: 75px;
@@ -85,9 +85,9 @@ const CatalogSearch = () => {
return (
<>
{!isMobile && '['}
<span
<button
type='button'
className={`${styles.filtersButton} button`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -98,13 +98,14 @@ const CatalogSearch = () => {
onClick={handleToggleSearch}
>
{t('search')}
</span>
</button>
{!isMobile && ']'}
{openSearch && (
<div className={styles.searchContainer}>
<input
ref={(el) => el?.focus()}
type='text'
aria-label={t('search')}
value={inputValue}
onChange={handleSearchChange}
onKeyDown={(e) => {
@@ -113,9 +114,9 @@ const CatalogSearch = () => {
}
}}
/>
<span
<button
type='button'
className={styles.closeSearch}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -126,7 +127,7 @@ const CatalogSearch = () => {
onClick={handleCloseSearch}
>
</span>
</button>
</div>
)}
</>
@@ -318,10 +318,10 @@ describe('ChallengeModal', () => {
'https://mintpass.org',
);
const doneButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Done');
const doneButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Finish Challenge');
expect(doneButton?.getAttribute('aria-label')).toBe('Finish challenge');
await clickButton('Done');
await clickButton('Finish Challenge');
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
@@ -105,6 +105,15 @@ interface IframeChallengeProps {
}
type IframeOpenMode = 'trusted' | 'manual';
type IframeFrameState = { url: string; origin: string };
const getIframeSandbox = (iframeOrigin: string) => {
const permissions = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-top-navigation-by-user-activation'];
if (iframeOrigin !== window.location.origin) {
permissions.splice(3, 0, 'allow-same-origin');
}
return permissions.join(' ');
};
const IframeChallenge = ({
challenge,
@@ -121,8 +130,7 @@ const IframeChallenge = ({
const account = useAccount();
const [theme] = useTheme();
const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin);
const [iframeUrlState, setIframeUrl] = useState('');
const [iframeOrigin, setIframeOrigin] = useState('');
const [iframeFrame, setIframeFrame] = useState<IframeFrameState | null>(null);
const [inlineErrorMessage, setInlineErrorMessage] = useState('');
const [rememberPermission, setRememberPermission] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -144,8 +152,7 @@ const IframeChallenge = ({
(validatedUrl: { finalUrl: string; origin: string }) => {
queueMicrotask(() => {
if (!mountedRef.current) return;
setIframeUrl(validatedUrl.finalUrl);
setIframeOrigin(validatedUrl.origin);
setIframeFrame({ url: validatedUrl.finalUrl, origin: validatedUrl.origin });
onReady();
});
},
@@ -211,20 +218,21 @@ const IframeChallenge = ({
}, [onCancel]);
const sendThemeToIframe = useCallback(() => {
postThemeToIframe(iframeRef.current, iframeOrigin, theme);
}, [iframeOrigin, theme]);
postThemeToIframe(iframeRef.current, iframeFrame?.origin ?? '', theme);
}, [iframeFrame?.origin, theme]);
const handleIframeLoad = () => {
sendThemeToIframe();
};
useEffect(() => {
if (iframeRef.current && iframeUrlState && iframeOrigin) {
if (iframeRef.current && iframeFrame) {
sendThemeToIframe();
}
}, [iframeOrigin, iframeUrlState, sendThemeToIframe]);
}, [iframeFrame, sendThemeToIframe]);
useEffect(() => {
const iframeOrigin = iframeFrame?.origin ?? '';
if (!iframeOrigin || !expectedSessionId) {
handledAutoCompleteRef.current = false;
return;
@@ -246,9 +254,9 @@ const IframeChallenge = ({
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [expectedSessionId, iframeOrigin]);
}, [expectedSessionId, iframeFrame?.origin]);
if (!iframeUrlState) {
if (!iframeFrame) {
return (
<>
{publicationDetails}
@@ -256,7 +264,12 @@ const IframeChallenge = ({
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
{!inlineErrorMessage && (
<label className={styles.iframeTrustCheckbox}>
<input type='checkbox' checked={rememberPermission} onChange={(event) => setRememberPermission(event.target.checked)} />
<input
type='checkbox'
aria-label={rememberPermissionLabel}
checked={rememberPermission}
onChange={(event) => setRememberPermission(event.target.checked)}
/>
{rememberPermissionLabel}
</label>
)}
@@ -282,8 +295,8 @@ const IframeChallenge = ({
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
<iframe
ref={iframeRef}
src={iframeUrlState}
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'
src={iframeFrame.url}
sandbox={getIframeSandbox(iframeFrame.origin)}
onLoad={handleIframeLoad}
className={styles.iframe}
title='Challenge authentication'
@@ -291,8 +304,8 @@ const IframeChallenge = ({
</div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<div className={styles.iframeCloseButton}>
<button aria-label='Finish challenge' onClick={onDone}>
Done
<button type='button' aria-label='Finish challenge' onClick={onDone}>
Finish Challenge
</button>
</div>
</div>
@@ -473,21 +486,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const publicationDetails = (
<>
<div className={styles.name}>
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled readOnly />
<input type='text' aria-label={capitalize(t('name'))} value={displayName || capitalize(t('anonymous'))} disabled readOnly />
</div>
{title && (
<div className={styles.subject}>
<input type='text' value={title} disabled readOnly />
<input type='text' aria-label={capitalize(t('subject'))} value={title} disabled readOnly />
</div>
)}
{content && (
<div className={styles.content}>
<textarea value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
<textarea aria-label={capitalize(t('comment'))} value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
</div>
)}
{link && (
<div className={styles.link}>
<input type='text' value={link} disabled readOnly />
<input type='text' aria-label={capitalize(t('link'))} value={link} disabled readOnly />
</div>
)}
</>
@@ -533,6 +546,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
ref={inputRef}
className={styles.challengeAnswer}
type='text'
aria-label={t('challenge_answer')}
autoComplete='off'
autoCorrect='off'
spellCheck='false'
@@ -550,17 +564,23 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
<div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
<span className={styles.buttons}>
{!challenges?.[currentChallengeIndex + 1] && (
<button onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
<button type='button' onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
{t('submit')}
</button>
)}
<button onClick={abandonModal}>Cancel</button>
<button type='button' onClick={abandonModal}>
Cancel
</button>
{challenges?.length > 1 && (
<button disabled={!challenges?.[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
<button type='button' disabled={!challenges?.[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
{t('previous')}
</button>
)}
{challenges?.[currentChallengeIndex + 1] && <button onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>{t('next')}</button>}
{challenges?.[currentChallengeIndex + 1] && (
<button type='button' onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>
{t('next')}
</button>
)}
</span>
</div>
</>
@@ -393,41 +393,6 @@ describe('CommentContent', () => {
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('title')).toContain('ban:short:music-posting.eth:2024-01-01 12:00:00');
});
it('shows and hides the original content for edited comments', async () => {
await renderContent({
cid: 'post-1',
content: 'edited body',
edit: {
timestamp: 1_704_067_200,
},
original: {
content: 'original body',
},
postCid: 'post-1',
reason: 'typo',
});
expect(queryMarkdownText()).toEqual(['edited body']);
expect(container.textContent).toContain('comment_edited_at_timestamp:2024-01-01 12:00:00');
expect(container.textContent).toContain('reason:typo');
const showOriginal = container.querySelector('[data-testid="trans-action-click_here_to_show_original"]');
expect(showOriginal).toBeTruthy();
await act(async () => {
showOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(queryMarkdownText()).toEqual(['original body']);
const hideOriginal = container.querySelector('[data-testid="trans-action-click_here_to_hide_original"]');
await act(async () => {
hideOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(queryMarkdownText()).toEqual(['edited body']);
});
it('renders moderation and deletion states with the expected messaging', async () => {
await renderContent({
cid: 'post-1',
@@ -1,21 +1,21 @@
import { Fragment, type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import usePostNumberStore from '../../stores/use-post-number-store';
import getShortAddress from '../../lib/get-short-address';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { getFormattedDate } from '../../lib/utils/time-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import { isPostPageView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useStateString from '../../hooks/use-state-string';
import LoadingEllipsis from '../../components/loading-ellipsis';
import LoadingEllipsis from '../loading-ellipsis';
import BbcodeContent from '../../components/bbcode-content/bbcode-content';
import ErrorDisplay from '../../components/error-display/error-display';
import ReplyQuotePreview from '../../components/reply-quote-preview';
import Markdown from '../../components/markdown';
import Tooltip from '../../components/tooltip';
import ReplyQuotePreview from '../reply-quote-preview';
import Markdown from '../markdown';
import Tooltip from '../tooltip';
import styles from '../../views/post/post.module.css';
import capitalize from 'lodash/capitalize';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
@@ -95,15 +95,15 @@ const CommentContent = ({
const params = useParams();
const location = useLocation();
const isInPostView = isPostPageView(location.pathname, params);
const [showOriginal, setShowOriginal] = useState(false);
const isMobile = useIsMobile();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { cid, content, deleted, edit, original, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {};
const { cid, content, deleted, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost);
const authorAddress = resolvedPost?.author?.address;
const authorRole = getRoleByAddress(roles, authorAddress);
const shouldRenderBbcode = hasModQueueAccessRole(authorRole);
const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
const shouldRenderBbcode = isPrivilegedAuthor;
const purged = resolvedPost?.commentModeration?.purged;
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
const banned = !!banExpiresAt;
@@ -111,9 +111,9 @@ const CommentContent = ({
const [showFullComment, setShowFullComment] = useState(false);
const displayContent =
content &&
(!isInPostView && content.length > 1000 && !showFullComment
(!isInPostView && content.length > 1000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 1000)
: isInPostView && content.length > 2000 && !showFullComment
: isInPostView && content.length > 2000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 2000)
: content);
@@ -214,7 +214,7 @@ const CommentContent = ({
)
) : (
<>
{!showOriginal && renderContent(displayContent)}
{displayContent && renderContent(displayContent)}
{pendingApproval && (
<>
<br />
@@ -234,7 +234,7 @@ const CommentContent = ({
)}
</>
)}
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && (
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && !isPrivilegedAuthor && (
<span className={styles.abbr}>
<br />
<br />
@@ -243,9 +243,10 @@ const CommentContent = ({
shouldUnescape={true}
components={{
1: (
<span
<button
type='button'
key={cid}
role='button'
aria-label={t('view')}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -260,71 +261,6 @@ const CommentContent = ({
/>
</span>
)}
{edit && original?.content !== content && (
<span className={styles.editedInfo}>
{showOriginal && renderContent(original?.content)}
<br />
<br />
<Trans
i18nKey={'comment_edited_at_timestamp'}
values={{ timestamp: getFormattedDate(edit?.timestamp) }}
shouldUnescape={true}
components={{
1: (
<Tooltip key={edit?.timestamp} content={getFormattedTimeAgo(edit?.timestamp)}>
<Fragment key={edit?.timestamp}></Fragment>
</Tooltip>
),
}}
/>{' '}
{reason && <>{t('reason_reason', { reason: reason, interpolation: { escapeValue: false } })} </>}
{showOriginal ? (
<Trans
i18nKey={'click_here_to_hide_original'}
shouldUnescape={true}
components={{
1: (
<span
key={cid}
className={styles.showOriginal}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowOriginal(!showOriginal);
}
}}
onClick={() => setShowOriginal(!showOriginal)}
/>
),
}}
/>
) : (
<Trans
i18nKey={'click_here_to_show_original'}
shouldUnescape={true}
components={{
1: (
<span
key={cid}
className={styles.showOriginal}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowOriginal(!showOriginal);
}
}}
onClick={() => setShowOriginal(!showOriginal)}
/>
),
}}
/>
)}
</span>
)}
</>
)}
{banned && (
@@ -174,7 +174,7 @@ describe('CommentMedia', () => {
expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
expect(onMediaLoadFailureChange).toHaveBeenCalledWith('https://files.catbox.moe/missing.jpg');
const status = container.querySelector('[role="status"]');
const status = container.querySelector('output');
expect(status?.textContent).toContain('media_failed_to_load_inline:files.catbox.moe');
expect(status?.textContent).toContain('media_failed_to_load_open_source');
expect(status?.textContent).not.toContain('media_failed_to_load_hint');
@@ -209,7 +209,7 @@ describe('CommentMedia', () => {
image?.dispatchEvent(new Event('error', { bubbles: true }));
});
const status = container.querySelector('[role="status"]');
const status = container.querySelector('output');
expect(status?.textContent).toBe('');
expect(status?.getAttribute('aria-label')).toBe(
'media_failed_to_load. media_failed_to_load_source:files.catbox.moe. media_failed_to_load_hint. media_failed_to_load_open_source.',
@@ -273,7 +273,7 @@ describe('CommentMedia', () => {
expect(container.textContent).toContain('example.com');
const fallbackButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'example.com' && node.getAttribute('role') === 'button');
const fallbackButton = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'example.com');
await act(async () => {
fallbackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
@@ -296,7 +296,7 @@ describe('CommentMedia', () => {
expect(container.querySelector('[data-testid="embed"]')?.textContent).toContain('https://youtu.be/test');
const closeButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'close');
const closeButton = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'close');
expect(closeButton).toBeTruthy();
await act(async () => {
@@ -55,14 +55,31 @@
}
.thumbnailSmall span,
.thumbnailSmall a {
.thumbnailSmall a,
.thumbnailSmall .noThumbnailButton {
width: 100%;
display: inline-block;
word-wrap: break-word;
overflow-wrap: break-word;
text-align: center;
cursor: pointer;
}
.noThumbnailButton {
appearance: none;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
margin: 0;
padding: 0;
}
.noThumbnailButton:focus-visible {
outline: 1px dotted var(--post-link-text-color);
outline-offset: 1px;
}
.thumbnail {
float: left;
}
@@ -75,6 +92,33 @@
cursor: pointer;
}
.mediaToggleButton {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
max-height: 100%;
line-height: 0;
}
.mediaToggleButton:focus-visible {
outline: 1px dotted var(--post-link-text-color);
outline-offset: 1px;
}
.mediaToggleButton img,
.mediaToggleButton video {
display: block;
}
.thumbnailBig > .mediaToggleButton,
.thumbnailSmall > .mediaToggleButton {
width: 100%;
height: 100%;
}
.hide {
display: none !important;
}
+67 -178
View File
@@ -50,13 +50,13 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
return (
<>
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
<span className={styles.mediaLoadFailureStatus} role='status' aria-label={statusLabel} title={statusLabel} />
<output className={styles.mediaLoadFailureStatus} aria-label={statusLabel} title={statusLabel} />
</>
);
}
return (
<span className={styles.mediaLoadFailure} role='status' aria-label={statusLabel} title={statusLabel}>
<output className={styles.mediaLoadFailure} aria-label={statusLabel} title={statusLabel}>
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
<span className={styles.mediaLoadFailureSource}>{inline}</span>
{url && (
@@ -64,7 +64,7 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
{openSourceLabel}
</a>
)}
</span>
</output>
);
};
@@ -109,92 +109,40 @@ const Thumbnail = ({
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
const gifThumbnailButtonProps =
gifFrameStatus === 'loading'
? {
role: 'button' as const,
tabIndex: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
},
onClick: () => setShowThumbnail(false),
}
: {};
const handleOpenMedia = () => setShowThumbnail(false);
if (type === 'gif') {
thumbnailComponent =
gifFrameStatus === 'loading' ? (
<span className={styles.gifPlaceholder} aria-label='Loading GIF thumbnail' {...gifThumbnailButtonProps} />
<button type='button' className={`${styles.gifPlaceholder} ${styles.mediaToggleButton}`} aria-label='Loading GIF thumbnail' onClick={handleOpenMedia} />
) : (
<img
src={gifFrameUrl || url}
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open GIF' onClick={handleOpenMedia}>
<img src={gifFrameUrl || url} alt='' />
</button>
);
} else if (type === 'video') {
thumbnailComponent = thumbnail ? (
<img src={thumbnail} alt='' />
<img src={thumbnail} alt='Video thumbnail' />
) : (
// show first frame of the video, as a workaround for Safari not loading thumbnails
<video
src={`${url}#t=0.001`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open video' onClick={handleOpenMedia}>
<video src={`${url}#t=0.001`} aria-label='Video thumbnail' />
</button>
);
} else if (type === 'webpage') {
thumbnailComponent = (
<img
src={thumbnail}
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open webpage preview' onClick={handleOpenMedia}>
<img src={thumbnail} alt='' />
</button>
);
} else if (type === 'iframe') {
thumbnailComponent = iframeThumbnail ? (
<img
src={iframeThumbnail}
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
<img src={iframeThumbnail} alt='' />
</button>
) : null;
} else if (type === 'audio') {
thumbnailComponent = <audio src={url} controls />;
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
}
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
@@ -204,8 +152,9 @@ const Thumbnail = ({
const noThumbnailLink =
!hasThumbnail && linkWithoutThumbnail ? (
canEmbed(linkWithoutThumbnail) ? (
<span
role='button'
<button
type='button'
className={styles.noThumbnailButton}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -216,7 +165,7 @@ const Thumbnail = ({
onClick={() => setShowThumbnail(false)}
>
{fallbackLinkLabel}
</span>
</button>
) : (
<a href={url} target='_blank' rel='noopener noreferrer'>
{fallbackLinkLabel}
@@ -227,20 +176,9 @@ const Thumbnail = ({
return deleted || removed || purged ? (
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='File deleted' />
) : spoiler ? (
<img
className={styles.spoiler}
src='assets/spoiler.png'
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={handleOpenMedia}>
<img className={styles.spoiler} src='assets/spoiler.png' alt='' />
</button>
) : isOutOfFeed ? (
<span className={`${isFloatingEmbed ? styles.floatingEmbed : styles.communityAvatar}`}>{thumbnailComponent}</span>
) : isMobile || isReply ? (
@@ -272,43 +210,23 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
{type === 'iframe' && url ? (
<Embed url={url} />
) : type === 'gif' ? (
<img
src={url}
alt=''
role={disableToggle ? undefined : 'button'}
tabIndex={disableToggle ? undefined : 0}
onKeyDown={
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(true);
}
}
}
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
/>
disableToggle ? (
<img src={url} alt='' />
) : (
<button type='button' className={styles.mediaToggleButton} aria-label='Collapse GIF' onClick={() => setShowThumbnail(true)}>
<img src={url} alt='' />
</button>
)
) : type === 'video' ? (
<video src={url} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
) : type === 'webpage' ? (
<img
src={thumbnail}
alt=''
role={disableToggle ? undefined : 'button'}
tabIndex={disableToggle ? undefined : 0}
onKeyDown={
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(true);
}
}
}
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
/>
disableToggle ? (
<img src={thumbnail} alt='' />
) : (
<button type='button' className={styles.mediaToggleButton} aria-label='Collapse webpage preview' onClick={() => setShowThumbnail(true)}>
<img src={thumbnail} alt='' />
</button>
)
) : null}
{isMobile && type && (
<div className={styles.fileInfo}>
@@ -321,9 +239,9 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
)}
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
<div className={styles.closeButton}>
<span
<button
type='button'
className='button'
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -334,7 +252,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
onClick={() => setShowThumbnail(true)}
>
{t('close')}
</span>
</button>
</div>
)}
</span>
@@ -397,20 +315,9 @@ const Image = ({
className={`${isOutOfFeed ? styles.communityAvatar : styles.thumbnailBig} ${styles.thumbnail} ${isImageExpanded && isMobile ? styles.removeFloat : ''}`}
style={spoilerDimensions}
>
<img
className={styles.spoiler}
src='assets/spoiler.png'
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(true);
}
}}
onClick={() => setIsImageExpanded(true)}
/>
<button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={() => setIsImageExpanded(true)}>
<img className={styles.spoiler} src='assets/spoiler.png' alt='' />
</button>
</span>
);
}
@@ -424,26 +331,17 @@ const Image = ({
>
{hasError ? (
<MediaLoadFailure compact url={url} />
) : disableToggle ? (
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
) : (
<img
src={url}
onError={handleError}
onLoad={handleLoad}
alt=''
role={disableToggle ? undefined : 'button'}
tabIndex={disableToggle ? undefined : 0}
onKeyDown={
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(!isImageExpanded);
}
}
}
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
/>
<button
type='button'
className={styles.mediaToggleButton}
aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
onClick={() => setIsImageExpanded(!isImageExpanded)}
>
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
</button>
)}
</span>
{isImageExpanded && type && (
@@ -465,26 +363,17 @@ const Image = ({
>
{hasError ? (
<MediaLoadFailure url={url} />
) : disableToggle ? (
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
) : (
<img
src={url}
onError={handleError}
onLoad={handleLoad}
alt=''
role='button'
tabIndex={0}
onKeyDown={
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(!isImageExpanded);
}
}
}
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
/>
<button
type='button'
className={styles.mediaToggleButton}
aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
onClick={() => setIsImageExpanded(!isImageExpanded)}
>
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
</button>
)}
</span>
);
@@ -8,6 +8,14 @@
z-index: 999;
}
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.createBoardDialog {
position: absolute;
width: 500px;
@@ -22,7 +30,9 @@
box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px;
font-size: var(--body-font-size);
line-height: 16.003px;
z-index: 1000;
z-index: 1;
color: inherit;
padding: 0;
}
@media (max-width: 600px) {
@@ -122,4 +132,3 @@
justify-content: center;
gap: 10px;
}
@@ -8,29 +8,13 @@ const CreateBoardModal = () => {
return null;
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeCreateBoardModal();
}
};
return (
<div
className={styles.backdrop}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeCreateBoardModal();
}
}}
onClick={handleBackdropClick}
>
<div className={styles.createBoardDialog} role='dialog' aria-modal='true' aria-labelledby='create-board-modal-title'>
<div className={styles.backdrop}>
<button type='button' className={styles.backdropButton} aria-label='Close create board modal' onClick={closeCreateBoardModal} />
<dialog open className={styles.createBoardDialog} aria-modal='true' aria-labelledby='create-board-modal-title'>
<div className={styles.hd}>
<h2 id='create-board-modal-title'>Create a Board</h2>
<button className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' aria-label='Close' />
<button type='button' className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' aria-label='Close' />
</div>
<div className={styles.bd}>
<div className={styles.section}>
@@ -73,9 +57,11 @@ const CreateBoardModal = () => {
</div>
</div>
<div className={styles.createBoardFooter}>
<button onClick={closeCreateBoardModal}>Close</button>
</div>
<button type='button' onClick={closeCreateBoardModal}>
Close
</button>
</div>
</dialog>
</div>
);
};
@@ -12,12 +12,23 @@
background-color: rgba(128, 128, 128, 0.5);
}
.directoryDialog {
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.directoryDialog {
position: fixed;
width: 500px;
top: 50px;
max-width: calc(100% - 20px);
max-height: calc(100vh - 40px);
overflow-y: auto;
top: 50%;
left: 50%;
margin-left: -251px;
transform: translate(-50%, -50%);
background-color: var(--directory-modal-background-color, #f0e0d6);
border: var(--directory-modal-border, 1px solid #d9bfb7);
color: var(--directory-modal-text-color, #800);
@@ -25,7 +36,9 @@
font-family: arial, helvetica, clean, sans-serif;
font-size: 13px;
line-height: 18px;
z-index: 1000;
z-index: 1;
margin: 0;
padding: 0;
}
@media (max-width: 600px) {
@@ -34,10 +47,7 @@
max-width: 500px;
left: 50%;
top: 50%;
margin-left: 0;
transform: translate(-50%, -50%);
max-height: calc(100vh - 40px);
overflow-y: auto;
}
}
@@ -132,5 +142,3 @@
justify-content: center;
gap: 10px;
}
@@ -11,29 +11,19 @@ const DirectoryModal = () => {
return null;
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeDirectoryModal();
}
};
return (
<div
className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeDirectoryModal();
}
}}
onClick={handleBackdropClick}
>
<div className={styles.directoryDialog} role='dialog' aria-modal='true' aria-labelledby='directory-modal-title'>
<div className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}>
<button type='button' className={styles.backdropButton} aria-label='Close directory modal' onClick={closeDirectoryModal} />
<dialog open className={styles.directoryDialog} aria-modal='true' aria-labelledby='directory-modal-title'>
<div className={styles.hd}>
<h2 id='directory-modal-title'>Submit a Board to a Directory</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDirectoryModal} title='Close' aria-label='Close' />
<button
type='button'
className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`}
onClick={closeDirectoryModal}
title='Close'
aria-label='Close'
/>
</div>
<div className={styles.bd}>
<p className={styles.introMessage}>
@@ -74,9 +64,11 @@ const DirectoryModal = () => {
</div>
</div>
<div className={styles.directoryFooter}>
<button onClick={closeDirectoryModal}>Close</button>
</div>
<button type='button' onClick={closeDirectoryModal}>
Close
</button>
</div>
</dialog>
</div>
);
};
@@ -8,12 +8,23 @@
z-index: 999;
}
.disclaimerDialog {
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.disclaimerDialog {
position: fixed;
width: 300px;
top: 50px;
max-width: calc(100% - 20px);
max-height: calc(100vh - 40px);
overflow-y: auto;
top: 50%;
left: 50%;
margin-left: -151px;
transform: translate(-50%, -50%);
background-color: var(--disclaimer-modal-background-color, rgb(255, 255, 255));
border: var(--disclaimer-modal-border, 1px solid rgb(136, 0, 0));
color: var(--disclaimer-modal-text-color, rgb(136, 0, 0));
@@ -21,7 +32,9 @@
font-family: arial, helvetica, clean, sans-serif;
font-size: 13px;
line-height: 16.003px;
z-index: 1000;
z-index: 1;
margin: 0;
padding: 0;
}
.hd {
@@ -14,12 +14,6 @@ const DisclaimerModal = () => {
return null;
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeDisclaimerModal();
}
};
const handleAccept = () => {
// If there's no board path (placeholder), show directory modal instead
if (!targetBoardPath) {
@@ -31,22 +25,18 @@ const DisclaimerModal = () => {
};
return (
<div
className={styles.backdrop}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeDisclaimerModal();
}
}}
onClick={handleBackdropClick}
>
<div className={styles.disclaimerDialog} role='dialog' aria-modal='true' aria-labelledby='disclaimer-modal-title'>
<div className={styles.backdrop}>
<button type='button' className={styles.backdropButton} aria-label='Close disclaimer modal' onClick={closeDisclaimerModal} />
<dialog open className={styles.disclaimerDialog} aria-modal='true' aria-labelledby='disclaimer-modal-title'>
<div className={styles.hd}>
<h2 id='disclaimer-modal-title'>Disclaimer</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDisclaimerModal} title='Close' aria-label='Close' />
<button
type='button'
className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`}
onClick={closeDisclaimerModal}
title='Close'
aria-label='Close'
/>
</div>
<div className={styles.bd}>
<p>This web application may contain content for mature audiences only. By clicking &quot;Accept,&quot; you confirm that:</p>
@@ -57,10 +47,14 @@ const DisclaimerModal = () => {
</ol>
</div>
<div className={styles.disclaimerFooter}>
<button onClick={handleAccept}>Accept</button>
<button onClick={closeDisclaimerModal}>Cancel</button>
</div>
<button type='button' onClick={handleAccept}>
Accept
</button>
<button type='button' onClick={closeDisclaimerModal}>
Cancel
</button>
</div>
</dialog>
</div>
);
};
+63 -12
View File
@@ -19,8 +19,6 @@ import useAuthorPrivileges from '../../hooks/use-author-privileges';
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
const { addChallenge } = useChallengesStore.getState();
const daysToTimestampInSeconds = (days: number) => {
const now = new Date();
now.setDate(now.getDate() + days);
@@ -60,7 +58,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
useEffect(() => {
latestPostRef.current = resolvedPost;
}, [resolvedPost]);
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
const onChallenge = useCallback((...args: any) => useChallengesStore.getState().addChallenge([...args, latestPostRef.current]), []);
const defaultPublishEditOptions = useMemo(() => {
return {
@@ -102,6 +100,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
pinned,
reason,
removed,
parentCid,
purged,
spoiler,
communityAddress,
@@ -289,6 +288,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<span className={`${styles.checkbox} ${parentCid && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}>
<input
type='checkbox'
aria-label={t('edit')}
onChange={() => {
if (cid && canOpenEditMenu) {
if (!isEditMenuOpen) {
@@ -315,7 +315,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
<label>
[
<input onChange={onCheckbox} checked={publishCommentEditOptions.deleted ?? false} type='checkbox' id='deleted' />
<input
aria-label={capitalize(t('delete'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.deleted ?? false}
type='checkbox'
id='deleted'
/>
{capitalize(t('delete'))}?]
</label>
</div>
@@ -326,13 +332,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
<label>
[
<input type='checkbox' onChange={() => setIsContentEditorOpen(!isContentEditorOpen)} checked={isContentEditorOpen} />
<input
type='checkbox'
aria-label={capitalize(t('edit'))}
onChange={() => setIsContentEditorOpen(!isContentEditorOpen)}
checked={isContentEditorOpen}
/>
{capitalize(t('edit'))}?]
</label>
</div>
{isContentEditorOpen && (
<div>
<textarea
aria-label={capitalize(t('edit'))}
className={styles.editTextarea}
value={publishCommentEditOptions.content || ''}
onChange={(e) => {
@@ -349,13 +361,25 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
<label>
[
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.removed ?? false} type='checkbox' id='removed' />
<input
aria-label={capitalize(t('remove'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.commentModeration?.removed ?? false}
type='checkbox'
id='removed'
/>
{capitalize(t('remove'))}?]
</label>{' '}
<span className={styles.purgeItem}>
<label>
[
<input onChange={onPurgeCheckbox} checked={publishCommentEditOptions.commentModeration?.purged ?? false} type='checkbox' id='purged' />
<input
aria-label={capitalize(t('purge'))}
onChange={onPurgeCheckbox}
checked={publishCommentEditOptions.commentModeration?.purged ?? false}
type='checkbox'
id='purged'
/>
{capitalize(t('purge'))}?]
</label>
</span>
@@ -364,7 +388,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.locked ?? false} type='checkbox' id='locked' />
<input
aria-label={capitalize(t('close_thread'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.commentModeration?.locked ?? false}
type='checkbox'
id='locked'
/>
{capitalize(t('close_thread'))}?
</label>
]
@@ -373,7 +403,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.spoiler ?? false} type='checkbox' id='spoiler' />
<input
aria-label={capitalize(t('spoiler'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.commentModeration?.spoiler ?? false}
type='checkbox'
id='spoiler'
/>
{capitalize(t('spoiler'))}?
</label>
]
@@ -382,7 +418,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.archived ?? false} type='checkbox' id='archived' />
<input
aria-label={capitalize(t('archived'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.commentModeration?.archived ?? false}
type='checkbox'
id='archived'
/>
{capitalize(t('archived'))}?
</label>
]
@@ -391,7 +433,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.pinned ?? false} type='checkbox' id='pinned' />
<input
aria-label={capitalize(t('sticky'))}
onChange={onCheckbox}
checked={publishCommentEditOptions.commentModeration?.pinned ?? false}
type='checkbox'
id='pinned'
/>
{capitalize(t('sticky'))}?
</label>
]
@@ -402,6 +450,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<label>
<input
onChange={onCheckbox}
aria-label={capitalize(t('ban'))}
checked={publishCommentEditOptions.commentModeration?.author?.banExpiresAt !== undefined}
type='checkbox'
id='banUser'
@@ -414,6 +463,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<input
key='ban-duration-input'
className={styles.banInput}
aria-label={t('ban_duration_days')}
onChange={onBanDurationChange}
type='number'
min={1}
@@ -433,6 +483,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
{capitalize(t('reason'))}? ({t('optional')})
<input
type='text'
aria-label={capitalize(t('reason'))}
value={publishCommentEditOptions.commentModeration?.reason || ''}
onChange={(e) => {
const newReason = e.target.value;
@@ -451,7 +502,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
</>
)}
<div className={styles.bottom}>
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
<button type='button' className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
{t('save')}
</button>
</div>
@@ -2,7 +2,8 @@ import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Embed, { canEmbed } from '../embed';
import Embed from '../embed';
import { canEmbed } from '../embed-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
+60
View File
@@ -0,0 +1,60 @@
export const youtubeHosts = new Set<string>([
'youtube.com',
'www.youtube.com',
'youtu.be',
'www.youtu.be',
'm.youtube.com',
'music.youtube.com',
// working Invidious instances - https://docs.invidious.io/instances/ - https://uptime.invidious.io/
'yewtu.be',
'inv.nadeko.net',
'yt.artemislena.eu',
'invidious.nerdvpn.de',
]);
export const xHosts = new Set<string>(['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com']);
export const getXTweetId = (parsedUrl: URL): string | undefined => {
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
const statusIndex = pathParts.findIndex((part) => part === 'status' || part === 'statuses');
const statusId = statusIndex === -1 ? undefined : pathParts[statusIndex + 1];
return statusId && /^\d+$/.test(statusId) ? statusId : undefined;
};
export const redditHosts = new Set<string>(['reddit.com', 'www.reddit.com', 'old.reddit.com']);
export const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
export const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
export const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
export const odyseeHosts = new Set<string>(['odysee.com', 'www.odysee.com']);
export const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
export const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
export const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
export const soundcloudHosts = new Set<string>(['soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com', 'api.soundcloud.com', 'w.soundcloud.com']);
const canEmbedHosts = new Set<string>([
...youtubeHosts,
...xHosts,
...redditHosts,
...twitchHosts,
...tiktokHosts,
...instagramHosts,
...odyseeHosts,
...bitchuteHosts,
...soundcloudHosts,
...streamableHosts,
...spotifyHosts,
]);
export const canEmbed = (parsedUrl: URL): boolean => {
if (xHosts.has(parsedUrl.host)) {
return Boolean(getXTweetId(parsedUrl));
}
if (redditHosts.has(parsedUrl.host)) {
// Reddit posts are not embeddable if the URL does not include '/comments/'
return parsedUrl.pathname.includes('/comments/');
}
return canEmbedHosts.has(parsedUrl.host) || (parsedUrl.host.startsWith('yt.') && parsedUrl.searchParams.has('v'));
};
+22 -69
View File
@@ -1,4 +1,18 @@
import styles from './embed.module.css';
import {
bitchuteHosts,
getXTweetId,
instagramHosts,
odyseeHosts,
redditHosts,
soundcloudHosts,
spotifyHosts,
streamableHosts,
tiktokHosts,
twitchHosts,
xHosts,
youtubeHosts,
} from './embed-utils';
interface EmbedProps {
url: string;
@@ -47,20 +61,7 @@ interface EmbedComponentProps {
}
const srcDocSandbox = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
const youtubeHosts = new Set<string>([
'youtube.com',
'www.youtube.com',
'youtu.be',
'www.youtu.be',
'm.youtube.com',
'music.youtube.com',
// working Invidious instances - https://docs.invidious.io/instances/ - https://uptime.invidious.io/
'yewtu.be',
'inv.nadeko.net',
'yt.artemislena.eu',
'invidious.nerdvpn.de',
]);
const remoteEmbedSandbox = `${srcDocSandbox} allow-forms allow-presentation allow-same-origin`;
const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let embedSrc = '';
@@ -100,6 +101,7 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='origin'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={embedSrc}
/>
@@ -108,16 +110,6 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return null;
};
const xHosts = new Set<string>(['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com']);
const getXTweetId = (parsedUrl: URL): string | undefined => {
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
const statusIndex = pathParts.findIndex((part) => part === 'status' || part === 'statuses');
const statusId = statusIndex === -1 ? undefined : pathParts[statusIndex + 1];
return statusId && /^\d+$/.test(statusId) ? statusId : undefined;
};
const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const tweetId = getXTweetId(parsedUrl);
if (!tweetId) {
@@ -147,8 +139,6 @@ const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
);
};
const redditHosts = new Set<string>(['reddit.com', 'www.reddit.com', 'old.reddit.com']);
const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return (
<iframe
@@ -175,8 +165,6 @@ const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
);
};
const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let iframeUrl;
if (parsedUrl.pathname.startsWith('/videos/')) {
@@ -194,14 +182,13 @@ const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/.+\/video\//, '').replaceAll('/', '');
return (
@@ -223,8 +210,6 @@ const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
);
};
const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const pathNames = parsedUrl.pathname.replace(/\/+$/, '').split('/');
const id = pathNames[pathNames.length - 1];
@@ -247,8 +232,6 @@ const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
);
};
const odyseeHosts = new Set<string>(['odysee.com', 'www.odysee.com']);
const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://odysee.com/$/embed${parsedUrl.pathname}`;
return (
@@ -259,14 +242,13 @@ const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/\/video\//, '').replaceAll('/', '');
return (
@@ -277,14 +259,13 @@ const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={`https://www.bitchute.com/embed/${videoId}/`}
/>
);
};
const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replaceAll('/', '');
return (
@@ -295,14 +276,13 @@ const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={`https://streamable.com/e/${videoId}`}
/>
);
};
const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://open.spotify.com/embed${parsedUrl.pathname}?theme=0`;
return (
@@ -313,14 +293,13 @@ const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const soundcloudHosts = new Set(['soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com', 'api.soundcloud.com', 'w.soundcloud.com']);
// not officially documented https://stackoverflow.com/questions/20870270/how-to-get-soundcloud-embed-code-by-soundcloud-com-url
const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return (
@@ -331,37 +310,11 @@ const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href}
src={`https://w.soundcloud.com/player/?url=${parsedUrl.href}`}
/>
);
};
const canEmbedHosts = new Set<string>([
...youtubeHosts,
...xHosts,
...redditHosts,
...twitchHosts,
...tiktokHosts,
...instagramHosts,
...odyseeHosts,
...bitchuteHosts,
...soundcloudHosts,
...streamableHosts,
...spotifyHosts,
]);
export const canEmbed = (parsedUrl: URL): boolean => {
if (xHosts.has(parsedUrl.host)) {
return Boolean(getXTweetId(parsedUrl));
}
if (redditHosts.has(parsedUrl.host)) {
// Reddit posts are not embeddable if the URL does not include '/comments/'
return parsedUrl.pathname.includes('/comments/');
}
return canEmbedHosts.has(parsedUrl.host) || (parsedUrl.host.startsWith('yt.') && parsedUrl.searchParams.has('v'));
};
export default Embed;
+2 -1
View File
@@ -1 +1,2 @@
export { default, canEmbed } from './embed';
export { default } from './embed';
export { canEmbed } from './embed-utils';
+1 -1
View File
@@ -252,7 +252,7 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
<PageFooterMobile>
<div className={styles.threadMobileFooterContent}>
<div className={styles.mobileFooterButtons}>
<button className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}>
<button type='button' className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}>
{t('post_a_reply')}
</button>
</div>
@@ -460,7 +460,7 @@ describe('Markdown', () => {
content: 'https://cdn.example/image.png',
});
const toggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'embed');
const toggle = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'embed');
expect(toggle).toBeTruthy();
expect(container.querySelector('[data-testid="comment-media"]')).toBeNull();
@@ -470,7 +470,7 @@ describe('Markdown', () => {
expect(container.querySelector('[data-testid="comment-media"]')?.textContent).toBe('image:https://cdn.example/image.png');
const removeToggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'remove');
const removeToggle = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'remove');
await act(async () => {
removeToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
@@ -1,5 +1,5 @@
import type { MouseEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -64,7 +64,8 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
const boardLabel = getExternalQuoteBoardLabel(reference, directories);
const linkLabel = isOP ? `${reference.raw} (OP)` : reference.raw;
const updatePreviewPosition = (anchor: HTMLElement | null) => {
const updatePreviewPosition = useCallback(
(anchor: HTMLElement | null) => {
if (!anchor || isMobile) {
return;
}
@@ -78,7 +79,9 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
setPreviewPosition({ left, top });
};
},
[isMobile],
);
useEffect(() => {
if (!isPreviewOpen || isMobile) {
@@ -95,7 +98,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
};
}, [isMobile, isPreviewOpen, previewState.kind]);
}, [isMobile, isPreviewOpen, previewState.kind, updatePreviewPosition]);
const getInitialSearchMessage = () =>
getExternalQuoteStatusMessage(t, {
@@ -34,10 +34,23 @@
}
.embedButton {
appearance: none;
background: transparent;
border: 0;
color: var(--button-desktop-text-color);
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
text-transform: capitalize;
}
.embedButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.embedButton:hover {
cursor: pointer;
color: var(--button-desktop-text-color-hover);
+15 -9
View File
@@ -91,9 +91,9 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
{children}
</a>{' '}
[
<span
<button
type='button'
className={styles.embedButton}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -106,7 +106,7 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
{...getReferenceProps()}
>
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
</span>
</button>
]
{showMedia && (
<>
@@ -489,7 +489,7 @@ const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.
);
};
const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[] => {
const getQstBbcodeTextNodes = (text: string, keyPrefix: string): React.ReactNode[] => {
const elements: React.ReactNode[] = [];
const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g');
let lastIndex = 0;
@@ -511,7 +511,9 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
const closeEnd = closeStart + tag.length + 3;
const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`;
elements.push(renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, renderQstBbcodeText(text.slice(contentStart, closeStart), childKeyPrefix)));
elements.push(
renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, getQstBbcodeTextNodes(text.slice(contentStart, closeStart), childKeyPrefix)),
);
lastIndex = closeEnd;
regex.lastIndex = closeEnd;
}
@@ -523,7 +525,7 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
return elements;
};
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}</>;
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{getQstBbcodeTextNodes(text, `${tokenKey}/`)}</>;
const Fortune = ({ color, text }: { color: string; text: string }) => (
<span className='fortune' style={{ color }}>
@@ -608,12 +610,16 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const normalized = normalizeContent(content || '');
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
let lineOffset = 0;
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
lines.forEach((line, lineIndex) => {
const lineKey = `line-${lineOffset}`;
lineOffset += line.length + 1;
if (lineIndex > 0) {
elements.push(<br key={`br-${lineIndex}`} />);
elements.push(<br key={`br-${lineKey}`} />);
}
if (line.length === 0) return;
@@ -624,12 +630,12 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
if (isGreentext) {
elements.push(
<span key={`line-${lineIndex}`} className='greentext'>
<span key={lineKey} className='greentext'>
{lineElements}
</span>,
);
} else {
elements.push(<React.Fragment key={`line-${lineIndex}`}>{lineElements}</React.Fragment>);
elements.push(<React.Fragment key={lineKey}>{lineElements}</React.Fragment>);
}
});
@@ -1,4 +1,5 @@
.modEmptyState {
display: block;
padding: 1em 0;
text-align: center;
width: 100%;
@@ -10,12 +10,12 @@ const ModEmptyState = () => {
const { t } = useTranslation();
return (
<div className={styles.modEmptyState} role='status'>
<output className={styles.modEmptyState}>
<div className={styles.modEmptyStateTitle}>{t('not_mod_of_any_board')}</div>
<div className={styles.modEmptyStateAction}>
<Trans i18nKey='go_to_settings_to_import_mod_account' components={MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS} />
</div>
</div>
</output>
);
};
+4 -3
View File
@@ -8,6 +8,7 @@ interface PostAuthorFlagsProps {
}
const getBackgroundPosition = (x: number, y: number) => `${x === 0 ? 0 : -x}px ${y === 0 ? 0 : -y}px`;
const transparentPixelSrc = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E';
const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => {
if (!enabled) return null;
@@ -18,11 +19,11 @@ const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) =>
return (
<span className={styles.authorFlags}>
{flags.map((flag) => (
<span
<img
key={flag.key}
aria-label={flag.label}
alt={flag.label}
className={styles.authorFlag}
role='img'
src={transparentPixelSrc}
style={{
backgroundImage: `url("${flag.spritePath}")`,
backgroundPosition: getBackgroundPosition(flag.x, flag.y),
+25 -25
View File
@@ -72,8 +72,6 @@ import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAu
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
const { addChallenge } = useChallengesStore.getState();
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
hasMore ? (
<div className={styles.stateString}>
@@ -112,7 +110,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
useChallengesStore.getState().addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
@@ -131,7 +129,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
useChallengesStore.getState().addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
@@ -194,14 +192,14 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
<>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
<button type='button' className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
<button type='button' className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')}
</button>
]
@@ -318,7 +316,7 @@ const PostInfo = ({
const modQueueRemoveButton = onRemoveFromModQueue ? (
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onRemoveFromModQueue} disabled={isPublishing}>
<button type='button' className={styles.modQueueActionButton} onClick={onRemoveFromModQueue} disabled={isPublishing}>
{t('modQueue.dismiss')}
</button>
]
@@ -397,10 +395,10 @@ const PostInfo = ({
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
>
<span
<button
type='button'
title={t('highlight_posts')}
className={styles.userAddress}
role='button'
tabIndex={0}
onClick={() => handleUserAddressClick(userID, postCid)}
onKeyDown={(e) => {
@@ -412,7 +410,7 @@ const PostInfo = ({
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
>
{formatUserIDForDisplay(userID)}
</span>
</button>
</Tooltip>
)}
){' '}
@@ -440,10 +438,10 @@ const PostInfo = ({
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No.
</Link>
<span
<button
type='button'
className={styles.replyToPost}
title={t('reply_to_post')}
role='button'
tabIndex={0}
onMouseDown={onReplyModalClick}
onKeyDown={(e) => {
@@ -454,7 +452,7 @@ const PostInfo = ({
}}
>
{post?.number || '?'}
</span>
</button>
</span>
) : (
<>
@@ -523,14 +521,14 @@ const PostInfo = ({
)}
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
<button type='button' className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
<button type='button' className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
]
@@ -685,9 +683,9 @@ const PostMedia = ({
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
<span>
-[
<span
<button
type='button'
className={styles.closeMedia}
role='button'
tabIndex={0}
onClick={() => setShowThumbnail(true)}
onKeyDown={(e) => {
@@ -698,16 +696,16 @@ const PostMedia = ({
}}
>
{t('close')}
</span>
</button>
]
</span>
)}
{showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && (
<span>
-[
<span
<button
type='button'
className={styles.closeMedia}
role='button'
tabIndex={0}
onClick={() => setShowThumbnail(false)}
onKeyDown={(e) => {
@@ -718,7 +716,7 @@ const PostMedia = ({
}}
>
{t('open')}
</span>
</button>
]
</span>
)}
@@ -1139,9 +1137,10 @@ const PostDesktop = ({
<div className={isHidden ? styles.postDesktopHidden : ''}>
{!isInPostPageView && showReplies && (
<span className={`${styles.hideButtonWrapper} ${!hasThumbnail ? styles.hideButtonWrapperNoImage : ''}`}>
<span
<button
type='button'
aria-label={hidden ? t('unhide') : t('hide')}
className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`}
role='button'
tabIndex={0}
onClick={hidden ? unhide : hide}
onKeyDown={(e) => {
@@ -1207,9 +1206,10 @@ const PostDesktop = ({
</div>
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && (
<span className={styles.summary}>
<span
<button
type='button'
aria-label={showOmittedReplies[cid] ? t('hide_replies') : t('show_replies')}
className={`${showOmittedReplies[cid] ? styles.hideOmittedReplies : styles.showOmittedReplies} ${styles.omittedRepliesButtonWrapper}`}
role='button'
tabIndex={0}
onClick={() => setShowOmittedReplies(cid, !showOmittedReplies[cid])}
onKeyDown={(e) => {
@@ -144,21 +144,21 @@ describe('PostMenuDesktop', () => {
await renderMenu();
await openMenu();
const copyLink = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_direct_link');
const copyLink = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_direct_link');
await act(async () => {
copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1');
await openMenu();
const copyContentId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_content_id');
const copyContentId = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_content_id');
await act(async () => {
copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1');
await openMenu();
const copyUserId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_user_id');
const copyUserId = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_user_id');
await act(async () => {
copyUserId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
@@ -169,7 +169,7 @@ describe('PostMenuDesktop', () => {
await renderMenu('/mu');
await openMenu();
const hideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread');
const hideThread = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'hide_thread');
expect(hideThread).toBeTruthy();
await act(async () => {
@@ -181,7 +181,7 @@ describe('PostMenuDesktop', () => {
await renderMenu('/mu');
await openMenu();
const unhideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'unhide_thread');
const unhideThread = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'unhide_thread');
expect(unhideThread).toBeTruthy();
await act(async () => {
@@ -205,7 +205,7 @@ describe('PostMenuDesktop', () => {
expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined();
const imageSearch = Array.from(document.body.querySelectorAll('[role="button"]')).find((node) => node.textContent?.includes('Image_search'));
const imageSearch = Array.from(document.body.querySelectorAll('[class*="dropdown"]')).find((node) => node.textContent?.includes('Image_search'));
expect(imageSearch).toBeTruthy();
await act(async () => {
@@ -216,7 +216,7 @@ describe('PostMenuDesktop', () => {
expect(hrefs).toEqual(
expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://www.yandex.com/images/search?url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]),
);
@@ -7,6 +7,28 @@
z-index: 2;
}
.postMenu button {
appearance: none;
font: inherit;
margin: 0;
color: inherit;
text-align: left;
}
.postMenuBtn, .postMenuBtnCatalog {
appearance: none;
background: transparent;
border: 0;
font: inherit;
margin: 0;
padding: 0;
}
.postMenu button:focus-visible, .postMenuBtn:focus-visible, .postMenuBtnCatalog:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.postMenuBtnWrapper {
position: relative;
display: inline-block;
@@ -28,10 +50,13 @@
.postMenuItem {
cursor: pointer;
display: block;
width: 100%;
padding: 2px 4px;
border: 1px solid var(--post-menu-desktop-item-border-color);
background-color: var(--post-menu-desktop-item-background-color);
border-bottom: none;
box-sizing: border-box;
}
.postMenuItem:hover {
@@ -46,7 +71,7 @@
position: absolute;
left: 100%;
top: -1px;
display: none;
display: block;
border-bottom: var(--post-menu-desktop-border-bottom);
border-right: var(--post-menu-desktop-border-right);
}
@@ -56,10 +81,6 @@
color: inherit;
}
.dropdown:hover .dropdownMenu {
display: block;
}
.postMenuBtnCatalogWrapper {
left: 5px;
top: 5px;
@@ -51,9 +51,9 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
onClose();
};
return (
<div
<button
type='button'
className={styles.postMenuItem}
role='button'
tabIndex={0}
onClick={copyDirectLink}
onKeyDown={(e) => {
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
}}
>
{t('copy_direct_link')}
</div>
</button>
);
};
@@ -75,9 +75,9 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
onClose();
};
return (
<div
<button
type='button'
className={styles.postMenuItem}
role='button'
tabIndex={0}
onClick={copyContentId}
onKeyDown={(e) => {
@@ -88,7 +88,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
}}
>
{t('copy_content_id')}
</div>
</button>
);
};
@@ -99,9 +99,9 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
onClose();
};
return (
<div
<button
type='button'
className={styles.postMenuItem}
role='button'
tabIndex={0}
onClick={copyUserId}
onKeyDown={(e) => {
@@ -112,7 +112,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
}}
>
{t('copy_user_id')}
</div>
</button>
);
};
@@ -128,30 +128,31 @@ const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void
return (
<div
className={`${styles.postMenuItem} ${styles.dropdown}`}
role='button'
tabIndex={0}
className={styles.dropdown}
onMouseOver={() => setIsImageSearchMenuOpen(true)}
onFocus={() => setIsImageSearchMenuOpen(true)}
onMouseLeave={() => setIsImageSearchMenuOpen(false)}
onBlur={() => setIsImageSearchMenuOpen(false)}
ref={refs.setReference}
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClose();
}
}}
>
<button
type='button'
className={styles.postMenuItem}
aria-haspopup='true'
aria-expanded={isImageSearchMenuOpen}
onClick={() => setIsImageSearchMenuOpen((open) => !open)}
>
{capitalize(t('image_search'))} »
</button>
{isImageSearchMenuOpen && (
<div ref={refs.setFloating} style={floatingStyles} className={styles.dropdownMenu}>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>Google</div>
</a>
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
<a href={`https://www.yandex.com/images/search?url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>Yandex</div>
</a>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>SauceNAO</div>
</a>
</div>
@@ -202,10 +203,10 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
return (
<>
<span className={isInCatalogView ? styles.postMenuBtnCatalogWrapper : styles.postMenuBtnWrapper} ref={refs.setReference} {...getReferenceProps()}>
<span
<button
type='button'
className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn}
title='Post menu'
role='button'
tabIndex={0}
onClick={handleMenuClick}
onKeyDown={(e) => {
@@ -217,16 +218,16 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
</span>
</button>
</span>
{menuBtnRotated &&
cid &&
createPortal(
<FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<div
<button
type='button'
className={styles.postMenuItem}
role='button'
tabIndex={0}
onClick={() => {
alert("Reporting isn't available yet.");
@@ -241,11 +242,11 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
}}
>
{t('report_post')}
</div>
</button>
{!(isInPostPageView && postCid === cid) && (
<div
<button
type='button'
className={styles.postMenuItem}
role='button'
tabIndex={0}
onClick={() => {
if (hidden) {
@@ -268,7 +269,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
}}
>
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
</div>
</button>
)}
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
+8 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
@@ -373,6 +373,7 @@ const PostFormFields = ({
<label>
<input
type='checkbox'
aria-label={capitalize(t('spoiler'))}
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
/>
{capitalize(t('spoiler'))}?
@@ -500,7 +501,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, POST_OPTIONS_VALIDATION_DELAY_MS),
).current;
const resetFields = () => {
const resetFields = useCallback(() => {
if (textRef.current) {
textRef.current.value = '';
}
@@ -523,7 +524,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(null);
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
};
}, [checkContentLength, checkPostOptions]);
const getBoardIndexPath = () => {
if (effectiveBoardAddress) {
@@ -596,7 +597,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
navigate(`/pending/${postIndex}`, pendingPostBoardPath ? { state: { boardPath: pendingPostBoardPath } } : undefined);
}
}
}, [postIndex, pendingPostBoardPath, resetPublishPostOptions, navigate]);
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
// in post page, publish a reply to the post
const isInPostView = isPostPageView(location.pathname, params);
@@ -704,7 +705,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
navigate(nonokoRedirectPath);
}
}
}, [replyIndex, closeForm, navigate]);
}, [replyIndex, closeForm, navigate, resetFields]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
@@ -846,7 +847,7 @@ const PostForm = () => {
</div>
) : (
<>
<button className={`${styles.showFormButton} button`} onClick={() => setShowForm(showForm ? false : true)}>
<button type='button' className={`${styles.showFormButton} button`} onClick={() => setShowForm(showForm ? false : true)}>
{showForm ? t('close_post_form') : isInPostView ? t('post_a_reply') : t('start_new_thread')}
</button>
{showForm && <PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />}
@@ -871,7 +872,7 @@ const PostForm = () => {
) : !showForm ? (
<div>
[
<button className='button' onClick={() => setShowForm(true)}>
<button type='button' className='button' onClick={() => setShowForm(true)}>
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
</button>
]
@@ -256,7 +256,7 @@ describe('PostMenuMobile', () => {
expect(hrefs).toEqual(
expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://www.yandex.com/images/search?url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]),
);
@@ -1,4 +1,7 @@
.postMenuBtn {
appearance: none;
background: transparent;
border: 0;
color: var(--post-mobile-menu-button-color);
line-height: 1em;
width: 1em;
@@ -11,6 +14,13 @@
font-size: 16px;
height: 100%;
cursor: pointer;
margin: 0;
padding: 0;
}
.postMenuBtn:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.checkbox {
@@ -35,6 +45,20 @@
outline: none;
}
.postMenu button {
all: unset;
box-sizing: border-box;
color: inherit;
display: block;
font: inherit;
width: 100%;
}
.postMenu button:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: -2px;
}
.postMenuItem {
cursor: pointer;
position: relative;
@@ -70,8 +70,8 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
onClose();
};
return (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={copyDirectLink}
onKeyDown={(e) => {
@@ -82,7 +82,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
}}
>
<div className={styles.postMenuItem}>{t('copy_direct_link')}</div>
</div>
</button>
);
};
@@ -93,8 +93,8 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
onClose();
};
return (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={copyContentId}
onKeyDown={(e) => {
@@ -105,7 +105,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
}}
>
<div className={styles.postMenuItem}>{t('copy_content_id')}</div>
</div>
</button>
);
};
@@ -116,8 +116,8 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
onClose();
};
return (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={copyUserId}
onKeyDown={(e) => {
@@ -128,7 +128,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
}}
>
<div className={styles.postMenuItem}>{t('copy_user_id')}</div>
</div>
</button>
);
};
@@ -136,32 +136,20 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
const { t } = useTranslation();
const encodedUrl = encodeURIComponent(url);
return (
<div
role='button'
tabIndex={0}
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClose();
}
}}
>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<div>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
</a>
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
<a href={`https://www.yandex.com/images/search?url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_yandex')}</div>
</a>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_saucenao')}</div>
</a>
</div>
);
};
const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const reportPost = () => {
@@ -169,8 +157,8 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
onClose();
};
return (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={reportPost}
onKeyDown={(e) => {
@@ -181,7 +169,7 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
}}
>
<div className={styles.postMenuItem}>{t('report_post')}</div>
</div>
</button>
);
};
@@ -208,7 +196,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
latestPostRef.current = resolvedPost;
}, [resolvedPost]);
const onChallenge = useCallback(async (...args: unknown[]) => {
addChallenge([...args, latestPostRef.current]);
useChallengesStore.getState().addChallenge([...args, latestPostRef.current]);
}, []);
const deleteOptions = useMemo(
@@ -252,8 +240,8 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
};
return (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={deletePost}
onKeyDown={(e) => {
@@ -264,7 +252,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
}}
>
<div className={styles.postMenuItem}>{t('delete_post')}</div>
</div>
</button>
);
};
@@ -283,8 +271,8 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
};
return (
(!isInPostView || isReply) && (
<div
role='button'
<button
type='button'
tabIndex={0}
onClick={togglePostHidden}
onKeyDown={(e) => {
@@ -297,7 +285,7 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
<div className={styles.postMenuItem}>
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
</div>
</div>
</button>
)
);
};
@@ -343,10 +331,10 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
<>
{!(deleted || removed) && (
<>
<span
<button
type='button'
className={styles.postMenuBtn}
title='Post menu'
role='button'
tabIndex={0}
onClick={handleMenuClick}
onKeyDown={(e) => {
@@ -359,7 +347,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
{...getReferenceProps()}
>
...
</span>
</button>
{isMenuOpen &&
cid &&
createPortal(
+16 -18
View File
@@ -61,8 +61,6 @@ import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAu
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
const { addChallenge } = useChallengesStore.getState();
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
hasMore ? (
<div className={styles.stateString}>
@@ -135,7 +133,7 @@ const PostInfoAndMedia = ({
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, resolvedPost]);
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
@@ -154,7 +152,7 @@ const PostInfoAndMedia = ({
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, resolvedPost]);
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
@@ -331,10 +329,10 @@ const PostInfoAndMedia = ({
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
showTooltip={isInPostPageView || postReplyCount < 6}
>
<span
<button
type='button'
title={t('highlight_posts')}
className={styles.userAddress}
role='button'
tabIndex={0}
onClick={() => handleUserAddressClick(userID, postCid)}
onKeyDown={(e) => {
@@ -346,7 +344,7 @@ const PostInfoAndMedia = ({
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
>
{formatUserIDForDisplay(userID)}
</span>
</button>
</Tooltip>
)}
){' '}
@@ -406,10 +404,10 @@ const PostInfoAndMedia = ({
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No.
</Link>
<span
<button
type='button'
className={styles.replyToPost}
title={t('reply_to_post')}
role='button'
tabIndex={0}
onMouseDown={onReplyModalClick}
onKeyDown={(e) => {
@@ -420,7 +418,7 @@ const PostInfoAndMedia = ({
}}
>
{resolvedPost?.number || '?'}
</span>
</button>
</span>
) : (
<>
@@ -443,10 +441,10 @@ const PostInfoAndMedia = ({
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<button className={`button ${styles.approveButton}`} onClick={handlePendingApprove} disabled={isPublishingPending}>
<button type='button' className={`button ${styles.approveButton}`} onClick={handlePendingApprove} disabled={isPublishingPending}>
{t('approve')}
</button>
<button className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}>
<button type='button' className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')}
</button>
</>
@@ -625,7 +623,7 @@ const PostMobile = ({
const modQueueThreadRouteState = getQueuedCommentRouteState(resolvedPost);
const modQueueErrorMessage = formatErrorForDisplay(modQueueError);
const modQueueRemoveButton = onRemoveFromModQueue ? (
<button className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}>
<button type='button' className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}>
{capitalize(t('modQueue.dismiss'))}
</button>
) : null;
@@ -840,9 +838,9 @@ const PostMobile = ({
<>
<hr className={styles.unhideButtonHr} />
<span className={styles.mobileUnhideButton}>
<span
<button
type='button'
className='button'
role='button'
tabIndex={0}
onClick={unhide}
onKeyDown={(e) => {
@@ -853,7 +851,7 @@ const PostMobile = ({
}}
>
Show Hidden Thread
</span>
</button>
</span>
</>
) : (
@@ -921,10 +919,10 @@ const PostMobile = ({
{t('view_thread')}
</Link>
)}
<button className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
<button type='button' className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
<button type='button' className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
</>
+23 -10
View File
@@ -97,6 +97,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const nonokoRedirectPathRef = useRef<string | null>(null);
const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = useRef(0);
const initializedReplyContentKeyRef = useRef('');
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
const { selectedText } = useSelectedTextStore();
const openEmpty = useReplyModalStore((state) => state.openEmpty);
@@ -243,9 +244,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
);
useEffect(() => {
const checkContentLength = checkContentLengthRef.current;
const checkPostOptions = checkPostOptionsRef.current;
return () => {
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
checkContentLength.cancel();
checkPostOptions.cancel();
restoreBodyTextSelection();
};
}, []);
@@ -293,14 +297,24 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
// Enable spellcheck after initial content is injected into the textarea.
useEffect(() => {
if (showReplyModal && textRef.current) {
if (!showReplyModal || !textRef.current) {
initializedReplyContentKeyRef.current = '';
return;
}
const initialContent = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
const initialContentKey = `${parentCid}:${openEmpty ? 'empty' : 'quoted'}:${initialContent}`;
if (initializedReplyContentKeyRef.current === initialContentKey) {
return;
}
initializedReplyContentKeyRef.current = initialContentKey;
textRef.current.spellcheck = false;
textRef.current.value = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
textRef.current.value = initialContent;
const len = textRef.current.value.length;
lastSelectionStartRef.current = len;
lastSelectionEndRef.current = len;
const content = textRef.current.value;
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
@@ -313,8 +327,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
return () => {
window.clearTimeout(spellcheckTimeout);
};
}
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
}, [showReplyModal, parentCid, openEmpty, defaultParentQuote, selectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
useEffect(() => {
if (!showReplyModal) {
@@ -414,7 +427,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
@@ -568,7 +581,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
<span className={styles.spoilerButton}>
[
<label>
<input type='checkbox' onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
<input type='checkbox' aria-label={capitalize(t('spoiler'))} onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
{capitalize(t('spoiler'))}?
</label>
]
@@ -353,7 +353,9 @@ const MobileQuotePreview = ({
className={styles.backlink}
ref={refs.setReference}
onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)}
onFocus={() => handleMouseOver(normalizedBacklinkReply?.cid)}
onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
onBlur={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
>
{`>>${normalizedBacklinkReply?.number ?? '?'}`}
</span>
@@ -404,7 +406,9 @@ const MobileQuotePreview = ({
ref={quotelinkUnavailable || quotelinkPendingResolution ? undefined : refs.setReference}
className={quotelinkPendingResolution ? styles.quoteLink : quotelinkClassName}
onMouseOver={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
onFocus={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
onMouseLeave={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
onBlur={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
>
{formatQuoteNumber(resolvedQuotelinkNumber)}
{isOP && ' (OP)'}
@@ -80,12 +80,12 @@ const render = (initialEntry = '/all/settings') => {
const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? '';
const getLabelByText = (text: string) => {
const label = Array.from(container.querySelectorAll('label')).find((candidate) => (candidate.textContent ?? '').includes(text));
if (!label) {
throw new Error(`Label containing "${text}" not found`);
const getSectionToggleByText = (text: string) => {
const control = Array.from(container.querySelectorAll<HTMLElement>('label, button')).find((candidate) => (candidate.textContent ?? '').includes(text));
if (!control) {
throw new Error(`Section toggle containing "${text}" not found`);
}
return label;
return control;
};
describe('SettingsModal', () => {
@@ -115,7 +115,7 @@ describe('SettingsModal', () => {
expect(getLocationText()).toBe('/all/settings#account-settings');
await act(async () => {
getLabelByText('interface').click();
getSectionToggleByText('interface').click();
});
expect(getLocationText()).toBe('/all/settings#interface-settings');
@@ -123,14 +123,14 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
await act(async () => {
getLabelByText('interface').click();
getSectionToggleByText('interface').click();
});
expect(getLocationText()).toBe('/all/settings#account-settings');
expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
await act(async () => {
getLabelByText('bitsocial_account').click();
getSectionToggleByText('bitsocial_account').click();
});
expect(getLocationText()).toBe('/all/settings');
@@ -140,7 +140,7 @@ describe('SettingsModal', () => {
it('expands and collapses all settings sections', async () => {
render('/all/settings');
const expandAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => (candidate.textContent ?? '').includes('expand_all_settings'));
const expandAllControl = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').includes('expand_all_settings'));
if (!expandAllControl) {
throw new Error('expand_all_settings control not found');
}
@@ -157,9 +157,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
(candidate.textContent ?? '').includes('collapse_all_settings'),
);
const collapseAllControl = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').includes('collapse_all_settings'));
if (!collapseAllControl) {
throw new Error('collapse_all_settings control not found');
}
@@ -186,7 +184,7 @@ describe('SettingsModal', () => {
it('closes the modal when the overlay is clicked', async () => {
render('/all/settings#interface-settings');
const overlay = container.querySelector('[role="button"]');
const overlay = container.querySelector('button');
if (!overlay) {
throw new Error('overlay not found');
}
@@ -217,13 +217,19 @@ const AccountSettingsEditor = ({
<select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}>
{accountsOptions}
</select>{' '}
<button onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>{t('edit')}</button>{' '}
<button onClick={handleExportAccount}>{t('download_backup')}</button>
<button type='button' onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>
{t('edit')}
</button>{' '}
<button type='button' onClick={handleExportAccount}>
{t('download_backup')}
</button>
<div className={styles.info}>{accountStorageInfo}</div>
</div>
<div>
<button onClick={handleImportAccount}>{t('import_account_backup')}</button>
<button className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
<button type='button' onClick={handleImportAccount}>
{t('import_account_backup')}
</button>
<button type='button' className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
{t('delete_account')}
</button>
</div>
@@ -64,6 +64,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
<div className={styles.ipfsGatewaysSettings}>
<div className={styles.ipfsGatewaysSetting}>
<textarea
aria-label='IPFS gateway URLs'
defaultValue={ipfsGatewayUrlsDefaultValue}
ref={ipfsGatewayUrlsRef}
disabled={isConnectedToRpc}
@@ -77,6 +78,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
<div>
<input
type='text'
aria-label='Media IPFS gateway URL'
defaultValue={mediaIpfsGatewayUrl}
ref={mediaIpfsGatewayUrlRef}
disabled={isConnectedToRpc}
@@ -100,6 +102,7 @@ const PubsubProvidersSettings = ({ pubsubProvidersRef }: SettingsProps) => {
return (
<div className={styles.pubsubProvidersSettings}>
<textarea
aria-label='Pubsub providers'
defaultValue={pubsubProvidersDefaultValue}
ref={pubsubProvidersRef}
disabled={isConnectedToRpc}
@@ -124,6 +127,7 @@ const HttpRoutersSettings = ({ httpRoutersRef }: SettingsProps) => {
return (
<div className={styles.httpRoutersSettings}>
<textarea
aria-label='HTTP routers'
defaultValue={httpRoutersDefaultValue}
ref={httpRoutersRef}
disabled={isConnectedToRpc}
@@ -147,6 +151,7 @@ const BlockchainProvidersSettings = ({ ethRpcRef }: SettingsProps) => {
<span className={styles.settingTip}>Ethereum RPC, for .eth domains</span>
<div>
<textarea
aria-label='Ethereum RPC URLs'
defaultValue={ethRpcDefaultValue}
ref={ethRpcRef}
autoCorrect='off'
@@ -168,6 +173,7 @@ const P2pRPCSettings = ({ p2pRpcRef }: SettingsProps) => {
<div className={styles.p2pRPCSettings}>
<input
type='text'
aria-label='P2P RPC clients'
defaultValue={pkcRpcClientsOptions}
placeholder='ws://<IP>:<port>/<secretAuthKey>'
ref={p2pRpcRef}
@@ -188,7 +194,16 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => {
return (
<div className={styles.p2pDataPathSettings}>
<div>
<input autoCorrect='off' autoCapitalize='off' spellCheck='false' type='text' defaultValue={path} disabled={!isConnectedToRpc} ref={p2pDataPathRef} />
<input
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
type='text'
aria-label='P2P data path'
defaultValue={path}
disabled={!isConnectedToRpc}
ref={p2pDataPathRef}
/>
</div>
</div>
);
@@ -205,6 +220,7 @@ const PureP2PBrowserSettings = ({ onPureP2PBrowserChange, pureP2PBrowserEnabled,
<input
className={styles.pureP2PCheckbox}
type='checkbox'
aria-label='Pure P2P browser mode'
checked={isChecked}
ref={pureP2PBrowserRef}
onChange={(event) => onPureP2PBrowserChange?.(event.currentTarget.checked)}
@@ -363,7 +379,7 @@ const AdvancedSettings = () => {
{canConfigurePureP2PBrowser ? (
<PureP2PBrowserSettings pureP2PBrowserEnabled={browserPureP2PEnabled} pureP2PBrowserRef={pureP2PBrowserRef} onPureP2PBrowserChange={setBrowserPureP2PSelection} />
) : null}
<button className={styles.saveOptions} onClick={handleSave}>
<button type='button' className={styles.saveOptions} onClick={handleSave}>
{t('save_advanced_settings')}
</button>
</div>
@@ -178,6 +178,7 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
<div className={styles.cryptoAddressInput}>
<input
type='text'
aria-label='Crypto address'
placeholder='myaddress.bso'
value={cryptoAddress}
onChange={(e) => {
@@ -186,33 +187,48 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
setCryptoAddress(e.target.value);
}}
/>
<button className={styles.saveButton} onClick={saveCryptoAddress}>
<button type='button' className={styles.saveButton} onClick={saveCryptoAddress}>
{t('save')}
</button>
<button className={styles.infoButton} onClick={() => setShowCryptoAddressInfo(!showCryptoAddressInfo)}>
<button
type='button'
className={styles.infoButton}
aria-label={showCryptoAddressInfo ? 'Hide crypto address help' : 'Show crypto address help'}
onClick={() => setShowCryptoAddressInfo(!showCryptoAddressInfo)}
>
{showCryptoAddressInfo ? 'x' : '?'}
</button>
{showCryptoAddressInfo && (
<div className={styles.cryptoAddressInfo}>
steps to set a .bso account address:
<br />
A <code>.bso</code> address is just an ENS name you own, shown by 5chan with a <code>.bso</code> ending instead of <code>.eth</code>. To use one as your
account name:
<ol>
<li>
buy your desired .bso address as .eth on{' '}
Register a name (e.g. <code>yourname.eth</code>) at{' '}
<a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'>
app.ens.domains
</a>{' '}
</a>
.
</li>
<li>
Open that name on ENS and go to <strong>Records</strong> <strong>Edit Records</strong>.
</li>
<li>
Add a <strong>text record</strong> named <code>bitsocial</code> and paste this account&apos;s public key as its value:
<br />
<code>{account?.signer?.address}</code>
</li>
<li>
Come back here, type your name ending in <code>.bso</code> (e.g. <code>yourname.bso</code>) in the field above, press <strong>Check</strong> to confirm it
points to this account, then press <strong>Save</strong>.
</li>
<li>once you own the .eth address, go to its page on ENS, click on "records", then "edit records"</li>
<li>add a new text record with name "bitsocial" and value: {account?.signer?.address}</li>
<li>enter your .bso address in the input field above, click "check" to verify it's yours, then click "save"</li>
</ol>
</div>
)}
{savedCryptoAddress && <span className={styles.saved}>{t('saved')}</span>}
</div>
<div className={styles.checkCryptoAddress}>
<button className={styles.button} onClick={checkCryptoAddress}>
<button type='button' className={styles.button} onClick={checkCryptoAddress}>
{t('check')}
</button>{' '}
<span className={(transientResolutionStatus ?? resolutionStatus).resolveClass}>{(transientResolutionStatus ?? resolutionStatus).resolveString}</span>

Some files were not shown because too many files have changed in this diff Show More