From a7cd3ac87f5cb4499f849eabf9db2338858bc989 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 2 Jun 2026 14:58:32 +0700 Subject: [PATCH 1/2] fix(post-form): restore native board selector appearance Exclude the board dropdown from post-form field chrome so it keeps the browser default select styling, matching flag selector behavior. --- src/components/post-form/__tests__/post-form.test.tsx | 1 + src/components/post-form/post-form.module.css | 8 ++++++-- src/components/post-form/post-form.tsx | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index c9d30055..305e5be1 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -565,6 +565,7 @@ describe('PostForm', () => { expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg'); expect(textarea).toBeTruthy(); expect(select).toBeTruthy(); + expect(select?.className).toContain('boardSelector'); await dispatchInput(linkInput as HTMLInputElement, 'not-a-url'); await clickByText(table as HTMLTableElement, 'post'); diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index f2676845..5581372e 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -69,7 +69,7 @@ position: relative; } -.postFormTable input[type="text"], .postFormTable textarea, .postFormTable select:not(.flagSelector) { +.postFormTable input[type="text"], .postFormTable textarea, .postFormTable select:not(.flagSelector):not(.boardSelector) { border: var(--post-form-field-input-border, revert); font-family: var(--post-form-field-font-family, revert); appearance: var(--post-form-field-input-appearance, revert); @@ -80,9 +80,13 @@ font-size: 10pt; } -.postFormTable input[type="text"]:focus, .postFormTable textarea:focus, .postFormTable select:not(.flagSelector):focus { +.postFormTable input[type="text"]:focus, .postFormTable textarea:focus, .postFormTable select:not(.flagSelector):not(.boardSelector):focus { border: var(--post-form-field-input-focus-border, revert); } + +.boardSelector { + appearance: auto; +} .postFormTable td > input[type="text"] { width: 244px; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 770d5338..c7f16525 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -432,7 +432,12 @@ const PostFormFields = ({ {t('board')} - setPublishPostOptions({ communityAddress: e.target.value })} + value={communityAddress} + > {isInAllView && directories.map((community) => From 0a601c9392572430e3206b5dfa3de72e17fb1db9 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 2 Jun 2026 14:59:14 +0700 Subject: [PATCH 2/2] 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. --- public/translations/ar/default.json | 3 +- public/translations/bn/default.json | 3 +- public/translations/cs/default.json | 3 +- public/translations/da/default.json | 3 +- public/translations/de/default.json | 3 +- public/translations/el/default.json | 3 +- public/translations/en/default.json | 3 +- public/translations/es/default.json | 3 +- public/translations/fa/default.json | 3 +- public/translations/fi/default.json | 3 +- public/translations/fil/default.json | 3 +- public/translations/fr/default.json | 3 +- public/translations/he/default.json | 3 +- public/translations/hi/default.json | 3 +- public/translations/hu/default.json | 3 +- public/translations/id/default.json | 3 +- public/translations/it/default.json | 3 +- public/translations/ja/default.json | 3 +- public/translations/ko/default.json | 3 +- public/translations/mr/default.json | 3 +- public/translations/nl/default.json | 3 +- public/translations/no/default.json | 3 +- public/translations/pl/default.json | 3 +- public/translations/pt/default.json | 3 +- public/translations/ro/default.json | 3 +- public/translations/ru/default.json | 3 +- public/translations/sq/default.json | 3 +- public/translations/sv/default.json | 3 +- public/translations/te/default.json | 3 +- public/translations/th/default.json | 3 +- public/translations/tr/default.json | 3 +- public/translations/uk/default.json | 3 +- public/translations/ur/default.json | 3 +- public/translations/vi/default.json | 3 +- public/translations/zh/default.json | 3 +- .../post-form/__tests__/post-form.test.tsx | 68 ++++++++ src/components/post-form/post-form.tsx | 88 ++++++---- .../__tests__/reply-modal.test.tsx | 38 +++++ src/components/reply-modal/reply-modal.tsx | 57 ++++++- .../use-youtube-thumbnail-link-conversion.ts | 159 ++++++++++++++++++ src/lib/utils/__tests__/media-utils.test.ts | 3 + src/lib/utils/media-utils.ts | 25 ++- 42 files changed, 462 insertions(+), 81 deletions(-) create mode 100644 src/hooks/use-youtube-thumbnail-link-conversion.ts diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index b0d22087..399db325 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -400,5 +400,6 @@ "timestamp": "الطابع الزمني", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index 2085b7c4..928c4cf6 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -400,5 +400,6 @@ "timestamp": "টাইমস্ট্যাম্প", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}-এর মধ্যে থাম্বনেইল ছবি পেস্ট করা হচ্ছে..." } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 750ba134..a4b26708 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -400,5 +400,6 @@ "timestamp": "časové razítko", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 27942ea8..bdd9eb17 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -400,5 +400,6 @@ "timestamp": "tidsstempel", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index f4f36056..90f580e5 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -400,5 +400,6 @@ "timestamp": "Zeitstempel", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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..." } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 3c54ca98..eda6e509 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -400,5 +400,6 @@ "timestamp": "χρονοσήμανση", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 4b8d99f3..f2a96db2 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -419,5 +419,6 @@ "timestamp": "timestamp", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index f5e43b78..2be29ad9 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -400,5 +400,6 @@ "timestamp": "marca de tiempo", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 075e2049..a0d9ec18 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -400,5 +400,6 @@ "timestamp": "مهر زمان", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 419488ed..863f6f81 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -400,5 +400,6 @@ "timestamp": "aikaleimaustiedosto", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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..." } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 66e459df..5bc99431 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -400,5 +400,6 @@ "timestamp": "timestamp", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 39364684..2409955e 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -400,5 +400,6 @@ "timestamp": "horodatage", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index d823e75d..4d696903 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -400,5 +400,6 @@ "timestamp": "חותמת זמן", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index e0546c6b..a4a2bcd9 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -400,5 +400,6 @@ "timestamp": "टाइमस्टैंप", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}} में थंबनेल इमेज पेस्ट की जा रही है..." } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 30bf5d2c..699cabc1 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -400,5 +400,6 @@ "timestamp": "időbélyeg", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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..." } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index dedc1dbb..a007633a 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -400,5 +400,6 @@ "timestamp": "stempel waktu", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 2b9e9913..d24494ff 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -400,5 +400,6 @@ "timestamp": "marca temporale", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index c9108e4b..57b06b4c 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -400,5 +400,6 @@ "timestamp": "タイムスタンプ", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}後にサムネイル画像を貼り付けます..." } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index ba6556c5..72dae021 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -400,5 +400,6 @@ "timestamp": "타임스탬프", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}} 후에 썸네일 이미지를 붙여넣는 중..." } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index b5cd4a15..bb01a9b6 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -400,5 +400,6 @@ "timestamp": "टाइमस्टॅम्प", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}} मध्ये थंबनेल प्रतिमा पेस्ट करत आहे..." } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 1177756e..4791f927 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -400,5 +400,6 @@ "timestamp": "tijdstempel", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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..." } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index e5f06010..21310230 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -400,5 +400,6 @@ "timestamp": "tidsstempel", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 09686d6a..d81aec32 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -400,5 +400,6 @@ "timestamp": "znacznik czasu", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 495362dd..660fda77 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -400,5 +400,6 @@ "timestamp": "marca de tempo", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 20a6c8b8..555d2a50 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -400,5 +400,6 @@ "timestamp": "marcă de timp", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index ee59405e..8f6ec977 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -400,5 +400,6 @@ "timestamp": "временная метка", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index d957627a..64e66eb5 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -400,5 +400,6 @@ "timestamp": "vulë kohe", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 16461023..9d7c4819 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -400,5 +400,6 @@ "timestamp": "tidsstämpel", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 85819308..2d63affa 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -400,5 +400,6 @@ "timestamp": "టైమ్‌స్టాంప్", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}లో థంబ్‌నెయిల్ చిత్రాన్ని పేస్ట్ చేస్తున్నాము..." } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 40d6aec4..4e9fd5f1 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -400,5 +400,6 @@ "timestamp": "แสตมป์เวลา", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 40ec38cb..9dfb080c 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -400,5 +400,6 @@ "timestamp": "zaman damgası", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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..." } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index bfc8b604..2e2080b0 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -400,5 +400,6 @@ "timestamp": "часова мітка", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 0effd6bc..12762325 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -400,5 +400,6 @@ "timestamp": "ٹائم سٹیمپ", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}} میں تھمب نیل تصویر پیسٹ کی جا رہی ہے..." } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 3a8dc69d..02b39b89 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -400,5 +400,6 @@ "timestamp": "dấu thời gian", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}}..." } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index a76b591e..6e332675 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -400,5 +400,6 @@ "timestamp": "时间戳", "tag": "tag", "post_form_flash_upload_prompt": "Recommended SWF host: 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}} 后粘贴缩略图..." } diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index c9d30055..e136442a 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -88,6 +88,7 @@ vi.mock('react-i18next', async () => { useTranslation: () => ({ t: (key: string, options?: Record) => { if (key === 'choose_one') return 'Choose one:'; + if (typeof options?.count !== 'undefined') return `${key}:${options.count}`; return options?.domain ? `${key}:${options.domain}` : key; }, }), @@ -299,6 +300,16 @@ vi.mock('../../../lib/utils/media-utils', () => ({ } 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', () => ({ @@ -599,6 +610,63 @@ describe('PostForm', () => { 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('input[type="text"]')[3]; + const getConversionNotice = () => + Array.from(container.querySelectorAll('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 () => { testState.directories.push({ address: 'oekaki-posting.bso', diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 770d5338..95325b39 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -34,6 +34,7 @@ import useIsMobile from '../../hooks/use-is-mobile'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; import useSafeAccountComment from '../../hooks/use-safe-account-comment'; 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 usePublishReply from '../../hooks/use-publish-reply'; import { useFileUpload } from '../../hooks/use-file-upload'; @@ -144,10 +145,10 @@ interface PostFormFieldsProps { lengthError: string | null; handleContentChange: (e: React.ChangeEvent) => void; handleContentValueChange: (content: string, options?: string) => void; + handleLinkChange: (link: string) => void; handleOptionsChange: (e: React.ChangeEvent) => void; setPublishPostOptions: (opts: Record) => void; setPublishReplyOptions: (opts: Record) => void; - setUrl: (url: string) => void; isUploading: boolean; uploadedFileName: string | null | undefined; showUploadControls: boolean; @@ -195,10 +196,10 @@ const PostFormFields = ({ lengthError, handleContentChange, handleContentValueChange, + handleLinkChange, handleOptionsChange, setPublishPostOptions, setPublishReplyOptions, - setUrl, isUploading, uploadedFileName, showUploadControls, @@ -356,12 +357,7 @@ const PostFormFields = ({ ref={urlRef} disabled={isUploading} onChange={(e) => { - setUrl(e.target.value); - if (isInPostView) { - setPublishReplyOptions({ link: e.target.value }); - } else { - setPublishPostOptions({ link: e.target.value }); - } + handleLinkChange(e.target.value); }} /> {url && } @@ -602,6 +598,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }; const onPublishPost = () => { + const appliedYouTubeConversion = applyPendingConversion(); + const currentTitle = subjectRef.current?.value.trim() || ''; const currentContent = textRef.current?.value || ''; const currentUrl = urlRef.current?.value.trim() || ''; @@ -653,7 +651,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }; 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 @@ -725,6 +723,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }; const onPublishReply = () => { + const appliedYouTubeConversion = applyPendingConversion(); + const currentUrl = urlRef.current?.value.trim() || ''; const currentOptions = optionsRef.current?.value || ''; const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode); @@ -764,7 +764,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value); 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(() => { @@ -782,33 +811,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({ onUploadComplete: (uploadedUrl: string) => { if (uploadedUrl) { - setUrl(uploadedUrl); + cancelPendingConversion(); + setLinkValue(uploadedUrl); if (urlRef.current) { urlRef.current.value = uploadedUrl; } - if (isInPostView) { - setPublishReplyOptions({ link: uploadedUrl }); - } else { - setPublishPostOptions({ link: uploadedUrl }); - } } }, }); - const handleOekakiClearUploadedUrl = useCallback( - (uploadedUrl: string) => { - if ((urlRef.current?.value || url) !== uploadedUrl) return; - setUrl(''); - if (urlRef.current) { - urlRef.current.value = ''; - } - if (isInPostView) { - setPublishReplyOptions({ link: '' }); - } else { - setPublishPostOptions({ link: '' }); - } - }, - [isInPostView, setPublishPostOptions, setPublishReplyOptions, url], - ); + const handleOekakiClearUploadedUrl = (uploadedUrl: string) => { + if ((urlRef.current?.value || url) !== uploadedUrl) return; + cancelPendingConversion(); + setLinkValue(''); + if (urlRef.current) { + urlRef.current.value = ''; + } + }; const uploadMode = useMediaHostingStore((state) => state.uploadMode); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); @@ -846,10 +864,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: lengthError={lengthError} handleContentChange={handleContentChange} handleContentValueChange={handleContentValueChange} + handleLinkChange={handleLinkChange} handleOptionsChange={handleOptionsChange} setPublishPostOptions={setPublishPostOptions} setPublishReplyOptions={setPublishReplyOptions} - setUrl={setUrl} isUploading={isUploading} uploadedFileName={uploadedFileName} showUploadControls={showUploadControls} @@ -881,7 +899,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: {moderationPostingWarning ?
{moderationPostingWarning}
: null} - {formError ? ( + {youtubeThumbnailConversionCountdown !== null ? ( +
+ {t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })} +
+ ) : formError ? (
{isPostOptionsValidationError(formError) ? : formError}
diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 1131290d..992bfafe 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -621,6 +621,44 @@ describe('ReplyModal', () => { 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('textarea') as HTMLTextAreaElement; + const linkInput = container.querySelectorAll('input[type="text"]')[2]; + const linkContainer = linkInput.parentElement as HTMLElement; + const getConversionNotice = () => + Array.from(container.querySelectorAll('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 () => { const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25); testState.openEmpty = true; diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index f5ed67c4..7bff86ec 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -28,6 +28,7 @@ import { findDirectoryByAddress, useDirectories } from '../../hooks/use-director import usePublishReply from '../../hooks/use-publish-reply'; import useIsMobile from '../../hooks/use-is-mobile'; import { useFileUpload } from '../../hooks/use-file-upload'; +import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion'; import { useCommunityField } from '../../hooks/use-stable-community'; import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; @@ -140,6 +141,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa ); const onPublishReply = () => { + const appliedYouTubeConversion = applyPendingConversion(); + const currentContent = textRef.current?.value || ''; const currentUrl = urlRef.current?.value.trim() || ''; const currentOptions = optionsRef.current?.value || ''; @@ -180,7 +183,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa setError(null); nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null; - publishReply({ content: publishContent, ...flagPublishOptions }); + publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions }); }; useEffect(() => { @@ -391,6 +394,39 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa 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(() => { const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current; @@ -438,25 +474,27 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({ onUploadComplete: (uploadedUrl: string) => { if (uploadedUrl) { - setUrl(uploadedUrl); + cancelPendingConversion(); + setLinkValue(uploadedUrl); if (urlRef.current) { urlRef.current.value = uploadedUrl; } - setPublishReplyOptions({ link: uploadedUrl }); } }, }); const handleOekakiClearUploadedUrl = (uploadedUrl: string) => { if ((urlRef.current?.value || url) !== uploadedUrl) return; - setUrl(''); + cancelPendingConversion(); + setLinkValue(''); if (urlRef.current) { urlRef.current.value = ''; } - setPublishReplyOptions({ link: '' }); }; const uploadMode = useMediaHostingStore((state) => state.uploadMode); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const displayedFileName = getPublishURLFilename(url) || uploadedFileName; + const youtubeThumbnailConversionNotice = + youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null; const hasInitializedDisplayName = useRef(false); useEffect(() => { @@ -555,8 +593,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))} disabled={isUploading} onChange={(e) => { - setUrl(e.target.value); - setPublishReplyOptions({ link: e.target.value }); + handleLinkChange(e.target.value); }} /> @@ -613,7 +650,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa {moderationPostingWarning ?
{moderationPostingWarning}
: null} - {lengthError ? ( + {youtubeThumbnailConversionNotice ? ( +
+ {youtubeThumbnailConversionNotice} +
+ ) : lengthError ? (
{lengthError}
) : error ? (
{isPostOptionsValidationError(error) ? : error}
diff --git a/src/hooks/use-youtube-thumbnail-link-conversion.ts b/src/hooks/use-youtube-thumbnail-link-conversion.ts new file mode 100644 index 00000000..d94da17b --- /dev/null +++ b/src/hooks/use-youtube-thumbnail-link-conversion.ts @@ -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 = { + readonly current: T | null; +}; + +interface PendingYouTubeThumbnailLinkConversion { + thumbnailLink: string; + youtubeLink: string; +} + +interface UseYouTubeThumbnailLinkConversionOptions { + enabled: boolean; + onContentChange: (content: string) => void; + onLinkChange: (link: string) => void; + textRef: ElementRef; + urlRef: ElementRef; +} + +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(null); + const pendingConversionRef = useRef(null); + const timeoutRef = useRef(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, + }; +}; diff --git a/src/lib/utils/__tests__/media-utils.test.ts b/src/lib/utils/__tests__/media-utils.test.ts index dd9a1fb4..327e1988 100644 --- a/src/lib/utils/__tests__/media-utils.test.ts +++ b/src/lib/utils/__tests__/media-utils.test.ts @@ -46,6 +46,7 @@ import { getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink, + getYouTubeThumbnailUrlFromLink, } from '../media-utils'; const clearMemoizedCache = (fn: unknown) => { @@ -125,6 +126,8 @@ describe('media-utils', () => { expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg'); expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video'); 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', () => { diff --git a/src/lib/utils/media-utils.ts b/src/lib/utils/media-utils.ts index 97f2553f..47173daa 100644 --- a/src/lib/utils/media-utils.ts +++ b/src/lib/utils/media-utils.ts @@ -1,5 +1,5 @@ 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 { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils'; import { Capacitor, CapacitorHttp } from '@capacitor/core'; @@ -65,6 +65,22 @@ export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undef 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( (commentMediaInfo: CommentMediaInfo | undefined, link: string | undefined): boolean => { if (!link || !commentMediaInfo) return false; @@ -81,10 +97,9 @@ export const getHasThumbnail = memoize( ); const getPatternThumbnailUrl = (url: URL): string | undefined => { - const videoId = getYouTubeVideoId(url); - if (videoId) { - return `https://img.youtube.com/vi/${videoId}/0.jpg`; - } + const youtubeThumbnailUrl = getYouTubeThumbnailUrl(url); + if (youtubeThumbnailUrl) return youtubeThumbnailUrl; + if (url.host.includes('streamable.com')) { const videoId = url.pathname.split('/')[1]; return `https://cdn-cf-east.streamable.com/image/${videoId}.jpg`;