From b4a6f0a65afcce0414704f891925dc6bf3ba70b0 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 2 Jul 2026 14:52:06 +0700 Subject: [PATCH] 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 --- public/translations/ar/default.json | 14 +- public/translations/bn/default.json | 14 +- public/translations/cs/default.json | 14 +- public/translations/da/default.json | 14 +- public/translations/de/default.json | 14 +- public/translations/el/default.json | 14 +- public/translations/en/default.json | 14 +- public/translations/es/default.json | 14 +- public/translations/fa/default.json | 14 +- public/translations/fi/default.json | 14 +- public/translations/fil/default.json | 14 +- public/translations/fr/default.json | 14 +- public/translations/he/default.json | 14 +- public/translations/hi/default.json | 14 +- public/translations/hu/default.json | 14 +- public/translations/id/default.json | 14 +- public/translations/it/default.json | 14 +- public/translations/ja/default.json | 14 +- public/translations/ko/default.json | 14 +- public/translations/mr/default.json | 14 +- public/translations/nl/default.json | 14 +- public/translations/no/default.json | 14 +- public/translations/pl/default.json | 14 +- public/translations/pt/default.json | 14 +- public/translations/ro/default.json | 14 +- public/translations/ru/default.json | 14 +- public/translations/sq/default.json | 14 +- public/translations/sv/default.json | 14 +- public/translations/te/default.json | 14 +- public/translations/th/default.json | 14 +- public/translations/tr/default.json | 14 +- public/translations/uk/default.json | 14 +- public/translations/ur/default.json | 14 +- public/translations/vi/default.json | 14 +- public/translations/zh/default.json | 14 +- .../__tests__/post-transferred-tag.test.tsx | 46 ++ .../__tests__/comment-content.test.tsx | 4 +- .../edit-menu/__tests__/edit-menu.test.tsx | 51 ++ src/components/edit-menu/edit-menu.module.css | 16 + src/components/edit-menu/edit-menu.tsx | 71 +- src/components/post-author-flags.tsx | 3 +- src/components/post-desktop/post-desktop.tsx | 14 + src/components/post-mobile/post-mobile.tsx | 8 + .../post-transfer-modal.module.css | 146 ++++ .../post-transfer-modal.tsx | 461 +++++++++++ .../post-transferred-tag.module.css | 3 + src/components/post-transferred-tag.tsx | 21 + src/lib/__tests__/comment-transfer.test.ts | 138 ++++ src/lib/comment-flags.ts | 6 + src/lib/comment-transfer.ts | 156 ++++ .../mod-queue/__tests__/mod-queue.test.tsx | 776 +++++++++++++++++- src/views/mod-queue/mod-queue.module.css | 5 +- src/views/mod-queue/mod-queue.tsx | 332 +++++--- src/views/post/__tests__/post.test.tsx | 86 ++ src/views/post/post.tsx | 9 + 55 files changed, 2688 insertions(+), 154 deletions(-) create mode 100644 src/components/__tests__/post-transferred-tag.test.tsx create mode 100644 src/components/post-transfer-modal/post-transfer-modal.module.css create mode 100644 src/components/post-transfer-modal/post-transfer-modal.tsx create mode 100644 src/components/post-transferred-tag.module.css create mode 100644 src/components/post-transferred-tag.tsx create mode 100644 src/lib/__tests__/comment-transfer.test.ts create mode 100644 src/lib/comment-transfer.ts diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index f473890d..ec2f10b0 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -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 حسابا مؤقتا جديدا لهذا المنشور المنسوخ فقط، ثم يحذفه بعد النشر." } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index ddb9577d..3a59e4f4 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -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 শুধু এই কপি করা পোস্টের জন্য একটি নতুন অস্থায়ী অ্যাকাউন্ট তৈরি করবে, তারপর প্রকাশের পর সেটি মুছে দেবে।" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 1ba3c22e..14142723 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -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." } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 1dca3eba..152db8cd 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -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." } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 8b2c8e96..a8d1c284 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -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." } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 861ee00a..b61ce096 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -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 και θα τον διαγράψει μετά τη δημοσίευση." } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index ea0ac4a1..8b8606aa 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -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." } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index 43b8d3e4..b8fb73bf 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -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." } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 9fdb82b5..ae42ed05 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -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 فقط برای این پست کپی‌شده یک حساب موقت جدید می‌سازد و پس از انتشار آن را حذف می‌کند." } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 0ee9b748..7ae253b1 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -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." } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index e8a9cd19..1c35a78b 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -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." } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 5d985c65..662b6cf3 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -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." } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 608cb5c8..58125093 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -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 ייצור חשבון זמני חדש רק עבור הפוסט המועתק הזה, ואז ימחק אותו לאחר הפרסום." } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 29439467..867b174e 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -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 सिर्फ इस कॉपी किए गए पोस्ट के लिए एक नया अस्थायी खाता बनाएगा, फिर प्रकाशित करने के बाद उसे हटा देगा।" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 02e76efa..00c59cea 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -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." } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 357197e3..3a5b5856 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -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." } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 09986ea8..ce4a6ff0 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -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." } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 93a3f3e4..b3ba9d22 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -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 はこのコピー投稿専用の一時アカウントを新しく作成し、公開後に削除します。" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 4ddbb106..87dbf737 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -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은 이 복사된 게시글만을 위한 새 임시 계정을 만든 뒤 게시 후 삭제합니다." } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index a6a0b107..5c50530c 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -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 या कॉपी केलेल्या पोस्टसाठीच एक नवीन तात्पुरते खाते तयार करेल आणि प्रकाशित झाल्यावर ते हटवेल." } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 8c632d44..558ce729 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -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." } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 4048d6e8..94efad63 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -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." } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index febba8d6..9f90eec8 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -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." } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 66e31302..87463478 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -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." } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index bdf22181..04ed6272 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -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." } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 12510029..6d111474 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -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 создаст новый временный аккаунт только для этой скопированной публикации, а затем удалит его после публикации." } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 531fc740..dd726531 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -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." } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 23556097..97a2c997 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -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." } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 03f78828..51f8de34 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -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 ఈ కాపీ చేసిన పోస్ట్ కోసమే కొత్త తాత్కాలిక ఖాతాను సృష్టించి, ప్రచురించిన తర్వాత దాన్ని తొలగిస్తుంది." } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index e62fa11f..c946f108 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -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 จะสร้างบัญชีชั่วคราวใหม่สำหรับโพสต์ที่คัดลอกนี้เท่านั้น แล้วลบบัญชีนั้นหลังเผยแพร่" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 3dcede1a..ac85524f 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -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." } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 48f5e741..4f418de0 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -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 створить новий тимчасовий акаунт лише для цього скопійованого допису, а після публікації видалить його." } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index af33246c..11c0e0f6 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -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 صرف اس کاپی شدہ پوسٹ کے لیے ایک نیا عارضی اکاؤنٹ بنائے گا، پھر شائع ہونے کے بعد اسے حذف کر دے گا۔" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 83d332fe..4082490b 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -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." } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index d8228bc6..deea9cb1 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -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 会专门为这个复制后的帖子创建一个新的临时账号,并在发布后删除它。" } diff --git a/src/components/__tests__/post-transferred-tag.test.tsx b/src/components/__tests__/post-transferred-tag.test.tsx new file mode 100644 index 00000000..5d21691a --- /dev/null +++ b/src/components/__tests__/post-transferred-tag.test.tsx @@ -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 | Promise }).act as (callback: () => void | Promise) => void | Promise; + +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(''); + }); +}); diff --git a/src/components/comment-content/__tests__/comment-content.test.tsx b/src/components/comment-content/__tests__/comment-content.test.tsx index 65507fca..79399de9 100644 --- a/src/components/comment-content/__tests__/comment-content.test.tsx +++ b/src/components/comment-content/__tests__/comment-content.test.tsx @@ -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', diff --git a/src/components/edit-menu/__tests__/edit-menu.test.tsx b/src/components/edit-menu/__tests__/edit-menu.test.tsx index 35d7b5db..9d936863 100644 --- a/src/components/edit-menu/__tests__/edit-menu.test.tsx +++ b/src/components/edit-menu/__tests__/edit-menu.test.tsx @@ -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; 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, diff --git a/src/components/edit-menu/edit-menu.module.css b/src/components/edit-menu/edit-menu.module.css index e63a687d..927b7a18 100644 --- a/src/components/edit-menu/edit-menu.module.css +++ b/src/components/edit-menu/edit-menu.module.css @@ -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; } diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index b3a750c2..c8ac2a8b 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -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) => { @@ -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 }) => { setIsContentEditorOpen((isOpen) => !isOpen)} + onChange={() => dispatchUiState({ type: 'toggle-content-editor' })} checked={isContentEditorOpen} /> {capitalize(t('edit'))}?] @@ -507,6 +548,15 @@ const EditMenu = ({ post }: { post: Comment }) => { /> + {canTransferPost && ( +
+ [ + + ?] +
+ )} )}
@@ -519,6 +569,7 @@ const EditMenu = ({ post }: { post: Comment }) => { )} + {canTransferPost && isTransferOpen && dispatchUiState({ type: 'close-transfer' })} />} ); }; diff --git a/src/components/post-author-flags.tsx b/src/components/post-author-flags.tsx index 620a5d11..105296ec 100644 --- a/src/components/post-author-flags.tsx +++ b/src/components/post-author-flags.tsx @@ -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; diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index a0368a24..dec9db15 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -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 = ({ )} + {isInModQueueView && isOverThreshold ? ( @@ -465,6 +468,15 @@ const PostInfo = ({ ] + {onTransfer && ( + + [ + + ] + + )} )} @@ -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} diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index be5d60cd..fa49e928 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -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 = ({ )} + {pinned && ( @@ -535,6 +537,7 @@ const PostMobile = ({ isPublishing, onApprove, onReject, + onTransfer, onRemoveFromModQueue, }: PostProps) => { const { t } = useTranslation(); @@ -857,6 +860,11 @@ const PostMobile = ({ + {onTransfer && ( + + )} )}
diff --git a/src/components/post-transfer-modal/post-transfer-modal.module.css b/src/components/post-transfer-modal/post-transfer-modal.module.css new file mode 100644 index 00000000..302e8ae7 --- /dev/null +++ b/src/components/post-transfer-modal/post-transfer-modal.module.css @@ -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; + } +} diff --git a/src/components/post-transfer-modal/post-transfer-modal.tsx b/src/components/post-transfer-modal/post-transfer-modal.tsx new file mode 100644 index 00000000..a062b3da --- /dev/null +++ b/src/components/post-transfer-modal/post-transfer-modal.tsx @@ -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; +type PublishCommentAction = (publishCommentOptions: Record, accountName?: string) => Promise<{ index?: number } | undefined>; +type DeleteCommentAction = (commentCidOrAccountCommentIndex: string | number, accountName?: string) => Promise; +type DeleteAccountAction = (accountName?: string) => Promise; +type PublishCommentModerationAction = (publishCommentModerationOptions: Record, accountName?: string) => Promise; +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(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) => { + 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( + +
+
+ {transferTitle} +
+ +
+
{t('modQueue.transferRecreateNotice')}
+
{t('modQueue.transferRepliesNotice')}
+
{t('modQueue.transferTemporaryAccountNotice')}
+
+ {t('modQueue.transferSource')} + {sourceBoardLabel} +
+ + +
+ {t('modQueue.transferFields')} + {availableFields.length > 0 ? ( + availableFields.map((field) => ( + + )) + ) : ( +
{t('no_content')}
+ )} +
+ + {availableFields.length > 0 && !hasSelectedTransferFields(selectedFields, availableFields) && ( +
{t('modQueue.transferNoFields')}
+ )} + {(transferState === 'failed' || transferState === 'finalizationFailed') && transferError !== undefined && ( +
+ +
+ )} + {transferState === 'succeeded' &&
{t('modQueue.transferSuccess')}
} +
+ +
+ + +
+
+
, + document.body, + ); +}; + +export default PostTransferModal; diff --git a/src/components/post-transferred-tag.module.css b/src/components/post-transferred-tag.module.css new file mode 100644 index 00000000..48c6a92a --- /dev/null +++ b/src/components/post-transferred-tag.module.css @@ -0,0 +1,3 @@ +.transferredTag { + font-weight: bold; +} diff --git a/src/components/post-transferred-tag.tsx b/src/components/post-transferred-tag.tsx new file mode 100644 index 00000000..5b405535 --- /dev/null +++ b/src/components/post-transferred-tag.tsx @@ -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 ( + + [{t('transferred')}]{' '} + + ); +}; + +export default PostTransferredTag; diff --git a/src/lib/__tests__/comment-transfer.test.ts b/src/lib/__tests__/comment-transfer.test.ts new file mode 100644 index 00000000..233cdb33 --- /dev/null +++ b/src/lib/__tests__/comment-transfer.test.ts @@ -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); + }); +}); diff --git a/src/lib/comment-flags.ts b/src/lib/comment-flags.ts index 60c871ca..788b19a1 100644 --- a/src/lib/comment-flags.ts +++ b/src/lib/comment-flags.ts @@ -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; diff --git a/src/lib/comment-transfer.ts b/src/lib/comment-transfer.ts new file mode 100644 index 00000000..035121b3 --- /dev/null +++ b/src/lib/comment-transfer.ts @@ -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; + +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 => 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 => { + const payload: Record = { + 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 => { + 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); +}; diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx index 49040d78..514989a4 100644 --- a/src/views/mod-queue/__tests__/mod-queue.test.tsx +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -10,30 +10,55 @@ const act = (React as { act?: (callback: () => void | Promise) => 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>; + 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) => (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; + accountsActions: { + createAccount: typeof testState.createAccountMock; + deleteAccount: typeof testState.deleteAccountMock; + deleteComment: typeof testState.deleteCommentMock; + publishComment: typeof testState.publishCommentMock; + publishCommentModeration: typeof testState.publishCommentModerationActionMock; + }; accountsEditsSummaries: Record>; 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('react'); + const normalizeStyle = (style: Record | 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('button')).find((button) => button.textContent === 'transfer'); + expect(transferButton).toBeTruthy(); + + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[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('select'); + expect(Array.from(targetSelect?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'tech-posting.eth']); + const submitButton = Array.from(dialog?.querySelectorAll('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('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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const targetSelect = dialog?.querySelector('select'); + await act(async () => { + if (targetSelect) { + targetSelect.value = 'tech-posting.eth'; + targetSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + const submitButton = Array.from(dialog?.querySelectorAll('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('button') ?? []).find((button) => button.textContent === 'close'); + await act(async () => { + closeButton?.click(); + }); + + expect(Array.from(container.querySelectorAll('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((resolve) => { + resolveDeleteAccount = resolve; + }), + ); + + await renderModQueue(); + + const transferButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const targetSelect = dialog?.querySelector('select'); + await act(async () => { + if (targetSelect) { + targetSelect.value = 'tech-posting.eth'; + targetSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + const submitButton = Array.from(dialog?.querySelectorAll('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 | 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('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('button')).filter((button) => button.textContent === 'transfer')).toHaveLength(0); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const closeButton = Array.from(dialog?.querySelectorAll('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('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('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('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const targetSelect = dialog?.querySelector('select'); + await act(async () => { + if (targetSelect) { + targetSelect.value = 'tech-posting.eth'; + targetSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + const submitButton = Array.from(dialog?.querySelectorAll('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('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('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('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('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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const targetSelect = dialog?.querySelector('select'); + await act(async () => { + if (targetSelect) { + targetSelect.value = 'tech-posting.eth'; + targetSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + const submitButton = Array.from(dialog?.querySelectorAll('button') ?? []).find((button) => button.type === 'submit'); + + await act(async () => { + submitButton?.click(); + await Promise.resolve(); + }); + expect(container.textContent).toContain('publishing'); + expect( + Array.from(container.querySelectorAll('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('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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + expect(dialog?.textContent).toContain('Transfer Post No.8'); + + const closeButton = Array.from(dialog?.querySelectorAll('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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const targetSelect = dialog?.querySelector('select'); + await act(async () => { + if (targetSelect) { + targetSelect.value = 'tech-posting.eth'; + targetSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + + const submitButton = Array.from(dialog?.querySelectorAll('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('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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[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('button')).find((button) => button.textContent === 'transfer'); + await act(async () => { + transferButton?.click(); + }); + + const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record, 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 = [ { diff --git a/src/views/mod-queue/mod-queue.module.css b/src/views/mod-queue/mod-queue.module.css index 7b2c9e85..5904dbd5 100644 --- a/src/views/mod-queue/mod-queue.module.css +++ b/src/views/mod-queue/mod-queue.module.css @@ -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 */ diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index bc162962..aa681773 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -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>; +} + interface ModQueueActionState { status: 'approved' | 'rejected' | 'failed' | null; error?: unknown; @@ -151,6 +159,7 @@ interface ModQueueActionState { isPublishing: boolean; handleApprove: () => Promise; handleReject: () => Promise; + handleTransfer?: () => void; handleRemove?: () => void; } @@ -161,6 +170,7 @@ interface ModQueueActionsProps { isPublishing: boolean; handleApprove: () => Promise; handleReject: () => Promise; + 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('idle'); + const transferStateRef = useRef('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 ? ( + + ) : 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 ] + {handleTransfer && ( + + [ + + ] + + )} ) : (
@@ -354,6 +435,11 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr + {handleTransfer && ( + + )}
); @@ -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 ( -
-
{number ?? 'N/A'}
- {showBoard && ( -
{modQueueUrl ? /{boardDisplayPath ?? '—'}/ : /{boardDisplayPath ?? '—'}/}
- )} -
- -
-
- {isMobile ? ( - // On mobile, show shorter time ago format without tooltip + <> +
+
{number ?? 'N/A'}
+ {showBoard && ( +
{modQueueUrl ? /{boardDisplayPath ?? '—'}/ : /{boardDisplayPath ?? '—'}/}
+ )} +
+ +
+
+ {isMobile ? ( + // On mobile, show shorter time ago format without tooltip + isAwaitingApproval && isOverThreshold ? ( + {getFormattedTimeAgo(timestamp)} + ) : ( + {getFormattedTimeAgo(timestamp)} + ) + ) : // On desktop, show full date with tooltip isAwaitingApproval && isOverThreshold ? ( - {getFormattedTimeAgo(timestamp)} + <> + + {getFormattedDate(timestamp)} + + + {' '} + ({getFormattedTimeAgo(timestamp)}) + + ) : ( - {getFormattedTimeAgo(timestamp)} - ) - ) : // On desktop, show full date with tooltip - isAwaitingApproval && isOverThreshold ? ( - <> {getFormattedDate(timestamp)} - - {' '} - ({getFormattedTimeAgo(timestamp)}) - - - ) : ( - - {getFormattedDate(timestamp)} - - )} + )} +
+
{isReply ? capitalize(t('reply')) : capitalize(t('post'))}
+
{hasThumbnail ? t('yes') : t('no')}
+
+ +
-
{isReply ? capitalize(t('reply')) : capitalize(t('post'))}
-
{hasThumbnail ? t('yes') : t('no')}
-
- -
-
+ {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 ( -
-
- - No. {number ?? 'N/A'} - {showBoard && boardPath && ( - <> - - - {modQueueUrl ? /{boardDisplayPath}/ : /{boardDisplayPath}/} - - )} - - - {isAwaitingApproval && isOverThreshold ? ( - <> - {getFormattedDate(timestamp)} ({getFormattedTimeAgo(timestamp)}) - - ) : ( - getFormattedDate(timestamp) - )} - + <> +
+
+ + No. {number ?? 'N/A'} + {showBoard && boardPath && ( + <> + - + {modQueueUrl ? /{boardDisplayPath}/ : /{boardDisplayPath}/} + + )} + + + {isAwaitingApproval && isOverThreshold ? ( + <> + {getFormattedDate(timestamp)} ({getFormattedTimeAgo(timestamp)}) + + ) : ( + getFormattedDate(timestamp) + )} + +
+
+ {t('excerpt')}: / {t('type')}:{' '} + {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))} +
+
-
- {t('excerpt')}: / {t('type')}:{' '} - {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))} -
- -
+ {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 ( - + <> + + {transferModal} + ); }); ModQueueFeedPost.displayName = 'ModQueueFeedPost'; @@ -748,8 +856,9 @@ interface ModQueueContentProps { loadMore: () => void; resolvedAddress: string | undefined; selectedBoardFilter: string | null; - setSelectedBoardFilter: React.Dispatch>; + 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) => ( - + ))} {communityError?.message && feedLength === 0 && (
@@ -932,6 +1044,7 @@ ModQueueContent.displayName = 'ModQueueContent'; const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => { const params = useParams(); const [selectedBoardFilter, setSelectedBoardFilter] = useState(null); + const [activeTransferCommentCid, setActiveTransferCommentCid] = useState(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) => , + [transferControls], ); - const feedPostItemContent = useCallback((_index: number, 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} /> diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index e9fad736..aedb671b 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -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; 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; 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': { diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index a198d9cf..882fad16 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -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; } @@ -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 (
@@ -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 ); },