mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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 حسابا مؤقتا جديدا لهذا المنشور المنسوخ فقط، ثم يحذفه بعد النشر."
|
||||
}
|
||||
|
||||
@@ -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 শুধু এই কপি করা পোস্টের জন্য একটি নতুন অস্থায়ী অ্যাকাউন্ট তৈরি করবে, তারপর প্রকাশের পর সেটি মুছে দেবে।"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 και θα τον διαγράψει μετά τη δημοσίευση."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 فقط برای این پست کپیشده یک حساب موقت جدید میسازد و پس از انتشار آن را حذف میکند."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 d’origine pointe maintenant vers le nouveau board.",
|
||||
"transferred": "Transféré",
|
||||
"modQueue.transferTitleWithNumber": "Transférer le message No.{{number}}",
|
||||
"modQueue.transferRecreateNotice": "Ce n’est pas un vrai transfert au niveau du protocole. 5chan recrée le message sur le board choisi et retire l’original.",
|
||||
"modQueue.transferRepliesNotice": "Les réponses ne sont pas transférées. Elles sont supprimées avec le message d’origine.",
|
||||
"modQueue.transferTemporaryAccountNotice": "5chan créera un nouveau compte temporaire uniquement pour ce message copié, puis le supprimera après publication."
|
||||
}
|
||||
|
||||
@@ -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 ייצור חשבון זמני חדש רק עבור הפוסט המועתק הזה, ואז ימחק אותו לאחר הפרסום."
|
||||
}
|
||||
|
||||
@@ -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 सिर्फ इस कॉपी किए गए पोस्ट के लिए एक नया अस्थायी खाता बनाएगा, फिर प्रकाशित करने के बाद उसे हटा देगा।"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 l’originale.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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 はこのコピー投稿専用の一時アカウントを新しく作成し、公開後に削除します。"
|
||||
}
|
||||
|
||||
@@ -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은 이 복사된 게시글만을 위한 새 임시 계정을 만든 뒤 게시 후 삭제합니다."
|
||||
}
|
||||
|
||||
@@ -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 या कॉपी केलेल्या पोस्टसाठीच एक नवीन तात्पुरते खाते तयार करेल आणि प्रकाशित झाल्यावर ते हटवेल."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 создаст новый временный аккаунт только для этой скопированной публикации, а затем удалит его после публикации."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 ఈ కాపీ చేసిన పోస్ట్ కోసమే కొత్త తాత్కాలిక ఖాతాను సృష్టించి, ప్రచురించిన తర్వాత దాన్ని తొలగిస్తుంది."
|
||||
}
|
||||
|
||||
@@ -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 จะสร้างบัญชีชั่วคราวใหม่สำหรับโพสต์ที่คัดลอกนี้เท่านั้น แล้วลบบัญชีนั้นหลังเผยแพร่"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 створить новий тимчасовий акаунт лише для цього скопійованого допису, а після публікації видалить його."
|
||||
}
|
||||
|
||||
@@ -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 صرف اس کاپی شدہ پوسٹ کے لیے ایک نیا عارضی اکاؤنٹ بنائے گا، پھر شائع ہونے کے بعد اسے حذف کر دے گا۔"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user