fix(post-form): auto-convert YouTube links to thumbnail URLs

Add a countdown notice and shared hook to replace YouTube watch links
with img.youtube.com thumbnails in post and reply forms, with i18n copy.
This commit is contained in:
Tommaso Casaburi
2026-06-02 14:59:14 +07:00
parent ea7883b107
commit 0a601c9392
42 changed files with 462 additions and 81 deletions
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "الطابع الزمني", "timestamp": "الطابع الزمني",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "فيديو YouTube" "youtube_video": "فيديو YouTube",
"youtube_thumbnail_link_conversion_notice": "تم اكتشاف رابط YouTube. جارٍ لصق الصورة المصغرة خلال {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "টাইমস্ট্যাম্প", "timestamp": "টাইমস্ট্যাম্প",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube ভিডিও" "youtube_video": "YouTube ভিডিও",
"youtube_thumbnail_link_conversion_notice": "YouTube লিংক শনাক্ত হয়েছে। {{count}}-এর মধ্যে থাম্বনেইল ছবি পেস্ট করা হচ্ছে..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "časové razítko", "timestamp": "časové razítko",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Byl zjištěn odkaz na YouTube. Vkládám náhledový obrázek za {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "tidsstempel", "timestamp": "tidsstempel",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video" "youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-link registreret. Indsætter miniaturebillede om {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "Zeitstempel", "timestamp": "Zeitstempel",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-Video" "youtube_video": "YouTube-Video",
"youtube_thumbnail_link_conversion_notice": "YouTube-Link erkannt. Vorschaubild wird in {{count}} eingefügt..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "χρονοσήμανση", "timestamp": "χρονοσήμανση",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "βίντεο YouTube" "youtube_video": "βίντεο YouTube",
"youtube_thumbnail_link_conversion_notice": "Εντοπίστηκε σύνδεσμος YouTube. Επικόλληση μικρογραφίας σε {{count}}..."
} }
+2 -1
View File
@@ -419,5 +419,6 @@
"timestamp": "timestamp", "timestamp": "timestamp",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube video" "youtube_video": "YouTube video",
"youtube_thumbnail_link_conversion_notice": "YouTube link detected. Pasting thumbnail image in {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "marca de tiempo", "timestamp": "marca de tiempo",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video de YouTube" "youtube_video": "video de YouTube",
"youtube_thumbnail_link_conversion_notice": "Se detectó un enlace de YouTube. Pegando imagen en miniatura en {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "مهر زمان", "timestamp": "مهر زمان",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "ویدیوی YouTube" "youtube_video": "ویدیوی YouTube",
"youtube_thumbnail_link_conversion_notice": "پیوند YouTube شناسایی شد. در حال چسباندن تصویر بندانگشتی در {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "aikaleimaustiedosto", "timestamp": "aikaleimaustiedosto",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video" "youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-linkki havaittu. Liitetään pikkukuva {{count}} kuluttua..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "timestamp", "timestamp": "timestamp",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video sa YouTube" "youtube_video": "video sa YouTube",
"youtube_thumbnail_link_conversion_notice": "May nakita na link ng YouTube. Ipi-paste ang thumbnail image sa loob ng {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "horodatage", "timestamp": "horodatage",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "vidéo YouTube" "youtube_video": "vidéo YouTube",
"youtube_thumbnail_link_conversion_notice": "Lien YouTube détecté. Collage de l'image miniature dans {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "חותמת זמן", "timestamp": "חותמת זמן",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "סרטון YouTube" "youtube_video": "סרטון YouTube",
"youtube_thumbnail_link_conversion_notice": "זוהה קישור YouTube. מדביק תמונת ממוזערת בעוד {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "टाइमस्टैंप", "timestamp": "टाइमस्टैंप",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube वीडियो" "youtube_video": "YouTube वीडियो",
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक मिला। {{count}} में थंबनेल इमेज पेस्ट की जा रही है..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "időbélyeg", "timestamp": "időbélyeg",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-videó" "youtube_video": "YouTube-videó",
"youtube_thumbnail_link_conversion_notice": "YouTube-link észlelve. Bélyegkép beillesztése {{count}} múlva..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "stempel waktu", "timestamp": "stempel waktu",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Tautan YouTube terdeteksi. Menempelkan gambar thumbnail dalam {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "marca temporale", "timestamp": "marca temporale",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Link YouTube rilevato. Incollando l'immagine di anteprima tra {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "タイムスタンプ", "timestamp": "タイムスタンプ",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube動画" "youtube_video": "YouTube動画",
"youtube_thumbnail_link_conversion_notice": "YouTubeリンクを検出しました。{{count}}後にサムネイル画像を貼り付けます..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "타임스탬프", "timestamp": "타임스탬프",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube 동영상" "youtube_video": "YouTube 동영상",
"youtube_thumbnail_link_conversion_notice": "YouTube 링크가 감지되었습니다. {{count}} 후에 썸네일 이미지를 붙여넣는 중..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "टाइमस्टॅम्प", "timestamp": "टाइमस्टॅम्प",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube व्हिडिओ" "youtube_video": "YouTube व्हिडिओ",
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक आढळली. {{count}} मध्ये थंबनेल प्रतिमा पेस्ट करत आहे..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "tijdstempel", "timestamp": "tijdstempel",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video" "youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-link gedetecteerd. Miniatuurafbeelding wordt over {{count}} geplakt..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "tidsstempel", "timestamp": "tidsstempel",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video" "youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-lenke oppdaget. Limer inn miniatyrbilde om {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "znacznik czasu", "timestamp": "znacznik czasu",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "wideo YouTube" "youtube_video": "wideo YouTube",
"youtube_thumbnail_link_conversion_notice": "Wykryto link YouTube. Wklejam miniaturę za {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "marca de tempo", "timestamp": "marca de tempo",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "vídeo do YouTube" "youtube_video": "vídeo do YouTube",
"youtube_thumbnail_link_conversion_notice": "Link do YouTube detectado. Colando imagem em miniatura em {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "marcă de timp", "timestamp": "marcă de timp",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Link YouTube detectat. Lipesc imaginea miniatură în {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "временная метка", "timestamp": "временная метка",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "видео YouTube" "youtube_video": "видео YouTube",
"youtube_thumbnail_link_conversion_notice": "Обнаружена ссылка YouTube. Вставка миниатюры через {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "vulë kohe", "timestamp": "vulë kohe",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "U zbulua lidhje YouTube. Po ngjis imazhin miniaturë pas {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "tidsstämpel", "timestamp": "tidsstämpel",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video" "youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-länk upptäckt. Klistrar in miniatyrbild om {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "టైమ్‌స్టాంప్", "timestamp": "టైమ్‌స్టాంప్",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube వీడియో" "youtube_video": "YouTube వీడియో",
"youtube_thumbnail_link_conversion_notice": "YouTube లింక్ గుర్తించబడింది. {{count}}లో థంబ్‌నెయిల్ చిత్రాన్ని పేస్ట్ చేస్తున్నాము..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "แสตมป์เวลา", "timestamp": "แสตมป์เวลา",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "วิดีโอ YouTube" "youtube_video": "วิดีโอ YouTube",
"youtube_thumbnail_link_conversion_notice": "ตรวจพบลิงก์ YouTube กำลังวางรูปภาพขนาดย่อในอีก {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "zaman damgası", "timestamp": "zaman damgası",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube videosu" "youtube_video": "YouTube videosu",
"youtube_thumbnail_link_conversion_notice": "YouTube bağlantısı algılandı. Küçük resim {{count}} içinde yapıştırılıyor..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "часова мітка", "timestamp": "часова мітка",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "відео YouTube" "youtube_video": "відео YouTube",
"youtube_thumbnail_link_conversion_notice": "Виявлено посилання YouTube. Вставляю мініатюру через {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "ٹائم سٹیمپ", "timestamp": "ٹائم سٹیمپ",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube ویڈیو" "youtube_video": "YouTube ویڈیو",
"youtube_thumbnail_link_conversion_notice": "YouTube لنک مل گیا۔ {{count}} میں تھمب نیل تصویر پیسٹ کی جا رہی ہے..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "dấu thời gian", "timestamp": "dấu thời gian",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube" "youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Đã phát hiện liên kết YouTube. Đang dán ảnh thu nhỏ sau {{count}}..."
} }
+2 -1
View File
@@ -400,5 +400,6 @@
"timestamp": "时间戳", "timestamp": "时间戳",
"tag": "tag", "tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.", "post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube 视频" "youtube_video": "YouTube 视频",
"youtube_thumbnail_link_conversion_notice": "检测到 YouTube 链接。将在 {{count}} 后粘贴缩略图..."
} }
@@ -88,6 +88,7 @@ vi.mock('react-i18next', async () => {
useTranslation: () => ({ useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => { t: (key: string, options?: Record<string, unknown>) => {
if (key === 'choose_one') return 'Choose one:'; if (key === 'choose_one') return 'Choose one:';
if (typeof options?.count !== 'undefined') return `${key}:${options.count}`;
return options?.domain ? `${key}:${options.domain}` : key; return options?.domain ? `${key}:${options.domain}` : key;
}, },
}), }),
@@ -299,6 +300,16 @@ vi.mock('../../../lib/utils/media-utils', () => ({
} }
return { type: 'link', url: link }; return { type: 'link', url: link };
}, },
getYouTubeThumbnailUrlFromLink: (link: string) => {
try {
const url = new URL(link);
if (!url.hostname.includes('youtube.com')) return undefined;
const videoId = url.searchParams.get('v');
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
} catch {
return undefined;
}
},
})); }));
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({ vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
@@ -599,6 +610,63 @@ describe('PostForm', () => {
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' }); expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
}); });
it('converts YouTube links to thumbnail file links on media-only post forms', async () => {
const youtubeLink = 'https://www.youtube.com/watch?v=abc123';
const thumbnailLink = 'https://img.youtube.com/vi/abc123/0.jpg';
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const select = table.querySelector('select') as HTMLSelectElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
const getConversionNotice = () =>
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
.reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
await dispatchChange(select, 'music-posting.eth');
vi.useFakeTimers();
try {
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice');
expect(container.textContent).toContain('3');
expect(table.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(getConversionNotice()?.className).toContain('formError');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('2');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('1');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(youtubeLink);
expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
await clickByText(table, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
});
it('shows Oekaki draw controls only on the /i/ board form', async () => { it('shows Oekaki draw controls only on the /i/ board form', async () => {
testState.directories.push({ testState.directories.push({
address: 'oekaki-posting.bso', address: 'oekaki-posting.bso',
+55 -33
View File
@@ -34,6 +34,7 @@ import useIsMobile from '../../hooks/use-is-mobile';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
import usePublishPost from '../../hooks/use-publish-post'; import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply'; import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload'; import { useFileUpload } from '../../hooks/use-file-upload';
@@ -144,10 +145,10 @@ interface PostFormFieldsProps {
lengthError: string | null; lengthError: string | null;
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleContentValueChange: (content: string, options?: string) => void; handleContentValueChange: (content: string, options?: string) => void;
handleLinkChange: (link: string) => void;
handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void; handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
setPublishPostOptions: (opts: Record<string, unknown>) => void; setPublishPostOptions: (opts: Record<string, unknown>) => void;
setPublishReplyOptions: (opts: Record<string, unknown>) => void; setPublishReplyOptions: (opts: Record<string, unknown>) => void;
setUrl: (url: string) => void;
isUploading: boolean; isUploading: boolean;
uploadedFileName: string | null | undefined; uploadedFileName: string | null | undefined;
showUploadControls: boolean; showUploadControls: boolean;
@@ -195,10 +196,10 @@ const PostFormFields = ({
lengthError, lengthError,
handleContentChange, handleContentChange,
handleContentValueChange, handleContentValueChange,
handleLinkChange,
handleOptionsChange, handleOptionsChange,
setPublishPostOptions, setPublishPostOptions,
setPublishReplyOptions, setPublishReplyOptions,
setUrl,
isUploading, isUploading,
uploadedFileName, uploadedFileName,
showUploadControls, showUploadControls,
@@ -356,12 +357,7 @@ const PostFormFields = ({
ref={urlRef} ref={urlRef}
disabled={isUploading} disabled={isUploading}
onChange={(e) => { onChange={(e) => {
setUrl(e.target.value); handleLinkChange(e.target.value);
if (isInPostView) {
setPublishReplyOptions({ link: e.target.value });
} else {
setPublishPostOptions({ link: e.target.value });
}
}} }}
/> />
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span> <span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
@@ -602,6 +598,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}; };
const onPublishPost = () => { const onPublishPost = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentTitle = subjectRef.current?.value.trim() || ''; const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || ''; const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || ''; const currentUrl = urlRef.current?.value.trim() || '';
@@ -653,7 +651,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}; };
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null; nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...publishOptions }); publishPost({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...publishOptions });
}; };
// redirect to pending page when pending comment is created // redirect to pending page when pending comment is created
@@ -725,6 +723,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}; };
const onPublishReply = () => { const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentUrl = urlRef.current?.value.trim() || ''; const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || ''; const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode); const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
@@ -764,7 +764,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value); const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null; nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent, ...flagPublishOptions }); publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
};
const setLinkValue = (nextUrl: string) => {
setUrl(nextUrl);
if (isInPostView) {
setPublishReplyOptions({ link: nextUrl });
} else {
setPublishPostOptions({ link: nextUrl });
}
};
const {
applyPendingConversion,
cancelPendingConversion,
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
onContentChange: handleContentValueChange,
onLinkChange: setLinkValue,
textRef,
urlRef,
});
const handleLinkChange = (nextUrl: string) => {
setLinkValue(nextUrl);
if (queueLinkConversion(nextUrl)) {
setFormError(null);
}
}; };
useEffect(() => { useEffect(() => {
@@ -782,33 +811,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({ const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => { onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) { if (uploadedUrl) {
setUrl(uploadedUrl); cancelPendingConversion();
setLinkValue(uploadedUrl);
if (urlRef.current) { if (urlRef.current) {
urlRef.current.value = uploadedUrl; urlRef.current.value = uploadedUrl;
} }
if (isInPostView) {
setPublishReplyOptions({ link: uploadedUrl });
} else {
setPublishPostOptions({ link: uploadedUrl });
}
} }
}, },
}); });
const handleOekakiClearUploadedUrl = useCallback( const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
(uploadedUrl: string) => { if ((urlRef.current?.value || url) !== uploadedUrl) return;
if ((urlRef.current?.value || url) !== uploadedUrl) return; cancelPendingConversion();
setUrl(''); setLinkValue('');
if (urlRef.current) { if (urlRef.current) {
urlRef.current.value = ''; urlRef.current.value = '';
} }
if (isInPostView) { };
setPublishReplyOptions({ link: '' });
} else {
setPublishPostOptions({ link: '' });
}
},
[isInPostView, setPublishPostOptions, setPublishReplyOptions, url],
);
const uploadMode = useMediaHostingStore((state) => state.uploadMode); const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
@@ -846,10 +864,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
lengthError={lengthError} lengthError={lengthError}
handleContentChange={handleContentChange} handleContentChange={handleContentChange}
handleContentValueChange={handleContentValueChange} handleContentValueChange={handleContentValueChange}
handleLinkChange={handleLinkChange}
handleOptionsChange={handleOptionsChange} handleOptionsChange={handleOptionsChange}
setPublishPostOptions={setPublishPostOptions} setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions} setPublishReplyOptions={setPublishReplyOptions}
setUrl={setUrl}
isUploading={isUploading} isUploading={isUploading}
uploadedFileName={uploadedFileName} uploadedFileName={uploadedFileName}
showUploadControls={showUploadControls} showUploadControls={showUploadControls}
@@ -881,7 +899,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
</tbody> </tbody>
</table> </table>
{moderationPostingWarning ? <div className={`${styles.error} ${styles.formError}`}>{moderationPostingWarning}</div> : null} {moderationPostingWarning ? <div className={`${styles.error} ${styles.formError}`}>{moderationPostingWarning}</div> : null}
{formError ? ( {youtubeThumbnailConversionCountdown !== null ? (
<div className={`${styles.error} ${styles.formError}`} aria-live='polite'>
{t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })}
</div>
) : formError ? (
<div className={`${styles.error} ${styles.formError}`}> <div className={`${styles.error} ${styles.formError}`}>
{isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError} {isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError}
</div> </div>
@@ -621,6 +621,44 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1); expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
}); });
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
const youtubeLink = 'https://youtu.be/reply123';
const thumbnailLink = 'https://img.youtube.com/vi/reply123/0.jpg';
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const linkContainer = linkInput.parentElement as HTMLElement;
const getConversionNotice = () =>
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
.reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":3}');
expect(linkContainer.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(getConversionNotice()?.className).toContain('error');
await clickButtonByText('post');
expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(`${youtubeLink}\nreply body`);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`,
link: thumbnailLink,
});
});
it('validates unsupported options and stores fortune output in reply content', async () => { it('validates unsupported options and stores fortune output in reply content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25); const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true; testState.openEmpty = true;
+49 -8
View File
@@ -28,6 +28,7 @@ import { findDirectoryByAddress, useDirectories } from '../../hooks/use-director
import usePublishReply from '../../hooks/use-publish-reply'; import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload'; import { useFileUpload } from '../../hooks/use-file-upload';
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
import { useCommunityField } from '../../hooks/use-stable-community'; import { useCommunityField } from '../../hooks/use-stable-community';
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy'; import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
@@ -140,6 +141,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
); );
const onPublishReply = () => { const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentContent = textRef.current?.value || ''; const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || ''; const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || ''; const currentOptions = optionsRef.current?.value || '';
@@ -180,7 +183,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setError(null); setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null; nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent, ...flagPublishOptions }); publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
}; };
useEffect(() => { useEffect(() => {
@@ -391,6 +394,39 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setIsBbcodePreviewing(true); setIsBbcodePreviewing(true);
}; };
const setLinkValue = (nextUrl: string) => {
setUrl(nextUrl);
setPublishReplyOptions({ link: nextUrl });
};
const handleConvertedContentChange = (content: string) => {
const nextSelection = content.length;
if (textRef.current) {
textRef.current.setSelectionRange(nextSelection, nextSelection);
}
handleContentValueChange(content, nextSelection, nextSelection);
};
const {
applyPendingConversion,
cancelPendingConversion,
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
onContentChange: handleConvertedContentChange,
onLinkChange: setLinkValue,
textRef,
urlRef,
});
const handleLinkChange = (nextUrl: string) => {
setLinkValue(nextUrl);
if (queueLinkConversion(nextUrl)) {
setError(null);
}
};
useEffect(() => { useEffect(() => {
const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current; const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current;
@@ -438,25 +474,27 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({ const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => { onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) { if (uploadedUrl) {
setUrl(uploadedUrl); cancelPendingConversion();
setLinkValue(uploadedUrl);
if (urlRef.current) { if (urlRef.current) {
urlRef.current.value = uploadedUrl; urlRef.current.value = uploadedUrl;
} }
setPublishReplyOptions({ link: uploadedUrl });
} }
}, },
}); });
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => { const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return; if ((urlRef.current?.value || url) !== uploadedUrl) return;
setUrl(''); cancelPendingConversion();
setLinkValue('');
if (urlRef.current) { if (urlRef.current) {
urlRef.current.value = ''; urlRef.current.value = '';
} }
setPublishReplyOptions({ link: '' });
}; };
const uploadMode = useMediaHostingStore((state) => state.uploadMode); const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishURLFilename(url) || uploadedFileName; const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
const youtubeThumbnailConversionNotice =
youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null;
const hasInitializedDisplayName = useRef(false); const hasInitializedDisplayName = useRef(false);
useEffect(() => { useEffect(() => {
@@ -555,8 +593,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))} placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading} disabled={isUploading}
onChange={(e) => { onChange={(e) => {
setUrl(e.target.value); handleLinkChange(e.target.value);
setPublishReplyOptions({ link: e.target.value });
}} }}
/> />
</div> </div>
@@ -613,7 +650,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</button> </button>
</div> </div>
{moderationPostingWarning ? <div className={styles.error}>{moderationPostingWarning}</div> : null} {moderationPostingWarning ? <div className={styles.error}>{moderationPostingWarning}</div> : null}
{lengthError ? ( {youtubeThumbnailConversionNotice ? (
<div className={styles.error} aria-live='polite'>
{youtubeThumbnailConversionNotice}
</div>
) : lengthError ? (
<div className={styles.error}>{lengthError}</div> <div className={styles.error}>{lengthError}</div>
) : error ? ( ) : error ? (
<div className={styles.error}>{isPostOptionsValidationError(error) ? <PostOptionsErrorMessage error={error} directories={directories} /> : error}</div> <div className={styles.error}>{isPostOptionsValidationError(error) ? <PostOptionsErrorMessage error={error} directories={directories} /> : error}</div>
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getYouTubeThumbnailUrlFromLink } from '../lib/utils/media-utils';
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_DELAY_SECONDS = 3;
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS = 1000;
type ElementRef<T extends HTMLElement> = {
readonly current: T | null;
};
interface PendingYouTubeThumbnailLinkConversion {
thumbnailLink: string;
youtubeLink: string;
}
interface UseYouTubeThumbnailLinkConversionOptions {
enabled: boolean;
onContentChange: (content: string) => void;
onLinkChange: (link: string) => void;
textRef: ElementRef<HTMLTextAreaElement>;
urlRef: ElementRef<HTMLInputElement>;
}
const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): string => {
if (!content) return youtubeLink;
if (content === youtubeLink || content.startsWith(`${youtubeLink}\n`) || content.startsWith(`${youtubeLink}\r\n`)) return content;
return `${youtubeLink}\n${content}`;
};
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
const youtubeLink = link.trim();
const thumbnailLink = getYouTubeThumbnailUrlFromLink(youtubeLink);
if (!youtubeLink || !thumbnailLink || youtubeLink === thumbnailLink) {
return null;
}
return { thumbnailLink, youtubeLink };
};
export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, onLinkChange, textRef, urlRef }: UseYouTubeThumbnailLinkConversionOptions) => {
const [noticeCountdown, setNoticeCountdown] = useState<number | null>(null);
const pendingConversionRef = useRef<PendingYouTubeThumbnailLinkConversion | null>(null);
const timeoutRef = useRef<number | null>(null);
const clearCountdownTimer = useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const cancelPendingConversion = useCallback(() => {
clearCountdownTimer();
pendingConversionRef.current = null;
setNoticeCountdown(null);
}, [clearCountdownTimer]);
const applyPendingConversion = useCallback(() => {
if (!enabled) {
cancelPendingConversion();
return false;
}
const currentLink = urlRef.current?.value.trim() || '';
const pendingConversion = pendingConversionRef.current ?? getPendingConversion(currentLink);
if (!pendingConversion || currentLink !== pendingConversion.youtubeLink) {
cancelPendingConversion();
return false;
}
clearCountdownTimer();
pendingConversionRef.current = null;
setNoticeCountdown(null);
const currentContent = textRef.current?.value || '';
const nextContent = getContentWithYouTubeLinkAtTop(currentContent, pendingConversion.youtubeLink);
if (textRef.current) {
textRef.current.value = nextContent;
}
if (urlRef.current) {
urlRef.current.value = pendingConversion.thumbnailLink;
}
onContentChange(nextContent);
onLinkChange(pendingConversion.thumbnailLink);
return true;
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);
const scheduleConversionCountdown = useCallback(
(remainingSeconds: number) => {
clearCountdownTimer();
const tick = (seconds: number) => {
if (!pendingConversionRef.current) {
setNoticeCountdown(null);
return;
}
if (seconds <= 0) {
applyPendingConversion();
return;
}
setNoticeCountdown(seconds);
timeoutRef.current = window.setTimeout(() => tick(seconds - 1), YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS);
};
tick(remainingSeconds);
},
[applyPendingConversion, clearCountdownTimer],
);
const queueLinkConversion = useCallback(
(link: string) => {
if (!enabled) {
cancelPendingConversion();
return false;
}
const pendingConversion = getPendingConversion(link);
if (!pendingConversion) {
cancelPendingConversion();
return false;
}
if (pendingConversionRef.current?.youtubeLink === pendingConversion.youtubeLink) {
return true;
}
pendingConversionRef.current = pendingConversion;
scheduleConversionCountdown(YOUTUBE_THUMBNAIL_LINK_CONVERSION_DELAY_SECONDS);
return true;
},
[cancelPendingConversion, enabled, scheduleConversionCountdown],
);
useEffect(() => {
if (!enabled) {
cancelPendingConversion();
}
}, [cancelPendingConversion, enabled]);
useEffect(
() => () => {
clearCountdownTimer();
pendingConversionRef.current = null;
},
[clearCountdownTimer],
);
return {
applyPendingConversion,
cancelPendingConversion,
noticeCountdown,
queueLinkConversion,
};
};
@@ -46,6 +46,7 @@ import {
getMediaDimensions, getMediaDimensions,
getPostMediaTypeLabel, getPostMediaTypeLabel,
getYouTubeEmbedPostMediaFileLink, getYouTubeEmbedPostMediaFileLink,
getYouTubeThumbnailUrlFromLink,
} from '../media-utils'; } from '../media-utils';
const clearMemoizedCache = (fn: unknown) => { const clearMemoizedCache = (fn: unknown) => {
@@ -125,6 +126,8 @@ describe('media-utils', () => {
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg'); expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg');
expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video'); expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video');
expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined(); expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined();
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/0.jpg');
expect(getYouTubeThumbnailUrlFromLink('https://example.com/watch?v=not-youtube')).toBeUndefined();
}); });
it('recognizes which media types expose thumbnails', () => { it('recognizes which media types expose thumbnails', () => {
+20 -5
View File
@@ -1,5 +1,5 @@
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js'; import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { canEmbed, getYouTubeVideoId } from '../../components/embed/embed-utils'; import { canEmbed, getYouTubeVideoId, youtubeHosts } from '../../components/embed/embed-utils';
import memoize from 'memoizee'; import memoize from 'memoizee';
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils'; import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
import { Capacitor, CapacitorHttp } from '@capacitor/core'; import { Capacitor, CapacitorHttp } from '@capacitor/core';
@@ -65,6 +65,22 @@ export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undef
return getDisplayMediaInfoType(resolvedType, t); return getDisplayMediaInfoType(resolvedType, t);
}; };
const isYouTubeLikeUrl = (url: URL): boolean => youtubeHosts.has(url.host) || url.host.startsWith('yt.');
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
if (!isYouTubeLikeUrl(url)) {
return undefined;
}
const videoId = getYouTubeVideoId(url);
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
};
export const getYouTubeThumbnailUrlFromLink = (link: string): string | undefined => {
const parsedUrl = parseHttpUrl(link.trim());
return parsedUrl ? getYouTubeThumbnailUrl(parsedUrl) : undefined;
};
export const getHasThumbnail = memoize( export const getHasThumbnail = memoize(
(commentMediaInfo: CommentMediaInfo | undefined, link: string | undefined): boolean => { (commentMediaInfo: CommentMediaInfo | undefined, link: string | undefined): boolean => {
if (!link || !commentMediaInfo) return false; if (!link || !commentMediaInfo) return false;
@@ -81,10 +97,9 @@ export const getHasThumbnail = memoize(
); );
const getPatternThumbnailUrl = (url: URL): string | undefined => { const getPatternThumbnailUrl = (url: URL): string | undefined => {
const videoId = getYouTubeVideoId(url); const youtubeThumbnailUrl = getYouTubeThumbnailUrl(url);
if (videoId) { if (youtubeThumbnailUrl) return youtubeThumbnailUrl;
return `https://img.youtube.com/vi/${videoId}/0.jpg`;
}
if (url.host.includes('streamable.com')) { if (url.host.includes('streamable.com')) {
const videoId = url.pathname.split('/')[1]; const videoId = url.pathname.split('/')[1];
return `https://cdn-cf-east.streamable.com/image/${videoId}.jpg`; return `https://cdn-cf-east.streamable.com/image/${videoId}.jpg`;