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_worksafe_boards_only": "عرض لوحات آمنة للعمل فقط",
"show_all_content": "عرض جميع المحتويات", "show_all_content": "عرض جميع المحتويات",
"link_type": "نوع الرابط", "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": "جار التحميل", "loading": "جار التحميل",
"posted_by": "نشر بواسطة", "posted_by": "نشر بواسطة",
"by": "بواسطة", "by": "بواسطة",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات</1> لاستيراد حساب المشرف الخاص بك", "go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات</1> لاستيراد حساب المشرف الخاص بك",
"post_form_rules_faq_prompt": "يُرجى قراءة <rules>القواعد</rules> و<faq>الأسئلة الشائعة</faq> قبل النشر.", "post_form_rules_faq_prompt": "يُرجى قراءة <rules>القواعد</rules> و<faq>الأسئلة الشائعة</faq> قبل النشر.",
"pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\".", "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_worksafe_boards_only": "শুধুমাত্র কাজ-সম্পূর্ণ বোর্ড দেখান",
"show_all_content": "সমস্ত কন্টেন্ট দেখান", "show_all_content": "সমস্ত কন্টেন্ট দেখান",
"link_type": "লিংক প্রকার", "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": "লোড হচ্ছে", "loading": "লোড হচ্ছে",
"posted_by": "পোস্ট করেছেন", "posted_by": "পোস্ট করেছেন",
"by": "দ্বারা", "by": "দ্বারা",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস</1>-এ যান", "go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস</1>-এ যান",
"post_form_rules_faq_prompt": "পোস্ট করার আগে <rules>নিয়ম</rules> এবং <faq>প্রশ্নোত্তর</faq> পড়ুন।", "post_form_rules_faq_prompt": "পোস্ট করার আগে <rules>নিয়ম</rules> এবং <faq>প্রশ্নোত্তর</faq> পড়ুন।",
"pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\".", "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_worksafe_boards_only": "Zobrazit pouze bezpečné pracovní desky",
"show_all_content": "Zobrazit veškerý obsah", "show_all_content": "Zobrazit veškerý obsah",
"link_type": "Typ odkazu", "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í", "loading": "Načítání",
"posted_by": "Publikoval(a)", "posted_by": "Publikoval(a)",
"by": "od", "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", "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>.", "post_form_rules_faq_prompt": "Před odesláním si přečtěte <rules>Pravidla</rules> a <faq>FAQ</faq>.",
"pending_mod_approval_reason": "AI moderace odeslala tento příspěvek do fronty moderátorů. Důvod: \"{{reason}}\".", "pending_mod_approval_reason": "AI moderace odeslala tento příspěvek do fronty moderátorů. Důvod: \"{{reason}}\".",
"flag": "Vlajka" "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_worksafe_boards_only": "Vis kun Worksafe boards",
"show_all_content": "Vis alt indhold", "show_all_content": "Vis alt indhold",
"link_type": "Link type", "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", "loading": "Indlæser",
"posted_by": "Skrevet af", "posted_by": "Skrevet af",
"by": "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", "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.", "post_form_rules_faq_prompt": "Læs venligst <rules>Reglerne</rules> og <faq>FAQ</faq>, før du poster.",
"pending_mod_approval_reason": "AI-moderering sendte dette opslag til moderatorkøen. Grund: \"{{reason}}\".", "pending_mod_approval_reason": "AI-moderering sendte dette opslag til moderatorkøen. Grund: \"{{reason}}\".",
"flag": "Flag" "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_worksafe_boards_only": "Nur sichere Boards anzeigen",
"show_all_content": "Alle Inhalte anzeigen", "show_all_content": "Alle Inhalte anzeigen",
"link_type": "Linktyp", "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", "loading": "Laden",
"posted_by": "Geschrieben von", "posted_by": "Geschrieben von",
"by": "von", "by": "von",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Gehe zu <1>Einstellungen</1>, um dein Moderatorkonto zu importieren", "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.", "post_form_rules_faq_prompt": "Bitte lesen Sie die <rules>Regeln</rules> und die <faq>FAQ</faq>, bevor Sie posten.",
"pending_mod_approval_reason": "Die KI-Moderation hat diesen Beitrag in die Moderationswarteschlange verschoben. Grund: \"{{reason}}\".", "pending_mod_approval_reason": "Die KI-Moderation hat diesen Beitrag in die Moderationswarteschlange verschoben. Grund: \"{{reason}}\".",
"flag": "Flagge" "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_worksafe_boards_only": "Εμφάνιση μόνο ασφαλών πινάκων εργασίας",
"show_all_content": "Εμφάνιση όλων των περιεχομένων", "show_all_content": "Εμφάνιση όλων των περιεχομένων",
"link_type": "Τύπος συνδέσμου", "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": "Φόρτωση", "loading": "Φόρτωση",
"posted_by": "Δημοσιεύτηκε από", "posted_by": "Δημοσιεύτηκε από",
"by": "από", "by": "από",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις</1> για να εισαγάγετε τον λογαριασμό διαχειριστή σας", "go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις</1> για να εισαγάγετε τον λογαριασμό διαχειριστή σας",
"post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους <rules>Κανόνες</rules> και τις <faq>Συχνές ερωτήσεις</faq> πριν δημοσιεύσετε.", "post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους <rules>Κανόνες</rules> και τις <faq>Συχνές ερωτήσεις</faq> πριν δημοσιεύσετε.",
"pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\".", "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", "nsfw_boards_only": "NSFW Boards Only",
"worksafe_boards_only": "Worksafe Boards Only", "worksafe_boards_only": "Worksafe Boards Only",
"link_type": "Link type", "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", "loading": "Loading",
"posted_by": "Posted by", "posted_by": "Posted by",
"by": "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.", "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.", "post_form_rules_faq_prompt": "Please read the <rules>Rules</rules> and <faq>FAQ</faq> before posting.",
"pending_mod_approval_reason": "AI moderation sent this post to the mod queue. Reason: \"{{reason}}\".", "pending_mod_approval_reason": "AI moderation sent this post to the mod queue. Reason: \"{{reason}}\".",
"flag": "Flag" "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_worksafe_boards_only": "Mostrar solo tableros seguros para el trabajo",
"show_all_content": "Mostrar todo el contenido", "show_all_content": "Mostrar todo el contenido",
"link_type": "Tipo de enlace", "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", "loading": "Cargando",
"posted_by": "Publicado por", "posted_by": "Publicado por",
"by": "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", "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.", "post_form_rules_faq_prompt": "Por favor, lee las <rules>Reglas</rules> y las <faq>FAQ</faq> antes de publicar.",
"pending_mod_approval_reason": "La moderación con IA envió esta publicación a la cola de moderación. Motivo: \"{{reason}}\".", "pending_mod_approval_reason": "La moderación con IA envió esta publicación a la cola de moderación. Motivo: \"{{reason}}\".",
"flag": "Bandera" "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_worksafe_boards_only": "نمایش تنها بوردهای ایمن برای کار",
"show_all_content": "نمایش تمام محتوا", "show_all_content": "نمایش تمام محتوا",
"link_type": "نوع پیوند", "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": "در حال بارگذاری", "loading": "در حال بارگذاری",
"posted_by": "ارسال شده توسط", "posted_by": "ارسال شده توسط",
"by": "توسط", "by": "توسط",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات</1> بروید", "go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات</1> بروید",
"post_form_rules_faq_prompt": "لطفاً قبل از ارسال <rules>قوانین</rules> و <faq>پرسش‌های متداول</faq> را بخوانید.", "post_form_rules_faq_prompt": "لطفاً قبل از ارسال <rules>قوانین</rules> و <faq>پرسش‌های متداول</faq> را بخوانید.",
"pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\".", "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_worksafe_boards_only": "Näytä vain työturvalliset hallit",
"show_all_content": "Näytä kaikki sisällöt", "show_all_content": "Näytä kaikki sisällöt",
"link_type": "Linkin tyyppi", "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", "loading": "Lataa",
"posted_by": "Lähettäjä", "posted_by": "Lähettäjä",
"by": "kirjoittanut", "by": "kirjoittanut",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Siirry <1>asetuksiin</1> tuodaksesi moderaattoritilisi", "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.", "post_form_rules_faq_prompt": "Lue <rules>Säännöt</rules> ja <faq>UKK</faq> ennen julkaisemista.",
"pending_mod_approval_reason": "Tekoälymoderointi lähetti tämän viestin moderointijonoon. Syy: \"{{reason}}\".", "pending_mod_approval_reason": "Tekoälymoderointi lähetti tämän viestin moderointijonoon. Syy: \"{{reason}}\".",
"flag": "Lippu" "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_worksafe_boards_only": "Ipakita lamang ang mga Worksafe boards",
"show_all_content": "Ipakita lahat ng nilalaman", "show_all_content": "Ipakita lahat ng nilalaman",
"link_type": "Uri ng Link", "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", "loading": "Naglo-load",
"posted_by": "Nai-post ni", "posted_by": "Nai-post ni",
"by": "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", "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.", "post_form_rules_faq_prompt": "Pakibasa ang <rules>Rules</rules> at <faq>FAQ</faq> bago mag-post.",
"pending_mod_approval_reason": "Ipinadala ng AI moderation ang post na ito sa mod queue. Dahilan: \"{{reason}}\".", "pending_mod_approval_reason": "Ipinadala ng AI moderation ang post na ito sa mod queue. Dahilan: \"{{reason}}\".",
"flag": "Bandila" "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_worksafe_boards_only": "Afficher uniquement les tableaux Worksafe",
"show_all_content": "Afficher tout le contenu", "show_all_content": "Afficher tout le contenu",
"link_type": "Type de lien", "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", "loading": "Chargement",
"posted_by": "Posté par", "posted_by": "Posté par",
"by": "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", "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.", "post_form_rules_faq_prompt": "Veuillez lire le <rules>Règlement</rules> et la <faq>FAQ</faq> avant de publier.",
"pending_mod_approval_reason": "La modération par IA a envoyé ce post dans la file de modération. Raison: \"{{reason}}\".", "pending_mod_approval_reason": "La modération par IA a envoyé ce post dans la file de modération. Raison: \"{{reason}}\".",
"flag": "Drapeau" "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_worksafe_boards_only": "הצג לוחות עבודה בטוחים בלבד",
"show_all_content": "הצג את כל התוכן", "show_all_content": "הצג את כל התוכן",
"link_type": "סוג קישור", "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": "טוען", "loading": "טוען",
"posted_by": "פורסם על ידי", "posted_by": "פורסם על ידי",
"by": "על ידי", "by": "על ידי",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות</1> כדי לייבא את חשבון המוד שלך", "go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות</1> כדי לייבא את חשבון המוד שלך",
"post_form_rules_faq_prompt": "אנא קרא את <rules>הכללים</rules> ואת <faq>שאלות נפוצות</faq> לפני פרסום.", "post_form_rules_faq_prompt": "אנא קרא את <rules>הכללים</rules> ואת <faq>שאלות נפוצות</faq> לפני פרסום.",
"pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\".", "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_worksafe_boards_only": "केवल कामसुरक्षित बोर्ड्स दिखाएं",
"show_all_content": "सभी सामग्री दिखाएं", "show_all_content": "सभी सामग्री दिखाएं",
"link_type": "लिंक प्रकार", "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": "लोड हो रहा है", "loading": "लोड हो रहा है",
"posted_by": "द्वारा लिखित", "posted_by": "द्वारा लिखित",
"by": "द्वारा", "by": "द्वारा",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स</1> पर जाएं", "go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स</1> पर जाएं",
"post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया <rules>नियम</rules> और <faq>अक्सर पूछे जाने वाले सवाल</faq> पढ़ें।", "post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया <rules>नियम</rules> और <faq>अक्सर पूछे जाने वाले सवाल</faq> पढ़ें।",
"pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\".", "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_worksafe_boards_only": "Csak munkabiztos táblák mutatása",
"show_all_content": "Mutasd az összes tartalmat", "show_all_content": "Mutasd az összes tartalmat",
"link_type": "Link típusa", "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", "loading": "Betöltés",
"posted_by": "Írta", "posted_by": "Írta",
"by": "által", "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", "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.", "post_form_rules_faq_prompt": "Kérjük, olvasd el a <rules>Szabályokat</rules> és a <faq>GYIK</faq>-et posztolás előtt.",
"pending_mod_approval_reason": "Az AI-moderáció ezt a bejegyzést a moderátori várólistára küldte. Ok: \"{{reason}}\".", "pending_mod_approval_reason": "Az AI-moderáció ezt a bejegyzést a moderátori várólistára küldte. Ok: \"{{reason}}\".",
"flag": "Zászló" "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_worksafe_boards_only": "Tampilkan Hanya Board Worksafe",
"show_all_content": "Tampilkan Semua Konten", "show_all_content": "Tampilkan Semua Konten",
"link_type": "Tipe tautan", "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", "loading": "Memuat",
"posted_by": "Diposting oleh", "posted_by": "Diposting oleh",
"by": "oleh", "by": "oleh",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Buka <1>pengaturan</1> untuk mengimpor akun mod Anda", "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.", "post_form_rules_faq_prompt": "Silakan baca <rules>Aturan</rules> dan <faq>FAQ</faq> sebelum memposting.",
"pending_mod_approval_reason": "Moderasi AI mengirim postingan ini ke antrean moderator. Alasan: \"{{reason}}\".", "pending_mod_approval_reason": "Moderasi AI mengirim postingan ini ke antrean moderator. Alasan: \"{{reason}}\".",
"flag": "Bendera" "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_worksafe_boards_only": "Mostra solo le board Worksafe",
"show_all_content": "Mostra tutti i contenuti", "show_all_content": "Mostra tutti i contenuti",
"link_type": "Tipo di link", "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", "loading": "Caricamento",
"posted_by": "Pubblicato da", "posted_by": "Pubblicato da",
"by": "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", "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.", "post_form_rules_faq_prompt": "Leggi le <rules>Regole</rules> e le <faq>FAQ</faq> prima di pubblicare.",
"pending_mod_approval_reason": "La moderazione AI ha inviato questo post alla coda di moderazione. Motivo: \"{{reason}}\".", "pending_mod_approval_reason": "La moderazione AI ha inviato questo post alla coda di moderazione. Motivo: \"{{reason}}\".",
"flag": "Bandiera" "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_worksafe_boards_only": "Worksafeボードのみ表示",
"show_all_content": "すべてのコンテンツを表示", "show_all_content": "すべてのコンテンツを表示",
"link_type": "リンクの種類", "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": "読み込み中", "loading": "読み込み中",
"posted_by": "投稿者", "posted_by": "投稿者",
"by": "によって", "by": "によって",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "モデレーターアカウントをインポートするには<1>設定</1>に移動してください", "go_to_settings_to_import_mod_account": "モデレーターアカウントをインポートするには<1>設定</1>に移動してください",
"post_form_rules_faq_prompt": "投稿する前に<rules>ルール</rules>と<faq>FAQ</faq>をお読みください。", "post_form_rules_faq_prompt": "投稿する前に<rules>ルール</rules>と<faq>FAQ</faq>をお読みください。",
"pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\".", "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_worksafe_boards_only": "작업 안전 보드만 표시",
"show_all_content": "모든 콘텐츠 보기", "show_all_content": "모든 콘텐츠 보기",
"link_type": "링크 유형", "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": "로딩 중", "loading": "로딩 중",
"posted_by": "작성자", "posted_by": "작성자",
"by": "작성자:", "by": "작성자:",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "관리자 계정을 가져오려면 <1>설정</1>으로 이동하세요", "go_to_settings_to_import_mod_account": "관리자 계정을 가져오려면 <1>설정</1>으로 이동하세요",
"post_form_rules_faq_prompt": "게시하기 전에 <rules>규칙</rules>과 <faq>FAQ</faq>을 읽어 주세요.", "post_form_rules_faq_prompt": "게시하기 전에 <rules>규칙</rules>과 <faq>FAQ</faq>을 읽어 주세요.",
"pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\".", "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_worksafe_boards_only": "केवळ कामसुरक्षित बोर्ड्स दाखवा",
"show_all_content": "सर्व सामग्री दाखवा", "show_all_content": "सर्व सामग्री दाखवा",
"link_type": "लिंक प्रकार", "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": "लोड होत आहे", "loading": "लोड होत आहे",
"posted_by": "ने टप्पल केला", "posted_by": "ने टप्पल केला",
"by": "व्यक्ती", "by": "व्यक्ती",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज</1> वर जा", "go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज</1> वर जा",
"post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी <rules>नियम</rules> आणि <faq>वारंवार विचारले जाणारे प्रश्न</faq> वाचा.", "post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी <rules>नियम</rules> आणि <faq>वारंवार विचारले जाणारे प्रश्न</faq> वाचा.",
"pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\".", "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_worksafe_boards_only": "Toon alleen Worksafe boards",
"show_all_content": "Toon alle inhoud", "show_all_content": "Toon alle inhoud",
"link_type": "Link type", "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", "loading": "Laden",
"posted_by": "Geplaatst door", "posted_by": "Geplaatst door",
"by": "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", "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.", "post_form_rules_faq_prompt": "Lees de <rules>Regels</rules> en de <faq>FAQ</faq> voordat je plaatst.",
"pending_mod_approval_reason": "AI-moderatie heeft dit bericht naar de moderatiewachtrij gestuurd. Reden: \"{{reason}}\".", "pending_mod_approval_reason": "AI-moderatie heeft dit bericht naar de moderatiewachtrij gestuurd. Reden: \"{{reason}}\".",
"flag": "Vlag" "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_worksafe_boards_only": "Vis kun Worksafe boards",
"show_all_content": "Vis allt innhold", "show_all_content": "Vis allt innhold",
"link_type": "Lenketype", "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", "loading": "Laster",
"posted_by": "Skrevet av", "posted_by": "Skrevet av",
"by": "av", "by": "av",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Gå til <1>innstillinger</1> for å importere modkontoen din", "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.", "post_form_rules_faq_prompt": "Vennligst les <rules>Reglene</rules> og <faq>FAQ</faq> før du poster.",
"pending_mod_approval_reason": "AI-moderering sendte dette innlegget til moderatorkøen. Årsak: \"{{reason}}\".", "pending_mod_approval_reason": "AI-moderering sendte dette innlegget til moderatorkøen. Årsak: \"{{reason}}\".",
"flag": "Flagg" "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_worksafe_boards_only": "Pokaż tylko bezpieczne tablice",
"show_all_content": "Pokaż całą zawartość", "show_all_content": "Pokaż całą zawartość",
"link_type": "Typ łącza", "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", "loading": "Ładowanie",
"posted_by": "Opublikowane przez", "posted_by": "Opublikowane przez",
"by": "przez", "by": "przez",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Przejdź do <1>ustawień</1>, aby zaimportować swoje konto moderatora", "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>.", "post_form_rules_faq_prompt": "Przed opublikowaniem przeczytaj <rules>Zasady</rules> i <faq>FAQ</faq>.",
"pending_mod_approval_reason": "Moderacja AI wysłała ten post do kolejki moderatorów. Powód: \"{{reason}}\".", "pending_mod_approval_reason": "Moderacja AI wysłała ten post do kolejki moderatorów. Powód: \"{{reason}}\".",
"flag": "Flaga" "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_worksafe_boards_only": "Mostrar apenas boards Worksafe",
"show_all_content": "Mostrar Todo o Conteúdo", "show_all_content": "Mostrar Todo o Conteúdo",
"link_type": "Tipo de link", "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", "loading": "Carregando",
"posted_by": "Postado por", "posted_by": "Postado por",
"by": "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", "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.", "post_form_rules_faq_prompt": "Leia as <rules>Regras</rules> e as <faq>FAQ</faq> antes de publicar.",
"pending_mod_approval_reason": "A moderação por IA enviou esta postagem para a fila de moderação. Razão: \"{{reason}}\".", "pending_mod_approval_reason": "A moderação por IA enviou esta postagem para a fila de moderação. Razão: \"{{reason}}\".",
"flag": "Bandeira" "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_worksafe_boards_only": "Afișați doar tablourile Worksafe",
"show_all_content": "Afișați tot conținutul", "show_all_content": "Afișați tot conținutul",
"link_type": "Tipul linkului", "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", "loading": "Încărcare",
"posted_by": "Postat de", "posted_by": "Postat de",
"by": "de către", "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", "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.", "post_form_rules_faq_prompt": "Citește <rules>Regulile</rules> și <faq>Întrebările frecvente</faq> înainte de a publica.",
"pending_mod_approval_reason": "Moderarea AI a trimis această postare în coada moderatorilor. Motiv: \"{{reason}}\".", "pending_mod_approval_reason": "Moderarea AI a trimis această postare în coada moderatorilor. Motiv: \"{{reason}}\".",
"flag": "Steag" "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_worksafe_boards_only": "Показывать только безопасные доски",
"show_all_content": "Показать весь контент", "show_all_content": "Показать весь контент",
"link_type": "Тип ссылки", "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": "Загрузка", "loading": "Загрузка",
"posted_by": "Опубликовано", "posted_by": "Опубликовано",
"by": "от", "by": "от",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Перейдите в <1>настройки</1>, чтобы импортировать свой аккаунт модератора", "go_to_settings_to_import_mod_account": "Перейдите в <1>настройки</1>, чтобы импортировать свой аккаунт модератора",
"post_form_rules_faq_prompt": "Пожалуйста, прочитайте <rules>Правила</rules> и <faq>FAQ</faq> перед публикацией.", "post_form_rules_faq_prompt": "Пожалуйста, прочитайте <rules>Правила</rules> и <faq>FAQ</faq> перед публикацией.",
"pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\".", "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_worksafe_boards_only": "Shfaq vetëm taulat e sigurta të punës",
"show_all_content": "Shfaq të gjitha përmbajtjet", "show_all_content": "Shfaq të gjitha përmbajtjet",
"link_type": "Lloji i lidhjes", "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", "loading": "Ngarkimi",
"posted_by": "Postuar nga", "posted_by": "Postuar nga",
"by": "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", "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.", "post_form_rules_faq_prompt": "Ju lutemi lexoni <rules>Rregullat</rules> dhe <faq>Pyetjet e shpeshta</faq> para se të postoni.",
"pending_mod_approval_reason": "Moderimi me AI e dërgoi këtë postim në radhën e moderatorëve. Arsye: \"{{reason}}\".", "pending_mod_approval_reason": "Moderimi me AI e dërgoi këtë postim në radhën e moderatorëve. Arsye: \"{{reason}}\".",
"flag": "Flamur" "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_worksafe_boards_only": "Visa endast Worksafe-styrelser",
"show_all_content": "Visa allt innehåll", "show_all_content": "Visa allt innehåll",
"link_type": "Länktyp", "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", "loading": "Laddar",
"posted_by": "Postat av", "posted_by": "Postat av",
"by": "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", "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.", "post_form_rules_faq_prompt": "Läs <rules>Reglerna</rules> och <faq>FAQ</faq> innan du postar.",
"pending_mod_approval_reason": "AI-moderering skickade det här inlägget till moderatorkön. Anledning: \"{{reason}}\".", "pending_mod_approval_reason": "AI-moderering skickade det här inlägget till moderatorkön. Anledning: \"{{reason}}\".",
"flag": "Flagga" "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_worksafe_boards_only": "పనిమాడు భద్రపరిచిన బోర్డులను మాత్రమే చూపించు",
"show_all_content": "అన్ని కాంటెంట్ చూపించు", "show_all_content": "అన్ని కాంటెంట్ చూపించు",
"link_type": "లింక్ రకం", "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": "లోడ్ అవుతోంది", "loading": "లోడ్ అవుతోంది",
"posted_by": "పోస్ట్ చేసినవాడు", "posted_by": "పోస్ట్ చేసినవాడు",
"by": "ద్వారా", "by": "ద్వారా",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలు</1>కి వెళ్లండి", "go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలు</1>కి వెళ్లండి",
"post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు <rules>నియమాలు</rules> మరియు <faq>తరచుగా అడిగే ప్రశ్నలు</faq> చదవండి.", "post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు <rules>నియమాలు</rules> మరియు <faq>తరచుగా అడిగే ప్రశ్నలు</faq> చదవండి.",
"pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్‌ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\".", "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_worksafe_boards_only": "แสดงบอร์ด Worksafe เท่านั้น",
"show_all_content": "แสดงเนื้อหาทั้งหมด", "show_all_content": "แสดงเนื้อหาทั้งหมด",
"link_type": "ประเภทลิงก์", "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": "กำลังโหลด", "loading": "กำลังโหลด",
"posted_by": "โพสต์โดย", "posted_by": "โพสต์โดย",
"by": "โดย", "by": "โดย",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า</1> เพื่อนำเข้าบัญชีผู้ดูแลของคุณ", "go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า</1> เพื่อนำเข้าบัญชีผู้ดูแลของคุณ",
"post_form_rules_faq_prompt": "โปรดอ่าน<rules>กฎ</rules>และ<faq>คำถามที่พบบ่อย</faq>ก่อนโพสต์", "post_form_rules_faq_prompt": "โปรดอ่าน<rules>กฎ</rules>และ<faq>คำถามที่พบบ่อย</faq>ก่อนโพสต์",
"pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\".", "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_worksafe_boards_only": "Sadece Worksafe Panoları Göster",
"show_all_content": "Tüm İçeriği Göster", "show_all_content": "Tüm İçeriği Göster",
"link_type": "Bağlantı türü", "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", "loading": "Yükleniyor",
"posted_by": "Tarafından gönderildi", "posted_by": "Tarafından gönderildi",
"by": "tarafından", "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", "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.", "post_form_rules_faq_prompt": "Lütfen göndermeden önce <rules>Kuralları</rules> ve <faq>SSS</faq>'yi okuyun.",
"pending_mod_approval_reason": "AI moderasyonu bu gönderiyi moderatör kuyruğuna gönderdi. Neden: \"{{reason}}\".", "pending_mod_approval_reason": "AI moderasyonu bu gönderiyi moderatör kuyruğuna gönderdi. Neden: \"{{reason}}\".",
"flag": "Bayrak" "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_worksafe_boards_only": "Показувати лише безпечні дошки",
"show_all_content": "Показати весь вміст", "show_all_content": "Показати весь вміст",
"link_type": "Тип посилання", "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": "Завантаження", "loading": "Завантаження",
"posted_by": "Опубліковано", "posted_by": "Опубліковано",
"by": "від", "by": "від",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань</1>, щоб імпортувати свій обліковий запис модератора", "go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань</1>, щоб імпортувати свій обліковий запис модератора",
"post_form_rules_faq_prompt": "Будь ласка, прочитайте <rules>Правила</rules> і <faq>Часті запитання</faq> перед публікацією.", "post_form_rules_faq_prompt": "Будь ласка, прочитайте <rules>Правила</rules> і <faq>Часті запитання</faq> перед публікацією.",
"pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\".", "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_worksafe_boards_only": "صرف کام سے بچائے گئے بورڈز دکھائیں",
"show_all_content": "تمام مواد دکھائیں", "show_all_content": "تمام مواد دکھائیں",
"link_type": "لنک کی قسم", "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": "لوڈ ہو رہا ہے", "loading": "لوڈ ہو رہا ہے",
"posted_by": "پوسٹ کیا گیا", "posted_by": "پوسٹ کیا گیا",
"by": "کے ذریعے", "by": "کے ذریعے",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات</1> پر جائیں", "go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات</1> پر جائیں",
"post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے <rules>اصول</rules> اور <faq>اکثر پوچھے گئے سوالات</faq> پڑھیں۔", "post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے <rules>اصول</rules> اور <faq>اکثر پوچھے گئے سوالات</faq> پڑھیں۔",
"pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\".", "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_worksafe_boards_only": "Chỉ hiển thị bảng Worksafe",
"show_all_content": "Hiển thị tất cả nội dung", "show_all_content": "Hiển thị tất cả nội dung",
"link_type": "Loại liên kết", "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", "loading": "Đang tải",
"posted_by": "Được đăng bởi", "posted_by": "Được đăng bởi",
"by": "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", "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.", "post_form_rules_faq_prompt": "Vui lòng đọc <rules>Quy tắc</rules> và <faq>Câu hỏi thường gặp</faq> trước khi đăng.",
"pending_mod_approval_reason": "Kiểm duyệt AI đã gửi bài viết này vào hàng đợi kiểm duyệt. Lý do: \"{{reason}}\".", "pending_mod_approval_reason": "Kiểm duyệt AI đã gửi bài viết này vào hàng đợi kiểm duyệt. Lý do: \"{{reason}}\".",
"flag": "Cờ" "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_worksafe_boards_only": "仅显示安全工作板",
"show_all_content": "显示所有内容", "show_all_content": "显示所有内容",
"link_type": "链接类型", "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": "加载中", "loading": "加载中",
"posted_by": "由...发布", "posted_by": "由...发布",
"by": "由...发布", "by": "由...发布",
@@ -393,5 +389,13 @@
"go_to_settings_to_import_mod_account": "前往<1>设置</1>以导入您的版主账户", "go_to_settings_to_import_mod_account": "前往<1>设置</1>以导入您的版主账户",
"post_form_rules_faq_prompt": "发帖前请阅读<rules>规则</rules>和<faq>常见问题</faq>。", "post_form_rules_faq_prompt": "发帖前请阅读<rules>规则</rules>和<faq>常见问题</faq>。",
"pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。", "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 styles from './app.module.css';
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons'; import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
import Blotter from './views/blotter'; import Blotter from './views/blotter';
import FAQ from './views/faq'; import Faq from './views/faq';
import Home from './views/home'; import Home from './views/home';
import Archive from './views/archive/archive'; import Archive from './views/archive/archive';
import Directory from './views/directory/directory'; import Directory from './views/directory/directory';
@@ -282,7 +282,7 @@ const App = () => {
<Routes> <Routes>
<Route element={<GlobalLayout />}> <Route element={<GlobalLayout />}>
<Route path='/' element={<Home />} /> <Route path='/' element={<Home />} />
<Route path='/faq' element={<FAQ />} /> <Route path='/faq' element={<Faq />} />
<Route path='/pass' element={<Pass />} /> <Route path='/pass' element={<Pass />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} /> <Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route path='/blotter' element={<Blotter />} /> <Route path='/blotter' element={<Blotter />} />
@@ -34,6 +34,22 @@
text-transform: capitalize; 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, .hideButton:hover,
.actionLink:hover { .actionLink:hover {
cursor: pointer; cursor: pointer;
@@ -19,8 +19,8 @@ const BoardBlotter = () => {
<div className={`${styles.content} ${styles.show}`}> <div className={`${styles.content} ${styles.show}`}>
<table className={styles.blotter}> <table className={styles.blotter}>
<thead> <thead>
<tr> <tr aria-label='Blotter separator'>
<td> <td aria-label='Blotter divider'>
<hr /> <hr />
</td> </td>
</tr> </tr>
@@ -40,9 +40,9 @@ const BoardBlotter = () => {
<tr> <tr>
<td> <td>
[ [
<span <button
type='button'
className={styles.hideButton} className={styles.hideButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -53,7 +53,7 @@ const BoardBlotter = () => {
onClick={() => toggleVisibility()} onClick={() => toggleVisibility()}
> >
{isHidden ? t('show_blotter') : t('hide')} {isHidden ? t('show_blotter') : t('hide')}
</span> </button>
] ]
{!isHidden && ( {!isHidden && (
<> <>
+18 -9
View File
@@ -110,7 +110,7 @@ export const ArchiveButton = ({ address, isInAllView, isInSubscriptionsView, isI
} }
return ( return (
<button className='button' onClick={() => navigate(archivePath)}> <button type='button' className='button' onClick={() => navigate(archivePath)}>
{t('archive')} {t('archive')}
</button> </button>
); );
@@ -121,7 +121,7 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address }); const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
return ( return (
<button className='button' onClick={subscribed ? unsubscribe : subscribe}> <button type='button' className='button' onClick={subscribed ? unsubscribe : subscribe}>
{subscribed ? t('unsubscribe') : t('subscribe')} {subscribed ? t('unsubscribe') : t('subscribe')}
</button> </button>
); );
@@ -186,7 +186,7 @@ export const RefreshButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const reset = useFeedResetStore((state) => state.reset); const reset = useFeedResetStore((state) => state.reset);
return ( return (
<button className='button' onClick={() => reset && reset()}> <button type='button' className='button' onClick={() => reset && reset()}>
{t('refresh')} {t('refresh')}
</button> </button>
); );
@@ -250,7 +250,7 @@ export const UpdateButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate); const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
return ( return (
<button className='button' onClick={() => requestUpdate()}> <button type='button' className='button' onClick={() => requestUpdate()}>
{t('update')} {t('update')}
</button> </button>
); );
@@ -281,7 +281,7 @@ export const BottomButton = () => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
}; };
return ( return (
<button className='button' onClick={scrollToBottom}> <button type='button' className='button' onClick={scrollToBottom}>
{t('bottom')} {t('bottom')}
</button> </button>
); );
@@ -293,7 +293,7 @@ export const TopButton = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}; };
return ( return (
<button className='button' onClick={scrollToTop}> <button type='button' className='button' onClick={scrollToTop}>
{t('top')} {t('top')}
</button> </button>
); );
@@ -381,6 +381,7 @@ const ModQueueAlertThreshold = () => {
{t('alert_threshold')}: {t('alert_threshold')}:
<input <input
type='number' type='number'
aria-label={t('alert_threshold')}
min='1' min='1'
step='1' step='1'
value={alertThresholdValue} value={alertThresholdValue}
@@ -670,7 +671,7 @@ export const CatalogSearchResultsLabel = () => {
return ( return (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('search_results_for')}: <strong>{searchText}</strong> ({t('search_results_for')}: <strong>{searchText}</strong>)
</span> </span>
); );
} }
@@ -679,7 +680,7 @@ export const CatalogSearchResultsLabel = () => {
return ( return (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong> ({t('filtered_threads')}: <strong>{filteredCount}</strong>)
</span> </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('/subs/ - Subscriptions');
expect(container.textContent).toContain('subscriptions_subtitle:{"count":2}'); 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 () => { await act(async () => {
clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
@@ -25,14 +25,27 @@
} }
.clickableSubtitle { .clickableSubtitle {
appearance: none;
background: transparent;
border: 0;
color: inherit;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
} }
.clickableSubtitle:hover { .clickableSubtitle:hover {
text-decoration: underline; text-decoration: underline;
} }
.clickableSubtitle:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.content hr { .content hr {
width: 90%; width: 90%;
} }
+3 -3
View File
@@ -110,9 +110,9 @@ const BoardHeader = () => {
</div> </div>
<div className={styles.boardSubtitle}> <div className={styles.boardSubtitle}>
{isInSubscriptionsView ? ( {isInSubscriptionsView ? (
<span <button
type='button'
className={styles.clickableSubtitle} className={styles.clickableSubtitle}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -123,7 +123,7 @@ const BoardHeader = () => {
onClick={() => navigate('/subs/settings#subscriptions-settings')} onClick={() => navigate('/subs/settings#subscriptions-settings')}
> >
{subtitle} {subtitle}
</span> </button>
) : isInDirectoryListView ? ( ) : isInDirectoryListView ? (
<span>{subtitle}</span> <span>{subtitle}</span>
) : !isInAllView && !isInModView && subtitle ? ( ) : !isInAllView && !isInModView && subtitle ? (
@@ -90,7 +90,7 @@ describe('BoardPagination', () => {
expect(container.textContent).toContain('catalog'); expect(container.textContent).toContain('catalog');
expect(container.textContent).toContain('archive'); 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(); expect(allLink).toBeTruthy();
await act(async () => { await act(async () => {
@@ -45,9 +45,9 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
{currentPage === 1 && ( {currentPage === 1 && (
<span className={styles.footerPageItem}> <span className={styles.footerPageItem}>
<span className={styles.footerPageBracket}>[</span> <span className={styles.footerPageBracket}>[</span>
<span <button
type='button'
className={styles.footerPageLink} className={styles.footerPageLink}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => setEnableInfiniteScroll(true)} onClick={() => setEnableInfiniteScroll(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -58,7 +58,7 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
}} }}
> >
{t('all')} {t('all')}
</span> </button>
<span className={styles.footerPageBracket}>]</span> <span className={styles.footerPageBracket}>]</span>
</span> </span>
)} )}
@@ -56,14 +56,14 @@ describe('BoardsBarEditModal', () => {
input?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); 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); expect(useBoardsBarEditModalStore.getState().showModal).toBe(true);
}); });
it('still closes when the backdrop itself handles keyboard activation', async () => { it('still closes when the backdrop itself handles keyboard activation', async () => {
await renderModal(); await renderModal();
const backdrop = container.querySelector<HTMLElement>('[role="button"]'); const backdrop = container.querySelector<HTMLElement>('button[aria-label="Close board list modal"]');
expect(backdrop).toBeTruthy(); expect(backdrop).toBeTruthy();
await act(async () => { await act(async () => {
@@ -8,6 +8,14 @@
z-index: 999; z-index: 999;
} }
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.boardsbarEditDialog { .boardsbarEditDialog {
position: absolute; position: absolute;
width: 400px; width: 400px;
@@ -25,9 +33,12 @@
box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 5px 0px; box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 5px 0px;
font-size: var(--body-font-size); font-size: var(--body-font-size);
line-height: normal; line-height: normal;
z-index: 1000; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: inherit;
margin-top: 0;
margin-bottom: 0;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -71,7 +71,13 @@ const BoardsBarEditModalForm = ({
{subscriptions.length > 0 && ( {subscriptions.length > 0 && (
<div className={styles.section}> <div className={styles.section}>
<div className={styles.checkboxItem}> <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> <label htmlFor='show-subscriptions'>show subscriptions</label>
<span className={styles.editSubscriptionsWrapper}> <span className={styles.editSubscriptionsWrapper}>
( (
@@ -91,7 +97,9 @@ const BoardsBarEditModalForm = ({
</div> </div>
)} )}
<div className={styles.boardsbarEditFooter}> <div className={styles.boardsbarEditFooter}>
<button onClick={handleSave}>Save</button> <button type='button' onClick={handleSave}>
Save
</button>
</div> </div>
</> </>
); );
@@ -108,28 +116,23 @@ const BoardsBarEditModal = () => {
return null; return null;
} }
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeBoardsBarEditModal();
}
};
const formKey = `${directoriesToString(visibleDirectories)}-${showSubscriptionsInBoardsBar}`; const formKey = `${directoriesToString(visibleDirectories)}-${showSubscriptionsInBoardsBar}`;
return ( return (
<div <div className={styles.backdrop}>
className={styles.backdrop} <button
role='button' type='button'
tabIndex={0} className={styles.backdropButton}
aria-label='Close board list modal'
onClick={closeBoardsBarEditModal}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
closeBoardsBarEditModal(); closeBoardsBarEditModal();
} }
}} }}
onClick={handleBackdropClick} />
> <dialog open className={styles.boardsbarEditDialog} aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
<div className={styles.boardsbarEditDialog} role='dialog' aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
<div className={styles.hd}> <div className={styles.hd}>
<h2 id='boards-bar-edit-modal-title'>Custom Board List</h2> <h2 id='boards-bar-edit-modal-title'>Custom Board List</h2>
<button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' /> <button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' />
@@ -146,7 +149,7 @@ const BoardsBarEditModal = () => {
location={location} location={location}
/> />
</div> </div>
</div> </dialog>
</div> </div>
); );
}; };
@@ -220,7 +220,7 @@ describe('BoardsBar', () => {
await renderBoardsBar('/mu'); await renderBoardsBar('/mu');
await act(async () => { 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"]'); const inputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
@@ -5,13 +5,30 @@
position: relative; 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 { .checkingSubscriptions {
font-size: var(--boardsbar-font-size); font-size: var(--boardsbar-font-size);
color: var(--boardsbar-desktop-link-text-color); color: var(--boardsbar-desktop-link-text-color);
text-transform: lowercase; text-transform: lowercase;
} }
.boardNavDesktop a, .navTopRight span { .boardNavDesktop a, .navTopRight span, .navTopRight button {
color: var(--boardsbar-desktop-link-text-color); color: var(--boardsbar-desktop-link-text-color);
text-decoration: var(--boardsbar-desktop-link-text-decoration); text-decoration: var(--boardsbar-desktop-link-text-decoration);
} }
@@ -20,13 +37,13 @@
padding: 0 1px; 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); color: var(--boardsbar-desktop-link-text-color-hover);
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover); text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
cursor: pointer; cursor: pointer;
} }
.placeholder { .boardNavDesktop .placeholder {
color: var(--boardsbar-separator-color); color: var(--boardsbar-separator-color);
} }
@@ -45,13 +62,17 @@
margin-right: -7px; margin-right: -7px;
} }
.temporaryButton { .boardNavDesktop .temporaryButton {
color: var(--boardsbar-desktop-link-text-color); color: var(--boardsbar-desktop-link-text-color);
text-decoration: var(--boardsbar-desktop-link-text-decoration); text-decoration: var(--boardsbar-desktop-link-text-decoration);
text-transform: capitalize; text-transform: capitalize;
} }
.temporaryButton:hover { .navTopRight button {
text-transform: capitalize;
}
.boardNavDesktop .temporaryButton:hover {
color: var(--boardsbar-desktop-link-text-color-hover); color: var(--boardsbar-desktop-link-text-color-hover);
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover); text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
cursor: pointer; cursor: pointer;
@@ -80,7 +101,7 @@
justify-content: space-around; 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); color: var(--boardsbar-mobile-button-text-color);
text-decoration: var(--button-text-decoration-mobile); text-decoration: var(--button-text-decoration-mobile);
} }
@@ -108,7 +129,7 @@
padding: 3px 5px 0 0; padding: 3px 5px 0 0;
} }
.pageJump a, .pageJump span { .pageJump a, .pageJump span, .pageJump button {
padding-right: 5px; padding-right: 5px;
text-decoration: var(--homepage-box-link-text-decoration); text-decoration: var(--homepage-box-link-text-decoration);
cursor: pointer; cursor: pointer;
+31 -22
View File
@@ -70,7 +70,16 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
return ( return (
<div className={styles.searchBar} ref={searchBarRef}> <div className={styles.searchBar} ref={searchBarRef}>
<form onSubmit={handleSearchSubmit}> <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> </form>
</div> </div>
); );
@@ -154,9 +163,9 @@ const BoardsBarDesktop = () => {
const linkContent = ( const linkContent = (
<> <>
{isPlaceholder ? ( {isPlaceholder ? (
<span <button
type='button'
className={styles.placeholder} className={styles.placeholder}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -168,7 +177,7 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{code} {code}
</span> </button>
) : ( ) : (
<Link to={`/${code}${catalogSuffix}`} onClick={openDirectoryForPlaceholder}> <Link to={`/${code}${catalogSuffix}`} onClick={openDirectoryForPlaceholder}>
{code} {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)); const visibleCodes = group.filter((code) => directoriesToShow.has(code));
if (visibleCodes.length === 0) return null; 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 && ( {hasHiddenDirectories && !showAllTemporarily && (
<> <>
{' '} {' '}
[ [
<span <button
type='button'
className={styles.temporaryButton} className={styles.temporaryButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -234,7 +243,7 @@ const BoardsBarDesktop = () => {
title='Show all' title='Show all'
> >
... ...
</span> </button>
]{' '} ]{' '}
</> </>
)} )}
@@ -242,9 +251,9 @@ const BoardsBarDesktop = () => {
<>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] </> <>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] </>
)} )}
[ [
<span <button
type='button'
className={styles.temporaryButton} className={styles.temporaryButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -256,11 +265,11 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{capitalize(t('edit'))} {capitalize(t('edit'))}
</span> </button>
] [ ] [
<span <button
type='button'
className={styles.temporaryButton} className={styles.temporaryButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -272,13 +281,13 @@ const BoardsBarDesktop = () => {
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{t('create_board')} {t('create_board')}
</span> </button>
] ]
</span> </span>
<span className={styles.navTopRight}> <span className={styles.navTopRight}>
[<Link to={!location.pathname.endsWith('settings') ? location.pathname.replace(/\/$/, '') + '/settings' : location.pathname}>{t('settings')}</Link>] [ [<Link to={!location.pathname.endsWith('settings') ? location.pathname.replace(/\/$/, '') + '/settings' : location.pathname}>{t('settings')}</Link>] [
<span <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -288,8 +297,8 @@ const BoardsBarDesktop = () => {
}} }}
onClick={() => setShowSearchBar(!showSearchBar)} onClick={() => setShowSearchBar(!showSearchBar)}
> >
{t('search')} {capitalize(t('search'))}
</span> </button>
] [<Link to='/'>{t('home')}</Link>] ] [<Link to='/'>{t('home')}</Link>]
</span> </span>
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />} {showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
@@ -387,8 +396,8 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
</div> </div>
<div className={styles.pageJump}> <div className={styles.pageJump}>
<Link to={location.pathname.replace(/\/$/, '') + '/settings'}>{t('settings')}</Link> <Link to={location.pathname.replace(/\/$/, '') + '/settings'}>{t('settings')}</Link>
<span <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -399,7 +408,7 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
onClick={() => setShowSearchBar(!showSearchBar)} onClick={() => setShowSearchBar(!showSearchBar)}
> >
{capitalize(t('search'))} {capitalize(t('search'))}
</span> </button>
<Link to='/'>{t('home')}</Link> <Link to='/'>{t('home')}</Link>
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />} {showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
</div> </div>
@@ -122,7 +122,7 @@ const renderCatalogFilters = () => {
}; };
const openModal = async () => { 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); await click(button ?? null);
}; };
@@ -176,7 +176,7 @@ describe('CatalogFilters', () => {
expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip'); expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip');
expect(container.textContent).toContain('filter_and_highlights_help'); 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); await click(overlay ?? null);
expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull(); expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull();
@@ -220,14 +220,15 @@ describe('CatalogFilters', () => {
expect(container.textContent).toContain('x4'); expect(container.textContent).toContain('x4');
const initialRows = getRows(); const initialRows = getRows();
const moveUpButton = initialRows[1]?.querySelector('[role="button"]'); const moveUpButton = initialRows[1]?.querySelector('button');
await click(moveUpButton ?? null); await click(moveUpButton ?? null);
const reorderedInputs = getTextInputs(); const reorderedInputs = getTextInputs();
expect(reorderedInputs[0]?.value).toBe('beta'); expect(reorderedInputs[0]?.value).toBe('beta');
expect(reorderedInputs[1]?.value).toBe('alpha'); 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); await click(deleteButton ?? null);
expect(getTextInputs()).toHaveLength(1); expect(getTextInputs()).toHaveLength(1);
@@ -1,4 +1,5 @@
.overlay { .overlay {
all: unset;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -88,6 +89,11 @@
} }
.closeButton { .closeButton {
appearance: none;
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
right: 5px; right: 5px;
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -101,6 +107,11 @@
} }
.openHelpButton { .openHelpButton {
appearance: none;
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
right: 23px; right: 23px;
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -113,6 +124,11 @@
cursor: pointer; cursor: pointer;
} }
.closeButton:focus-visible, .openHelpButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.filters { .filters {
padding: 5px; padding: 5px;
text-align: left; text-align: left;
@@ -190,6 +206,16 @@
cursor: pointer; cursor: pointer;
} }
.filtersTable button {
appearance: none;
background: transparent;
border: 0;
color: inherit;
font: inherit;
margin: 0;
padding: 0;
}
.filtersTable tfoot td { .filtersTable tfoot td {
padding-top: 20px; padding-top: 20px;
} }
@@ -152,9 +152,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
{localFilterItems.map((item, index) => ( {localFilterItems.map((item, index) => (
<tr key={item.id ?? index}> <tr key={item.id ?? index}>
<td> <td>
<span <button
type='button'
className={styles.orderButton} className={styles.orderButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -165,11 +165,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onClick={() => moveLocalFilterItemUp(index)} onClick={() => moveLocalFilterItemUp(index)}
> >
</span> </button>
</td> </td>
<td> <td>
<input <input
type='checkbox' type='checkbox'
aria-label={`Enable filter ${index + 1}`}
className={styles.onCheckbox} className={styles.onCheckbox}
checked={item.enabled} checked={item.enabled}
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })} onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
@@ -178,6 +179,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
<td> <td>
<input <input
type='text' type='text'
aria-label={`Filter text ${index + 1}`}
autoCorrect='off' autoCorrect='off'
autoComplete='off' autoComplete='off'
spellCheck='false' spellCheck='false'
@@ -192,15 +194,25 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} /> <HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
</td> </td>
<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>
<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>
<td> <td>
<span <button
type='button'
className={styles.deleteButton} className={styles.deleteButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -211,7 +223,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onClick={() => removeLocalFilterItem(index)} onClick={() => removeLocalFilterItem(index)}
> >
× ×
</span> </button>
</td> </td>
<td className={styles.filterHits}> <td className={styles.filterHits}>
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`} {currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`}
@@ -259,9 +271,10 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
return ( return (
<> <>
<div <button
type='button'
aria-label={t('close')}
className={styles.overlay} className={styles.overlay}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -279,10 +292,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span> <span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span>
{!showHelp && ( {!showHelp && (
<span <button
type='button'
className={styles.openHelpButton} className={styles.openHelpButton}
aria-label={t('help')}
title={t('help')} title={t('help')}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -293,10 +307,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
onClick={openHelp} onClick={openHelp}
/> />
)} )}
<span <button
type='button'
className={styles.closeButton} className={styles.closeButton}
aria-label={t('close')}
title={t('close')} title={t('close')}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -323,9 +338,9 @@ const CatalogFilters = () => {
return ( return (
<> <>
<span <button
type='button'
className={`${styles.filtersButton} button`} className={`${styles.filtersButton} button`}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -336,7 +351,7 @@ const CatalogFilters = () => {
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
> >
{t('filters')} {t('filters')}
</span> </button>
{showModal && <FiltersModal closeModal={closeModal} />} {showModal && <FiltersModal closeModal={closeModal} />}
</> </>
); );
@@ -1,4 +1,16 @@
.colorPickerBackdrop {
all: unset;
cursor: default;
position: fixed;
inset: 0;
z-index: 30;
}
.colorClickbox { .colorClickbox {
appearance: none;
padding: 0;
margin: 0;
box-sizing: border-box;
width: 16px; width: 16px;
height: 16px; height: 16px;
line-height: 16px; line-height: 16px;
@@ -35,6 +47,9 @@
} }
.colorOption { .colorOption {
appearance: none;
padding: 0;
box-sizing: border-box;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
margin: auto; margin: auto;
width: 16px; width: 16px;
@@ -52,6 +67,9 @@
} }
.colorPreview { .colorPreview {
appearance: none;
padding: 0;
box-sizing: border-box;
display: inline-block; display: inline-block;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -100,6 +118,11 @@
} }
.colorPickerModal tfoot .button { .colorPickerModal tfoot .button {
appearance: none;
background: transparent;
border: 0;
font: inherit;
padding: 0;
color: var(--button-desktop-text-color); color: var(--button-desktop-text-color);
cursor: pointer; cursor: pointer;
margin: 0 2px; margin: 0 2px;
@@ -118,3 +141,11 @@
content: ']'; content: ']';
color: var(--body-font-color); 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 ( return (
<> <>
<span <button
type='button'
aria-label={`Select filter color ${index + 1}`}
className={styles.colorClickbox} className={styles.colorClickbox}
style={{ backgroundColor: item.color || '#fff' }} style={{ backgroundColor: item.color || '#fff' }}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -72,20 +73,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
{...getReferenceProps()} {...getReferenceProps()}
> >
{!item.color && ''} {!item.color && ''}
</span> </button>
{isColorPickerOpen && ( {isColorPickerOpen && (
<FloatingPortal> <FloatingPortal>
<div <button
style={{ type='button'
position: 'fixed', aria-label='Close color picker'
top: 0, className={styles.colorPickerBackdrop}
left: 0,
right: 0,
bottom: 0,
zIndex: 30,
}}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -111,14 +106,15 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
> >
<table className={styles.colorGrid}> <table className={styles.colorGrid}>
<tbody> <tbody>
{colorRows.map((row, rowIndex) => ( {colorRows.map((row) => (
<tr key={rowIndex}> <tr key={row.join('|')}>
{row.map((color, colorIndex) => ( {row.map((color) => (
<td key={colorIndex}> <td key={color}>
<span <button
type='button'
aria-label={`Use color ${color}`}
className={styles.colorOption} className={styles.colorOption}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -139,13 +135,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
</tr> </tr>
<tr> <tr>
<td className={styles.middleTxt} colSpan={4}> <td className={styles.middleTxt} colSpan={4}>
<input type='text' ref={inputRef} value={customColor} onChange={handleCustomColorChange} /> <input type='text' aria-label='Custom highlight color' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
<span <button
type='button'
aria-label='Apply custom highlight color'
className={styles.colorPreview} className={styles.colorPreview}
style={{ style={{
backgroundColor: customColor || '#fff', backgroundColor: customColor || '#fff',
}} }}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -159,9 +156,9 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
</tr> </tr>
<tr> <tr>
<td colSpan={4}> <td colSpan={4}>
<span <button
type='button'
className={styles.button} className={styles.button}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -172,10 +169,10 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
onClick={() => setIsColorPickerOpen(false)} onClick={() => setIsColorPickerOpen(false)}
> >
<span className={styles.buttonText}>Close</span> <span className={styles.buttonText}>Close</span>
</span> </button>
<span <button
type='button'
className={styles.button} className={styles.button}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -186,7 +183,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
onClick={clearColor} onClick={clearColor}
> >
<span className={styles.buttonText}>Clear</span> <span className={styles.buttonText}>Clear</span>
</span> </button>
</td> </td>
</tr> </tr>
</tfoot> </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} /> <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 // 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) { } else if (type === 'webpage' && !hasError) {
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />; thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'iframe' && iframeThumbnail && !hasError) { } else if (type === 'iframe' && iframeThumbnail && !hasError) {
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />; thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'audio') { } else if (type === 'audio') {
thumbnailComponent = <audio src={url} controls />; thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
} }
return ( return (
@@ -238,7 +238,7 @@ const CatalogPost = memo(
return ( return (
<> <>
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}> <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 ? ( {shouldMaskPost ? (
<Link to={postLink}> <Link to={postLink}>
<span className={styles.hiddenThumbnail} /> <span className={styles.hiddenThumbnail} />
@@ -246,11 +246,10 @@ const CatalogPost = memo(
) : hasThumbnail ? ( ) : hasThumbnail ? (
<> <>
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />} {shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
<Link to={postLink}> <Link
<div to={postLink}
className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`}
ref={refs.setReference}
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))} onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
onFocus={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
onMouseLeave={() => { onMouseLeave={() => {
setShowPortal(false); setShowPortal(false);
if (timeoutRef.current) { if (timeoutRef.current) {
@@ -258,7 +257,15 @@ const CatalogPost = memo(
timeoutRef.current = null; 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} {threadIcons}
{spoiler ? ( {spoiler ? (
<img src='assets/spoiler.png' alt='' /> <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 querySearchButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'search') ?? null;
const queryCloseButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === '✖') ?? null; const queryCloseButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '✖') ?? null;
const queryInput = () => container.querySelector('input'); const queryInput = () => container.querySelector('input');
const dispatchInput = async (element: HTMLInputElement, value: string) => { const dispatchInput = async (element: HTMLInputElement, value: string) => {
@@ -31,8 +31,13 @@
} }
.closeSearch { .closeSearch {
appearance: none;
background: transparent;
cursor: pointer; cursor: pointer;
border: none; border: none;
font: inherit;
margin: 0;
padding: 0;
white-space: nowrap; white-space: nowrap;
color: var(--button-desktop-text-color, revert); color: var(--button-desktop-text-color, revert);
} }
@@ -41,6 +46,11 @@
color: var(--button-desktop-text-color-hover, revert); color: var(--button-desktop-text-color-hover, revert);
} }
.closeSearch:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.searchContainer input { .searchContainer input {
width: 75px; width: 75px;
@@ -85,9 +85,9 @@ const CatalogSearch = () => {
return ( return (
<> <>
{!isMobile && '['} {!isMobile && '['}
<span <button
type='button'
className={`${styles.filtersButton} button`} className={`${styles.filtersButton} button`}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -98,13 +98,14 @@ const CatalogSearch = () => {
onClick={handleToggleSearch} onClick={handleToggleSearch}
> >
{t('search')} {t('search')}
</span> </button>
{!isMobile && ']'} {!isMobile && ']'}
{openSearch && ( {openSearch && (
<div className={styles.searchContainer}> <div className={styles.searchContainer}>
<input <input
ref={(el) => el?.focus()} ref={(el) => el?.focus()}
type='text' type='text'
aria-label={t('search')}
value={inputValue} value={inputValue}
onChange={handleSearchChange} onChange={handleSearchChange}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -113,9 +114,9 @@ const CatalogSearch = () => {
} }
}} }}
/> />
<span <button
type='button'
className={styles.closeSearch} className={styles.closeSearch}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -126,7 +127,7 @@ const CatalogSearch = () => {
onClick={handleCloseSearch} onClick={handleCloseSearch}
> >
</span> </button>
</div> </div>
)} )}
</> </>
@@ -318,10 +318,10 @@ describe('ChallengeModal', () => {
'https://mintpass.org', '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'); expect(doneButton?.getAttribute('aria-label')).toBe('Finish challenge');
await clickButton('Done'); await clickButton('Finish Challenge');
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
}); });
@@ -105,6 +105,15 @@ interface IframeChallengeProps {
} }
type IframeOpenMode = 'trusted' | 'manual'; 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 = ({ const IframeChallenge = ({
challenge, challenge,
@@ -121,8 +130,7 @@ const IframeChallenge = ({
const account = useAccount(); const account = useAccount();
const [theme] = useTheme(); const [theme] = useTheme();
const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin); const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin);
const [iframeUrlState, setIframeUrl] = useState(''); const [iframeFrame, setIframeFrame] = useState<IframeFrameState | null>(null);
const [iframeOrigin, setIframeOrigin] = useState('');
const [inlineErrorMessage, setInlineErrorMessage] = useState(''); const [inlineErrorMessage, setInlineErrorMessage] = useState('');
const [rememberPermission, setRememberPermission] = useState(false); const [rememberPermission, setRememberPermission] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -144,8 +152,7 @@ const IframeChallenge = ({
(validatedUrl: { finalUrl: string; origin: string }) => { (validatedUrl: { finalUrl: string; origin: string }) => {
queueMicrotask(() => { queueMicrotask(() => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
setIframeUrl(validatedUrl.finalUrl); setIframeFrame({ url: validatedUrl.finalUrl, origin: validatedUrl.origin });
setIframeOrigin(validatedUrl.origin);
onReady(); onReady();
}); });
}, },
@@ -211,20 +218,21 @@ const IframeChallenge = ({
}, [onCancel]); }, [onCancel]);
const sendThemeToIframe = useCallback(() => { const sendThemeToIframe = useCallback(() => {
postThemeToIframe(iframeRef.current, iframeOrigin, theme); postThemeToIframe(iframeRef.current, iframeFrame?.origin ?? '', theme);
}, [iframeOrigin, theme]); }, [iframeFrame?.origin, theme]);
const handleIframeLoad = () => { const handleIframeLoad = () => {
sendThemeToIframe(); sendThemeToIframe();
}; };
useEffect(() => { useEffect(() => {
if (iframeRef.current && iframeUrlState && iframeOrigin) { if (iframeRef.current && iframeFrame) {
sendThemeToIframe(); sendThemeToIframe();
} }
}, [iframeOrigin, iframeUrlState, sendThemeToIframe]); }, [iframeFrame, sendThemeToIframe]);
useEffect(() => { useEffect(() => {
const iframeOrigin = iframeFrame?.origin ?? '';
if (!iframeOrigin || !expectedSessionId) { if (!iframeOrigin || !expectedSessionId) {
handledAutoCompleteRef.current = false; handledAutoCompleteRef.current = false;
return; return;
@@ -246,9 +254,9 @@ const IframeChallenge = ({
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage);
}, [expectedSessionId, iframeOrigin]); }, [expectedSessionId, iframeFrame?.origin]);
if (!iframeUrlState) { if (!iframeFrame) {
return ( return (
<> <>
{publicationDetails} {publicationDetails}
@@ -256,7 +264,12 @@ const IframeChallenge = ({
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div> <div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
{!inlineErrorMessage && ( {!inlineErrorMessage && (
<label className={styles.iframeTrustCheckbox}> <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} {rememberPermissionLabel}
</label> </label>
)} )}
@@ -282,8 +295,8 @@ const IframeChallenge = ({
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}> <div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={iframeUrlState} src={iframeFrame.url}
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation' sandbox={getIframeSandbox(iframeFrame.origin)}
onLoad={handleIframeLoad} onLoad={handleIframeLoad}
className={styles.iframe} className={styles.iframe}
title='Challenge authentication' title='Challenge authentication'
@@ -291,8 +304,8 @@ const IframeChallenge = ({
</div> </div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}> <div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<div className={styles.iframeCloseButton}> <div className={styles.iframeCloseButton}>
<button aria-label='Finish challenge' onClick={onDone}> <button type='button' aria-label='Finish challenge' onClick={onDone}>
Done Finish Challenge
</button> </button>
</div> </div>
</div> </div>
@@ -473,21 +486,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const publicationDetails = ( const publicationDetails = (
<> <>
<div className={styles.name}> <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> </div>
{title && ( {title && (
<div className={styles.subject}> <div className={styles.subject}>
<input type='text' value={title} disabled readOnly /> <input type='text' aria-label={capitalize(t('subject'))} value={title} disabled readOnly />
</div> </div>
)} )}
{content && ( {content && (
<div className={styles.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> </div>
)} )}
{link && ( {link && (
<div className={styles.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> </div>
)} )}
</> </>
@@ -533,6 +546,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
ref={inputRef} ref={inputRef}
className={styles.challengeAnswer} className={styles.challengeAnswer}
type='text' type='text'
aria-label={t('challenge_answer')}
autoComplete='off' autoComplete='off'
autoCorrect='off' autoCorrect='off'
spellCheck='false' 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> <div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
<span className={styles.buttons}> <span className={styles.buttons}>
{!challenges?.[currentChallengeIndex + 1] && ( {!challenges?.[currentChallengeIndex + 1] && (
<button onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}> <button type='button' onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
{t('submit')} {t('submit')}
</button> </button>
)} )}
<button onClick={abandonModal}>Cancel</button> <button type='button' onClick={abandonModal}>
Cancel
</button>
{challenges?.length > 1 && ( {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')} {t('previous')}
</button> </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> </span>
</div> </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'); 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 () => { it('renders moderation and deletion states with the expected messaging', async () => {
await renderContent({ await renderContent({
cid: 'post-1', 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 { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks'; import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import usePostNumberStore from '../../stores/use-post-number-store'; import usePostNumberStore from '../../stores/use-post-number-store';
import getShortAddress from '../../lib/get-short-address'; 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 { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import { isPostPageView } from '../../lib/utils/view-utils'; import { isPostPageView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import useStateString from '../../hooks/use-state-string'; 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 BbcodeContent from '../../components/bbcode-content/bbcode-content';
import ErrorDisplay from '../../components/error-display/error-display'; import ErrorDisplay from '../../components/error-display/error-display';
import ReplyQuotePreview from '../../components/reply-quote-preview'; import ReplyQuotePreview from '../reply-quote-preview';
import Markdown from '../../components/markdown'; import Markdown from '../markdown';
import Tooltip from '../../components/tooltip'; import Tooltip from '../tooltip';
import styles from '../../views/post/post.module.css'; import styles from '../../views/post/post.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
@@ -95,15 +95,15 @@ const CommentContent = ({
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const isInPostView = isPostPageView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params);
const [showOriginal, setShowOriginal] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const resolvedPost = withResolvedCommentCommunityAddress(post); 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 communityAddress = getCommentCommunityAddress(resolvedPost);
const authorAddress = resolvedPost?.author?.address; const authorAddress = resolvedPost?.author?.address;
const authorRole = getRoleByAddress(roles, authorAddress); const authorRole = getRoleByAddress(roles, authorAddress);
const shouldRenderBbcode = hasModQueueAccessRole(authorRole); const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
const shouldRenderBbcode = isPrivilegedAuthor;
const purged = resolvedPost?.commentModeration?.purged; const purged = resolvedPost?.commentModeration?.purged;
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt; const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
const banned = !!banExpiresAt; const banned = !!banExpiresAt;
@@ -111,9 +111,9 @@ const CommentContent = ({
const [showFullComment, setShowFullComment] = useState(false); const [showFullComment, setShowFullComment] = useState(false);
const displayContent = const displayContent =
content && content &&
(!isInPostView && content.length > 1000 && !showFullComment (!isInPostView && content.length > 1000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 1000) ? content.slice(0, 1000)
: isInPostView && content.length > 2000 && !showFullComment : isInPostView && content.length > 2000 && !showFullComment && !isPrivilegedAuthor
? content.slice(0, 2000) ? content.slice(0, 2000)
: content); : content);
@@ -214,7 +214,7 @@ const CommentContent = ({
) )
) : ( ) : (
<> <>
{!showOriginal && renderContent(displayContent)} {displayContent && renderContent(displayContent)}
{pendingApproval && ( {pendingApproval && (
<> <>
<br /> <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}> <span className={styles.abbr}>
<br /> <br />
<br /> <br />
@@ -243,9 +243,10 @@ const CommentContent = ({
shouldUnescape={true} shouldUnescape={true}
components={{ components={{
1: ( 1: (
<span <button
type='button'
key={cid} key={cid}
role='button' aria-label={t('view')}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -260,71 +261,6 @@ const CommentContent = ({
/> />
</span> </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 && ( {banned && (
@@ -174,7 +174,7 @@ describe('CommentMedia', () => {
expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy(); expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
expect(onMediaLoadFailureChange).toHaveBeenCalledWith('https://files.catbox.moe/missing.jpg'); 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_inline:files.catbox.moe');
expect(status?.textContent).toContain('media_failed_to_load_open_source'); expect(status?.textContent).toContain('media_failed_to_load_open_source');
expect(status?.textContent).not.toContain('media_failed_to_load_hint'); expect(status?.textContent).not.toContain('media_failed_to_load_hint');
@@ -209,7 +209,7 @@ describe('CommentMedia', () => {
image?.dispatchEvent(new Event('error', { bubbles: true })); image?.dispatchEvent(new Event('error', { bubbles: true }));
}); });
const status = container.querySelector('[role="status"]'); const status = container.querySelector('output');
expect(status?.textContent).toBe(''); expect(status?.textContent).toBe('');
expect(status?.getAttribute('aria-label')).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.', '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'); 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 () => { await act(async () => {
fallbackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); 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'); 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(); expect(closeButton).toBeTruthy();
await act(async () => { await act(async () => {
@@ -55,14 +55,31 @@
} }
.thumbnailSmall span, .thumbnailSmall span,
.thumbnailSmall a { .thumbnailSmall a,
.thumbnailSmall .noThumbnailButton {
width: 100%; width: 100%;
display: inline-block; display: inline-block;
word-wrap: break-word; overflow-wrap: break-word;
text-align: center; text-align: center;
cursor: pointer; 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 { .thumbnail {
float: left; float: left;
} }
@@ -75,6 +92,33 @@
cursor: pointer; 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 { .hide {
display: none !important; display: none !important;
} }
+67 -178
View File
@@ -50,13 +50,13 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
return ( return (
<> <>
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' /> <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 ( 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' /> <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
<span className={styles.mediaLoadFailureSource}>{inline}</span> <span className={styles.mediaLoadFailureSource}>{inline}</span>
{url && ( {url && (
@@ -64,7 +64,7 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
{openSourceLabel} {openSourceLabel}
</a> </a>
)} )}
</span> </output>
); );
}; };
@@ -109,92 +109,40 @@ const Thumbnail = ({
const iframeThumbnail = patternThumbnailUrl || thumbnail; const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState; const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
const hasThumbnail = getHasThumbnail(commentMediaInfo, url); const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
const gifThumbnailButtonProps = const handleOpenMedia = () => setShowThumbnail(false);
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),
}
: {};
if (type === 'gif') { if (type === 'gif') {
thumbnailComponent = thumbnailComponent =
gifFrameStatus === 'loading' ? ( 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 <button type='button' className={styles.mediaToggleButton} aria-label='Open GIF' onClick={handleOpenMedia}>
src={gifFrameUrl || url} <img src={gifFrameUrl || url} alt='' />
alt='' </button>
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
); );
} else if (type === 'video') { } else if (type === 'video') {
thumbnailComponent = thumbnail ? ( 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 // show first frame of the video, as a workaround for Safari not loading thumbnails
<video <button type='button' className={styles.mediaToggleButton} aria-label='Open video' onClick={handleOpenMedia}>
src={`${url}#t=0.001`} <video src={`${url}#t=0.001`} aria-label='Video thumbnail' />
role='button' </button>
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
); );
} else if (type === 'webpage') { } else if (type === 'webpage') {
thumbnailComponent = ( thumbnailComponent = (
<img <button type='button' className={styles.mediaToggleButton} aria-label='Open webpage preview' onClick={handleOpenMedia}>
src={thumbnail} <img src={thumbnail} alt='' />
alt='' </button>
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
); );
} else if (type === 'iframe') { } else if (type === 'iframe') {
thumbnailComponent = iframeThumbnail ? ( thumbnailComponent = iframeThumbnail ? (
<img <button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
src={iframeThumbnail} <img src={iframeThumbnail} alt='' />
alt='' </button>
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
) : null; ) : null;
} else if (type === 'audio') { } 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; const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
@@ -204,8 +152,9 @@ const Thumbnail = ({
const noThumbnailLink = const noThumbnailLink =
!hasThumbnail && linkWithoutThumbnail ? ( !hasThumbnail && linkWithoutThumbnail ? (
canEmbed(linkWithoutThumbnail) ? ( canEmbed(linkWithoutThumbnail) ? (
<span <button
role='button' type='button'
className={styles.noThumbnailButton}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -216,7 +165,7 @@ const Thumbnail = ({
onClick={() => setShowThumbnail(false)} onClick={() => setShowThumbnail(false)}
> >
{fallbackLinkLabel} {fallbackLinkLabel}
</span> </button>
) : ( ) : (
<a href={url} target='_blank' rel='noopener noreferrer'> <a href={url} target='_blank' rel='noopener noreferrer'>
{fallbackLinkLabel} {fallbackLinkLabel}
@@ -227,20 +176,9 @@ const Thumbnail = ({
return deleted || removed || purged ? ( return deleted || removed || purged ? (
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='File deleted' /> <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='File deleted' />
) : spoiler ? ( ) : spoiler ? (
<img <button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={handleOpenMedia}>
className={styles.spoiler} <img className={styles.spoiler} src='assets/spoiler.png' alt='' />
src='assets/spoiler.png' </button>
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(false);
}
}}
onClick={() => setShowThumbnail(false)}
/>
) : isOutOfFeed ? ( ) : isOutOfFeed ? (
<span className={`${isFloatingEmbed ? styles.floatingEmbed : styles.communityAvatar}`}>{thumbnailComponent}</span> <span className={`${isFloatingEmbed ? styles.floatingEmbed : styles.communityAvatar}`}>{thumbnailComponent}</span>
) : isMobile || isReply ? ( ) : isMobile || isReply ? (
@@ -272,43 +210,23 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
{type === 'iframe' && url ? ( {type === 'iframe' && url ? (
<Embed url={url} /> <Embed url={url} />
) : type === 'gif' ? ( ) : type === 'gif' ? (
<img disableToggle ? (
src={url} <img src={url} alt='' />
alt='' ) : (
role={disableToggle ? undefined : 'button'} <button type='button' className={styles.mediaToggleButton} aria-label='Collapse GIF' onClick={() => setShowThumbnail(true)}>
tabIndex={disableToggle ? undefined : 0} <img src={url} alt='' />
onKeyDown={ </button>
disableToggle )
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(true);
}
}
}
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
/>
) : type === 'video' ? ( ) : type === 'video' ? (
<video src={url} controls autoPlay loop muted={!unmuteExpandedVideoSound} /> <video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
) : type === 'webpage' ? ( ) : type === 'webpage' ? (
<img disableToggle ? (
src={thumbnail} <img src={thumbnail} alt='' />
alt='' ) : (
role={disableToggle ? undefined : 'button'} <button type='button' className={styles.mediaToggleButton} aria-label='Collapse webpage preview' onClick={() => setShowThumbnail(true)}>
tabIndex={disableToggle ? undefined : 0} <img src={thumbnail} alt='' />
onKeyDown={ </button>
disableToggle )
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowThumbnail(true);
}
}
}
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
/>
) : null} ) : null}
{isMobile && type && ( {isMobile && type && (
<div className={styles.fileInfo}> <div className={styles.fileInfo}>
@@ -321,9 +239,9 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
)} )}
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && ( {isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
<div className={styles.closeButton}> <div className={styles.closeButton}>
<span <button
type='button'
className='button' className='button'
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -334,7 +252,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
onClick={() => setShowThumbnail(true)} onClick={() => setShowThumbnail(true)}
> >
{t('close')} {t('close')}
</span> </button>
</div> </div>
)} )}
</span> </span>
@@ -397,20 +315,9 @@ const Image = ({
className={`${isOutOfFeed ? styles.communityAvatar : styles.thumbnailBig} ${styles.thumbnail} ${isImageExpanded && isMobile ? styles.removeFloat : ''}`} className={`${isOutOfFeed ? styles.communityAvatar : styles.thumbnailBig} ${styles.thumbnail} ${isImageExpanded && isMobile ? styles.removeFloat : ''}`}
style={spoilerDimensions} style={spoilerDimensions}
> >
<img <button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={() => setIsImageExpanded(true)}>
className={styles.spoiler} <img className={styles.spoiler} src='assets/spoiler.png' alt='' />
src='assets/spoiler.png' </button>
alt=''
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(true);
}
}}
onClick={() => setIsImageExpanded(true)}
/>
</span> </span>
); );
} }
@@ -424,26 +331,17 @@ const Image = ({
> >
{hasError ? ( {hasError ? (
<MediaLoadFailure compact url={url} /> <MediaLoadFailure compact url={url} />
) : disableToggle ? (
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
) : ( ) : (
<img <button
src={url} type='button'
onError={handleError} className={styles.mediaToggleButton}
onLoad={handleLoad} aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
alt='' onClick={() => setIsImageExpanded(!isImageExpanded)}
role={disableToggle ? undefined : 'button'} >
tabIndex={disableToggle ? undefined : 0} <img src={url} onError={handleError} onLoad={handleLoad} alt='' />
onKeyDown={ </button>
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(!isImageExpanded);
}
}
}
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
/>
)} )}
</span> </span>
{isImageExpanded && type && ( {isImageExpanded && type && (
@@ -465,26 +363,17 @@ const Image = ({
> >
{hasError ? ( {hasError ? (
<MediaLoadFailure url={url} /> <MediaLoadFailure url={url} />
) : disableToggle ? (
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
) : ( ) : (
<img <button
src={url} type='button'
onError={handleError} className={styles.mediaToggleButton}
onLoad={handleLoad} aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
alt='' onClick={() => setIsImageExpanded(!isImageExpanded)}
role='button' >
tabIndex={0} <img src={url} onError={handleError} onLoad={handleLoad} alt='' />
onKeyDown={ </button>
disableToggle
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsImageExpanded(!isImageExpanded);
}
}
}
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
/>
)} )}
</span> </span>
); );
@@ -8,6 +8,14 @@
z-index: 999; z-index: 999;
} }
.backdropButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.createBoardDialog { .createBoardDialog {
position: absolute; position: absolute;
width: 500px; width: 500px;
@@ -22,7 +30,9 @@
box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px; box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px;
font-size: var(--body-font-size); font-size: var(--body-font-size);
line-height: 16.003px; line-height: 16.003px;
z-index: 1000; z-index: 1;
color: inherit;
padding: 0;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -122,4 +132,3 @@
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
} }
@@ -8,29 +8,13 @@ const CreateBoardModal = () => {
return null; return null;
} }
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeCreateBoardModal();
}
};
return ( return (
<div <div className={styles.backdrop}>
className={styles.backdrop} <button type='button' className={styles.backdropButton} aria-label='Close create board modal' onClick={closeCreateBoardModal} />
role='button' <dialog open className={styles.createBoardDialog} aria-modal='true' aria-labelledby='create-board-modal-title'>
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.hd}> <div className={styles.hd}>
<h2 id='create-board-modal-title'>Create a Board</h2> <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>
<div className={styles.bd}> <div className={styles.bd}>
<div className={styles.section}> <div className={styles.section}>
@@ -73,9 +57,11 @@ const CreateBoardModal = () => {
</div> </div>
</div> </div>
<div className={styles.createBoardFooter}> <div className={styles.createBoardFooter}>
<button onClick={closeCreateBoardModal}>Close</button> <button type='button' onClick={closeCreateBoardModal}>
</div> Close
</button>
</div> </div>
</dialog>
</div> </div>
); );
}; };
@@ -12,12 +12,23 @@
background-color: rgba(128, 128, 128, 0.5); background-color: rgba(128, 128, 128, 0.5);
} }
.directoryDialog { .backdropButton {
all: unset;
position: absolute; position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.directoryDialog {
position: fixed;
width: 500px; width: 500px;
top: 50px; max-width: calc(100% - 20px);
max-height: calc(100vh - 40px);
overflow-y: auto;
top: 50%;
left: 50%; left: 50%;
margin-left: -251px; transform: translate(-50%, -50%);
background-color: var(--directory-modal-background-color, #f0e0d6); background-color: var(--directory-modal-background-color, #f0e0d6);
border: var(--directory-modal-border, 1px solid #d9bfb7); border: var(--directory-modal-border, 1px solid #d9bfb7);
color: var(--directory-modal-text-color, #800); color: var(--directory-modal-text-color, #800);
@@ -25,7 +36,9 @@
font-family: arial, helvetica, clean, sans-serif; font-family: arial, helvetica, clean, sans-serif;
font-size: 13px; font-size: 13px;
line-height: 18px; line-height: 18px;
z-index: 1000; z-index: 1;
margin: 0;
padding: 0;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -34,10 +47,7 @@
max-width: 500px; max-width: 500px;
left: 50%; left: 50%;
top: 50%; top: 50%;
margin-left: 0;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
max-height: calc(100vh - 40px);
overflow-y: auto;
} }
} }
@@ -132,5 +142,3 @@
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
} }
@@ -11,29 +11,19 @@ const DirectoryModal = () => {
return null; return null;
} }
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeDirectoryModal();
}
};
return ( return (
<div <div className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}>
className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`} <button type='button' className={styles.backdropButton} aria-label='Close directory modal' onClick={closeDirectoryModal} />
role='button' <dialog open className={styles.directoryDialog} aria-modal='true' aria-labelledby='directory-modal-title'>
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.hd}> <div className={styles.hd}>
<h2 id='directory-modal-title'>Submit a Board to a Directory</h2> <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>
<div className={styles.bd}> <div className={styles.bd}>
<p className={styles.introMessage}> <p className={styles.introMessage}>
@@ -74,9 +64,11 @@ const DirectoryModal = () => {
</div> </div>
</div> </div>
<div className={styles.directoryFooter}> <div className={styles.directoryFooter}>
<button onClick={closeDirectoryModal}>Close</button> <button type='button' onClick={closeDirectoryModal}>
</div> Close
</button>
</div> </div>
</dialog>
</div> </div>
); );
}; };
@@ -8,12 +8,23 @@
z-index: 999; z-index: 999;
} }
.disclaimerDialog { .backdropButton {
all: unset;
position: absolute; position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.disclaimerDialog {
position: fixed;
width: 300px; width: 300px;
top: 50px; max-width: calc(100% - 20px);
max-height: calc(100vh - 40px);
overflow-y: auto;
top: 50%;
left: 50%; left: 50%;
margin-left: -151px; transform: translate(-50%, -50%);
background-color: var(--disclaimer-modal-background-color, rgb(255, 255, 255)); background-color: var(--disclaimer-modal-background-color, rgb(255, 255, 255));
border: var(--disclaimer-modal-border, 1px solid rgb(136, 0, 0)); border: var(--disclaimer-modal-border, 1px solid rgb(136, 0, 0));
color: var(--disclaimer-modal-text-color, 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-family: arial, helvetica, clean, sans-serif;
font-size: 13px; font-size: 13px;
line-height: 16.003px; line-height: 16.003px;
z-index: 1000; z-index: 1;
margin: 0;
padding: 0;
} }
.hd { .hd {
@@ -14,12 +14,6 @@ const DisclaimerModal = () => {
return null; return null;
} }
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
closeDisclaimerModal();
}
};
const handleAccept = () => { const handleAccept = () => {
// If there's no board path (placeholder), show directory modal instead // If there's no board path (placeholder), show directory modal instead
if (!targetBoardPath) { if (!targetBoardPath) {
@@ -31,22 +25,18 @@ const DisclaimerModal = () => {
}; };
return ( return (
<div <div className={styles.backdrop}>
className={styles.backdrop} <button type='button' className={styles.backdropButton} aria-label='Close disclaimer modal' onClick={closeDisclaimerModal} />
role='button' <dialog open className={styles.disclaimerDialog} aria-modal='true' aria-labelledby='disclaimer-modal-title'>
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.hd}> <div className={styles.hd}>
<h2 id='disclaimer-modal-title'>Disclaimer</h2> <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>
<div className={styles.bd}> <div className={styles.bd}>
<p>This web application may contain content for mature audiences only. By clicking &quot;Accept,&quot; you confirm that:</p> <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> </ol>
</div> </div>
<div className={styles.disclaimerFooter}> <div className={styles.disclaimerFooter}>
<button onClick={handleAccept}>Accept</button> <button type='button' onClick={handleAccept}>
<button onClick={closeDisclaimerModal}>Cancel</button> Accept
</div> </button>
<button type='button' onClick={closeDisclaimerModal}>
Cancel
</button>
</div> </div>
</dialog>
</div> </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 { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
const { addChallenge } = useChallengesStore.getState();
const daysToTimestampInSeconds = (days: number) => { const daysToTimestampInSeconds = (days: number) => {
const now = new Date(); const now = new Date();
now.setDate(now.getDate() + days); now.setDate(now.getDate() + days);
@@ -60,7 +58,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
useEffect(() => { useEffect(() => {
latestPostRef.current = resolvedPost; latestPostRef.current = resolvedPost;
}, [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(() => { const defaultPublishEditOptions = useMemo(() => {
return { return {
@@ -102,6 +100,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
pinned, pinned,
reason, reason,
removed, removed,
parentCid,
purged, purged,
spoiler, spoiler,
communityAddress, communityAddress,
@@ -289,6 +288,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<span className={`${styles.checkbox} ${parentCid && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}> <span className={`${styles.checkbox} ${parentCid && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}>
<input <input
type='checkbox' type='checkbox'
aria-label={t('edit')}
onChange={() => { onChange={() => {
if (cid && canOpenEditMenu) { if (cid && canOpenEditMenu) {
if (!isEditMenuOpen) { if (!isEditMenuOpen) {
@@ -315,7 +315,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
<label> <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'))}?] {capitalize(t('delete'))}?]
</label> </label>
</div> </div>
@@ -326,13 +332,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
<label> <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'))}?] {capitalize(t('edit'))}?]
</label> </label>
</div> </div>
{isContentEditorOpen && ( {isContentEditorOpen && (
<div> <div>
<textarea <textarea
aria-label={capitalize(t('edit'))}
className={styles.editTextarea} className={styles.editTextarea}
value={publishCommentEditOptions.content || ''} value={publishCommentEditOptions.content || ''}
onChange={(e) => { onChange={(e) => {
@@ -349,13 +361,25 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
<label> <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'))}?] {capitalize(t('remove'))}?]
</label>{' '} </label>{' '}
<span className={styles.purgeItem}> <span className={styles.purgeItem}>
<label> <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'))}?] {capitalize(t('purge'))}?]
</label> </label>
</span> </span>
@@ -364,7 +388,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
[ [
<label> <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'))}? {capitalize(t('close_thread'))}?
</label> </label>
] ]
@@ -373,7 +403,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
[ [
<label> <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'))}? {capitalize(t('spoiler'))}?
</label> </label>
] ]
@@ -382,7 +418,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
[ [
<label> <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'))}? {capitalize(t('archived'))}?
</label> </label>
] ]
@@ -391,7 +433,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
<div className={styles.menuItem}> <div className={styles.menuItem}>
[ [
<label> <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'))}? {capitalize(t('sticky'))}?
</label> </label>
] ]
@@ -402,6 +450,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<label> <label>
<input <input
onChange={onCheckbox} onChange={onCheckbox}
aria-label={capitalize(t('ban'))}
checked={publishCommentEditOptions.commentModeration?.author?.banExpiresAt !== undefined} checked={publishCommentEditOptions.commentModeration?.author?.banExpiresAt !== undefined}
type='checkbox' type='checkbox'
id='banUser' id='banUser'
@@ -414,6 +463,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<input <input
key='ban-duration-input' key='ban-duration-input'
className={styles.banInput} className={styles.banInput}
aria-label={t('ban_duration_days')}
onChange={onBanDurationChange} onChange={onBanDurationChange}
type='number' type='number'
min={1} min={1}
@@ -433,6 +483,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
{capitalize(t('reason'))}? ({t('optional')}) {capitalize(t('reason'))}? ({t('optional')})
<input <input
type='text' type='text'
aria-label={capitalize(t('reason'))}
value={publishCommentEditOptions.commentModeration?.reason || ''} value={publishCommentEditOptions.commentModeration?.reason || ''}
onChange={(e) => { onChange={(e) => {
const newReason = e.target.value; const newReason = e.target.value;
@@ -451,7 +502,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
</> </>
)} )}
<div className={styles.bottom}> <div className={styles.bottom}>
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}> <button type='button' className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
{t('save')} {t('save')}
</button> </button>
</div> </div>
@@ -2,7 +2,8 @@ import * as React from 'react';
import { createElement } from 'react'; import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 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; (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>; 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 styles from './embed.module.css';
import {
bitchuteHosts,
getXTweetId,
instagramHosts,
odyseeHosts,
redditHosts,
soundcloudHosts,
spotifyHosts,
streamableHosts,
tiktokHosts,
twitchHosts,
xHosts,
youtubeHosts,
} from './embed-utils';
interface EmbedProps { interface EmbedProps {
url: string; url: string;
@@ -47,20 +61,7 @@ interface EmbedComponentProps {
} }
const srcDocSandbox = 'allow-scripts allow-popups allow-popups-to-escape-sandbox'; const srcDocSandbox = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
const remoteEmbedSandbox = `${srcDocSandbox} allow-forms allow-presentation allow-same-origin`;
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 YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => { const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let embedSrc = ''; let embedSrc = '';
@@ -100,6 +101,7 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='origin' referrerPolicy='origin'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={embedSrc} src={embedSrc}
/> />
@@ -108,16 +110,6 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return null; 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 XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const tweetId = getXTweetId(parsedUrl); const tweetId = getXTweetId(parsedUrl);
if (!tweetId) { 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) => { const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return ( return (
<iframe <iframe
@@ -175,8 +165,6 @@ const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
); );
}; };
const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => { const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let iframeUrl; let iframeUrl;
if (parsedUrl.pathname.startsWith('/videos/')) { if (parsedUrl.pathname.startsWith('/videos/')) {
@@ -194,14 +182,13 @@ const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={iframeUrl} src={iframeUrl}
/> />
); );
}; };
const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => { const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/.+\/video\//, '').replaceAll('/', ''); const videoId = parsedUrl.pathname.replace(/.+\/video\//, '').replaceAll('/', '');
return ( return (
@@ -223,8 +210,6 @@ const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
); );
}; };
const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => { const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const pathNames = parsedUrl.pathname.replace(/\/+$/, '').split('/'); const pathNames = parsedUrl.pathname.replace(/\/+$/, '').split('/');
const id = pathNames[pathNames.length - 1]; 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 OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://odysee.com/$/embed${parsedUrl.pathname}`; const iframeUrl = `https://odysee.com/$/embed${parsedUrl.pathname}`;
return ( return (
@@ -259,14 +242,13 @@ const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={iframeUrl} src={iframeUrl}
/> />
); );
}; };
const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => { const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/\/video\//, '').replaceAll('/', ''); const videoId = parsedUrl.pathname.replace(/\/video\//, '').replaceAll('/', '');
return ( return (
@@ -277,14 +259,13 @@ const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={`https://www.bitchute.com/embed/${videoId}/`} src={`https://www.bitchute.com/embed/${videoId}/`}
/> />
); );
}; };
const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => { const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replaceAll('/', ''); const videoId = parsedUrl.pathname.replaceAll('/', '');
return ( return (
@@ -295,14 +276,13 @@ const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={`https://streamable.com/e/${videoId}`} src={`https://streamable.com/e/${videoId}`}
/> />
); );
}; };
const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => { const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://open.spotify.com/embed${parsedUrl.pathname}?theme=0`; const iframeUrl = `https://open.spotify.com/embed${parsedUrl.pathname}?theme=0`;
return ( return (
@@ -313,14 +293,13 @@ const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={iframeUrl} 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 // not officially documented https://stackoverflow.com/questions/20870270/how-to-get-soundcloud-embed-code-by-soundcloud-com-url
const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => { const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return ( return (
@@ -331,37 +310,11 @@ const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share' allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen allowFullScreen
sandbox={remoteEmbedSandbox}
title={parsedUrl.href} title={parsedUrl.href}
src={`https://w.soundcloud.com/player/?url=${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; 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> <PageFooterMobile>
<div className={styles.threadMobileFooterContent}> <div className={styles.threadMobileFooterContent}>
<div className={styles.mobileFooterButtons}> <div className={styles.mobileFooterButtons}>
<button className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}> <button type='button' className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}>
{t('post_a_reply')} {t('post_a_reply')}
</button> </button>
</div> </div>
@@ -460,7 +460,7 @@ describe('Markdown', () => {
content: 'https://cdn.example/image.png', 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(toggle).toBeTruthy();
expect(container.querySelector('[data-testid="comment-media"]')).toBeNull(); 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'); 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 () => { await act(async () => {
removeToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); removeToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
@@ -1,5 +1,5 @@
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -64,7 +64,8 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
const boardLabel = getExternalQuoteBoardLabel(reference, directories); const boardLabel = getExternalQuoteBoardLabel(reference, directories);
const linkLabel = isOP ? `${reference.raw} (OP)` : reference.raw; const linkLabel = isOP ? `${reference.raw} (OP)` : reference.raw;
const updatePreviewPosition = (anchor: HTMLElement | null) => { const updatePreviewPosition = useCallback(
(anchor: HTMLElement | null) => {
if (!anchor || isMobile) { if (!anchor || isMobile) {
return; 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)); const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
setPreviewPosition({ left, top }); setPreviewPosition({ left, top });
}; },
[isMobile],
);
useEffect(() => { useEffect(() => {
if (!isPreviewOpen || isMobile) { if (!isPreviewOpen || isMobile) {
@@ -95,7 +98,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
window.removeEventListener('resize', reposition); window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true); window.removeEventListener('scroll', reposition, true);
}; };
}, [isMobile, isPreviewOpen, previewState.kind]); }, [isMobile, isPreviewOpen, previewState.kind, updatePreviewPosition]);
const getInitialSearchMessage = () => const getInitialSearchMessage = () =>
getExternalQuoteStatusMessage(t, { getExternalQuoteStatusMessage(t, {
@@ -34,10 +34,23 @@
} }
.embedButton { .embedButton {
appearance: none;
background: transparent;
border: 0;
color: var(--button-desktop-text-color); color: var(--button-desktop-text-color);
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
text-transform: capitalize; text-transform: capitalize;
} }
.embedButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.embedButton:hover { .embedButton:hover {
cursor: pointer; cursor: pointer;
color: var(--button-desktop-text-color-hover); color: var(--button-desktop-text-color-hover);
+15 -9
View File
@@ -91,9 +91,9 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
{children} {children}
</a>{' '} </a>{' '}
[ [
<span <button
type='button'
className={styles.embedButton} className={styles.embedButton}
role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -106,7 +106,7 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
{...getReferenceProps()} {...getReferenceProps()}
> >
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')} {showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
</span> </button>
] ]
{showMedia && ( {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 elements: React.ReactNode[] = [];
const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g'); const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g');
let lastIndex = 0; let lastIndex = 0;
@@ -511,7 +511,9 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
const closeEnd = closeStart + tag.length + 3; const closeEnd = closeStart + tag.length + 3;
const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`; 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; lastIndex = closeEnd;
regex.lastIndex = closeEnd; regex.lastIndex = closeEnd;
} }
@@ -523,7 +525,7 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
return elements; 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 }) => ( const Fortune = ({ color, text }: { color: string; text: string }) => (
<span className='fortune' style={{ color }}> <span className='fortune' style={{ color }}>
@@ -608,12 +610,16 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const normalized = normalizeContent(content || ''); const normalized = normalizeContent(content || '');
const lines = normalized.split('\n'); const lines = normalized.split('\n');
const elements: React.ReactNode[] = []; const elements: React.ReactNode[] = [];
let lineOffset = 0;
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode }; const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
lines.forEach((line, lineIndex) => { lines.forEach((line, lineIndex) => {
const lineKey = `line-${lineOffset}`;
lineOffset += line.length + 1;
if (lineIndex > 0) { if (lineIndex > 0) {
elements.push(<br key={`br-${lineIndex}`} />); elements.push(<br key={`br-${lineKey}`} />);
} }
if (line.length === 0) return; if (line.length === 0) return;
@@ -624,12 +630,12 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
if (isGreentext) { if (isGreentext) {
elements.push( elements.push(
<span key={`line-${lineIndex}`} className='greentext'> <span key={lineKey} className='greentext'>
{lineElements} {lineElements}
</span>, </span>,
); );
} else { } 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 { .modEmptyState {
display: block;
padding: 1em 0; padding: 1em 0;
text-align: center; text-align: center;
width: 100%; width: 100%;
@@ -10,12 +10,12 @@ const ModEmptyState = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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.modEmptyStateTitle}>{t('not_mod_of_any_board')}</div>
<div className={styles.modEmptyStateAction}> <div className={styles.modEmptyStateAction}>
<Trans i18nKey='go_to_settings_to_import_mod_account' components={MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS} /> <Trans i18nKey='go_to_settings_to_import_mod_account' components={MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS} />
</div> </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 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) => { const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => {
if (!enabled) return null; if (!enabled) return null;
@@ -18,11 +19,11 @@ const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) =>
return ( return (
<span className={styles.authorFlags}> <span className={styles.authorFlags}>
{flags.map((flag) => ( {flags.map((flag) => (
<span <img
key={flag.key} key={flag.key}
aria-label={flag.label} alt={flag.label}
className={styles.authorFlag} className={styles.authorFlag}
role='img' src={transparentPixelSrc}
style={{ style={{
backgroundImage: `url("${flag.spritePath}")`, backgroundImage: `url("${flag.spritePath}")`,
backgroundPosition: getBackgroundPosition(flag.x, flag.y), 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 { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
const { addChallenge } = useChallengesStore.getState();
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) => const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
hasMore ? ( hasMore ? (
<div className={styles.stateString}> <div className={styles.stateString}>
@@ -112,7 +110,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
communityAddress, communityAddress,
commentModeration: approvePendingCommentModeration, commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => { onChallenge: async (...args: any) => {
addChallenge([...args, post]); useChallengesStore.getState().addChallenge([...args, post]);
}, },
onChallengeVerification: async (challengeVerification, comment) => { onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment); alertChallengeVerificationFailed(challengeVerification, comment);
@@ -131,7 +129,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
communityAddress, communityAddress,
commentModeration: rejectPendingCommentModeration, commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => { onChallenge: async (...args: any) => {
addChallenge([...args, post]); useChallengesStore.getState().addChallenge([...args, post]);
}, },
onChallengeVerification: async (challengeVerification, comment) => { onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment); alertChallengeVerificationFailed(challengeVerification, comment);
@@ -194,14 +192,14 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
<> <>
<span className={styles.modQueueButtonWrapper}> <span className={styles.modQueueButtonWrapper}>
[ [
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}> <button type='button' className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
{t('approve')} {t('approve')}
</button> </button>
] ]
</span> </span>
<span className={styles.modQueueButtonWrapper}> <span className={styles.modQueueButtonWrapper}>
[ [
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}> <button type='button' className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')} {t('reject')}
</button> </button>
] ]
@@ -318,7 +316,7 @@ const PostInfo = ({
const modQueueRemoveButton = onRemoveFromModQueue ? ( const modQueueRemoveButton = onRemoveFromModQueue ? (
<span className={styles.modQueueButtonWrapper}> <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')} {t('modQueue.dismiss')}
</button> </button>
] ]
@@ -397,10 +395,10 @@ const PostInfo = ({
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`} content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)} showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
> >
<span <button
type='button'
title={t('highlight_posts')} title={t('highlight_posts')}
className={styles.userAddress} className={styles.userAddress}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => handleUserAddressClick(userID, postCid)} onClick={() => handleUserAddressClick(userID, postCid)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -412,7 +410,7 @@ const PostInfo = ({
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }} style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
> >
{formatUserIDForDisplay(userID)} {formatUserIDForDisplay(userID)}
</span> </button>
</Tooltip> </Tooltip>
)} )}
){' '} ){' '}
@@ -440,10 +438,10 @@ const PostInfo = ({
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}> <Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No. No.
</Link> </Link>
<span <button
type='button'
className={styles.replyToPost} className={styles.replyToPost}
title={t('reply_to_post')} title={t('reply_to_post')}
role='button'
tabIndex={0} tabIndex={0}
onMouseDown={onReplyModalClick} onMouseDown={onReplyModalClick}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -454,7 +452,7 @@ const PostInfo = ({
}} }}
> >
{post?.number || '?'} {post?.number || '?'}
</span> </button>
</span> </span>
) : ( ) : (
<> <>
@@ -523,14 +521,14 @@ const PostInfo = ({
)} )}
<span className={styles.modQueueButtonWrapper}> <span className={styles.modQueueButtonWrapper}>
[ [
<button className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}> <button type='button' className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
{t('approve')} {t('approve')}
</button> </button>
] ]
</span> </span>
<span className={styles.modQueueButtonWrapper}> <span className={styles.modQueueButtonWrapper}>
[ [
<button className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}> <button type='button' className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
{t('reject')} {t('reject')}
</button> </button>
] ]
@@ -685,9 +683,9 @@ const PostMedia = ({
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && ( {!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
<span> <span>
-[ -[
<span <button
type='button'
className={styles.closeMedia} className={styles.closeMedia}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => setShowThumbnail(true)} onClick={() => setShowThumbnail(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -698,16 +696,16 @@ const PostMedia = ({
}} }}
> >
{t('close')} {t('close')}
</span> </button>
] ]
</span> </span>
)} )}
{showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && ( {showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && (
<span> <span>
-[ -[
<span <button
type='button'
className={styles.closeMedia} className={styles.closeMedia}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => setShowThumbnail(false)} onClick={() => setShowThumbnail(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -718,7 +716,7 @@ const PostMedia = ({
}} }}
> >
{t('open')} {t('open')}
</span> </button>
] ]
</span> </span>
)} )}
@@ -1139,9 +1137,10 @@ const PostDesktop = ({
<div className={isHidden ? styles.postDesktopHidden : ''}> <div className={isHidden ? styles.postDesktopHidden : ''}>
{!isInPostPageView && showReplies && ( {!isInPostPageView && showReplies && (
<span className={`${styles.hideButtonWrapper} ${!hasThumbnail ? styles.hideButtonWrapperNoImage : ''}`}> <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}`} className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`}
role='button'
tabIndex={0} tabIndex={0}
onClick={hidden ? unhide : hide} onClick={hidden ? unhide : hide}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -1207,9 +1206,10 @@ const PostDesktop = ({
</div> </div>
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && ( {!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && (
<span className={styles.summary}> <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}`} className={`${showOmittedReplies[cid] ? styles.hideOmittedReplies : styles.showOmittedReplies} ${styles.omittedRepliesButtonWrapper}`}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => setShowOmittedReplies(cid, !showOmittedReplies[cid])} onClick={() => setShowOmittedReplies(cid, !showOmittedReplies[cid])}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -144,21 +144,21 @@ describe('PostMenuDesktop', () => {
await renderMenu(); await renderMenu();
await openMenu(); 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 () => { await act(async () => {
copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1'); expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1');
await openMenu(); 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 () => { await act(async () => {
copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true })); copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1'); expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1');
await openMenu(); 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 () => { await act(async () => {
copyUserId?.dispatchEvent(new MouseEvent('click', { bubbles: true })); copyUserId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}); });
@@ -169,7 +169,7 @@ describe('PostMenuDesktop', () => {
await renderMenu('/mu'); await renderMenu('/mu');
await openMenu(); 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(); expect(hideThread).toBeTruthy();
await act(async () => { await act(async () => {
@@ -181,7 +181,7 @@ describe('PostMenuDesktop', () => {
await renderMenu('/mu'); await renderMenu('/mu');
await openMenu(); 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(); expect(unhideThread).toBeTruthy();
await act(async () => { await act(async () => {
@@ -205,7 +205,7 @@ describe('PostMenuDesktop', () => {
expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined(); 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(); expect(imageSearch).toBeTruthy();
await act(async () => { await act(async () => {
@@ -216,7 +216,7 @@ describe('PostMenuDesktop', () => {
expect(hrefs).toEqual( expect(hrefs).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png', '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', 'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]), ]),
); );
@@ -7,6 +7,28 @@
z-index: 2; 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 { .postMenuBtnWrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -28,10 +50,13 @@
.postMenuItem { .postMenuItem {
cursor: pointer; cursor: pointer;
display: block;
width: 100%;
padding: 2px 4px; padding: 2px 4px;
border: 1px solid var(--post-menu-desktop-item-border-color); border: 1px solid var(--post-menu-desktop-item-border-color);
background-color: var(--post-menu-desktop-item-background-color); background-color: var(--post-menu-desktop-item-background-color);
border-bottom: none; border-bottom: none;
box-sizing: border-box;
} }
.postMenuItem:hover { .postMenuItem:hover {
@@ -46,7 +71,7 @@
position: absolute; position: absolute;
left: 100%; left: 100%;
top: -1px; top: -1px;
display: none; display: block;
border-bottom: var(--post-menu-desktop-border-bottom); border-bottom: var(--post-menu-desktop-border-bottom);
border-right: var(--post-menu-desktop-border-right); border-right: var(--post-menu-desktop-border-right);
} }
@@ -56,10 +81,6 @@
color: inherit; color: inherit;
} }
.dropdown:hover .dropdownMenu {
display: block;
}
.postMenuBtnCatalogWrapper { .postMenuBtnCatalogWrapper {
left: 5px; left: 5px;
top: 5px; top: 5px;
@@ -51,9 +51,9 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
onClose(); onClose();
}; };
return ( return (
<div <button
type='button'
className={styles.postMenuItem} className={styles.postMenuItem}
role='button'
tabIndex={0} tabIndex={0}
onClick={copyDirectLink} onClick={copyDirectLink}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
}} }}
> >
{t('copy_direct_link')} {t('copy_direct_link')}
</div> </button>
); );
}; };
@@ -75,9 +75,9 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
onClose(); onClose();
}; };
return ( return (
<div <button
type='button'
className={styles.postMenuItem} className={styles.postMenuItem}
role='button'
tabIndex={0} tabIndex={0}
onClick={copyContentId} onClick={copyContentId}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -88,7 +88,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
}} }}
> >
{t('copy_content_id')} {t('copy_content_id')}
</div> </button>
); );
}; };
@@ -99,9 +99,9 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
onClose(); onClose();
}; };
return ( return (
<div <button
type='button'
className={styles.postMenuItem} className={styles.postMenuItem}
role='button'
tabIndex={0} tabIndex={0}
onClick={copyUserId} onClick={copyUserId}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -112,7 +112,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
}} }}
> >
{t('copy_user_id')} {t('copy_user_id')}
</div> </button>
); );
}; };
@@ -128,30 +128,31 @@ const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void
return ( return (
<div <div
className={`${styles.postMenuItem} ${styles.dropdown}`} className={styles.dropdown}
role='button'
tabIndex={0}
onMouseOver={() => setIsImageSearchMenuOpen(true)} onMouseOver={() => setIsImageSearchMenuOpen(true)}
onFocus={() => setIsImageSearchMenuOpen(true)}
onMouseLeave={() => setIsImageSearchMenuOpen(false)} onMouseLeave={() => setIsImageSearchMenuOpen(false)}
onBlur={() => setIsImageSearchMenuOpen(false)}
ref={refs.setReference} ref={refs.setReference}
onClick={onClose} >
onKeyDown={(e) => { <button
if (e.key === 'Enter' || e.key === ' ') { type='button'
e.preventDefault(); className={styles.postMenuItem}
onClose(); aria-haspopup='true'
} aria-expanded={isImageSearchMenuOpen}
}} onClick={() => setIsImageSearchMenuOpen((open) => !open)}
> >
{capitalize(t('image_search'))} » {capitalize(t('image_search'))} »
</button>
{isImageSearchMenuOpen && ( {isImageSearchMenuOpen && (
<div ref={refs.setFloating} style={floatingStyles} className={styles.dropdownMenu}> <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> <div className={styles.postMenuItem}>Google</div>
</a> </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> <div className={styles.postMenuItem}>Yandex</div>
</a> </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> <div className={styles.postMenuItem}>SauceNAO</div>
</a> </a>
</div> </div>
@@ -202,10 +203,10 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
return ( return (
<> <>
<span className={isInCatalogView ? styles.postMenuBtnCatalogWrapper : styles.postMenuBtnWrapper} ref={refs.setReference} {...getReferenceProps()}> <span className={isInCatalogView ? styles.postMenuBtnCatalogWrapper : styles.postMenuBtnWrapper} ref={refs.setReference} {...getReferenceProps()}>
<span <button
type='button'
className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn} className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn}
title='Post menu' title='Post menu'
role='button'
tabIndex={0} tabIndex={0}
onClick={handleMenuClick} onClick={handleMenuClick}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -217,16 +218,16 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }} style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }}
> >
</span> </button>
</span> </span>
{menuBtnRotated && {menuBtnRotated &&
cid && cid &&
createPortal( createPortal(
<FloatingFocusManager context={context} modal={false}> <FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}> <div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<div <button
type='button'
className={styles.postMenuItem} className={styles.postMenuItem}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
alert("Reporting isn't available yet."); alert("Reporting isn't available yet.");
@@ -241,11 +242,11 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
}} }}
> >
{t('report_post')} {t('report_post')}
</div> </button>
{!(isInPostPageView && postCid === cid) && ( {!(isInPostPageView && postCid === cid) && (
<div <button
type='button'
className={styles.postMenuItem} className={styles.postMenuItem}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
if (hidden) { 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')} {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 && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} 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 { Trans, useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
@@ -373,6 +373,7 @@ const PostFormFields = ({
<label> <label>
<input <input
type='checkbox' type='checkbox'
aria-label={capitalize(t('spoiler'))}
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))} onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
/> />
{capitalize(t('spoiler'))}? {capitalize(t('spoiler'))}?
@@ -500,7 +501,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, POST_OPTIONS_VALIDATION_DELAY_MS), }, POST_OPTIONS_VALIDATION_DELAY_MS),
).current; ).current;
const resetFields = () => { const resetFields = useCallback(() => {
if (textRef.current) { if (textRef.current) {
textRef.current.value = ''; textRef.current.value = '';
} }
@@ -523,7 +524,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(null); setFormError(null);
setIsBbcodePreviewing(false); setIsBbcodePreviewing(false);
setBbcodePreviewContent(''); setBbcodePreviewContent('');
}; }, [checkContentLength, checkPostOptions]);
const getBoardIndexPath = () => { const getBoardIndexPath = () => {
if (effectiveBoardAddress) { if (effectiveBoardAddress) {
@@ -596,7 +597,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
navigate(`/pending/${postIndex}`, pendingPostBoardPath ? { state: { boardPath: pendingPostBoardPath } } : undefined); 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 // in post page, publish a reply to the post
const isInPostView = isPostPageView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params);
@@ -704,7 +705,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
navigate(nonokoRedirectPath); navigate(nonokoRedirectPath);
} }
} }
}, [replyIndex, closeForm, navigate]); }, [replyIndex, closeForm, navigate, resetFields]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => { onUploadComplete: (uploadedUrl: string) => {
@@ -846,7 +847,7 @@ const PostForm = () => {
</div> </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')} {showForm ? t('close_post_form') : isInPostView ? t('post_a_reply') : t('start_new_thread')}
</button> </button>
{showForm && <PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />} {showForm && <PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />}
@@ -871,7 +872,7 @@ const PostForm = () => {
) : !showForm ? ( ) : !showForm ? (
<div> <div>
[ [
<button className='button' onClick={() => setShowForm(true)}> <button type='button' className='button' onClick={() => setShowForm(true)}>
{isInPostView ? t('post_a_reply') : t('start_new_thread')} {isInPostView ? t('post_a_reply') : t('start_new_thread')}
</button> </button>
] ]
@@ -256,7 +256,7 @@ describe('PostMenuMobile', () => {
expect(hrefs).toEqual( expect(hrefs).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png', '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', 'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]), ]),
); );
@@ -1,4 +1,7 @@
.postMenuBtn { .postMenuBtn {
appearance: none;
background: transparent;
border: 0;
color: var(--post-mobile-menu-button-color); color: var(--post-mobile-menu-button-color);
line-height: 1em; line-height: 1em;
width: 1em; width: 1em;
@@ -11,6 +14,13 @@
font-size: 16px; font-size: 16px;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
margin: 0;
padding: 0;
}
.postMenuBtn:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
} }
.checkbox { .checkbox {
@@ -35,6 +45,20 @@
outline: none; 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 { .postMenuItem {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
@@ -70,8 +70,8 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
onClose(); onClose();
}; };
return ( return (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={copyDirectLink} onClick={copyDirectLink}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -82,7 +82,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
}} }}
> >
<div className={styles.postMenuItem}>{t('copy_direct_link')}</div> <div className={styles.postMenuItem}>{t('copy_direct_link')}</div>
</div> </button>
); );
}; };
@@ -93,8 +93,8 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
onClose(); onClose();
}; };
return ( return (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={copyContentId} onClick={copyContentId}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -105,7 +105,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
}} }}
> >
<div className={styles.postMenuItem}>{t('copy_content_id')}</div> <div className={styles.postMenuItem}>{t('copy_content_id')}</div>
</div> </button>
); );
}; };
@@ -116,8 +116,8 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
onClose(); onClose();
}; };
return ( return (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={copyUserId} onClick={copyUserId}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -128,7 +128,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
}} }}
> >
<div className={styles.postMenuItem}>{t('copy_user_id')}</div> <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 { t } = useTranslation();
const encodedUrl = encodeURIComponent(url); const encodedUrl = encodeURIComponent(url);
return ( return (
<div <div>
role='button' <a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
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 className={styles.postMenuItem}>{t('search_image_on_google')}</div> <div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
</a> </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> <div className={styles.postMenuItem}>{t('search_image_on_yandex')}</div>
</a> </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> <div className={styles.postMenuItem}>{t('search_image_on_saucenao')}</div>
</a> </a>
</div> </div>
); );
}; };
const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => { const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const reportPost = () => { const reportPost = () => {
@@ -169,8 +157,8 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
onClose(); onClose();
}; };
return ( return (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={reportPost} onClick={reportPost}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -181,7 +169,7 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
}} }}
> >
<div className={styles.postMenuItem}>{t('report_post')}</div> <div className={styles.postMenuItem}>{t('report_post')}</div>
</div> </button>
); );
}; };
@@ -208,7 +196,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
latestPostRef.current = resolvedPost; latestPostRef.current = resolvedPost;
}, [resolvedPost]); }, [resolvedPost]);
const onChallenge = useCallback(async (...args: unknown[]) => { const onChallenge = useCallback(async (...args: unknown[]) => {
addChallenge([...args, latestPostRef.current]); useChallengesStore.getState().addChallenge([...args, latestPostRef.current]);
}, []); }, []);
const deleteOptions = useMemo( const deleteOptions = useMemo(
@@ -252,8 +240,8 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
}; };
return ( return (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={deletePost} onClick={deletePost}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -264,7 +252,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
}} }}
> >
<div className={styles.postMenuItem}>{t('delete_post')}</div> <div className={styles.postMenuItem}>{t('delete_post')}</div>
</div> </button>
); );
}; };
@@ -283,8 +271,8 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
}; };
return ( return (
(!isInPostView || isReply) && ( (!isInPostView || isReply) && (
<div <button
role='button' type='button'
tabIndex={0} tabIndex={0}
onClick={togglePostHidden} onClick={togglePostHidden}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -297,7 +285,7 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
<div className={styles.postMenuItem}> <div className={styles.postMenuItem}>
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')} {hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
</div> </div>
</div> </button>
) )
); );
}; };
@@ -343,10 +331,10 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
<> <>
{!(deleted || removed) && ( {!(deleted || removed) && (
<> <>
<span <button
type='button'
className={styles.postMenuBtn} className={styles.postMenuBtn}
title='Post menu' title='Post menu'
role='button'
tabIndex={0} tabIndex={0}
onClick={handleMenuClick} onClick={handleMenuClick}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -359,7 +347,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
{...getReferenceProps()} {...getReferenceProps()}
> >
... ...
</span> </button>
{isMenuOpen && {isMenuOpen &&
cid && cid &&
createPortal( createPortal(
+16 -18
View File
@@ -61,8 +61,6 @@ import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAu
import { getAuthorBadge } from '../../lib/utils/author-display-utils'; import { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
const { addChallenge } = useChallengesStore.getState();
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) => const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
hasMore ? ( hasMore ? (
<div className={styles.stateString}> <div className={styles.stateString}>
@@ -135,7 +133,7 @@ const PostInfoAndMedia = ({
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: approvePendingCommentModeration, commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => { onChallenge: async (...args: any) => {
addChallenge([...args, resolvedPost]); useChallengesStore.getState().addChallenge([...args, resolvedPost]);
}, },
onChallengeVerification: async (challengeVerification, comment) => { onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment); alertChallengeVerificationFailed(challengeVerification, comment);
@@ -154,7 +152,7 @@ const PostInfoAndMedia = ({
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined, communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: rejectPendingCommentModeration, commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => { onChallenge: async (...args: any) => {
addChallenge([...args, resolvedPost]); useChallengesStore.getState().addChallenge([...args, resolvedPost]);
}, },
onChallengeVerification: async (challengeVerification, comment) => { onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(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 })}`} content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
showTooltip={isInPostPageView || postReplyCount < 6} showTooltip={isInPostPageView || postReplyCount < 6}
> >
<span <button
type='button'
title={t('highlight_posts')} title={t('highlight_posts')}
className={styles.userAddress} className={styles.userAddress}
role='button'
tabIndex={0} tabIndex={0}
onClick={() => handleUserAddressClick(userID, postCid)} onClick={() => handleUserAddressClick(userID, postCid)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -346,7 +344,7 @@ const PostInfoAndMedia = ({
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }} style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
> >
{formatUserIDForDisplay(userID)} {formatUserIDForDisplay(userID)}
</span> </button>
</Tooltip> </Tooltip>
)} )}
){' '} ){' '}
@@ -406,10 +404,10 @@ const PostInfoAndMedia = ({
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}> <Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No. No.
</Link> </Link>
<span <button
type='button'
className={styles.replyToPost} className={styles.replyToPost}
title={t('reply_to_post')} title={t('reply_to_post')}
role='button'
tabIndex={0} tabIndex={0}
onMouseDown={onReplyModalClick} onMouseDown={onReplyModalClick}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -420,7 +418,7 @@ const PostInfoAndMedia = ({
}} }}
> >
{resolvedPost?.number || '?'} {resolvedPost?.number || '?'}
</span> </button>
</span> </span>
) : ( ) : (
<> <>
@@ -443,10 +441,10 @@ const PostInfoAndMedia = ({
<LoadingEllipsis string={t('publishing')} /> <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')} {t('approve')}
</button> </button>
<button className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}> <button type='button' className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')} {t('reject')}
</button> </button>
</> </>
@@ -625,7 +623,7 @@ const PostMobile = ({
const modQueueThreadRouteState = getQueuedCommentRouteState(resolvedPost); const modQueueThreadRouteState = getQueuedCommentRouteState(resolvedPost);
const modQueueErrorMessage = formatErrorForDisplay(modQueueError); const modQueueErrorMessage = formatErrorForDisplay(modQueueError);
const modQueueRemoveButton = onRemoveFromModQueue ? ( const modQueueRemoveButton = onRemoveFromModQueue ? (
<button className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}> <button type='button' className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}>
{capitalize(t('modQueue.dismiss'))} {capitalize(t('modQueue.dismiss'))}
</button> </button>
) : null; ) : null;
@@ -840,9 +838,9 @@ const PostMobile = ({
<> <>
<hr className={styles.unhideButtonHr} /> <hr className={styles.unhideButtonHr} />
<span className={styles.mobileUnhideButton}> <span className={styles.mobileUnhideButton}>
<span <button
type='button'
className='button' className='button'
role='button'
tabIndex={0} tabIndex={0}
onClick={unhide} onClick={unhide}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -853,7 +851,7 @@ const PostMobile = ({
}} }}
> >
Show Hidden Thread Show Hidden Thread
</span> </button>
</span> </span>
</> </>
) : ( ) : (
@@ -921,10 +919,10 @@ const PostMobile = ({
{t('view_thread')} {t('view_thread')}
</Link> </Link>
)} )}
<button className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}> <button type='button' className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
{t('approve')} {t('approve')}
</button> </button>
<button className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}> <button type='button' className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
{t('reject')} {t('reject')}
</button> </button>
</> </>
+23 -10
View File
@@ -97,6 +97,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const nonokoRedirectPathRef = useRef<string | null>(null); const nonokoRedirectPathRef = useRef<string | null>(null);
const lastSelectionStartRef = useRef(0); const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = useRef(0); const lastSelectionEndRef = useRef(0);
const initializedReplyContentKeyRef = useRef('');
const lastProcessedQuoteInsertRequestIdRef = useRef(0); const lastProcessedQuoteInsertRequestIdRef = useRef(0);
const { selectedText } = useSelectedTextStore(); const { selectedText } = useSelectedTextStore();
const openEmpty = useReplyModalStore((state) => state.openEmpty); const openEmpty = useReplyModalStore((state) => state.openEmpty);
@@ -243,9 +244,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
); );
useEffect(() => { useEffect(() => {
const checkContentLength = checkContentLengthRef.current;
const checkPostOptions = checkPostOptionsRef.current;
return () => { return () => {
checkContentLengthRef.current.cancel(); checkContentLength.cancel();
checkPostOptionsRef.current.cancel(); checkPostOptions.cancel();
restoreBodyTextSelection(); restoreBodyTextSelection();
}; };
}, []); }, []);
@@ -293,14 +297,24 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
// Enable spellcheck after initial content is injected into the textarea. // Enable spellcheck after initial content is injected into the textarea.
useEffect(() => { 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.spellcheck = false;
textRef.current.value = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`; textRef.current.value = initialContent;
const len = textRef.current.value.length; const len = textRef.current.value.length;
lastSelectionStartRef.current = len; lastSelectionStartRef.current = len;
lastSelectionEndRef.current = len; lastSelectionEndRef.current = len;
const content = textRef.current.value; const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent }); setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t); checkContentLengthRef.current(publishContent, t);
@@ -313,8 +327,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
return () => { return () => {
window.clearTimeout(spellcheckTimeout); window.clearTimeout(spellcheckTimeout);
}; };
} }, [showReplyModal, parentCid, openEmpty, defaultParentQuote, selectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
useEffect(() => { useEffect(() => {
if (!showReplyModal) { if (!showReplyModal) {
@@ -414,7 +427,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode); const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
setPublishReplyOptions({ content: publishContent }); setPublishReplyOptions({ content: publishContent });
checkContentLengthRef.current(publishContent, t); checkContentLengthRef.current(publishContent, t);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]); }, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => { onUploadComplete: (uploadedUrl: string) => {
@@ -568,7 +581,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
<span className={styles.spoilerButton}> <span className={styles.spoilerButton}>
[ [
<label> <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'))}? {capitalize(t('spoiler'))}?
</label> </label>
] ]
@@ -353,7 +353,9 @@ const MobileQuotePreview = ({
className={styles.backlink} className={styles.backlink}
ref={refs.setReference} ref={refs.setReference}
onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)} onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)}
onFocus={() => handleMouseOver(normalizedBacklinkReply?.cid)}
onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)} onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
onBlur={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
> >
{`>>${normalizedBacklinkReply?.number ?? '?'}`} {`>>${normalizedBacklinkReply?.number ?? '?'}`}
</span> </span>
@@ -404,7 +406,9 @@ const MobileQuotePreview = ({
ref={quotelinkUnavailable || quotelinkPendingResolution ? undefined : refs.setReference} ref={quotelinkUnavailable || quotelinkPendingResolution ? undefined : refs.setReference}
className={quotelinkPendingResolution ? styles.quoteLink : quotelinkClassName} className={quotelinkPendingResolution ? styles.quoteLink : quotelinkClassName}
onMouseOver={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)} onMouseOver={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
onFocus={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
onMouseLeave={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)} onMouseLeave={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
onBlur={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
> >
{formatQuoteNumber(resolvedQuotelinkNumber)} {formatQuoteNumber(resolvedQuotelinkNumber)}
{isOP && ' (OP)'} {isOP && ' (OP)'}
@@ -80,12 +80,12 @@ const render = (initialEntry = '/all/settings') => {
const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? ''; const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? '';
const getLabelByText = (text: string) => { const getSectionToggleByText = (text: string) => {
const label = Array.from(container.querySelectorAll('label')).find((candidate) => (candidate.textContent ?? '').includes(text)); const control = Array.from(container.querySelectorAll<HTMLElement>('label, button')).find((candidate) => (candidate.textContent ?? '').includes(text));
if (!label) { if (!control) {
throw new Error(`Label containing "${text}" not found`); throw new Error(`Section toggle containing "${text}" not found`);
} }
return label; return control;
}; };
describe('SettingsModal', () => { describe('SettingsModal', () => {
@@ -115,7 +115,7 @@ describe('SettingsModal', () => {
expect(getLocationText()).toBe('/all/settings#account-settings'); expect(getLocationText()).toBe('/all/settings#account-settings');
await act(async () => { await act(async () => {
getLabelByText('interface').click(); getSectionToggleByText('interface').click();
}); });
expect(getLocationText()).toBe('/all/settings#interface-settings'); expect(getLocationText()).toBe('/all/settings#interface-settings');
@@ -123,14 +123,14 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull(); expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
await act(async () => { await act(async () => {
getLabelByText('interface').click(); getSectionToggleByText('interface').click();
}); });
expect(getLocationText()).toBe('/all/settings#account-settings'); expect(getLocationText()).toBe('/all/settings#account-settings');
expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull(); expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
await act(async () => { await act(async () => {
getLabelByText('bitsocial_account').click(); getSectionToggleByText('bitsocial_account').click();
}); });
expect(getLocationText()).toBe('/all/settings'); expect(getLocationText()).toBe('/all/settings');
@@ -140,7 +140,7 @@ describe('SettingsModal', () => {
it('expands and collapses all settings sections', async () => { it('expands and collapses all settings sections', async () => {
render('/all/settings'); 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) { if (!expandAllControl) {
throw new Error('expand_all_settings control not found'); 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="advanced-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-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) => const collapseAllControl = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').includes('collapse_all_settings'));
(candidate.textContent ?? '').includes('collapse_all_settings'),
);
if (!collapseAllControl) { if (!collapseAllControl) {
throw new Error('collapse_all_settings control not found'); 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 () => { it('closes the modal when the overlay is clicked', async () => {
render('/all/settings#interface-settings'); render('/all/settings#interface-settings');
const overlay = container.querySelector('[role="button"]'); const overlay = container.querySelector('button');
if (!overlay) { if (!overlay) {
throw new Error('overlay not found'); throw new Error('overlay not found');
} }
@@ -217,13 +217,19 @@ const AccountSettingsEditor = ({
<select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}> <select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}>
{accountsOptions} {accountsOptions}
</select>{' '} </select>{' '}
<button onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>{t('edit')}</button>{' '} <button type='button' onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>
<button onClick={handleExportAccount}>{t('download_backup')}</button> {t('edit')}
</button>{' '}
<button type='button' onClick={handleExportAccount}>
{t('download_backup')}
</button>
<div className={styles.info}>{accountStorageInfo}</div> <div className={styles.info}>{accountStorageInfo}</div>
</div> </div>
<div> <div>
<button onClick={handleImportAccount}>{t('import_account_backup')}</button> <button type='button' onClick={handleImportAccount}>
<button className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}> {t('import_account_backup')}
</button>
<button type='button' className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
{t('delete_account')} {t('delete_account')}
</button> </button>
</div> </div>
@@ -64,6 +64,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
<div className={styles.ipfsGatewaysSettings}> <div className={styles.ipfsGatewaysSettings}>
<div className={styles.ipfsGatewaysSetting}> <div className={styles.ipfsGatewaysSetting}>
<textarea <textarea
aria-label='IPFS gateway URLs'
defaultValue={ipfsGatewayUrlsDefaultValue} defaultValue={ipfsGatewayUrlsDefaultValue}
ref={ipfsGatewayUrlsRef} ref={ipfsGatewayUrlsRef}
disabled={isConnectedToRpc} disabled={isConnectedToRpc}
@@ -77,6 +78,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
<div> <div>
<input <input
type='text' type='text'
aria-label='Media IPFS gateway URL'
defaultValue={mediaIpfsGatewayUrl} defaultValue={mediaIpfsGatewayUrl}
ref={mediaIpfsGatewayUrlRef} ref={mediaIpfsGatewayUrlRef}
disabled={isConnectedToRpc} disabled={isConnectedToRpc}
@@ -100,6 +102,7 @@ const PubsubProvidersSettings = ({ pubsubProvidersRef }: SettingsProps) => {
return ( return (
<div className={styles.pubsubProvidersSettings}> <div className={styles.pubsubProvidersSettings}>
<textarea <textarea
aria-label='Pubsub providers'
defaultValue={pubsubProvidersDefaultValue} defaultValue={pubsubProvidersDefaultValue}
ref={pubsubProvidersRef} ref={pubsubProvidersRef}
disabled={isConnectedToRpc} disabled={isConnectedToRpc}
@@ -124,6 +127,7 @@ const HttpRoutersSettings = ({ httpRoutersRef }: SettingsProps) => {
return ( return (
<div className={styles.httpRoutersSettings}> <div className={styles.httpRoutersSettings}>
<textarea <textarea
aria-label='HTTP routers'
defaultValue={httpRoutersDefaultValue} defaultValue={httpRoutersDefaultValue}
ref={httpRoutersRef} ref={httpRoutersRef}
disabled={isConnectedToRpc} disabled={isConnectedToRpc}
@@ -147,6 +151,7 @@ const BlockchainProvidersSettings = ({ ethRpcRef }: SettingsProps) => {
<span className={styles.settingTip}>Ethereum RPC, for .eth domains</span> <span className={styles.settingTip}>Ethereum RPC, for .eth domains</span>
<div> <div>
<textarea <textarea
aria-label='Ethereum RPC URLs'
defaultValue={ethRpcDefaultValue} defaultValue={ethRpcDefaultValue}
ref={ethRpcRef} ref={ethRpcRef}
autoCorrect='off' autoCorrect='off'
@@ -168,6 +173,7 @@ const P2pRPCSettings = ({ p2pRpcRef }: SettingsProps) => {
<div className={styles.p2pRPCSettings}> <div className={styles.p2pRPCSettings}>
<input <input
type='text' type='text'
aria-label='P2P RPC clients'
defaultValue={pkcRpcClientsOptions} defaultValue={pkcRpcClientsOptions}
placeholder='ws://<IP>:<port>/<secretAuthKey>' placeholder='ws://<IP>:<port>/<secretAuthKey>'
ref={p2pRpcRef} ref={p2pRpcRef}
@@ -188,7 +194,16 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => {
return ( return (
<div className={styles.p2pDataPathSettings}> <div className={styles.p2pDataPathSettings}>
<div> <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>
</div> </div>
); );
@@ -205,6 +220,7 @@ const PureP2PBrowserSettings = ({ onPureP2PBrowserChange, pureP2PBrowserEnabled,
<input <input
className={styles.pureP2PCheckbox} className={styles.pureP2PCheckbox}
type='checkbox' type='checkbox'
aria-label='Pure P2P browser mode'
checked={isChecked} checked={isChecked}
ref={pureP2PBrowserRef} ref={pureP2PBrowserRef}
onChange={(event) => onPureP2PBrowserChange?.(event.currentTarget.checked)} onChange={(event) => onPureP2PBrowserChange?.(event.currentTarget.checked)}
@@ -363,7 +379,7 @@ const AdvancedSettings = () => {
{canConfigurePureP2PBrowser ? ( {canConfigurePureP2PBrowser ? (
<PureP2PBrowserSettings pureP2PBrowserEnabled={browserPureP2PEnabled} pureP2PBrowserRef={pureP2PBrowserRef} onPureP2PBrowserChange={setBrowserPureP2PSelection} /> <PureP2PBrowserSettings pureP2PBrowserEnabled={browserPureP2PEnabled} pureP2PBrowserRef={pureP2PBrowserRef} onPureP2PBrowserChange={setBrowserPureP2PSelection} />
) : null} ) : null}
<button className={styles.saveOptions} onClick={handleSave}> <button type='button' className={styles.saveOptions} onClick={handleSave}>
{t('save_advanced_settings')} {t('save_advanced_settings')}
</button> </button>
</div> </div>
@@ -178,6 +178,7 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
<div className={styles.cryptoAddressInput}> <div className={styles.cryptoAddressInput}>
<input <input
type='text' type='text'
aria-label='Crypto address'
placeholder='myaddress.bso' placeholder='myaddress.bso'
value={cryptoAddress} value={cryptoAddress}
onChange={(e) => { onChange={(e) => {
@@ -186,33 +187,48 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
setCryptoAddress(e.target.value); setCryptoAddress(e.target.value);
}} }}
/> />
<button className={styles.saveButton} onClick={saveCryptoAddress}> <button type='button' className={styles.saveButton} onClick={saveCryptoAddress}>
{t('save')} {t('save')}
</button> </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' : '?'} {showCryptoAddressInfo ? 'x' : '?'}
</button> </button>
{showCryptoAddressInfo && ( {showCryptoAddressInfo && (
<div className={styles.cryptoAddressInfo}> <div className={styles.cryptoAddressInfo}>
steps to set a .bso account address: 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
<br /> account name:
<ol> <ol>
<li> <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'> <a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'>
app.ens.domains 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>
<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> </ol>
</div> </div>
)} )}
{savedCryptoAddress && <span className={styles.saved}>{t('saved')}</span>} {savedCryptoAddress && <span className={styles.saved}>{t('saved')}</span>}
</div> </div>
<div className={styles.checkCryptoAddress}> <div className={styles.checkCryptoAddress}>
<button className={styles.button} onClick={checkCryptoAddress}> <button type='button' className={styles.button} onClick={checkCryptoAddress}>
{t('check')} {t('check')}
</button>{' '} </button>{' '}
<span className={(transientResolutionStatus ?? resolutionStatus).resolveClass}>{(transientResolutionStatus ?? resolutionStatus).resolveString}</span> <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