feat(mod queue): transfer posts across boards (#1182)

* feat(mod queue): transfer posts across boards

* fix(mod queue): polish transfer modal and reason

* fix(mod queue): recover abandoned transfer challenges

* fix(mod queue): address transfer review feedback

* fix(mod queue): hide transfer for unavailable posts

* fix(mod queue): prevent repeat transfer submit

* fix(mod queue): sync transfer state with queue actions

* fix(mod queue): omit transfer temp index

* fix(mod queue): harden transfer failure states

* fix(mod queue): keep hidden transfer lock

* fix(mod queue): release hidden transfer lock
This commit is contained in:
Tommaso Casaburi
2026-07-02 14:52:06 +07:00
committed by GitHub
parent 546ee99f12
commit b4a6f0a65a
55 changed files with 2688 additions and 154 deletions
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "يمكنك تمييز بناء الجملة والحفاظ على المسافات البيضاء باستخدام وسوم [code].",
"reply_link_required_alert": "يجب عليك الرد برابط.",
"reply_media_link_required_alert": "يجب عليك الرد برابط لصورة أو فيديو.",
"reply_links_not_allowed_alert": "لا يُسمح بروابط الردود في هذه اللوحة."
"reply_links_not_allowed_alert": "لا يُسمح بروابط الردود في هذه اللوحة.",
"transfer": "نقل",
"modQueue.transferTitle": "نقل المنشور",
"modQueue.transferSource": "من",
"modQueue.transferTarget": "إلى منتدى",
"modQueue.transferFields": "نسخ الحقول",
"modQueue.transferNoFields": "اختر حقلًا واحدًا على الأقل للنسخ.",
"modQueue.transferSuccess": "تم النقل. يشير المنشور الأصلي الآن إلى اللوحة الجديدة.",
"transferred": "منقول",
"modQueue.transferTitleWithNumber": "نقل المنشور رقم {{number}}",
"modQueue.transferRecreateNotice": "هذا ليس نقلا حقيقيا على مستوى البروتوكول. يعيد 5chan إنشاء المنشور على اللوحة المحددة ويزيل الأصل.",
"modQueue.transferRepliesNotice": "لن تُنقل الردود. سيتم حذفها مع المنشور الأصلي.",
"modQueue.transferTemporaryAccountNotice": "سينشئ 5chan حسابا مؤقتا جديدا لهذا المنشور المنسوخ فقط، ثم يحذفه بعد النشر."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "আপনি [code] ট্যাগ ব্যবহার করে সিনট্যাক্স হাইলাইট করতে এবং সাদা স্থান সংরক্ষণ করতে পারেন।",
"reply_link_required_alert": "আপনাকে একটি লিঙ্ক দিয়ে উত্তর দিতে হবে।",
"reply_media_link_required_alert": "আপনাকে একটি ছবি বা ভিডিও লিঙ্ক দিয়ে উত্তর দিতে হবে।",
"reply_links_not_allowed_alert": "এই বোর্ডে উত্তরের লিঙ্ক অনুমোদিত নয়।"
"reply_links_not_allowed_alert": "এই বোর্ডে উত্তরের লিঙ্ক অনুমোদিত নয়।",
"transfer": "স্থানান্তর",
"modQueue.transferTitle": "পোস্ট স্থানান্তর",
"modQueue.transferSource": "থেকে",
"modQueue.transferTarget": "বোর্ডে",
"modQueue.transferFields": "ক্ষেত্র কপি করুন",
"modQueue.transferNoFields": "কপি করতে অন্তত একটি ক্ষেত্র নির্বাচন করুন।",
"modQueue.transferSuccess": "স্থানান্তরিত হয়েছে। মূল পোস্টটি এখন নতুন বোর্ডের দিকে নির্দেশ করে।",
"transferred": "স্থানান্তরিত",
"modQueue.transferTitleWithNumber": "পোস্ট No.{{number}} স্থানান্তর",
"modQueue.transferRecreateNotice": "এটি প্রোটোকলের আসল স্থানান্তর নয়। 5chan নির্বাচিত বোর্ডে পোস্টটি আবার তৈরি করে এবং আসলটি সরিয়ে দেয়।",
"modQueue.transferRepliesNotice": "উত্তরগুলো স্থানান্তরিত হবে না। সেগুলো মূল পোস্টের সঙ্গে মুছে যাবে।",
"modQueue.transferTemporaryAccountNotice": "5chan শুধু এই কপি করা পোস্টের জন্য একটি নতুন অস্থায়ী অ্যাকাউন্ট তৈরি করবে, তারপর প্রকাশের পর সেটি মুছে দেবে।"
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Syntax můžete zvýraznit a zachovat mezery pomocí značek [code].",
"reply_link_required_alert": "Musíte odpovědět odkazem.",
"reply_media_link_required_alert": "Musíte odpovědět odkazem na obrázek nebo video.",
"reply_links_not_allowed_alert": "Odkazy v odpovědích nejsou na této nástěnce povoleny."
"reply_links_not_allowed_alert": "Odkazy v odpovědích nejsou na této nástěnce povoleny.",
"transfer": "Přesunout",
"modQueue.transferTitle": "Přesunout příspěvek",
"modQueue.transferSource": "Od",
"modQueue.transferTarget": "Do fóra",
"modQueue.transferFields": "Kopírovat pole",
"modQueue.transferNoFields": "Vyberte alespoň jedno pole ke kopírování.",
"modQueue.transferSuccess": "Přesunuto. Původní příspěvek teď odkazuje na novou nástěnku.",
"transferred": "Přesunuto",
"modQueue.transferTitleWithNumber": "Přesunout příspěvek č. {{number}}",
"modQueue.transferRecreateNotice": "Toto není skutečný protokolový přesun. 5chan příspěvek znovu vytvoří na vybrané nástěnce a původní odstraní.",
"modQueue.transferRepliesNotice": "Odpovědi se nepřesunou. Budou odstraněny spolu s původním příspěvkem.",
"modQueue.transferTemporaryAccountNotice": "5chan vytvoří nový dočasný účet jen pro tento zkopírovaný příspěvek a po zveřejnění ho smaže."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Du kan fremhæve syntaks og bevare mellemrum ved at bruge [code]-tags.",
"reply_link_required_alert": "Du skal svare med et link.",
"reply_media_link_required_alert": "Du skal svare med et billed- eller videolink.",
"reply_links_not_allowed_alert": "Svarlinks er ikke tilladt på dette board."
"reply_links_not_allowed_alert": "Svarlinks er ikke tilladt på dette board.",
"transfer": "Overfør",
"modQueue.transferTitle": "Overfør indlæg",
"modQueue.transferSource": "Fra",
"modQueue.transferTarget": "Til forum",
"modQueue.transferFields": "Kopiér felter",
"modQueue.transferNoFields": "Vælg mindst ét felt, der skal kopieres.",
"modQueue.transferSuccess": "Overført. Det oprindelige opslag peger nu på det nye board.",
"transferred": "Overført",
"modQueue.transferTitleWithNumber": "Overfør opslag No.{{number}}",
"modQueue.transferRecreateNotice": "Dette er ikke en rigtig protokoloverførsel. 5chan genskaber opslaget på det valgte board og fjerner originalen.",
"modQueue.transferRepliesNotice": "Svar bliver ikke overført. De slettes sammen med det oprindelige opslag.",
"modQueue.transferTemporaryAccountNotice": "5chan opretter en ny midlertidig konto kun til dette kopierede opslag og sletter den efter publicering."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Sie können Syntax hervorheben und Leerzeichen beibehalten, indem Sie [code]-Tags verwenden.",
"reply_link_required_alert": "Du musst mit einem Link antworten.",
"reply_media_link_required_alert": "Du musst mit einem Bild- oder Videolink antworten.",
"reply_links_not_allowed_alert": "Antwortlinks sind auf diesem Board nicht erlaubt."
"reply_links_not_allowed_alert": "Antwortlinks sind auf diesem Board nicht erlaubt.",
"transfer": "Übertragen",
"modQueue.transferTitle": "Beitrag übertragen",
"modQueue.transferSource": "Von",
"modQueue.transferTarget": "Zum Forum",
"modQueue.transferFields": "Felder kopieren",
"modQueue.transferNoFields": "Wählen Sie mindestens ein Feld zum Kopieren aus.",
"modQueue.transferSuccess": "Übertragen. Der ursprüngliche Beitrag verweist jetzt auf das neue Board.",
"transferred": "Übertragen",
"modQueue.transferTitleWithNumber": "Beitrag Nr. {{number}} übertragen",
"modQueue.transferRecreateNotice": "Das ist keine echte Protokoll-Übertragung. 5chan erstellt den Beitrag auf dem gewählten Board neu und entfernt das Original.",
"modQueue.transferRepliesNotice": "Antworten werden nicht übertragen. Sie werden mit dem ursprünglichen Beitrag gelöscht.",
"modQueue.transferTemporaryAccountNotice": "5chan erstellt nur für diesen kopierten Beitrag ein neues temporäres Konto und löscht es nach dem Veröffentlichen."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Μπορείτε να επισημάνετε τη σύνταξη και να διατηρήσετε τα κενά χρησιμοποιώντας ετικέτες [code].",
"reply_link_required_alert": "Πρέπει να απαντήσετε με έναν σύνδεσμο.",
"reply_media_link_required_alert": "Πρέπει να απαντήσετε με σύνδεσμο εικόνας ή βίντεο.",
"reply_links_not_allowed_alert": "Οι σύνδεσμοι στις απαντήσεις δεν επιτρέπονται σε αυτόν τον πίνακα."
"reply_links_not_allowed_alert": "Οι σύνδεσμοι στις απαντήσεις δεν επιτρέπονται σε αυτόν τον πίνακα.",
"transfer": "Μεταφορά",
"modQueue.transferTitle": "Μεταφορά δημοσίευσης",
"modQueue.transferSource": "Από",
"modQueue.transferTarget": "Στο φόρουμ",
"modQueue.transferFields": "Αντιγραφή πεδίων",
"modQueue.transferNoFields": "Επιλέξτε τουλάχιστον ένα πεδίο για αντιγραφή.",
"modQueue.transferSuccess": "Μεταφέρθηκε. Η αρχική ανάρτηση δείχνει πλέον στον νέο πίνακα.",
"transferred": "Μεταφέρθηκε",
"modQueue.transferTitleWithNumber": "Μεταφορά ανάρτησης No.{{number}}",
"modQueue.transferRecreateNotice": "Αυτό δεν είναι πραγματική μεταφορά στο πρωτόκολλο. Το 5chan αναδημιουργεί την ανάρτηση στον επιλεγμένο πίνακα και αφαιρεί την αρχική.",
"modQueue.transferRepliesNotice": "Οι απαντήσεις δεν μεταφέρονται. Θα διαγραφούν μαζί με την αρχική ανάρτηση.",
"modQueue.transferTemporaryAccountNotice": "Το 5chan θα δημιουργήσει έναν νέο προσωρινό λογαριασμό μόνο για αυτό το αντιγραμμένο post και θα τον διαγράψει μετά τη δημοσίευση."
}
+13 -1
View File
@@ -468,5 +468,17 @@
"post_form_code_tags_prompt": "You may highlight syntax and preserve whitespace by using [code] tags.",
"reply_link_required_alert": "You must reply with a link.",
"reply_media_link_required_alert": "You must reply with an image or video link.",
"reply_links_not_allowed_alert": "Reply links are not allowed on this board."
"reply_links_not_allowed_alert": "Reply links are not allowed on this board.",
"transfer": "Transfer",
"modQueue.transferTitle": "Transfer post",
"modQueue.transferSource": "From",
"modQueue.transferTarget": "To board",
"modQueue.transferFields": "Copy fields",
"modQueue.transferNoFields": "Choose at least one field to copy.",
"modQueue.transferSuccess": "Transferred. The original post now points to the new board.",
"transferred": "Transferred",
"modQueue.transferTitleWithNumber": "Transfer Post No.{{number}}",
"modQueue.transferRecreateNotice": "This is not a real protocol transfer. 5chan recreates the post on the selected board and removes the original.",
"modQueue.transferRepliesNotice": "Replies are not transferred. They are deleted with the original post.",
"modQueue.transferTemporaryAccountNotice": "5chan will create a new temporary account just for this copied post, then delete it after publishing."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Puede resaltar la sintaxis y conservar los espacios en blanco usando etiquetas [code].",
"reply_link_required_alert": "Debes responder con un enlace.",
"reply_media_link_required_alert": "Debes responder con un enlace a una imagen o un video.",
"reply_links_not_allowed_alert": "Los enlaces en respuestas no están permitidos en este tablón."
"reply_links_not_allowed_alert": "Los enlaces en respuestas no están permitidos en este tablón.",
"transfer": "Transferir",
"modQueue.transferTitle": "Transferir publicación",
"modQueue.transferSource": "De",
"modQueue.transferTarget": "Al foro",
"modQueue.transferFields": "Copiar campos",
"modQueue.transferNoFields": "Elige al menos un campo para copiar.",
"modQueue.transferSuccess": "Transferido. La publicación original ahora apunta al nuevo foro.",
"transferred": "Transferido",
"modQueue.transferTitleWithNumber": "Transferir publicación No.{{number}}",
"modQueue.transferRecreateNotice": "Esto no es una transferencia real del protocolo. 5chan vuelve a crear la publicación en el foro seleccionado y elimina la original.",
"modQueue.transferRepliesNotice": "Las respuestas no se transfieren. Se eliminan junto con la publicación original.",
"modQueue.transferTemporaryAccountNotice": "5chan creará una nueva cuenta temporal solo para esta publicación copiada y la eliminará después de publicarla."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "می‌توانید با استفاده از برچسب‌های [code] نحو را برجسته کرده و فاصله‌ها را حفظ کنید.",
"reply_link_required_alert": "باید با یک پیوند پاسخ دهید.",
"reply_media_link_required_alert": "باید با یک پیوند تصویر یا ویدیو پاسخ دهید.",
"reply_links_not_allowed_alert": "پیوندها در پاسخ‌ها در این برد مجاز نیستند."
"reply_links_not_allowed_alert": "پیوندها در پاسخ‌ها در این برد مجاز نیستند.",
"transfer": "انتقال",
"modQueue.transferTitle": "انتقال پست",
"modQueue.transferSource": "از",
"modQueue.transferTarget": "به تالار",
"modQueue.transferFields": "کپی فیلدها",
"modQueue.transferNoFields": "حداقل یک فیلد برای کپی انتخاب کنید.",
"modQueue.transferSuccess": "منتقل شد. پست اصلی اکنون به برد جدید اشاره می‌کند.",
"transferred": "منتقل شد",
"modQueue.transferTitleWithNumber": "انتقال پست شماره {{number}}",
"modQueue.transferRecreateNotice": "این یک انتقال واقعی در سطح پروتکل نیست. 5chan پست را در برد انتخاب‌شده دوباره می‌سازد و نسخه اصلی را حذف می‌کند.",
"modQueue.transferRepliesNotice": "پاسخ‌ها منتقل نمی‌شوند. آن‌ها همراه با پست اصلی حذف می‌شوند.",
"modQueue.transferTemporaryAccountNotice": "5chan فقط برای این پست کپی‌شده یک حساب موقت جدید می‌سازد و پس از انتشار آن را حذف می‌کند."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Voit korostaa syntaksia ja säilyttää välilyönnit käyttämällä [code]-tageja.",
"reply_link_required_alert": "Sinun on vastattava linkillä.",
"reply_media_link_required_alert": "Sinun on vastattava kuva- tai videolinkillä.",
"reply_links_not_allowed_alert": "Vastauslinkit eivät ole sallittuja tällä laudalla."
"reply_links_not_allowed_alert": "Vastauslinkit eivät ole sallittuja tällä laudalla.",
"transfer": "Siirrä",
"modQueue.transferTitle": "Siirrä viesti",
"modQueue.transferSource": "Mistä",
"modQueue.transferTarget": "Foorumille",
"modQueue.transferFields": "Kopioi kentät",
"modQueue.transferNoFields": "Valitse vähintään yksi kopioitava kenttä.",
"modQueue.transferSuccess": "Siirretty. Alkuperäinen viesti osoittaa nyt uudelle laudalle.",
"transferred": "Siirretty",
"modQueue.transferTitleWithNumber": "Siirrä viesti No.{{number}}",
"modQueue.transferRecreateNotice": "Tämä ei ole oikea protokollatason siirto. 5chan luo viestin uudelleen valitulle laudalle ja poistaa alkuperäisen.",
"modQueue.transferRepliesNotice": "Vastauksia ei siirretä. Ne poistetaan alkuperäisen viestin mukana.",
"modQueue.transferTemporaryAccountNotice": "5chan luo uuden väliaikaisen tilin vain tätä kopioitua viestiä varten ja poistaa sen julkaisun jälkeen."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Maaari mong i-highlight ang syntax at panatilihin ang whitespace gamit ang [code] tags.",
"reply_link_required_alert": "Kailangan mong tumugon gamit ang isang link.",
"reply_media_link_required_alert": "Kailangan mong tumugon gamit ang link ng larawan o video.",
"reply_links_not_allowed_alert": "Hindi pinapayagan ang mga link sa tugon sa board na ito."
"reply_links_not_allowed_alert": "Hindi pinapayagan ang mga link sa tugon sa board na ito.",
"transfer": "Ilipat",
"modQueue.transferTitle": "Ilipat ang post",
"modQueue.transferSource": "Mula sa",
"modQueue.transferTarget": "Sa board",
"modQueue.transferFields": "Kopyahin ang mga field",
"modQueue.transferNoFields": "Pumili ng kahit isang field na kopyahin.",
"modQueue.transferSuccess": "Nailipat. Nakaturo na ngayon ang orihinal na post sa bagong board.",
"transferred": "Nailipat",
"modQueue.transferTitleWithNumber": "Ilipat ang post No.{{number}}",
"modQueue.transferRecreateNotice": "Hindi ito tunay na protocol transfer. Muling ginagawa ng 5chan ang post sa napiling board at inaalis ang orihinal.",
"modQueue.transferRepliesNotice": "Hindi maililipat ang mga reply. Mabubura ang mga ito kasama ng orihinal na post.",
"modQueue.transferTemporaryAccountNotice": "Gagawa ang 5chan ng bagong pansamantalang account para lang sa kinopyang post na ito, at buburahin ito pagkatapos ma-publish."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Vous pouvez surligner la syntaxe et préserver les espaces en utilisant des balises [code].",
"reply_link_required_alert": "Vous devez répondre avec un lien.",
"reply_media_link_required_alert": "Vous devez répondre avec un lien d'image ou de vidéo.",
"reply_links_not_allowed_alert": "Les liens dans les réponses ne sont pas autorisés sur ce board."
"reply_links_not_allowed_alert": "Les liens dans les réponses ne sont pas autorisés sur ce board.",
"transfer": "Transférer",
"modQueue.transferTitle": "Transférer la publication",
"modQueue.transferSource": "De",
"modQueue.transferTarget": "Au forum",
"modQueue.transferFields": "Copier les champs",
"modQueue.transferNoFields": "Choisissez au moins un champ à copier.",
"modQueue.transferSuccess": "Transféré. Le message dorigine pointe maintenant vers le nouveau board.",
"transferred": "Transféré",
"modQueue.transferTitleWithNumber": "Transférer le message No.{{number}}",
"modQueue.transferRecreateNotice": "Ce nest pas un vrai transfert au niveau du protocole. 5chan recrée le message sur le board choisi et retire loriginal.",
"modQueue.transferRepliesNotice": "Les réponses ne sont pas transférées. Elles sont supprimées avec le message dorigine.",
"modQueue.transferTemporaryAccountNotice": "5chan créera un nouveau compte temporaire uniquement pour ce message copié, puis le supprimera après publication."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "ניתן להדגיש תחביר ולשמור על רווחים לבנים באמצעות תגיות [code].",
"reply_link_required_alert": "עליך להשיב עם קישור.",
"reply_media_link_required_alert": "עליך להשיב עם קישור לתמונה או לסרטון.",
"reply_links_not_allowed_alert": "קישורים בתגובות אינם מותרים בלוח הזה."
"reply_links_not_allowed_alert": "קישורים בתגובות אינם מותרים בלוח הזה.",
"transfer": "העבר",
"modQueue.transferTitle": "העברת פוסט",
"modQueue.transferSource": "מ-",
"modQueue.transferTarget": "לפורום",
"modQueue.transferFields": "העתק שדות",
"modQueue.transferNoFields": "בחר לפחות שדה אחד להעתקה.",
"modQueue.transferSuccess": "הועבר. הפוסט המקורי מפנה עכשיו ללוח החדש.",
"transferred": "הועבר",
"modQueue.transferTitleWithNumber": "העברת פוסט מס׳ {{number}}",
"modQueue.transferRecreateNotice": "זו לא העברה אמיתית ברמת הפרוטוקול. 5chan יוצר מחדש את הפוסט בלוח שנבחר ומסיר את המקור.",
"modQueue.transferRepliesNotice": "תגובות לא מועברות. הן נמחקות יחד עם הפוסט המקורי.",
"modQueue.transferTemporaryAccountNotice": "5chan ייצור חשבון זמני חדש רק עבור הפוסט המועתק הזה, ואז ימחק אותו לאחר הפרסום."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "आप [code] टैग का उपयोग करके सिंटैक्स को हाइलाइट कर सकते हैं और व्हाइटस्पेस को संरक्षित कर सकते हैं।",
"reply_link_required_alert": "आपको एक लिंक के साथ जवाब देना होगा।",
"reply_media_link_required_alert": "आपको किसी छवि या वीडियो लिंक के साथ जवाब देना होगा।",
"reply_links_not_allowed_alert": "इस बोर्ड पर जवाबों में लिंक की अनुमति नहीं है।"
"reply_links_not_allowed_alert": "इस बोर्ड पर जवाबों में लिंक की अनुमति नहीं है।",
"transfer": "स्थानांतरित करें",
"modQueue.transferTitle": "पोस्ट स्थानांतरित करें",
"modQueue.transferSource": "से",
"modQueue.transferTarget": "फोरम पर",
"modQueue.transferFields": "फ़ील्ड कॉपी करें",
"modQueue.transferNoFields": "कॉपी करने के लिए कम से कम एक फ़ील्ड चुनें।",
"modQueue.transferSuccess": "स्थानांतरित हो गया। मूल पोस्ट अब नए बोर्ड की ओर इशारा करती है।",
"transferred": "स्थानांतरित",
"modQueue.transferTitleWithNumber": "पोस्ट No.{{number}} स्थानांतरित करें",
"modQueue.transferRecreateNotice": "यह असली प्रोटोकॉल ट्रांसफर नहीं है। 5chan चुने गए बोर्ड पर पोस्ट को फिर से बनाता है और मूल पोस्ट हटा देता है।",
"modQueue.transferRepliesNotice": "जवाब स्थानांतरित नहीं होंगे। वे मूल पोस्ट के साथ हटा दिए जाएंगे।",
"modQueue.transferTemporaryAccountNotice": "5chan सिर्फ इस कॉपी किए गए पोस्ट के लिए एक नया अस्थायी खाता बनाएगा, फिर प्रकाशित करने के बाद उसे हटा देगा।"
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "A szintaxis kiemelhető és a szóközök megőrizhetők a [code] címkék használatával.",
"reply_link_required_alert": "Linkkel kell válaszolnod.",
"reply_media_link_required_alert": "Kép- vagy videólinkkel kell válaszolnod.",
"reply_links_not_allowed_alert": "Ezen a táblán nem engedélyezettek a válaszlinkek."
"reply_links_not_allowed_alert": "Ezen a táblán nem engedélyezettek a válaszlinkek.",
"transfer": "Átvitel",
"modQueue.transferTitle": "Bejegyzés áthelyezése",
"modQueue.transferSource": "Honnan",
"modQueue.transferTarget": "Fórumra",
"modQueue.transferFields": "Mezők másolása",
"modQueue.transferNoFields": "Válasszon legalább egy mezőt a másoláshoz.",
"modQueue.transferSuccess": "Áthelyezve. Az eredeti bejegyzés most az új táblára mutat.",
"transferred": "Áthelyezve",
"modQueue.transferTitleWithNumber": "No.{{number}} bejegyzés áthelyezése",
"modQueue.transferRecreateNotice": "Ez nem valódi protokollszintű áthelyezés. A 5chan újra létrehozza a bejegyzést a kiválasztott táblán, majd eltávolítja az eredetit.",
"modQueue.transferRepliesNotice": "A válaszok nem kerülnek át. Az eredeti bejegyzéssel együtt törlődnek.",
"modQueue.transferTemporaryAccountNotice": "A 5chan új ideiglenes fiókot hoz létre csak ehhez a másolt bejegyzéshez, majd közzététel után törli."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Anda dapat menyorot sintaks dan mempertahankan spasi dengan menggunakan tag [code].",
"reply_link_required_alert": "Anda harus membalas dengan tautan.",
"reply_media_link_required_alert": "Anda harus membalas dengan tautan gambar atau video.",
"reply_links_not_allowed_alert": "Tautan balasan tidak diizinkan di papan ini."
"reply_links_not_allowed_alert": "Tautan balasan tidak diizinkan di papan ini.",
"transfer": "Pindahkan",
"modQueue.transferTitle": "Transfer posting",
"modQueue.transferSource": "Dari",
"modQueue.transferTarget": "Ke forum",
"modQueue.transferFields": "Salin bidang",
"modQueue.transferNoFields": "Pilih setidaknya satu bidang untuk disalin.",
"modQueue.transferSuccess": "Ditransfer. Postingan asli sekarang mengarah ke board baru.",
"transferred": "Ditransfer",
"modQueue.transferTitleWithNumber": "Transfer postingan No.{{number}}",
"modQueue.transferRecreateNotice": "Ini bukan transfer protokol yang sebenarnya. 5chan membuat ulang postingan di board yang dipilih dan menghapus yang asli.",
"modQueue.transferRepliesNotice": "Balasan tidak ditransfer. Balasan akan dihapus bersama postingan asli.",
"modQueue.transferTemporaryAccountNotice": "5chan akan membuat akun sementara baru hanya untuk postingan salinan ini, lalu menghapusnya setelah dipublikasikan."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Puoi evidenziare la sintassi e preservare gli spazi bianchi usando i tag [code].",
"reply_link_required_alert": "Devi rispondere con un link.",
"reply_media_link_required_alert": "Devi rispondere con un link a un'immagine o a un video.",
"reply_links_not_allowed_alert": "I link nelle risposte non sono consentiti su questa board."
"reply_links_not_allowed_alert": "I link nelle risposte non sono consentiti su questa board.",
"transfer": "Trasferisci",
"modQueue.transferTitle": "Trasferisci post",
"modQueue.transferSource": "Da",
"modQueue.transferTarget": "Al board",
"modQueue.transferFields": "Copia campi",
"modQueue.transferNoFields": "Scegli almeno un campo da copiare.",
"modQueue.transferSuccess": "Trasferito. Il post originale ora punta alla nuova board.",
"transferred": "Trasferito",
"modQueue.transferTitleWithNumber": "Trasferisci post No.{{number}}",
"modQueue.transferRecreateNotice": "Questo non è un vero trasferimento del protocollo. 5chan ricrea il post nella board selezionata e rimuove loriginale.",
"modQueue.transferRepliesNotice": "Le risposte non vengono trasferite. Verranno eliminate insieme al post originale.",
"modQueue.transferTemporaryAccountNotice": "5chan creerà un nuovo account temporaneo solo per questo post copiato, poi lo eliminerà dopo la pubblicazione."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "[code] タグを使用して、構文をハイライトし、空白を保持できます。",
"reply_link_required_alert": "リンク付きで返信する必要があります。",
"reply_media_link_required_alert": "画像または動画のリンク付きで返信する必要があります。",
"reply_links_not_allowed_alert": "この板では返信リンクは許可されていません。"
"reply_links_not_allowed_alert": "この板では返信リンクは許可されていません。",
"transfer": "転送",
"modQueue.transferTitle": "投稿を転送",
"modQueue.transferSource": "移行元",
"modQueue.transferTarget": "掲示板へ",
"modQueue.transferFields": "フィールドをコピー",
"modQueue.transferNoFields": "コピーする項目を1つ以上選択してください。",
"modQueue.transferSuccess": "転送しました。元の投稿は新しい板を指すようになりました。",
"transferred": "転送済み",
"modQueue.transferTitleWithNumber": "投稿 No.{{number}} を転送",
"modQueue.transferRecreateNotice": "これはプロトコル上の本当の転送ではありません。5chan は選択した板に投稿を再作成し、元の投稿を削除します。",
"modQueue.transferRepliesNotice": "返信は転送されません。元の投稿と一緒に削除されます。",
"modQueue.transferTemporaryAccountNotice": "5chan はこのコピー投稿専用の一時アカウントを新しく作成し、公開後に削除します。"
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "[code] 태그를 사용하여 구문을 강조 표시하고 공백을 유지할 수 있습니다.",
"reply_link_required_alert": "링크를 포함해 답글을 달아야 합니다.",
"reply_media_link_required_alert": "이미지 또는 동영상 링크를 포함해 답글을 달아야 합니다.",
"reply_links_not_allowed_alert": "이 게시판에서는 답글 링크가 허용되지 않습니다."
"reply_links_not_allowed_alert": "이 게시판에서는 답글 링크가 허용되지 않습니다.",
"transfer": "이전",
"modQueue.transferTitle": "게시글 이동",
"modQueue.transferSource": "원본",
"modQueue.transferTarget": "게시판으로",
"modQueue.transferFields": "필드 복사",
"modQueue.transferNoFields": "복사할 필드를 하나 이상 선택하세요.",
"modQueue.transferSuccess": "이동되었습니다. 원래 게시글은 이제 새 게시판을 가리킵니다.",
"transferred": "이동됨",
"modQueue.transferTitleWithNumber": "게시글 No.{{number}} 이동",
"modQueue.transferRecreateNotice": "이것은 실제 프로토콜 전송이 아닙니다. 5chan은 선택한 게시판에 게시글을 다시 만들고 원본을 제거합니다.",
"modQueue.transferRepliesNotice": "답글은 이동되지 않습니다. 원본 게시글과 함께 삭제됩니다.",
"modQueue.transferTemporaryAccountNotice": "5chan은 이 복사된 게시글만을 위한 새 임시 계정을 만든 뒤 게시 후 삭제합니다."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "आप [code] टॅग वापरून सिंटॅक्स हायलाइट करू शकता आणि व्हाइटस्पेस जतन करू शकता.",
"reply_link_required_alert": "तुम्ही दुव्यासह उत्तर देणे आवश्यक आहे.",
"reply_media_link_required_alert": "तुम्ही प्रतिमा किंवा व्हिडिओ दुव्यासह उत्तर देणे आवश्यक आहे.",
"reply_links_not_allowed_alert": "या बोर्डवर उत्तरांमधील दुवे परवानगी नाहीत."
"reply_links_not_allowed_alert": "या बोर्डवर उत्तरांमधील दुवे परवानगी नाहीत.",
"transfer": "हस्तांतरित करा",
"modQueue.transferTitle": "पोस्ट हस्तांतरित करा",
"modQueue.transferSource": "पासून",
"modQueue.transferTarget": "फोरमवर",
"modQueue.transferFields": "फील्ड कॉपी करा",
"modQueue.transferNoFields": "कॉपी करण्यासाठी किमान एक फील्ड निवडा.",
"modQueue.transferSuccess": "स्थानांतरित झाले. मूळ पोस्ट आता नवीन बोर्डकडे निर्देश करते.",
"transferred": "स्थानांतरित",
"modQueue.transferTitleWithNumber": "पोस्ट No.{{number}} स्थानांतरित करा",
"modQueue.transferRecreateNotice": "हे खरे प्रोटोकॉल ट्रान्सफर नाही. 5chan निवडलेल्या बोर्डावर पोस्ट पुन्हा तयार करते आणि मूळ पोस्ट काढते.",
"modQueue.transferRepliesNotice": "प्रत्युत्तरे स्थानांतरित होणार नाहीत. ती मूळ पोस्टसोबत हटवली जातील.",
"modQueue.transferTemporaryAccountNotice": "5chan या कॉपी केलेल्या पोस्टसाठीच एक नवीन तात्पुरते खाते तयार करेल आणि प्रकाशित झाल्यावर ते हटवेल."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "U kunt syntaxis markeren en witruimte behouden door [code]-tags te gebruiken.",
"reply_link_required_alert": "Je moet antwoorden met een link.",
"reply_media_link_required_alert": "Je moet antwoorden met een afbeeldings- of videolink.",
"reply_links_not_allowed_alert": "Antwoordlinks zijn niet toegestaan op dit bord."
"reply_links_not_allowed_alert": "Antwoordlinks zijn niet toegestaan op dit bord.",
"transfer": "Overdragen",
"modQueue.transferTitle": "Bericht overdragen",
"modQueue.transferSource": "Van",
"modQueue.transferTarget": "Naar forum",
"modQueue.transferFields": "Velden kopiëren",
"modQueue.transferNoFields": "Kies minimaal één veld om te kopiëren.",
"modQueue.transferSuccess": "Overgezet. Het oorspronkelijke bericht verwijst nu naar het nieuwe board.",
"transferred": "Overgezet",
"modQueue.transferTitleWithNumber": "Bericht No.{{number}} overzetten",
"modQueue.transferRecreateNotice": "Dit is geen echte protocoloverdracht. 5chan maakt het bericht opnieuw op het gekozen board en verwijdert het origineel.",
"modQueue.transferRepliesNotice": "Reacties worden niet overgezet. Ze worden samen met het oorspronkelijke bericht verwijderd.",
"modQueue.transferTemporaryAccountNotice": "5chan maakt alleen voor dit gekopieerde bericht een nieuw tijdelijk account aan en verwijdert het na publicatie."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Du kan fremheve syntaks og bevare mellomrom ved å bruke [code]-tagger.",
"reply_link_required_alert": "Du må svare med en lenke.",
"reply_media_link_required_alert": "Du må svare med en bilde- eller videolenke.",
"reply_links_not_allowed_alert": "Svarlenker er ikke tillatt på dette forumet."
"reply_links_not_allowed_alert": "Svarlenker er ikke tillatt på dette forumet.",
"transfer": "Overfør",
"modQueue.transferTitle": "Overfør innlegg",
"modQueue.transferSource": "Fra",
"modQueue.transferTarget": "Til forum",
"modQueue.transferFields": "Kopier felt",
"modQueue.transferNoFields": "Velg minst ett felt som skal kopieres.",
"modQueue.transferSuccess": "Overført. Det opprinnelige innlegget peker nå til det nye boardet.",
"transferred": "Overført",
"modQueue.transferTitleWithNumber": "Overfør innlegg No.{{number}}",
"modQueue.transferRecreateNotice": "Dette er ikke en ekte protokolloverføring. 5chan oppretter innlegget på nytt på valgt board og fjerner originalen.",
"modQueue.transferRepliesNotice": "Svar overføres ikke. De slettes sammen med det opprinnelige innlegget.",
"modQueue.transferTemporaryAccountNotice": "5chan oppretter en ny midlertidig konto bare for dette kopierte innlegget, og sletter den etter publisering."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Możesz podświetlać składnię i zachować spacje, używając tagów [code].",
"reply_link_required_alert": "Musisz odpowiedzieć linkiem.",
"reply_media_link_required_alert": "Musisz odpowiedzieć linkiem do obrazu lub filmu.",
"reply_links_not_allowed_alert": "Linki w odpowiedziach nie są dozwolone na tej tablicy."
"reply_links_not_allowed_alert": "Linki w odpowiedziach nie są dozwolone na tej tablicy.",
"transfer": "Przenieś",
"modQueue.transferTitle": "Przenieś post",
"modQueue.transferSource": "Z",
"modQueue.transferTarget": "Do forum",
"modQueue.transferFields": "Kopiuj pola",
"modQueue.transferNoFields": "Wybierz co najmniej jedno pole do skopiowania.",
"modQueue.transferSuccess": "Przeniesiono. Oryginalny post wskazuje teraz nową tablicę.",
"transferred": "Przeniesiono",
"modQueue.transferTitleWithNumber": "Przenieś post No.{{number}}",
"modQueue.transferRecreateNotice": "To nie jest prawdziwe przeniesienie w protokole. 5chan odtwarza post na wybranej tablicy i usuwa oryginał.",
"modQueue.transferRepliesNotice": "Odpowiedzi nie są przenoszone. Zostaną usunięte razem z oryginalnym postem.",
"modQueue.transferTemporaryAccountNotice": "5chan utworzy nowe konto tymczasowe tylko dla tego skopiowanego posta, a potem usunie je po opublikowaniu."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Você pode destacar a sintaxe e preservar espaços em branco usando tags [code].",
"reply_link_required_alert": "Você deve responder com um link.",
"reply_media_link_required_alert": "Você deve responder com um link de imagem ou vídeo.",
"reply_links_not_allowed_alert": "Links em respostas não são permitidos neste board."
"reply_links_not_allowed_alert": "Links em respostas não são permitidos neste board.",
"transfer": "Transferir",
"modQueue.transferTitle": "Transferir publicação",
"modQueue.transferSource": "De",
"modQueue.transferTarget": "Para o fórum",
"modQueue.transferFields": "Copiar campos",
"modQueue.transferNoFields": "Escolha pelo menos um campo para copiar.",
"modQueue.transferSuccess": "Transferido. A postagem original agora aponta para o novo board.",
"transferred": "Transferido",
"modQueue.transferTitleWithNumber": "Transferir postagem No.{{number}}",
"modQueue.transferRecreateNotice": "Isto não é uma transferência real do protocolo. O 5chan recria a postagem no board selecionado e remove a original.",
"modQueue.transferRepliesNotice": "As respostas não são transferidas. Elas serão excluídas junto com a postagem original.",
"modQueue.transferTemporaryAccountNotice": "O 5chan criará uma nova conta temporária apenas para esta postagem copiada e a excluirá depois da publicação."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Puteți evidenția sintaxa și păstra spațiile albe folosind tag-uri [code].",
"reply_link_required_alert": "Trebuie să răspunzi cu un link.",
"reply_media_link_required_alert": "Trebuie să răspunzi cu un link de imagine sau video.",
"reply_links_not_allowed_alert": "Linkurile în răspunsuri nu sunt permise pe acest board."
"reply_links_not_allowed_alert": "Linkurile în răspunsuri nu sunt permise pe acest board.",
"transfer": "Transferă",
"modQueue.transferTitle": "Transferă postarea",
"modQueue.transferSource": "De la",
"modQueue.transferTarget": "La forum",
"modQueue.transferFields": "Copiază câmpurile",
"modQueue.transferNoFields": "Alege cel puțin un câmp de copiat.",
"modQueue.transferSuccess": "Transferat. Postarea originală trimite acum la noul board.",
"transferred": "Transferat",
"modQueue.transferTitleWithNumber": "Transferă postarea No.{{number}}",
"modQueue.transferRecreateNotice": "Acesta nu este un transfer real la nivel de protocol. 5chan recreează postarea pe boardul ales și elimină originalul.",
"modQueue.transferRepliesNotice": "Răspunsurile nu sunt transferate. Vor fi șterse odată cu postarea originală.",
"modQueue.transferTemporaryAccountNotice": "5chan va crea un cont temporar nou doar pentru această postare copiată, apoi îl va șterge după publicare."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Вы можете выделять синтаксис и сохранять пробелы с помощью тегов [code].",
"reply_link_required_alert": "Вы должны ответить ссылкой.",
"reply_media_link_required_alert": "Вы должны ответить ссылкой на изображение или видео.",
"reply_links_not_allowed_alert": "Ссылки в ответах на этой доске запрещены."
"reply_links_not_allowed_alert": "Ссылки в ответах на этой доске запрещены.",
"transfer": "Перенести",
"modQueue.transferTitle": "Перенести пост",
"modQueue.transferSource": "Из",
"modQueue.transferTarget": "На форум",
"modQueue.transferFields": "Копировать поля",
"modQueue.transferNoFields": "Выберите хотя бы одно поле для копирования.",
"modQueue.transferSuccess": "Перенесено. Исходный пост теперь указывает на новую доску.",
"transferred": "Перенесено",
"modQueue.transferTitleWithNumber": "Перенести пост No.{{number}}",
"modQueue.transferRecreateNotice": "Это не настоящий перенос на уровне протокола. 5chan заново создаёт пост на выбранной доске и удаляет оригинал.",
"modQueue.transferRepliesNotice": "Ответы не переносятся. Они будут удалены вместе с исходным постом.",
"modQueue.transferTemporaryAccountNotice": "5chan создаст новый временный аккаунт только для этой скопированной публикации, а затем удалит его после публикации."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Mund të theksosh sintaksën dhe të ruash hapësirat duke përdorur etiketat [code].",
"reply_link_required_alert": "Duhet të përgjigjesh me një lidhje.",
"reply_media_link_required_alert": "Duhet të përgjigjesh me një lidhje imazhi ose videoje.",
"reply_links_not_allowed_alert": "Lidhjet në përgjigje nuk lejohen në këtë bord."
"reply_links_not_allowed_alert": "Lidhjet në përgjigje nuk lejohen në këtë bord.",
"transfer": "Transfero",
"modQueue.transferTitle": "Transfero postimin",
"modQueue.transferSource": "Nga",
"modQueue.transferTarget": "Në forum",
"modQueue.transferFields": "Kopjo fushat",
"modQueue.transferNoFields": "Zgjidhni të paktën një fushë për të kopjuar.",
"modQueue.transferSuccess": "U transferua. Postimi origjinal tani të drejton te bordi i ri.",
"transferred": "U transferua",
"modQueue.transferTitleWithNumber": "Transfero postimin No.{{number}}",
"modQueue.transferRecreateNotice": "Ky nuk është transferim i vërtetë në protokoll. 5chan e rikrijon postimin në bordin e zgjedhur dhe heq origjinalin.",
"modQueue.transferRepliesNotice": "Përgjigjet nuk transferohen. Ato fshihen bashkë me postimin origjinal.",
"modQueue.transferTemporaryAccountNotice": "5chan do të krijojë një llogari të re të përkohshme vetëm për këtë postim të kopjuar, pastaj do ta fshijë pas publikimit."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Du kan markera syntax och bevara mellanslag genom att använda [code]-taggar.",
"reply_link_required_alert": "Du måste svara med en länk.",
"reply_media_link_required_alert": "Du måste svara med en bild- eller videolänk.",
"reply_links_not_allowed_alert": "Svarslänkar är inte tillåtna på detta bräde."
"reply_links_not_allowed_alert": "Svarslänkar är inte tillåtna på detta bräde.",
"transfer": "Överför",
"modQueue.transferTitle": "Överför inlägg",
"modQueue.transferSource": "Från",
"modQueue.transferTarget": "Till forum",
"modQueue.transferFields": "Kopiera fält",
"modQueue.transferNoFields": "Välj minst ett fält att kopiera.",
"modQueue.transferSuccess": "Överfört. Det ursprungliga inlägget pekar nu till den nya boarden.",
"transferred": "Överfört",
"modQueue.transferTitleWithNumber": "Överför inlägg No.{{number}}",
"modQueue.transferRecreateNotice": "Det här är inte en riktig protokollöverföring. 5chan återskapar inlägget på vald board och tar bort originalet.",
"modQueue.transferRepliesNotice": "Svar överförs inte. De raderas tillsammans med det ursprungliga inlägget.",
"modQueue.transferTemporaryAccountNotice": "5chan skapar ett nytt tillfälligt konto bara för det här kopierade inlägget och raderar det efter publicering."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "మీరు [code] ట్యాగ్‌లను ఉపయోగించి సింటాక్స్‌ను హైలైట్ చేయవచ్చు మరియు ఖాళీ స్థలాలను కాపాడవచ్చు.",
"reply_link_required_alert": "మీరు లింక్‌తో ప్రత్యుత్తరం ఇవ్వాలి.",
"reply_media_link_required_alert": "మీరు చిత్రం లేదా వీడియో లింక్‌తో ప్రత్యుత్తరం ఇవ్వాలి.",
"reply_links_not_allowed_alert": "ఈ బోర్డులో ప్రత్యుత్తర లింక్‌లకు అనుమతి లేదు."
"reply_links_not_allowed_alert": "ఈ బోర్డులో ప్రత్యుత్తర లింక్‌లకు అనుమతి లేదు.",
"transfer": "బదిలీ",
"modQueue.transferTitle": "పోస్ట్‌ను బదిలీ చేయండి",
"modQueue.transferSource": "నుండి",
"modQueue.transferTarget": "ఫోరంకు",
"modQueue.transferFields": "ఫీల్డ్‌లను కాపీ చేయండి",
"modQueue.transferNoFields": "కాపీ చేయడానికి కనీసం ఒక ఫీల్డ్ ఎంచుకోండి.",
"modQueue.transferSuccess": "బదిలీ చేయబడింది. అసలు పోస్ట్ ఇప్పుడు కొత్త బోర్డును సూచిస్తుంది.",
"transferred": "బదిలీ చేయబడింది",
"modQueue.transferTitleWithNumber": "పోస్ట్ No.{{number}} బదిలీ చేయండి",
"modQueue.transferRecreateNotice": "ఇది నిజమైన ప్రోటోకాల్ బదిలీ కాదు. 5chan ఎంచుకున్న బోర్డులో పోస్ట్‌ను మళ్లీ సృష్టించి అసలుదాన్ని తొలగిస్తుంది.",
"modQueue.transferRepliesNotice": "ప్రత్యుత్తరాలు బదిలీ చేయబడవు. అవి అసలు పోస్ట్‌తో పాటు తొలగించబడతాయి.",
"modQueue.transferTemporaryAccountNotice": "5chan ఈ కాపీ చేసిన పోస్ట్ కోసమే కొత్త తాత్కాలిక ఖాతాను సృష్టించి, ప్రచురించిన తర్వాత దాన్ని తొలగిస్తుంది."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "คุณสามารถไฮไลต์ไวยากรณ์และรักษาช่องว่างได้โดยใช้แท็ก [code]",
"reply_link_required_alert": "คุณต้องตอบกลับพร้อมลิงก์",
"reply_media_link_required_alert": "คุณต้องตอบกลับพร้อมลิงก์รูปภาพหรือวิดีโอ",
"reply_links_not_allowed_alert": "ไม่อนุญาตให้ใส่ลิงก์ในคำตอบบนบอร์ดนี้"
"reply_links_not_allowed_alert": "ไม่อนุญาตให้ใส่ลิงก์ในคำตอบบนบอร์ดนี้",
"transfer": "โอน",
"modQueue.transferTitle": "ย้ายโพสต์",
"modQueue.transferSource": "จาก",
"modQueue.transferTarget": "ไปยังกระดาน",
"modQueue.transferFields": "คัดลอกฟิลด์",
"modQueue.transferNoFields": "เลือกอย่างน้อยหนึ่งฟิลด์ที่จะคัดลอก",
"modQueue.transferSuccess": "โอนย้ายแล้ว โพสต์ต้นฉบับชี้ไปยังกระดานใหม่แล้ว",
"transferred": "โอนย้ายแล้ว",
"modQueue.transferTitleWithNumber": "โอนย้ายโพสต์ No.{{number}}",
"modQueue.transferRecreateNotice": "นี่ไม่ใช่การโอนย้ายจริงในระดับโปรโตคอล 5chan จะสร้างโพสต์ใหม่บนกระดานที่เลือกและลบต้นฉบับ",
"modQueue.transferRepliesNotice": "การตอบกลับจะไม่ถูกโอนย้าย และจะถูกลบไปพร้อมกับโพสต์ต้นฉบับ",
"modQueue.transferTemporaryAccountNotice": "5chan จะสร้างบัญชีชั่วคราวใหม่สำหรับโพสต์ที่คัดลอกนี้เท่านั้น แล้วลบบัญชีนั้นหลังเผยแพร่"
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "[code] etiketlerini kullanarak sözdizimini vurgulayabilir ve boşlukları koruyabilirsiniz.",
"reply_link_required_alert": "Bir bağlantıyla yanıt vermelisiniz.",
"reply_media_link_required_alert": "Bir görsel veya video bağlantısıyla yanıt vermelisiniz.",
"reply_links_not_allowed_alert": "Bu panoda yanıt bağlantılarına izin verilmiyor."
"reply_links_not_allowed_alert": "Bu panoda yanıt bağlantılarına izin verilmiyor.",
"transfer": "Aktar",
"modQueue.transferTitle": "Gönderiyi aktar",
"modQueue.transferSource": "Kaynak",
"modQueue.transferTarget": "Foruma",
"modQueue.transferFields": "Alanları kopyala",
"modQueue.transferNoFields": "Kopyalamak için en az bir alan seçin.",
"modQueue.transferSuccess": "Aktarıldı. Orijinal gönderi artık yeni panoya işaret ediyor.",
"transferred": "Aktarıldı",
"modQueue.transferTitleWithNumber": "No.{{number}} gönderisini aktar",
"modQueue.transferRecreateNotice": "Bu gerçek bir protokol aktarımı değildir. 5chan gönderiyi seçilen panoda yeniden oluşturur ve orijinali kaldırır.",
"modQueue.transferRepliesNotice": "Yanıtlar aktarılmaz. Orijinal gönderiyle birlikte silinirler.",
"modQueue.transferTemporaryAccountNotice": "5chan yalnızca bu kopyalanan gönderi için yeni bir geçici hesap oluşturacak, yayımladıktan sonra da silecek."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Ви можете підсвічувати синтаксис і зберігати пробіли, використовуючи теги [code].",
"reply_link_required_alert": "Ви маєте відповісти посиланням.",
"reply_media_link_required_alert": "Ви маєте відповісти посиланням на зображення або відео.",
"reply_links_not_allowed_alert": "Посилання у відповідях на цій дошці заборонені."
"reply_links_not_allowed_alert": "Посилання у відповідях на цій дошці заборонені.",
"transfer": "Перенести",
"modQueue.transferTitle": "Перенести допис",
"modQueue.transferSource": "З",
"modQueue.transferTarget": "На форум",
"modQueue.transferFields": "Копіювати поля",
"modQueue.transferNoFields": "Виберіть принаймні одне поле для копіювання.",
"modQueue.transferSuccess": "Перенесено. Початковий допис тепер указує на нову дошку.",
"transferred": "Перенесено",
"modQueue.transferTitleWithNumber": "Перенести допис No.{{number}}",
"modQueue.transferRecreateNotice": "Це не справжнє перенесення на рівні протоколу. 5chan заново створює допис на вибраній дошці та видаляє оригінал.",
"modQueue.transferRepliesNotice": "Відповіді не переносяться. Їх буде видалено разом із початковим дописом.",
"modQueue.transferTemporaryAccountNotice": "5chan створить новий тимчасовий акаунт лише для цього скопійованого допису, а після публікації видалить його."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "آپ [code] ٹیگز استعمال کر کے نحو کو نمایاں کر سکتے ہیں اور خالی جگہ کو محفوظ رکھ سکتے ہیں۔",
"reply_link_required_alert": "آپ کو ایک لنک کے ساتھ جواب دینا ہوگا۔",
"reply_media_link_required_alert": "آپ کو تصویر یا ویڈیو کے لنک کے ساتھ جواب دینا ہوگا۔",
"reply_links_not_allowed_alert": "اس بورڈ پر جوابات میں لنکس کی اجازت نہیں ہے۔"
"reply_links_not_allowed_alert": "اس بورڈ پر جوابات میں لنکس کی اجازت نہیں ہے۔",
"transfer": "منتقل کریں",
"modQueue.transferTitle": "پوسٹ منتقل کریں",
"modQueue.transferSource": "سے",
"modQueue.transferTarget": "بورڈ پر",
"modQueue.transferFields": "فیلڈز کاپی کریں",
"modQueue.transferNoFields": "کاپی کرنے کے لیے کم از کم ایک فیلڈ منتخب کریں۔",
"modQueue.transferSuccess": "منتقل کر دیا گیا۔ اصل پوسٹ اب نئے بورڈ کی طرف اشارہ کرتی ہے۔",
"transferred": "منتقل شدہ",
"modQueue.transferTitleWithNumber": "پوسٹ No.{{number}} منتقل کریں",
"modQueue.transferRecreateNotice": "یہ حقیقی پروٹوکول ٹرانسفر نہیں ہے۔ 5chan منتخب بورڈ پر پوسٹ دوبارہ بناتا ہے اور اصل پوسٹ ہٹا دیتا ہے۔",
"modQueue.transferRepliesNotice": "جوابات منتقل نہیں ہوں گے۔ وہ اصل پوسٹ کے ساتھ حذف ہو جائیں گے۔",
"modQueue.transferTemporaryAccountNotice": "5chan صرف اس کاپی شدہ پوسٹ کے لیے ایک نیا عارضی اکاؤنٹ بنائے گا، پھر شائع ہونے کے بعد اسے حذف کر دے گا۔"
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "Bạn có thể tô sáng cú pháp và giữ nguyên khoảng trắng bằng cách sử dụng thẻ [code].",
"reply_link_required_alert": "Bạn phải trả lời bằng một liên kết.",
"reply_media_link_required_alert": "Bạn phải trả lời bằng một liên kết hình ảnh hoặc video.",
"reply_links_not_allowed_alert": "Liên kết trong trả lời không được phép trên bảng này."
"reply_links_not_allowed_alert": "Liên kết trong trả lời không được phép trên bảng này.",
"transfer": "Chuyển",
"modQueue.transferTitle": "Chuyển bài đăng",
"modQueue.transferSource": "Từ",
"modQueue.transferTarget": "Đến diễn đàn",
"modQueue.transferFields": "Sao chép trường",
"modQueue.transferNoFields": "Chọn ít nhất một trường để sao chép.",
"modQueue.transferSuccess": "Đã chuyển. Bài viết gốc hiện trỏ tới bảng mới.",
"transferred": "Đã chuyển",
"modQueue.transferTitleWithNumber": "Chuyển bài viết No.{{number}}",
"modQueue.transferRecreateNotice": "Đây không phải là chuyển bài thật ở cấp giao thức. 5chan tạo lại bài viết trên bảng đã chọn và gỡ bài gốc.",
"modQueue.transferRepliesNotice": "Các trả lời sẽ không được chuyển. Chúng sẽ bị xóa cùng với bài viết gốc.",
"modQueue.transferTemporaryAccountNotice": "5chan sẽ tạo một tài khoản tạm thời mới chỉ cho bài viết được sao chép này, rồi xóa tài khoản đó sau khi đăng."
}
+13 -1
View File
@@ -449,5 +449,17 @@
"post_form_code_tags_prompt": "您可以使用 [code] 标签来高亮语法并保留空白字符。",
"reply_link_required_alert": "你必须使用链接回复。",
"reply_media_link_required_alert": "你必须使用图片或视频链接回复。",
"reply_links_not_allowed_alert": "此版块不允许回复链接。"
"reply_links_not_allowed_alert": "此版块不允许回复链接。",
"transfer": "转移",
"modQueue.transferTitle": "转移帖子",
"modQueue.transferSource": "来源",
"modQueue.transferTarget": "转至论坛",
"modQueue.transferFields": "复制字段",
"modQueue.transferNoFields": "请至少选择一个要复制的字段。",
"modQueue.transferSuccess": "已转移。原帖现在指向新版块。",
"transferred": "已转移",
"modQueue.transferTitleWithNumber": "转移帖子 No.{{number}}",
"modQueue.transferRecreateNotice": "这不是协议层面的真实转移。5chan 会在所选版块重新创建帖子,并移除原帖。",
"modQueue.transferRepliesNotice": "回复不会被转移。它们会随原帖一起删除。",
"modQueue.transferTemporaryAccountNotice": "5chan 会专门为这个复制后的帖子创建一个新的临时账号,并在发布后删除它。"
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostTransferredTag from '../post-transferred-tag';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => (key === 'transferred' ? 'Transferred' : key),
}),
}));
describe('PostTransferredTag', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the transferred label for moderation-written transfer markers', async () => {
await act(async () => {
root.render(createElement(PostTransferredTag, { comment: { commentModeration: { flairs: [{ text: '5chan:transferred' }] } } }));
});
expect(container.textContent).toBe('[Transferred] ');
});
it('ignores user-authored transferred-looking flairs', async () => {
await act(async () => {
root.render(createElement(PostTransferredTag, { comment: { flairs: [{ text: '5chan:transferred' }] } }));
});
expect(container.textContent).toBe('');
});
});
@@ -467,11 +467,11 @@ describe('CommentContent', () => {
cid: 'post-2',
content: 'ignored',
postCid: 'post-2',
reason: 'duplicate of >>96',
reason: 'Moved to >>>/int/, this post did not belong to /jp/ ([rules](/rules#jp))',
removed: true,
});
expect(container.textContent).toContain('this_post_was_removed');
expect(queryMarkdownText()).toEqual(['Reason: duplicate of >>96']);
expect(queryMarkdownText()).toEqual(['Reason: Moved to >>>/int/, this post did not belong to /jp/ ([rules](/rules#jp))']);
await renderContent({
cid: 'post-3',
@@ -111,6 +111,18 @@ vi.mock('../../../hooks/use-board-pseudonymity-mode', () => ({
useBoardPseudonymityMode: () => testState.pseudonymityMode,
}));
vi.mock('../../post-transfer-modal/post-transfer-modal', () => ({
default: ({ comment, onClose }: { comment: Record<string, unknown>; onClose: () => void }) =>
createElement(
'div',
{
'data-cid': comment.cid,
'data-testid': 'post-transfer-modal',
},
createElement('button', { onClick: onClose, type: 'button' }, 'close'),
),
}));
vi.mock('../../../stores/use-challenges-store', () => {
const hook = () => ({ challenges: [] });
return {
@@ -183,6 +195,8 @@ const clickButton = async (text: string) => {
await click(button);
};
const hasButton = (text: string) => Array.from(container.querySelectorAll('button')).some((candidate) => candidate.textContent === text);
describe('EditMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -421,6 +435,43 @@ describe('EditMenu', () => {
});
});
it('lets moderators transfer top-level published posts from the edit menu', async () => {
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: true,
isCommentAuthorMod: false,
};
await renderMenu(basePost);
await openMenu();
await clickButton('transfer');
expect(container.querySelector('[data-testid="post-transfer-modal"]')?.getAttribute('data-cid')).toBe('comment-1');
});
it.each([
['deleted', { deleted: true }],
['removed', { removed: true }],
['moderation removed', { commentModeration: { removed: true } }],
['purged', { commentModeration: { purged: true } }],
['archived', { archived: true }],
['moderation archived', { commentModeration: { archived: true } }],
])('does not let moderators transfer %s posts from the edit menu', async (_label, postPatch) => {
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: true,
isCommentAuthorMod: false,
};
await renderMenu({
...basePost,
...postPatch,
});
await openMenu();
expect(hasButton('transfer')).toBe(false);
});
it('lets moderators clear an existing canonical author ban', async () => {
testState.privileges = {
isAccountCommentAuthor: false,
@@ -33,6 +33,22 @@
cursor: pointer;
}
.menuButton {
margin: 0;
padding: 0;
color: var(--button-desktop-text-color);
font: inherit;
background: transparent;
border: 0;
cursor: pointer;
text-transform: inherit;
text-decoration: var(--button-text-decoration);
}
.menuButton:hover {
color: var(--button-desktop-text-color-hover);
}
.menuReason {
padding-bottom: 2px;
}
+61 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import {
@@ -18,6 +18,8 @@ import useIsMobile from '../../hooks/use-is-mobile';
import useAuthorPrivileges from '../../hooks/use-author-privileges';
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { canTransferComment } from '../../lib/comment-transfer';
import PostTransferModal from '../post-transfer-modal/post-transfer-modal';
const daysToTimestampInSeconds = (days: number) => {
const now = new Date();
@@ -30,6 +32,40 @@ const timestampToDays = (timestamp: number) => {
return Math.max(1, Math.floor((timestamp - now) / (24 * 60 * 60)));
};
interface EditMenuUiState {
isEditMenuOpen: boolean;
isContentEditorOpen: boolean;
isTransferOpen: boolean;
}
type EditMenuUiAction =
| { type: 'set-edit-menu-open'; open: boolean }
| { type: 'toggle-content-editor' }
| { type: 'close-content-editor' }
| { type: 'open-transfer' }
| { type: 'close-transfer' };
const initialEditMenuUiState: EditMenuUiState = {
isEditMenuOpen: false,
isContentEditorOpen: false,
isTransferOpen: false,
};
const editMenuUiReducer = (state: EditMenuUiState, action: EditMenuUiAction): EditMenuUiState => {
switch (action.type) {
case 'set-edit-menu-open':
return { ...state, isEditMenuOpen: action.open };
case 'toggle-content-editor':
return { ...state, isContentEditorOpen: !state.isContentEditorOpen };
case 'close-content-editor':
return { ...state, isContentEditorOpen: false };
case 'open-transfer':
return { ...state, isEditMenuOpen: false, isTransferOpen: true };
case 'close-transfer':
return { ...state, isTransferOpen: false };
}
};
const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
@@ -40,7 +76,8 @@ const EditMenu = ({ post }: { post: Comment }) => {
const authorDisplayName = resolvedPost?.author?.displayName;
const modBanExpiresAt = resolvedPost?.author?.community?.banExpiresAt ?? resolvedPost?.commentModeration?.author?.banExpiresAt;
const purged = resolvedPost?.commentModeration?.purged ?? false;
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
const [uiState, dispatchUiState] = useReducer(editMenuUiReducer, initialEditMenuUiState);
const { isEditMenuOpen, isContentEditorOpen, isTransferOpen } = uiState;
const account = useAccount();
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
@@ -53,8 +90,8 @@ const EditMenu = ({ post }: { post: Comment }) => {
const canAttemptAuthorDelete = isAccountCommentAuthor || allowsPseudonymousDelete;
const canOpenEditMenu = isAccountMod || canAttemptAuthorDelete;
const canEditOwnModPost = isAccountMod && isAccountCommentAuthor;
const canTransferPost = isAccountMod && canTransferComment(resolvedPost);
const signer = isAccountCommentAuthor ? account?.signer : undefined;
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const latestPostRef = useRef(resolvedPost);
useEffect(() => {
latestPostRef.current = resolvedPost;
@@ -180,10 +217,10 @@ const EditMenu = ({ post }: { post: Comment }) => {
const resetMenuState = () => {
setPublishCommentEditOptions(defaultPublishEditOptions);
setIsContentEditorOpen(false);
setBanDuration(
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
dispatchUiState({ type: 'close-content-editor' });
};
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -256,7 +293,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom-start',
open: isEditMenuOpen,
onOpenChange: setIsEditMenuOpen,
onOpenChange: (open) => dispatchUiState({ type: 'set-edit-menu-open', open }),
middleware: [offset(2), shift()],
whileElementsMounted: autoUpdate,
});
@@ -288,7 +325,11 @@ const EditMenu = ({ post }: { post: Comment }) => {
alert(error.message);
}
}
setIsEditMenuOpen(false);
dispatchUiState({ type: 'set-edit-menu-open', open: false });
};
const openTransfer = () => {
dispatchUiState({ type: 'open-transfer' });
};
return (
@@ -301,12 +342,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
if (cid && canOpenEditMenu) {
if (!isEditMenuOpen) {
resetMenuState();
setIsEditMenuOpen(true);
dispatchUiState({ type: 'set-edit-menu-open', open: true });
} else {
setIsEditMenuOpen(false);
dispatchUiState({ type: 'set-edit-menu-open', open: false });
}
} else {
setIsEditMenuOpen(false);
dispatchUiState({ type: 'set-edit-menu-open', open: false });
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
}
}}
@@ -345,7 +386,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
<input
aria-label={capitalize(t('edit'))}
type='checkbox'
onChange={() => setIsContentEditorOpen((isOpen) => !isOpen)}
onChange={() => dispatchUiState({ type: 'toggle-content-editor' })}
checked={isContentEditorOpen}
/>
{capitalize(t('edit'))}?]
@@ -507,6 +548,15 @@ const EditMenu = ({ post }: { post: Comment }) => {
/>
</label>
</div>
{canTransferPost && (
<div className={styles.menuItem}>
[
<button type='button' className={styles.menuButton} onClick={openTransfer}>
{t('transfer')}
</button>
?]
</div>
)}
</>
)}
<div className={styles.bottom}>
@@ -519,6 +569,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
</FloatingFocusManager>
</FloatingPortal>
)}
{canTransferPost && isTransferOpen && <PostTransferModal comment={resolvedPost} onClose={() => dispatchUiState({ type: 'close-transfer' })} />}
</>
);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { getCommentFlagFlairs, getAuthorFlagViewModels } from '../lib/comment-flags';
import { hasTransferredCommentMarker } from '../lib/comment-transfer';
import styles from '../views/post/post.module.css';
interface PostAuthorFlagsProps {
@@ -14,7 +15,7 @@ const transparentPixelSrc = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/
const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => {
const { t } = useTranslation();
if (!enabled) return null;
if (!enabled || hasTransferredCommentMarker(comment)) return null;
const flags = getAuthorFlagViewModels(getCommentFlagFlairs(comment, author));
if (flags.length === 0) return null;
@@ -33,6 +33,7 @@ import { canEmbed } from '../embed/embed-utils';
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostTransferredTag from '../post-transferred-tag';
import PostMenuDesktop from './post-menu-desktop/post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import Tooltip from '../tooltip/tooltip';
@@ -156,6 +157,7 @@ const PostInfo = ({
isPublishing,
onApprove,
onReject,
onTransfer,
onRemoveFromModQueue,
quotedByMap,
directRepliesByParentCid,
@@ -349,6 +351,7 @@ const PostInfo = ({
)}
</span>
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
<PostTransferredTag comment={post} />
<PostFlashTag comment={post} directory={directoryEntry} />
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
@@ -465,6 +468,15 @@ const PostInfo = ({
</button>
]
</span>
{onTransfer && (
<span className={styles.modQueueButtonWrapper}>
[
<button type='button' className={styles.modQueueActionButton} onClick={onTransfer} disabled={isPublishing}>
{t('transfer')}
</button>
]
</span>
)}
</>
)}
</span>
@@ -784,6 +796,7 @@ const PostDesktop = ({
isPublishing,
onApprove,
onReject,
onTransfer,
onRemoveFromModQueue,
}: PostProps) => {
const { t } = useTranslation();
@@ -1139,6 +1152,7 @@ const PostDesktop = ({
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
onTransfer={onTransfer}
onRemoveFromModQueue={onRemoveFromModQueue}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
@@ -30,6 +30,7 @@ import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostTransferredTag from '../post-transferred-tag';
import PostMenuMobile from './post-menu-mobile/post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import Tooltip from '../tooltip/tooltip';
@@ -282,6 +283,7 @@ const PostInfoAndMedia = ({
</>
)}
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
<PostTransferredTag comment={resolvedPost} />
<PostFlashTag comment={resolvedPost} directory={directoryEntry} />
{pinned && (
<span className={styles.stickyIconWrapper}>
@@ -535,6 +537,7 @@ const PostMobile = ({
isPublishing,
onApprove,
onReject,
onTransfer,
onRemoveFromModQueue,
}: PostProps) => {
const { t } = useTranslation();
@@ -857,6 +860,11 @@ const PostMobile = ({
<button type='button' className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
{onTransfer && (
<button type='button' className='button' onClick={onTransfer} disabled={isPublishing}>
{t('transfer')}
</button>
)}
</>
)}
</div>
@@ -0,0 +1,146 @@
.transferModal {
box-sizing: border-box;
position: fixed;
display: block;
z-index: 998;
width: 430px;
max-width: calc(100vw - 20px);
max-height: calc(100vh - 40px);
overflow: auto;
margin: 0;
padding: 2px;
color: inherit;
color-scheme: var(--color-scheme, light);
font-size: 10pt;
background-color: var(--challenge-modal-background-color);
border: var(--challenge-modal-border);
will-change: left, top;
}
.transferModal input {
filter: var(--filter80);
}
.transferHeader {
height: 18px;
margin: 0 0 1px;
padding: 0;
color: var(--challenge-modal-title-text-color);
font-size: var(--challenge-modal-title-font-size);
font-weight: var(--challenge-modal-title-font-weight);
line-height: 18px;
text-align: center;
background-color: var(--challenge-modal-title-background-color);
border: var(--challenge-modal-title-border);
cursor: move;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.transferCloseButton {
all: unset;
float: right;
cursor: pointer;
margin-bottom: -4px;
width: 18px;
height: 18px;
border: none;
image-rendering: pixelated;
background-image: var(--close-button-background-image);
}
.transferCloseButton:disabled {
cursor: not-allowed;
}
.transferBody {
display: grid;
gap: 8px;
padding: 6px;
}
.transferRow {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
gap: 6px;
align-items: center;
}
.transferRow select {
min-width: 0;
}
.transferLabel {
font-weight: 700;
}
.transferHint {
font-size: 9pt;
color: var(--settings-modal-alt-text-color, inherit);
}
.transferAlert {
color: var(--mod-queue-alert-color, #d00);
font-weight: 700;
}
.transferFields {
display: grid;
gap: 4px;
margin: 0;
padding: 5px;
border: var(--settings-modal-border-bottom);
}
.transferFields legend {
padding: 0 3px;
font-weight: 700;
}
.transferField {
display: inline-flex;
gap: 4px;
align-items: center;
}
.transferError {
color: var(--text-danger, #d00);
font-weight: 700;
}
.transferSuccess {
color: var(--text-success, #117743);
font-weight: 700;
}
.transferFooter {
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 6px;
}
.transferFooter button {
cursor: pointer;
}
.transferFooter button:disabled {
cursor: not-allowed;
}
@media (max-width: 640px) {
.transferModal {
width: calc(100vw - 12px);
max-width: calc(100vw - 12px);
}
.transferRow {
grid-template-columns: 1fr;
gap: 2px;
}
.transferHint {
font-size: 10pt;
}
}
@@ -0,0 +1,461 @@
import React, { useEffect, useEffectEvent, useLayoutEffect, useMemo, useReducer, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { type ChallengeVerification, type Comment } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import ErrorDisplay from '../error-display/error-display';
import { useDirectories } from '../../hooks/use-directories';
import useChallengesStore from '../../stores/use-challenges-store';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { areSameBoardAddress } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import capitalize from 'lodash/capitalize';
import styles from './post-transfer-modal.module.css';
import {
getAvailableTransferFields,
getInitialTransferFields,
getTargetTransferModerationFlairs,
getTransferBoardReference,
getTransferPublishPayload,
getTransferSourceBoardReference,
getTransferSourceBoardRulesLink,
getTransferSourceModeration,
getTransferredCommentCid,
hasSelectedTransferFields,
type PostTransferField,
type PostTransferFields,
} from '../../lib/comment-transfer';
export type PostTransferState = 'idle' | 'publishing' | 'succeeded' | 'failed' | 'finalizationFailed';
type CreateAccountAction = (accountName?: string) => Promise<void>;
type PublishCommentAction = (publishCommentOptions: Record<string, unknown>, accountName?: string) => Promise<{ index?: number } | undefined>;
type DeleteCommentAction = (commentCidOrAccountCommentIndex: string | number, accountName?: string) => Promise<void>;
type DeleteAccountAction = (accountName?: string) => Promise<void>;
type PublishCommentModerationAction = (publishCommentModerationOptions: Record<string, unknown>, accountName?: string) => Promise<void>;
type TransferModalPosition = { left: number; top: number };
const TRANSFER_MODAL_WIDTH_PX = 430;
const TRANSFER_MODAL_VIEWPORT_GUTTER_PX = 20;
interface TransferModalState {
targetBoardAddress: string;
selectedFields: PostTransferFields;
transferState: PostTransferState;
transferError?: unknown;
}
type TransferModalAction =
| { type: 'field'; field: PostTransferField; checked: boolean }
| { type: 'targetBoard'; value: string }
| { type: 'publishStarted' }
| { type: 'publishSucceeded' }
| { type: 'publishFinalizationFailed'; error: unknown }
| { type: 'publishFailed'; error: unknown };
interface PostTransferModalProps {
comment: Comment;
onClose: () => void;
onTransferStateChange?: (state: PostTransferState) => void;
onTransferSuccess?: () => void;
}
const getInitialTransferModalState = (comment: Comment): TransferModalState => ({
targetBoardAddress: '',
selectedFields: getInitialTransferFields(comment),
transferState: 'idle',
});
const resetTransferResult = (state: TransferModalState): TransferModalState =>
state.transferState === 'idle' ? state : { ...state, transferState: 'idle', transferError: undefined };
const isTerminalTransferState = (state: PostTransferState): boolean => state === 'succeeded' || state === 'finalizationFailed';
const transferModalReducer = (state: TransferModalState, action: TransferModalAction): TransferModalState => {
if (action.type === 'field') {
if (isTerminalTransferState(state.transferState)) return state;
return resetTransferResult({ ...state, selectedFields: { ...state.selectedFields, [action.field]: action.checked } });
}
if (action.type === 'targetBoard') {
if (isTerminalTransferState(state.transferState)) return state;
return resetTransferResult({ ...state, targetBoardAddress: action.value });
}
if (action.type === 'publishStarted') {
return { ...state, transferState: 'publishing', transferError: undefined };
}
if (action.type === 'publishSucceeded') {
return { ...state, transferState: 'succeeded' };
}
if (action.type === 'publishFinalizationFailed') {
return { ...state, transferState: 'finalizationFailed', transferError: action.error };
}
return { ...state, transferState: 'failed', transferError: action.error };
};
const getTransferBoardLabel = (community: { address?: string; directoryCode?: string; title?: string }): string =>
community.title || community.directoryCode || community.address || '';
const getTransferFieldLabel = (field: PostTransferField, t: (key: string) => string): string => {
if (field === 'displayName') return capitalize(t('name'));
if (field === 'title') return capitalize(t('subject'));
if (field === 'content') return capitalize(t('comment'));
if (field === 'link') return t('link');
if (field === 'spoiler') return capitalize(t('spoiler'));
return capitalize(t('tag'));
};
const getTemporaryTransferAccountName = (sourceCommentCid: string | undefined): string => {
const sourceHint = sourceCommentCid ? sourceCommentCid.slice(0, 8) : 'post';
return `5chan-transfer-${sourceHint}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
const getCenteredTransferModalPosition = (modalElement?: HTMLElement | null): TransferModalPosition => {
if (typeof window === 'undefined') {
return { left: 0, top: 0 };
}
const modalRect = modalElement?.getBoundingClientRect();
const fallbackWidth = Math.min(TRANSFER_MODAL_WIDTH_PX, Math.max(0, window.innerWidth - TRANSFER_MODAL_VIEWPORT_GUTTER_PX));
const modalWidth = modalRect?.width || fallbackWidth;
const modalHeight = modalRect?.height || 0;
return {
left: Math.round((window.innerWidth - modalWidth) / 2),
top: Math.round((window.innerHeight - modalHeight) / 2),
};
};
const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransferSuccess }: PostTransferModalProps) => {
const { t } = useTranslation();
const nodeRef = useRef<HTMLDivElement>(null);
const finalizedTransferRef = useRef(false);
const bodySelectionStyleBeforeDragRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
const directories = useDirectories();
const createAccount = useAccountsStore((state) => state.accountsActions.createAccount) as CreateAccountAction;
const publishComment = useAccountsStore((state) => state.accountsActions.publishComment) as PublishCommentAction;
const publishCommentModeration = useAccountsStore((state) => state.accountsActions.publishCommentModeration) as PublishCommentModerationAction;
const deleteComment = useAccountsStore((state) => state.accountsActions.deleteComment) as DeleteCommentAction;
const deleteAccount = useAccountsStore((state) => state.accountsActions.deleteAccount) as DeleteAccountAction;
const sourceCommunityAddress = getCommentCommunityAddress(comment);
const sourceCommentCid = comment.cid;
const boardOptions = useMemo(
() =>
directories
.filter((community) => community.address && (!sourceCommunityAddress || !areSameBoardAddress(community.address, sourceCommunityAddress)))
.sort((left, right) => getTransferBoardLabel(left).localeCompare(getTransferBoardLabel(right), undefined, { sensitivity: 'base' })),
[directories, sourceCommunityAddress],
);
const [modalState, dispatchModalState] = useReducer(transferModalReducer, comment, getInitialTransferModalState);
const { targetBoardAddress, selectedFields, transferState, transferError } = modalState;
const resolvedTargetBoardAddress = targetBoardAddress;
const resolvedTargetBoard = boardOptions.find((community) => community.address === targetBoardAddress);
const availableFields = useMemo(() => getAvailableTransferFields(comment), [comment]);
const sourceBoard = useMemo(
() => (sourceCommunityAddress ? directories.find((community) => areSameBoardAddress(community.address, sourceCommunityAddress)) : undefined),
[directories, sourceCommunityAddress],
);
const sourceBoardLabel = useMemo(() => {
return sourceBoard ? getTransferBoardLabel(sourceBoard) : sourceCommunityAddress || 'N/A';
}, [sourceBoard, sourceCommunityAddress]);
const transferTitle = comment.number !== undefined ? t('modQueue.transferTitleWithNumber', { number: comment.number }) : t('modQueue.transferTitle');
const isPublishingTransfer = transferState === 'publishing';
const isTransferComplete = isTerminalTransferState(transferState);
const canSubmit =
!isPublishingTransfer &&
!isTransferComplete &&
Boolean(targetBoardAddress) &&
Boolean(sourceCommunityAddress) &&
Boolean(sourceCommentCid) &&
typeof createAccount === 'function' &&
typeof publishComment === 'function' &&
typeof publishCommentModeration === 'function' &&
typeof deleteAccount === 'function' &&
hasSelectedTransferFields(selectedFields, availableFields);
const [{ left, top }, api] = useSpring(
() => ({
from: getCenteredTransferModalPosition(),
}),
[],
);
useLayoutEffect(() => {
api.start({ ...getCenteredTransferModalPosition(nodeRef.current), immediate: true });
}, [api]);
const disableBodyTextSelection = () => {
if (!bodySelectionStyleBeforeDragRef.current) {
bodySelectionStyleBeforeDragRef.current = {
userSelect: document.body.style.userSelect,
webkitUserSelect: document.body.style.webkitUserSelect,
};
}
Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
};
const restoreBodyTextSelection = () => {
const previousStyle = bodySelectionStyleBeforeDragRef.current;
Object.assign(document.body.style, {
userSelect: previousStyle?.userSelect ?? '',
webkitUserSelect: previousStyle?.webkitUserSelect ?? '',
});
bodySelectionStyleBeforeDragRef.current = null;
};
const bind = useDrag(
({ active, event, offset: [ox, oy] }) => {
const nextLeft = Math.round(ox);
const nextTop = Math.round(oy);
if (active) {
event.preventDefault();
disableBodyTextSelection();
} else {
restoreBodyTextSelection();
}
api.start({ left: nextLeft, top: nextTop, immediate: true });
},
{
from: () => [left.get(), top.get()],
filterTaps: true,
bounds: undefined,
},
);
const closeTransferModalOnEscape = useEffectEvent(() => {
if (!isPublishingTransfer) {
onClose();
}
});
useEffect(() => {
const handleTransferModalKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeTransferModalOnEscape();
}
};
document.addEventListener('keydown', handleTransferModalKeydown);
return () => {
document.removeEventListener('keydown', handleTransferModalKeydown);
restoreBodyTextSelection();
};
}, []);
const getTransferModerationCallbacks = (message: string) => ({
onChallenge: async (...args: any[]) => {
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification: ChallengeVerification, moderation: Comment) => {
alertChallengeVerificationFailed(challengeVerification, moderation);
},
onError: (error: Error & { details?: unknown }) => {
console.error(message, error, error.details);
},
});
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit) return;
let pendingIndex: number | undefined;
let temporaryAccountCreated = false;
const temporaryAccountName = getTemporaryTransferAccountName(sourceCommentCid);
const cleanupTemporaryAccount = async ({ deletePendingComment = false } = {}) => {
if (!temporaryAccountCreated) return;
if (deletePendingComment && pendingIndex !== undefined && typeof deleteComment === 'function') {
try {
await deleteComment(pendingIndex, temporaryAccountName);
} catch (error) {
console.error('Transfer pending comment cleanup failed:', error);
}
}
try {
await deleteAccount(temporaryAccountName);
temporaryAccountCreated = false;
} catch (error) {
console.error('Transfer temporary account cleanup failed:', error);
}
};
finalizedTransferRef.current = false;
onTransferStateChange?.('publishing');
dispatchModalState({ type: 'publishStarted' });
try {
await createAccount(temporaryAccountName);
temporaryAccountCreated = true;
const payload = getTransferPublishPayload(comment, selectedFields, resolvedTargetBoardAddress);
await publishComment(
{
...payload,
_onPendingCommentIndex: (index: number) => {
pendingIndex = index;
},
onChallenge: async (...args: any[]) => {
useChallengesStore.getState().addChallenge([...args, comment], async () => {
await cleanupTemporaryAccount({ deletePendingComment: true });
onTransferStateChange?.('failed');
dispatchModalState({ type: 'publishFailed', error: new Error('Transfer challenge was abandoned.') });
});
},
onChallengeVerification: async (challengeVerification: ChallengeVerification, challengeComment: Comment) => {
try {
alertChallengeVerificationFailed(challengeVerification, challengeComment);
if (challengeVerification?.challengeSuccess !== true) {
return;
}
if (finalizedTransferRef.current) return;
finalizedTransferRef.current = true;
const targetCommentCid = getTransferredCommentCid(challengeVerification, challengeComment);
if (!targetCommentCid) {
throw new Error('Transferred post was accepted, but no target CID was returned.');
}
// Queue the target marker first so a target moderation failure does not remove the original post.
await publishCommentModeration({
commentCid: targetCommentCid,
communityAddress: resolvedTargetBoardAddress,
commentModeration: {
flairs: getTargetTransferModerationFlairs(comment, selectedFields),
},
...getTransferModerationCallbacks('Transfer target moderation failed:'),
});
await publishCommentModeration({
commentCid: sourceCommentCid,
communityAddress: sourceCommunityAddress,
commentModeration: getTransferSourceModeration(
comment,
getTransferBoardReference(resolvedTargetBoard, resolvedTargetBoardAddress),
getTransferSourceBoardReference(sourceBoard, sourceCommunityAddress),
getTransferSourceBoardRulesLink(sourceBoard),
),
...getTransferModerationCallbacks('Transfer source moderation failed:'),
});
await cleanupTemporaryAccount();
try {
onTransferSuccess?.();
} catch (error) {
console.error('Transfer success callback failed:', error);
}
onTransferStateChange?.('succeeded');
dispatchModalState({ type: 'publishSucceeded' });
} catch (error) {
console.error('Transfer finalization failed:', error);
await cleanupTemporaryAccount();
onTransferStateChange?.('finalizationFailed');
dispatchModalState({ type: 'publishFinalizationFailed', error });
}
},
onError: async (error: Error & { details?: unknown }) => {
console.error('Transfer failed:', error, error.details);
await cleanupTemporaryAccount({ deletePendingComment: true });
onTransferStateChange?.('failed');
dispatchModalState({ type: 'publishFailed', error });
},
},
temporaryAccountName,
);
} catch (error) {
console.error('Transfer failed:', error);
await cleanupTemporaryAccount({ deletePendingComment: true });
onTransferStateChange?.('failed');
dispatchModalState({ type: 'publishFailed', error });
}
};
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<animated.div ref={nodeRef} className={styles.transferModal} role='dialog' aria-modal='false' aria-labelledby='post-transfer-title' style={{ left, top }}>
<form onSubmit={handleSubmit}>
<div className={`replyModalHandle ${styles.transferHeader}`} {...bind()}>
<span id='post-transfer-title'>{transferTitle}</span>
<button
type='button'
className={styles.transferCloseButton}
aria-label={t('close')}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
disabled={isPublishingTransfer}
/>
</div>
<div className={styles.transferBody}>
<div className={styles.transferAlert}>{t('modQueue.transferRecreateNotice')}</div>
<div className={styles.transferAlert}>{t('modQueue.transferRepliesNotice')}</div>
<div className={styles.transferHint}>{t('modQueue.transferTemporaryAccountNotice')}</div>
<div className={styles.transferRow}>
<span className={styles.transferLabel}>{t('modQueue.transferSource')}</span>
<span>{sourceBoardLabel}</span>
</div>
<label className={styles.transferRow}>
<span className={styles.transferLabel}>{t('modQueue.transferTarget')}</span>
<select
value={targetBoardAddress}
onChange={(event) => dispatchModalState({ type: 'targetBoard', value: event.target.value })}
disabled={isPublishingTransfer || isTransferComplete}
>
<option value='' disabled>
{t('choose_one')}
</option>
{boardOptions.map((community) => (
<option key={community.address} value={community.address}>
{getTransferBoardLabel(community)}
</option>
))}
</select>
</label>
<fieldset className={styles.transferFields}>
<legend>{t('modQueue.transferFields')}</legend>
{availableFields.length > 0 ? (
availableFields.map((field) => (
<label key={field} className={styles.transferField}>
<input
type='checkbox'
checked={selectedFields[field]}
onChange={(event) => dispatchModalState({ type: 'field', field, checked: event.target.checked })}
disabled={isPublishingTransfer || isTransferComplete}
/>
{getTransferFieldLabel(field, t)}
</label>
))
) : (
<div className={styles.transferHint}>{t('no_content')}</div>
)}
</fieldset>
{availableFields.length > 0 && !hasSelectedTransferFields(selectedFields, availableFields) && (
<div className={styles.transferError}>{t('modQueue.transferNoFields')}</div>
)}
{(transferState === 'failed' || transferState === 'finalizationFailed') && transferError !== undefined && (
<div className={styles.transferError}>
<ErrorDisplay error={transferError} inline={true} showImmediately={true} />
</div>
)}
{transferState === 'succeeded' && <div className={styles.transferSuccess}>{t('modQueue.transferSuccess')}</div>}
</div>
<div className={styles.transferFooter}>
<button type='button' onClick={onClose} disabled={isPublishingTransfer}>
{t('close')}
</button>
<button type='submit' disabled={!canSubmit}>
{isPublishingTransfer ? t('publishing') : t('transfer')}
</button>
</div>
</form>
</animated.div>,
document.body,
);
};
export default PostTransferModal;
@@ -0,0 +1,3 @@
.transferredTag {
font-weight: bold;
}
+21
View File
@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next';
import { hasTransferredCommentMarker } from '../lib/comment-transfer';
import styles from './post-transferred-tag.module.css';
interface PostTransferredTagProps {
comment: unknown;
}
const PostTransferredTag = ({ comment }: PostTransferredTagProps) => {
const { t } = useTranslation();
if (!hasTransferredCommentMarker(comment)) return null;
return (
<span className={styles.transferredTag} title={t('transferred')}>
[{t('transferred')}]{' '}
</span>
);
};
export default PostTransferredTag;
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import {
canTransferComment,
getTargetTransferModerationFlairs,
getTransferPublishPayload,
getTransferSourceBoardReference,
getTransferSourceBoardRulesLink,
getTransferSourceModeration,
hasTransferredCommentMarker,
TRANSFERRED_COMMENT_FLAIR,
} from '../comment-transfer';
describe('comment-transfer', () => {
const selectedFields = {
displayName: false,
title: true,
content: true,
link: true,
spoiler: true,
flairs: true,
};
it('only allows available top-level posts to transfer', () => {
expect(canTransferComment({ cid: 'source' } as never)).toBe(true);
expect(canTransferComment({ cid: 'reply', parentCid: 'source' } as never)).toBe(false);
expect(canTransferComment({ cid: '' } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', deleted: true } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', removed: true } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', commentModeration: { removed: true } } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', commentModeration: { purged: true } } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', archived: true } as never)).toBe(false);
expect(canTransferComment({ cid: 'source', commentModeration: { archived: true } } as never)).toBe(false);
});
it('copies selected post fields while excluding flag and transferred flairs', () => {
const payload = getTransferPublishPayload(
{
cid: 'source',
communityAddress: 'music-posting.eth',
content: 'wrong board',
flairs: [{ text: 'flag:country:auto', type: 'country' }, { text: 'flash:loop' }, TRANSFERRED_COMMENT_FLAIR],
link: 'https://example.com/file.swf',
spoiler: true,
title: 'Flash',
} as never,
selectedFields,
'flash-posting.eth',
);
expect(payload).toMatchObject({
communityAddress: 'flash-posting.eth',
content: 'wrong board',
flairs: [{ text: 'flash:loop' }],
link: 'https://example.com/file.swf',
spoiler: true,
title: 'Flash',
});
});
it('copies selected comment body text verbatim while still ignoring blank bodies', () => {
expect(
getTransferPublishPayload(
{
cid: 'source',
communityAddress: 'music-posting.eth',
content: '\n wrong board \n',
} as never,
selectedFields,
'tech-posting.eth',
),
).toMatchObject({
communityAddress: 'tech-posting.eth',
content: '\n wrong board \n',
});
expect(
getTransferPublishPayload(
{
cid: 'source',
communityAddress: 'music-posting.eth',
content: ' ',
} as never,
selectedFields,
'tech-posting.eth',
),
).not.toHaveProperty('content');
});
it('adds the transferred marker to target moderation flairs after safe copied tags', () => {
const flairs = getTargetTransferModerationFlairs(
{
cid: 'source',
flairs: [{ text: 'flag:country:US' }, { text: 'flash:loop' }],
} as never,
selectedFields,
);
expect(flairs).toEqual([{ text: 'flash:loop' }, { text: '5chan:transferred' }]);
});
it('marks pending source comments as rejected and published source comments as removed', () => {
expect(getTransferSourceModeration({ pendingApproval: true } as never, '>>>/g/', '/mu/', '[rules](/rules#mu)')).toEqual({
approved: false,
reason: 'Moved to >>>/g/, this post did not belong to /mu/ ([rules](/rules#mu))',
});
expect(getTransferSourceModeration({ pendingApproval: false } as never, '>>>/g/', '/mu/', '[rules](/rules#mu)')).toEqual({
removed: true,
reason: 'Moved to >>>/g/, this post did not belong to /mu/ ([rules](/rules#mu))',
});
});
it('formats source board references and rules links from directory metadata', () => {
expect(getTransferSourceBoardReference({ address: 'japanese-culture.eth', directoryCode: 'jp', title: '/jp/ - Otaku Culture' }, 'fallback.eth')).toBe('/jp/');
expect(getTransferSourceBoardRulesLink({ address: 'japanese-culture.eth', directoryCode: 'jp', title: '/jp/ - Otaku Culture' })).toBe('[rules](/rules#jp)');
expect(getTransferSourceBoardReference(undefined, 'source-board.eth')).toBe('source-board.eth');
expect(getTransferSourceBoardRulesLink(undefined)).toBe('the rules');
});
it('only treats moderation/update flairs as the transferred marker', () => {
expect(hasTransferredCommentMarker({ flairs: [{ text: '5chan:transferred' }] })).toBe(false);
expect(
hasTransferredCommentMarker({
raw: {
commentUpdate: {
flairs: [{ text: '5chan:transferred' }],
},
},
}),
).toBe(true);
expect(
hasTransferredCommentMarker({
commentModeration: {
flairs: [{ text: '5chan:transferred' }],
},
}),
).toBe(true);
});
});
+6
View File
@@ -66,6 +66,12 @@ const normalizeFlairsContainer = (flairs: unknown): unknown => {
return isRecord(flairs) && Array.isArray(flairs.author) ? flairs.author : undefined;
};
export const isCommentFlagFlair = (flair: unknown): boolean => {
if (!isRecord(flair)) return false;
return Boolean(parseStructuredFlagSource(flair as AuthorFlairLike) ?? parseTextFlagSource(toString(flair.text)));
};
export const getAuthorFlagFlairs = (author: unknown): unknown => {
if (!isRecord(author)) return undefined;
+156
View File
@@ -0,0 +1,156 @@
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import { isCommentFlagFlair } from './comment-flags';
import { isCommentArchived } from './utils/comment-moderation-utils';
import { normalizePublishURL } from './utils/url-utils';
export type PostTransferField = 'displayName' | 'title' | 'content' | 'link' | 'spoiler' | 'flairs';
export type PostTransferFields = Record<PostTransferField, boolean>;
export type TransferBoardLike = {
address?: string;
directoryCode?: string;
title?: string;
};
export const TRANSFERRED_COMMENT_FLAIR_TEXT = '5chan:transferred';
export const TRANSFERRED_COMMENT_FLAIR = { text: TRANSFERRED_COMMENT_FLAIR_TEXT } as const;
export const TRANSFER_FIELD_KEYS: PostTransferField[] = ['displayName', 'title', 'content', 'link', 'spoiler', 'flairs'];
const getTextField = (value: unknown): string | undefined => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
const getVerbatimTextField = (value: unknown): string | undefined => (typeof value === 'string' && value.trim().length > 0 ? value : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
export const getCommentDisplayName = (comment: Comment): string | undefined => getTextField(comment.displayName) ?? getTextField(comment.author?.displayName);
export const isTransferredCommentFlair = (flair: unknown): boolean => isRecord(flair) && getTextField(flair.text) === TRANSFERRED_COMMENT_FLAIR_TEXT;
const getFlairs = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
export const getTransferableCommentFlairs = (comment: Comment): unknown[] =>
getFlairs(comment.flairs).filter((flair) => !isCommentFlagFlair(flair) && !isTransferredCommentFlair(flair));
export const hasTransferableCommentFlairs = (comment: Comment): boolean => getTransferableCommentFlairs(comment).length > 0;
export const canTransferComment = (comment: Comment | undefined): boolean => {
if (!comment?.cid || comment.parentCid) {
return false;
}
return (
comment.deleted !== true &&
comment.removed !== true &&
comment.commentModeration?.removed !== true &&
comment.commentModeration?.purged !== true &&
!isCommentArchived(comment)
);
};
export const getInitialTransferFields = (comment: Comment): PostTransferFields => ({
displayName: false,
title: Boolean(getTextField(comment.title)),
content: Boolean(getTextField(comment.content)),
link: Boolean(getTextField(comment.link)),
spoiler: comment.spoiler === true,
flairs: hasTransferableCommentFlairs(comment),
});
export const getAvailableTransferFields = (comment: Comment): PostTransferField[] => {
const initialFields = getInitialTransferFields(comment);
return TRANSFER_FIELD_KEYS.filter((field) => (field === 'displayName' ? Boolean(getCommentDisplayName(comment)) : initialFields[field]));
};
export const hasSelectedTransferFields = (fields: PostTransferFields, availableFields: PostTransferField[]) => availableFields.some((field) => fields[field]);
export const getTransferPublishPayload = (comment: Comment, fields: PostTransferFields, targetBoardAddress: string): Record<string, unknown> => {
const payload: Record<string, unknown> = {
communityAddress: targetBoardAddress,
};
const displayName = getCommentDisplayName(comment);
const title = getTextField(comment.title);
const content = getVerbatimTextField(comment.content);
const link = getTextField(comment.link);
const flairs = getTransferableCommentFlairs(comment);
if (fields.displayName && displayName) {
payload.author = { displayName };
}
if (fields.title && title) {
payload.title = title;
}
if (fields.content && content) {
payload.content = content;
}
if (fields.link && link) {
payload.link = normalizePublishURL(link);
}
if (fields.spoiler && comment.spoiler === true) {
payload.spoiler = true;
}
if (fields.flairs && flairs.length > 0) {
payload.flairs = flairs;
}
return payload;
};
export const getTargetTransferModerationFlairs = (comment: Comment, fields: PostTransferFields): unknown[] => {
const copiedFlairs = fields.flairs ? getTransferableCommentFlairs(comment) : [];
return [...copiedFlairs, TRANSFERRED_COMMENT_FLAIR];
};
export const getTransferBoardReference = (targetBoard: TransferBoardLike | undefined, targetBoardAddress: string): string => {
const directoryCode = getTextField(targetBoard?.directoryCode);
if (directoryCode) return `>>>/${directoryCode}/`;
return getTextField(targetBoard?.address) ?? targetBoardAddress;
};
export const getTransferSourceBoardReference = (sourceBoard: TransferBoardLike | undefined, sourceBoardAddress: string | undefined): string => {
const directoryCode = getTextField(sourceBoard?.directoryCode);
if (directoryCode) return `/${directoryCode}/`;
return getTextField(sourceBoard?.address) ?? getTextField(sourceBoardAddress) ?? 'this board';
};
export const getTransferSourceBoardRulesLink = (sourceBoard: TransferBoardLike | undefined): string => {
const directoryCode = getTextField(sourceBoard?.directoryCode);
return directoryCode ? `[rules](/rules#${directoryCode})` : 'the rules';
};
export const getTransferSourceModeration = (
comment: Comment,
targetBoardReference: string,
sourceBoardReference = 'this board',
sourceBoardRulesLink = 'the rules',
): Record<string, unknown> => {
const reason = `Moved to ${targetBoardReference}, this post did not belong to ${sourceBoardReference} (${sourceBoardRulesLink})`;
return comment.pendingApproval === true ? { approved: false, reason } : { removed: true, reason };
};
export const getTransferredCommentCid = (challengeVerification: unknown, challengeComment: unknown): string | undefined => {
if (isRecord(challengeVerification)) {
const commentUpdate = challengeVerification.commentUpdate;
if (isRecord(commentUpdate)) {
const cid = getTextField(commentUpdate.cid);
if (cid) return cid;
}
const comment = challengeVerification.comment;
if (isRecord(comment)) {
const cid = getTextField(comment.cid);
if (cid) return cid;
}
}
return isRecord(challengeComment) ? getTextField(challengeComment.cid) : undefined;
};
const hasTransferredMarkerInFlairs = (flairs: unknown): boolean => getFlairs(flairs).some(isTransferredCommentFlair);
export const hasTransferredCommentMarker = (comment: unknown): boolean => {
if (!isRecord(comment)) return false;
const raw = isRecord(comment.raw) ? comment.raw : undefined;
const commentUpdate = raw && isRecord(raw.commentUpdate) ? raw.commentUpdate : undefined;
const commentModeration = isRecord(comment.commentModeration) ? comment.commentModeration : undefined;
return hasTransferredMarkerInFlairs(commentUpdate?.flairs) || hasTransferredMarkerInFlairs(commentModeration?.flairs);
};
@@ -10,30 +10,55 @@ const act = (React as { act?: (callback: () => void | Promise<void>) => void | P
type TestComment = {
approved?: boolean;
archived?: boolean;
author?: { displayName?: string };
cid: string;
commentModeration?: {
archived?: boolean;
purged?: boolean;
removed?: boolean;
};
content?: string;
communityAddress?: string;
deleted?: boolean;
flairs?: Array<Record<string, unknown>>;
link?: string;
number?: number;
parentCid?: string;
pendingApproval?: boolean;
removed?: boolean;
spoiler?: boolean;
timestamp?: number;
title?: string;
};
const testState = vi.hoisted(() => ({
account: { author: { address: '0x123' }, id: 'account' },
account: { author: { address: '0x123', shortAddress: '0x123' }, id: 'account', name: 'main' },
accounts: [
{ author: { address: '0x123', shortAddress: '0x123' }, id: 'account', name: 'main' },
{ author: { address: '0x999', shortAddress: '0x999' }, id: 'throwaway-account', name: 'throwaway' },
],
accountCommunityAddresses: ['music-posting.eth'],
addChallengeMock: vi.fn(),
communityError: null as Error | null,
createAccountMock: vi.fn(),
deleteAccountMock: vi.fn(),
deleteCommentMock: vi.fn(),
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
dismissedCommentCids: [] as string[],
feed: [] as TestComment[],
hasMore: false,
isMobile: false,
loadMoreMock: vi.fn(),
publishCommentMock: vi.fn(),
publishCommentModerationActionMock: vi.fn(),
publishCommentModerationMock: vi.fn(),
queuedCommentHistory: [] as TestComment[],
rememberCommentsInQueueMock: vi.fn(),
resetMock: vi.fn(),
setResetFunctionMock: vi.fn(),
springStartMock: vi.fn(),
useSpringMock: vi.fn(),
viewMode: 'compact' as 'compact' | 'feed',
}));
@@ -55,12 +80,13 @@ useModQueueStoreMock.getState = getModQueueState;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string, options?: Record<string, unknown>) => (key === 'modQueue.transferTitleWithNumber' ? `Transfer Post No.${options?.number}` : key),
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccounts: () => ({ accounts: testState.accounts }),
useCommunity: () => ({
error: testState.communityError,
roles: {
@@ -98,12 +124,26 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () =>
default: (
selector: (state: {
accounts: Record<string, typeof testState.account>;
accountsActions: {
createAccount: typeof testState.createAccountMock;
deleteAccount: typeof testState.deleteAccountMock;
deleteComment: typeof testState.deleteCommentMock;
publishComment: typeof testState.publishCommentMock;
publishCommentModeration: typeof testState.publishCommentModerationActionMock;
};
accountsEditsSummaries: Record<string, Record<string, unknown>>;
activeAccountId: string;
}) => unknown,
) =>
selector({
accounts: { account: testState.account },
accountsActions: {
createAccount: testState.createAccountMock,
deleteAccount: testState.deleteAccountMock,
deleteComment: testState.deleteCommentMock,
publishComment: testState.publishCommentMock,
publishCommentModeration: testState.publishCommentModerationActionMock,
},
accountsEditsSummaries: { account: {} },
activeAccountId: 'account',
}),
@@ -125,6 +165,40 @@ vi.mock('@floating-ui/react', () => ({
}),
}));
vi.mock('@react-spring/web', async () => {
const React = await vi.importActual<typeof import('react')>('react');
const normalizeStyle = (style: Record<string, unknown> | undefined) =>
style
? Object.fromEntries(
Object.entries(style).map(([key, value]) => [
key,
typeof value === 'object' && value !== null && 'get' in value && typeof (value as { get: unknown }).get === 'function'
? (value as { get: () => unknown }).get()
: value,
]),
)
: undefined;
return {
animated: {
div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: normalizeStyle(style) })),
},
useSpring: testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 50 },
},
{
start: testState.springStartMock,
},
]),
};
});
vi.mock('@use-gesture/react', () => ({
useDrag: () => () => ({}),
}));
vi.mock('react-virtuoso', () => ({
Virtuoso: ({
components,
@@ -181,6 +255,7 @@ vi.mock('../../../hooks/use-directories', () => ({
(value) => typeof value === 'string' && value.replace(/(\.bso|\.eth)$/, '') === address.replace(/(\.bso|\.eth)$/, ''),
),
),
getFallbackDirectoriesData: () => ({ communities: testState.directories }),
normalizeBoardAddress: (address: string) => address.replace(/(\.bso|\.eth)$/, ''),
useDirectories: () => testState.directories,
}));
@@ -208,17 +283,34 @@ vi.mock('../../../components/tooltip/tooltip', () => ({
}));
vi.mock('../../post/post', () => ({
Post: ({ isModQueue, post, showReplies }: { isModQueue?: boolean; post?: TestComment; showReplies?: boolean }) =>
Post: ({
isModQueue,
isPublishing,
modQueueStatus,
onTransfer,
post,
showReplies,
}: {
isModQueue?: boolean;
isPublishing?: boolean;
modQueueStatus?: string | null;
onTransfer?: () => void;
post?: TestComment;
showReplies?: boolean;
}) =>
createElement(
'div',
{
'data-cid': post?.cid,
'data-content': post?.content,
'data-is-mod-queue': String(Boolean(isModQueue)),
'data-is-publishing': String(Boolean(isPublishing)),
'data-mod-queue-status': modQueueStatus ?? '',
'data-show-replies': String(Boolean(showReplies)),
'data-testid': 'mod-queue-feed-post',
},
post?.cid ?? 'missing',
onTransfer ? createElement('button', { type: 'button', onClick: onTransfer }, 'transfer') : null,
),
}));
@@ -270,6 +362,11 @@ const renderModQueueWithOtherRoute = async () => {
describe('ModQueueView', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { author: { address: '0x123', shortAddress: '0x123' }, id: 'account', name: 'main' };
testState.accounts = [
{ author: { address: '0x123', shortAddress: '0x123' }, id: 'account', name: 'main' },
{ author: { address: '0x999', shortAddress: '0x999' }, id: 'throwaway-account', name: 'throwaway' },
];
testState.accountCommunityAddresses = ['music-posting.eth'];
testState.communityError = null;
testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }];
@@ -277,7 +374,22 @@ describe('ModQueueView', () => {
testState.feed = [];
testState.hasMore = false;
testState.isMobile = false;
testState.createAccountMock.mockResolvedValue(undefined);
testState.deleteAccountMock.mockResolvedValue(undefined);
testState.publishCommentMock.mockResolvedValue({ index: 12 });
testState.publishCommentModerationActionMock.mockResolvedValue(undefined);
testState.queuedCommentHistory = [];
testState.springStartMock.mockReset();
testState.useSpringMock.mockReset();
testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 50 },
},
{
start: testState.springStartMock,
},
]);
testState.viewMode = 'compact';
container = document.createElement('div');
@@ -290,6 +402,8 @@ describe('ModQueueView', () => {
root.unmount();
});
container.remove();
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
});
it('keeps the compact table visible with the empty state while an empty mod queue continues loading', async () => {
@@ -465,6 +579,662 @@ describe('ModQueueView', () => {
expect(previewPost?.getAttribute('data-show-replies')).toBe('false');
});
it('transfers a queued post to another board with a temporary account', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
{ address: 'anime-posting.eth', directoryCode: 'a', title: '/a/ - Anime & Manga' },
];
testState.feed = [
{
author: { displayName: 'Original name' },
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
flairs: [{ text: 'flag:country:auto', type: 'country' }, { text: 'flash:loop' }],
link: 'https://example.com/image.png',
number: 8,
pendingApproval: true,
spoiler: true,
timestamp: 90_000,
title: 'Wrong board',
},
];
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
expect(transferButton).toBeTruthy();
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
expect(dialog?.textContent).toContain('Transfer Post No.8');
expect(dialog?.textContent).toContain('/g/ - Technology');
expect(dialog?.textContent).toContain('modQueue.transferRecreateNotice');
expect(dialog?.textContent).toContain('modQueue.transferRepliesNotice');
expect(dialog?.textContent).toContain('modQueue.transferTemporaryAccountNotice');
expect(dialog?.textContent).not.toContain('modQueue.transferAccount');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
expect(Array.from(targetSelect?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'tech-posting.eth']);
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
expect(submitButton?.disabled).toBe(true);
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
expect(submitButton?.disabled).toBe(false);
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
expect(testState.createAccountMock).toHaveBeenCalledTimes(1);
const temporaryAccountName = testState.createAccountMock.mock.calls[0][0];
expect(temporaryAccountName).toEqual(expect.stringMatching(/^5chan-transfer-wrong-bo-/));
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
const [payload, accountName] = testState.publishCommentMock.mock.calls[0];
expect(accountName).toBe(temporaryAccountName);
expect(payload).toMatchObject({
communityAddress: 'tech-posting.eth',
content: 'belongs on tech',
flairs: [{ text: 'flash:loop' }],
link: 'https://example.com/image.png',
spoiler: true,
title: 'Wrong board',
});
expect(payload.author).toBeUndefined();
expect(typeof payload.onChallengeVerification).toBe('function');
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: false, reason: 'try again' }, { cid: 'retry-post', communityAddress: 'tech-posting.eth' });
await Promise.resolve();
});
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(testState.publishCommentModerationActionMock).not.toHaveBeenCalled();
expect(testState.deleteAccountMock).not.toHaveBeenCalled();
expect(dialog?.textContent).toContain('publishing');
alertSpy.mockRestore();
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' });
await Promise.resolve();
});
expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2);
expect(testState.publishCommentModerationActionMock.mock.calls[0][0]).toMatchObject({
commentCid: 'transferred-post',
communityAddress: 'tech-posting.eth',
commentModeration: {
flairs: [{ text: 'flash:loop' }, { text: '5chan:transferred' }],
},
});
expect(testState.publishCommentModerationActionMock.mock.calls[1][0]).toMatchObject({
commentCid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
commentModeration: {
approved: false,
reason: 'Moved to >>>/g/, this post did not belong to /mu/ ([rules](/rules#mu))',
},
});
expect(testState.deleteAccountMock).toHaveBeenCalledWith(temporaryAccountName);
expect(dialog?.textContent).toContain('modQueue.transferSuccess');
expect(dialog?.textContent).not.toContain('#12');
expect(submitButton?.disabled).toBe(true);
expect(targetSelect?.disabled).toBe(true);
expect(testState.rememberCommentsInQueueMock).toHaveBeenCalledWith([
expect.objectContaining({
approved: false,
cid: 'wrong-board-post',
pendingApproval: false,
}),
]);
expect(container.textContent).toContain('rejected');
expect(
Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => ['approve', 'reject', 'transfer'].includes(button.textContent ?? '')),
).toBe(false);
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
expect(testState.createAccountMock).toHaveBeenCalledTimes(1);
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2);
});
it('keeps post-acceptance transfer finalization failures from being retried', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
testState.publishCommentModerationActionMock.mockRejectedValueOnce(new Error('target marker failed'));
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
const [payload] = testState.publishCommentMock.mock.calls[0];
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' });
await Promise.resolve();
});
expect(dialog?.querySelector('[data-testid="error-display"]')?.textContent).toBe('target marker failed');
expect(submitButton?.disabled).toBe(true);
expect(targetSelect?.disabled).toBe(true);
expect(testState.deleteAccountMock).toHaveBeenCalled();
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
const closeButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.textContent === 'close');
await act(async () => {
closeButton?.click();
});
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => button.textContent === 'transfer')).toBe(false);
});
it('awaits transfer publish rollback before enabling retry', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
let resolveDeleteAccount: () => void = () => undefined;
testState.deleteAccountMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveDeleteAccount = resolve;
}),
);
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
const temporaryAccountName = testState.createAccountMock.mock.calls[0][0];
const [payload] = testState.publishCommentMock.mock.calls[0];
payload._onPendingCommentIndex(12);
let onErrorPromise: Promise<void> | undefined;
await act(async () => {
onErrorPromise = payload.onError(new Error('publish failed'));
await Promise.resolve();
});
expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, temporaryAccountName);
expect(testState.deleteAccountMock).toHaveBeenCalledWith(temporaryAccountName);
expect(dialog?.textContent).toContain('publishing');
expect(submitButton?.disabled).toBe(true);
await act(async () => {
resolveDeleteAccount();
await onErrorPromise;
await Promise.resolve();
});
expect(dialog?.querySelector('[data-testid="error-display"]')?.textContent).toBe('publish failed');
expect(dialog?.textContent).not.toContain('publishing');
expect(submitButton?.disabled).toBe(false);
});
it('only allows one transfer modal to be open from the mod queue', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
{
cid: 'also-wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'also belongs on tech',
number: 9,
pendingApproval: true,
timestamp: 91_000,
},
];
await renderModQueue();
let transferButtons = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter((button) => button.textContent === 'transfer');
expect(transferButtons).toHaveLength(2);
await act(async () => {
transferButtons[0]?.click();
});
expect(document.body.querySelectorAll('[role="dialog"][aria-labelledby="post-transfer-title"]')).toHaveLength(1);
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter((button) => button.textContent === 'transfer')).toHaveLength(0);
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const closeButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.textContent === 'close');
await act(async () => {
closeButton?.click();
});
expect(document.body.querySelectorAll('[role="dialog"][aria-labelledby="post-transfer-title"]')).toHaveLength(0);
transferButtons = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter((button) => button.textContent === 'transfer');
expect(transferButtons).toHaveLength(2);
});
it('keeps the transfer lock while a hidden active item is still publishing', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
const activeComment = {
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
};
const remainingComment = {
cid: 'also-wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'also belongs on tech',
number: 9,
pendingApproval: true,
timestamp: 91_000,
};
testState.feed = [activeComment, remainingComment];
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
expect(document.body.querySelectorAll('[role="dialog"][aria-labelledby="post-transfer-title"]')).toHaveLength(1);
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
const [payload] = testState.publishCommentMock.mock.calls[0];
testState.feed = [remainingComment];
await renderModQueue();
expect(document.body.querySelectorAll('[role="dialog"][aria-labelledby="post-transfer-title"]')).toHaveLength(0);
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter((button) => button.textContent === 'transfer')).toHaveLength(0);
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' });
await Promise.resolve();
});
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter((button) => button.textContent === 'transfer')).toHaveLength(1);
});
it('does not show transfer for queued replies', async () => {
testState.feed = [
{
cid: 'pending-reply',
communityAddress: 'music-posting.eth',
content: 'pending reply body',
number: 7,
parentCid: 'thread-cid',
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => button.textContent === 'transfer')).toBe(false);
});
it.each([
['deleted', { deleted: true }],
['removed', { removed: true }],
['moderation removed', { commentModeration: { removed: true } }],
['purged', { commentModeration: { purged: true } }],
['archived', { archived: true }],
['moderation archived', { commentModeration: { archived: true } }],
])('does not show transfer for %s queued posts', async (_label, commentPatch) => {
testState.feed = [
{
cid: 'unavailable-post',
communityAddress: 'music-posting.eth',
content: 'already unavailable',
number: 7,
pendingApproval: true,
timestamp: 90_000,
...commentPatch,
},
];
await renderModQueue();
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => button.textContent === 'transfer')).toBe(false);
});
it('cleans up the temporary account and unlocks the transfer modal when the challenge is abandoned', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
expect(container.textContent).toContain('publishing');
expect(
Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => ['approve', 'reject', 'transfer'].includes(button.textContent ?? '')),
).toBe(false);
const temporaryAccountName = testState.createAccountMock.mock.calls[0][0];
const [payload] = testState.publishCommentMock.mock.calls[0];
payload._onPendingCommentIndex(12);
await act(async () => {
await payload.onChallenge({ type: 'text-math' }, { cid: 'pending-transfer-post' });
});
const abandonTransferChallenge = testState.addChallengeMock.mock.calls[0][1];
await act(async () => {
await abandonTransferChallenge();
await Promise.resolve();
});
expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, temporaryAccountName);
expect(testState.deleteAccountMock).toHaveBeenCalledWith(temporaryAccountName);
expect(dialog?.textContent).toContain('Transfer challenge was abandoned.');
expect(container.textContent).not.toContain('publishing');
const closeButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.textContent === 'close');
expect(closeButton?.disabled).toBe(false);
expect(submitButton?.disabled).toBe(false);
});
it('opens and closes the transfer modal from feed mode', async () => {
testState.viewMode = 'feed';
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
expect(container.querySelector('[data-testid="mod-queue-feed-post"]')?.getAttribute('data-cid')).toBe('wrong-board-post');
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
expect(dialog?.textContent).toContain('Transfer Post No.8');
const closeButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.textContent === 'close');
await act(async () => {
closeButton?.click();
});
expect(document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]')).toBeNull();
});
it('locks feed-mode actions while transfer publishes and marks success as rejected', async () => {
testState.viewMode = 'feed';
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const feedPost = container.querySelector('[data-testid="mod-queue-feed-post"]');
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
await act(async () => {
if (targetSelect) {
targetSelect.value = 'tech-posting.eth';
targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
expect(feedPost?.getAttribute('data-is-publishing')).toBe('true');
const [payload] = testState.publishCommentMock.mock.calls[0];
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' });
await Promise.resolve();
});
expect(feedPost?.getAttribute('data-is-publishing')).toBe('false');
expect(feedPost?.getAttribute('data-mod-queue-status')).toBe('rejected');
expect(Array.from(container.querySelectorAll<HTMLButtonElement>('button')).some((button) => button.textContent === 'transfer')).toBe(false);
});
it('keeps the transfer modal non-blocking and closes it with Escape', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
expect(dialog?.textContent).toContain('Transfer Post No.8');
await act(async () => {
dialog?.querySelector('form')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]')).toBe(dialog);
await act(async () => {
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]')).toBe(dialog);
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
});
expect(document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]')).toBeNull();
});
it('opens transfer modals centered in the viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 640 });
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' },
];
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on tech',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
try {
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'transfer');
await act(async () => {
transferButton?.click();
});
const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(configFactory()).toEqual({
from: {
left: 235,
top: 320,
},
});
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('caps long content in the floating preview so the hover card stays compact', async () => {
testState.feed = [
{
+3 -2
View File
@@ -102,7 +102,7 @@
}
.actionsHeader {
width: 150px;
width: 220px;
flex-shrink: 0;
text-align: right;
}
@@ -283,7 +283,7 @@
}
.actions {
width: 150px;
width: 220px;
flex-shrink: 0;
display: flex;
gap: 4px;
@@ -354,6 +354,7 @@
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
/* Mobile compact view: match feed view approve/reject button styling */
+231 -101
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
@@ -45,6 +45,8 @@ import lowerCase from 'lodash/lowerCase';
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
import footerStyles from '../../components/footer/footer.module.css';
import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../../hooks/use-moderated-community-addresses';
import PostTransferModal, { type PostTransferState } from '../../components/post-transfer-modal/post-transfer-modal';
import { canTransferComment } from '../../lib/comment-transfer';
/** Path for display: directory code, or full address if has TLD, or shortened for long IPNS keys (no dot) */
const getBoardDisplayPath = (address: string, path: string): string => {
@@ -138,12 +140,18 @@ interface ModQueueRowProps {
comment: Comment;
isOdd?: boolean;
showBoard?: boolean;
transferControls: ModQueueTransferControls;
/** Board path for URLs (directory code or full address) */
boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
}
interface ModQueueTransferControls {
activeTransferCommentCid: string | null;
setActiveTransferCommentCid: React.Dispatch<React.SetStateAction<string | null>>;
}
interface ModQueueActionState {
status: 'approved' | 'rejected' | 'failed' | null;
error?: unknown;
@@ -151,6 +159,7 @@ interface ModQueueActionState {
isPublishing: boolean;
handleApprove: () => Promise<void>;
handleReject: () => Promise<void>;
handleTransfer?: () => void;
handleRemove?: () => void;
}
@@ -161,6 +170,7 @@ interface ModQueueActionsProps {
isPublishing: boolean;
handleApprove: () => Promise<void>;
handleReject: () => Promise<void>;
handleTransfer?: () => void;
handleRemove?: () => void;
variant: 'row' | 'card';
}
@@ -265,7 +275,69 @@ const ModQueueExcerptPreviewLink = ({ comment, excerpt, postUrl, postUrlState }:
);
};
const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove, variant }: ModQueueActionsProps) => {
const useModQueueTransfer = (comment: Comment, transferControls: ModQueueTransferControls) => {
const [transferState, setTransferState] = useState<PostTransferState>('idle');
const transferStateRef = useRef<PostTransferState>('idle');
const isMountedRef = useRef(true);
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const { activeTransferCommentCid, setActiveTransferCommentCid } = transferControls;
const transferCommentCid = comment.cid;
const canTransfer = canTransferComment(comment);
const isTransferOpen = Boolean(transferCommentCid && activeTransferCommentCid === transferCommentCid);
const isTransferPublishing = transferState === 'publishing';
const isTransferSucceeded = transferState === 'succeeded';
const isTransferTerminal = isTransferSucceeded || transferState === 'finalizationFailed';
const canOpenTransfer = canTransfer && !isTransferTerminal && !activeTransferCommentCid && Boolean(transferCommentCid);
const handleTransfer = useCallback(() => {
if (canOpenTransfer && transferCommentCid) {
setActiveTransferCommentCid(transferCommentCid);
}
}, [canOpenTransfer, setActiveTransferCommentCid, transferCommentCid]);
const handleCloseTransfer = useCallback(() => {
setActiveTransferCommentCid((currentCommentCid) => (currentCommentCid === transferCommentCid ? null : currentCommentCid));
}, [setActiveTransferCommentCid, transferCommentCid]);
const handleTransferStateChange = useCallback(
(nextTransferState: PostTransferState) => {
transferStateRef.current = nextTransferState;
if (isMountedRef.current) {
setTransferState(nextTransferState);
return;
}
if (nextTransferState !== 'publishing') {
handleCloseTransfer();
}
},
[handleCloseTransfer],
);
const handleTransferSuccess = useCallback(() => {
const rejectedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: false, pendingApproval: false });
if (rejectedSnapshot) {
rememberCommentsInQueue([rejectedSnapshot]);
}
}, [comment, rememberCommentsInQueue]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (transferStateRef.current !== 'publishing') {
handleCloseTransfer();
}
};
}, [handleCloseTransfer]);
return {
handleTransfer: canOpenTransfer ? handleTransfer : undefined,
isTransferPublishing,
isTransferSucceeded,
transferModal:
canTransfer && isTransferOpen ? (
<PostTransferModal comment={comment} onClose={handleCloseTransfer} onTransferStateChange={handleTransferStateChange} onTransferSuccess={handleTransferSuccess} />
) : null,
};
};
const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleApprove, handleReject, handleTransfer, handleRemove, variant }: ModQueueActionsProps) => {
const { t } = useTranslation();
const displayError = error || errorMessage;
@@ -345,6 +417,15 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
</button>
]
</span>
{handleTransfer && (
<span className={styles.buttonWrapper}>
[
<button type='button' className={styles.button} onClick={handleTransfer} disabled={isPublishing}>
{t('transfer')}
</button>
]
</span>
)}
</div>
) : (
<div className={styles.cardActions}>
@@ -354,6 +435,11 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
<button type='button' className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
{handleTransfer && (
<button type='button' className='button' onClick={handleTransfer} disabled={isPublishing}>
{t('transfer')}
</button>
)}
</div>
);
@@ -404,10 +490,10 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
// session, so combine that baseline with the live action-derived status.
const status = alreadyApproved || actionStatus === 'approved' ? 'approved' : alreadyRejected || actionStatus === 'rejected' ? 'rejected' : actionStatus;
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove: status ? handleRemove : undefined };
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove };
};
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, transferControls, boardPath, boardDisplayPath }: ModQueueRowProps) => {
const { t } = useTranslation();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const isMobile = useIsMobile();
@@ -446,56 +532,63 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
const postUrlState = getQueuedCommentRouteState(comment);
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
const { handleTransfer, isTransferPublishing, isTransferSucceeded, transferModal } = useModQueueTransfer(displayComment, transferControls);
const displayStatus = isTransferSucceeded ? 'rejected' : status;
const displayIsPublishing = isPublishing || isTransferPublishing;
return (
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
<div className={styles.number}>{number ?? 'N/A'}</div>
{showBoard && (
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</span>}</div>
)}
<div className={styles.excerpt}>
<ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} />
</div>
<div className={styles.time}>
{isMobile ? (
// On mobile, show shorter time ago format without tooltip
<>
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
<div className={styles.number}>{number ?? 'N/A'}</div>
{showBoard && (
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</span>}</div>
)}
<div className={styles.excerpt}>
<ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} />
</div>
<div className={styles.time}>
{isMobile ? (
// On mobile, show shorter time ago format without tooltip
isAwaitingApproval && isOverThreshold ? (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>
) : (
<span>{getFormattedTimeAgo(timestamp)}</span>
)
) : // On desktop, show full date with tooltip
isAwaitingApproval && isOverThreshold ? (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>
<>
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
<span className={styles.alertWrapper}>
{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</span>
</>
) : (
<span>{getFormattedTimeAgo(timestamp)}</span>
)
) : // On desktop, show full date with tooltip
isAwaitingApproval && isOverThreshold ? (
<>
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
<span className={styles.alertWrapper}>
{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</span>
</>
) : (
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
)}
)}
</div>
<div className={styles.type}>{isReply ? capitalize(t('reply')) : capitalize(t('post'))}</div>
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
<div className={styles.actions}>
<ModQueueActions
status={displayStatus}
error={error}
errorMessage={errorMessage}
isPublishing={displayIsPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleTransfer={handleTransfer}
handleRemove={displayStatus ? handleRemove : undefined}
variant='row'
/>
</div>
</div>
<div className={styles.type}>{isReply ? capitalize(t('reply')) : capitalize(t('post'))}</div>
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
<div className={styles.actions}>
<ModQueueActions
status={status}
error={error}
errorMessage={errorMessage}
isPublishing={isPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleRemove={handleRemove}
variant='row'
/>
</div>
</div>
{transferModal}
</>
);
});
ModQueueRow.displayName = 'ModQueueRow';
@@ -503,13 +596,14 @@ ModQueueRow.displayName = 'ModQueueRow';
interface ModQueueCardProps {
comment: Comment;
showBoard?: boolean;
transferControls: ModQueueTransferControls;
/** Board path for URLs (directory code or full address) */
boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
}
const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
const ModQueueCard = memo(({ comment, showBoard = false, transferControls, boardPath, boardDisplayPath }: ModQueueCardProps) => {
const { t } = useTranslation();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime();
@@ -544,66 +638,80 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
const postUrlState = getQueuedCommentRouteState(comment);
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
const { handleTransfer, isTransferPublishing, isTransferSucceeded, transferModal } = useModQueueTransfer(displayComment, transferControls);
const displayStatus = isTransferSucceeded ? 'rejected' : status;
const displayIsPublishing = isPublishing || isTransferPublishing;
return (
<div className={styles.mobileCard}>
<div className={styles.cardHeader}>
<span className={styles.cardHeaderLeft}>
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{showBoard && boardPath && (
<>
<span className={styles.cardBoardSeparator}> - </span>
<span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span>
</>
)}
</span>
<span className={styles.cardTime}>
{isAwaitingApproval && isOverThreshold ? (
<>
{getFormattedDate(timestamp)} (<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
getFormattedDate(timestamp)
)}
</span>
<>
<div className={styles.mobileCard}>
<div className={styles.cardHeader}>
<span className={styles.cardHeaderLeft}>
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{showBoard && boardPath && (
<>
<span className={styles.cardBoardSeparator}> - </span>
<span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span>
</>
)}
</span>
<span className={styles.cardTime}>
{isAwaitingApproval && isOverThreshold ? (
<>
{getFormattedDate(timestamp)} (<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
getFormattedDate(timestamp)
)}
</span>
</div>
<div className={styles.cardContent}>
{t('excerpt')}: <ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} /> / {t('type')}:{' '}
{isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
</div>
<ModQueueActions
status={displayStatus}
error={error}
errorMessage={errorMessage}
isPublishing={displayIsPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleTransfer={handleTransfer}
handleRemove={displayStatus ? handleRemove : undefined}
variant='card'
/>
</div>
<div className={styles.cardContent}>
{t('excerpt')}: <ModQueueExcerptPreviewLink comment={displayComment} excerpt={excerpt} postUrl={postUrl} postUrlState={postUrlState} /> / {t('type')}:{' '}
{isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
</div>
<ModQueueActions
status={status}
error={error}
errorMessage={errorMessage}
isPublishing={isPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleRemove={handleRemove}
variant='card'
/>
</div>
{transferModal}
</>
);
});
ModQueueCard.displayName = 'ModQueueCard';
const ModQueueFeedPost = memo(({ comment }: { comment: Comment }) => {
const ModQueueFeedPost = memo(({ comment, transferControls }: { comment: Comment; transferControls: ModQueueTransferControls }) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
const { handleTransfer, isTransferPublishing, isTransferSucceeded, transferModal } = useModQueueTransfer(displayComment, transferControls);
const displayStatus = isTransferSucceeded ? 'rejected' : status;
const displayIsPublishing = isPublishing || isTransferPublishing;
return (
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={status}
modQueueError={error || errorMessage}
isPublishing={isPublishing}
onApprove={handleApprove}
onReject={handleReject}
onRemoveFromModQueue={handleRemove}
/>
<>
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={displayStatus}
modQueueError={error || errorMessage}
isPublishing={displayIsPublishing}
onApprove={handleApprove}
onReject={handleReject}
onTransfer={handleTransfer}
onRemoveFromModQueue={displayStatus ? handleRemove : undefined}
/>
{transferModal}
</>
);
});
ModQueueFeedPost.displayName = 'ModQueueFeedPost';
@@ -748,8 +856,9 @@ interface ModQueueContentProps {
loadMore: () => void;
resolvedAddress: string | undefined;
selectedBoardFilter: string | null;
setSelectedBoardFilter: React.Dispatch<React.SetStateAction<string | null>>;
setSelectedBoardFilter: (boardAddress: string | null) => void;
showBoardColumn: boolean;
transferControls: ModQueueTransferControls;
viewMode: 'compact' | 'feed';
virtuosoFooterContext: ModQueueVirtuosoFooterContext | null;
}
@@ -774,6 +883,7 @@ const ModQueueContent = memo(
selectedBoardFilter,
setSelectedBoardFilter,
showBoardColumn,
transferControls,
viewMode,
virtuosoFooterContext,
}: ModQueueContentProps) => {
@@ -833,6 +943,7 @@ const ModQueueContent = memo(
comment={comment}
isOdd={index % 2 === 0}
showBoard={showBoardColumn}
transferControls={transferControls}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
@@ -875,6 +986,7 @@ const ModQueueContent = memo(
key={comment.cid}
comment={comment}
showBoard={showBoardColumn}
transferControls={transferControls}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
@@ -909,7 +1021,7 @@ const ModQueueContent = memo(
) : (
<>
{filteredFeed.map((comment) => (
<ModQueueFeedPost key={comment.cid} comment={comment} />
<ModQueueFeedPost key={comment.cid} comment={comment} transferControls={transferControls} />
))}
{communityError?.message && feedLength === 0 && (
<div className={styles.error}>
@@ -932,6 +1044,7 @@ ModQueueContent.displayName = 'ModQueueContent';
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
const params = useParams();
const [selectedBoardFilter, setSelectedBoardFilter] = useState<string | null>(null);
const [activeTransferCommentCid, setActiveTransferCommentCid] = useState<string | null>(null);
const viewMode = useModQueueStore((state) => state.viewMode);
const dismissedCommentCids = useModQueueStore((state) => state.dismissedCommentCids);
const queuedCommentHistory = useModQueueStore((state) => state.queuedCommentHistory);
@@ -1004,6 +1117,17 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
() => filterVisibleModQueueFeed(feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses),
[feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses],
);
const transferControls = useMemo(
() => ({
activeTransferCommentCid,
setActiveTransferCommentCid,
}),
[activeTransferCommentCid],
);
const handleSetSelectedBoardFilter = useCallback((boardFilter: string | null) => {
setActiveTransferCommentCid(null);
setSelectedBoardFilter(boardFilter);
}, []);
const hasVisibleComments = filteredFeed.length > 0;
const addressToPathMap = useMemo(() => {
@@ -1025,12 +1149,13 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
comment={comment}
isOdd={index % 2 === 0}
showBoard={showBoardColumn}
transferControls={transferControls}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
},
[addressToPathMap, showBoardColumn, directories],
[addressToPathMap, showBoardColumn, transferControls, directories],
);
const compactCardItemContent = useCallback(
(_index: number, comment: Comment) => {
@@ -1041,14 +1166,18 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
key={comment.cid}
comment={comment}
showBoard={showBoardColumn}
transferControls={transferControls}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
},
[addressToPathMap, showBoardColumn, directories],
[addressToPathMap, showBoardColumn, transferControls, directories],
);
const feedPostItemContent = useCallback(
(_index: number, comment: Comment) => <ModQueueFeedPost key={comment.cid} comment={comment} transferControls={transferControls} />,
[transferControls],
);
const feedPostItemContent = useCallback((_index: number, comment: Comment) => <ModQueueFeedPost key={comment.cid} comment={comment} />, []);
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
useEffect(() => {
@@ -1092,8 +1221,9 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
loadMore={visibleLoadMore}
resolvedAddress={resolvedAddress}
selectedBoardFilter={selectedBoardFilter}
setSelectedBoardFilter={setSelectedBoardFilter}
setSelectedBoardFilter={handleSetSelectedBoardFilter}
showBoardColumn={showBoardColumn}
transferControls={transferControls}
viewMode={viewMode}
virtuosoFooterContext={visibleVirtuosoFooterContext}
/>
+86
View File
@@ -21,6 +21,8 @@ type TestComment = {
error?: Error;
commentModeration?: {
archived?: boolean;
flairs?: Array<{ text?: string }>;
purged?: boolean;
};
locked?: boolean;
number?: number;
@@ -89,6 +91,10 @@ const enrichAccountCommentAuthor = (comment: TestComment | undefined): TestComme
};
};
function hasTransferredMarker(post?: TestComment): boolean {
return post?.commentModeration?.flairs?.some((flair) => flair.text === '5chan:transferred') ?? false;
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -194,11 +200,13 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
roles,
targetReplyCid,
replyPaginationOverride,
onTransfer,
}: {
post?: TestComment;
roles?: Record<string, unknown>;
targetReplyCid?: string;
replyPaginationOverride?: { replies?: TestComment[] };
onTransfer?: () => void;
}) =>
createElement(
'div',
@@ -210,6 +218,8 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
'data-transfer-enabled': String(typeof onTransfer === 'function'),
'data-transferred': String(hasTransferredMarker(post)),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -223,11 +233,13 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
roles,
targetReplyCid,
replyPaginationOverride,
onTransfer,
}: {
post?: TestComment;
roles?: Record<string, unknown>;
targetReplyCid?: string;
replyPaginationOverride?: { replies?: TestComment[] };
onTransfer?: () => void;
}) =>
createElement(
'div',
@@ -239,6 +251,8 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
'data-transfer-enabled': String(typeof onTransfer === 'function'),
'data-transferred': String(hasTransferredMarker(post)),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -505,6 +519,78 @@ describe('Post', () => {
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe('account-author');
});
it('rerenders posts when a transferred moderation marker appears', async () => {
await act(async () => {
root.render(
createElement(Post, {
post: {
cid: 'post-transferred',
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transferred')).toBe('false');
await act(async () => {
root.render(
createElement(Post, {
post: {
cid: 'post-transferred',
commentModeration: {
flairs: [{ text: '5chan:transferred' }],
},
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transferred')).toBe('true');
});
it('only forwards transfer handlers for top-level posts', async () => {
const handleTransfer = vi.fn();
await act(async () => {
root.render(
createElement(Post, {
onTransfer: handleTransfer,
post: {
cid: 'post-transfer-handler',
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transfer-enabled')).toBe('true');
await act(async () => {
root.render(
createElement(Post, {
onTransfer: handleTransfer,
post: {
cid: 'post-transfer-handler',
communityAddress: 'music-posting.eth',
content: 'body',
parentCid: 'thread-cid',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transfer-enabled')).toBe('false');
});
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
testState.commentsByCid = {
'cached-cid': {
+9
View File
@@ -27,6 +27,7 @@ import PostDesktop from '../../components/post-desktop/post-desktop';
import PostMobile from '../../components/post-mobile/post-mobile';
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils';
import { hasTransferredCommentMarker } from '../../lib/comment-transfer';
import { REPLIES_PER_PAGE } from '../../lib/constants';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
@@ -205,6 +206,7 @@ export interface PostProps {
isPublishing?: boolean;
onApprove?: () => void;
onReject?: () => void;
onTransfer?: () => void;
onRemoveFromModQueue?: () => void;
quotedByMap?: Map<string, Comment[]>;
}
@@ -221,6 +223,7 @@ export const Post = memo(
isPublishing,
onApprove,
onReject,
onTransfer,
onRemoveFromModQueue,
feedVirtualizationModeOverride,
replyPaginationOverride,
@@ -239,6 +242,7 @@ export const Post = memo(
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment });
comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined);
const transferHandler = comment?.parentCid ? undefined : onTransfer;
return (
<div className={styles.thread}>
@@ -259,6 +263,7 @@ export const Post = memo(
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
onTransfer={transferHandler}
onRemoveFromModQueue={onRemoveFromModQueue}
/>
) : (
@@ -277,6 +282,7 @@ export const Post = memo(
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
onTransfer={transferHandler}
onRemoveFromModQueue={onRemoveFromModQueue}
/>
)}
@@ -290,6 +296,7 @@ export const Post = memo(
return (
prev?.cid === next?.cid &&
prev?.number === next?.number &&
prev?.parentCid === next?.parentCid &&
prev?.postNumber === next?.postNumber &&
prev?.replyCount === next?.replyCount &&
prev?.updatedAt === next?.updatedAt &&
@@ -309,6 +316,7 @@ export const Post = memo(
prev?.deleted === next?.deleted &&
prev?.reason === next?.reason &&
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
hasTransferredCommentMarker(prev) === hasTransferredCommentMarker(next) &&
prevProps.showAllReplies === nextProps.showAllReplies &&
prevProps.showReplies === nextProps.showReplies &&
prevProps.targetReplyCid === nextProps.targetReplyCid &&
@@ -321,6 +329,7 @@ export const Post = memo(
prevProps.isPublishing === nextProps.isPublishing &&
prevProps.onApprove === nextProps.onApprove &&
prevProps.onReject === nextProps.onReject &&
prevProps.onTransfer === nextProps.onTransfer &&
prevProps.onRemoveFromModQueue === nextProps.onRemoveFromModQueue
);
},