fix(embed): restore youtube thumbnails and file-row labels (#1148)

* fix(comment-content): render reason text as comment content

* fix(post-form): use native browser styling for flash tag select

Exclude the flash tag dropdown from themed post-form select styling so it
renders with the browser's default select appearance, matching the flag selector.

* fix(p2p-stats): show peer flags for DNS6 relay hostnames

Extract embedded IPv6 addresses from dns6 multiaddrs so geo lookup and country flags work for relay peers that publish IPv6 via DNS hostnames.

* fix(flags): hide geolocation-only selectors on /int/ and /sp/

Country-only boards auto-publish geographic location flags without
showing a flag dropdown, matching existing /bant/ behavior.

* fix(embed): restore youtube thumbnails and file-row labels

Restore thumbnail-first previews for YouTube embeds in post media and markdown hover.
Desktop posts show File with the thumbnail image URL and a youtube video type label.

* fix(embed): address youtube thumbnail review feedback

Translate the youtube video label, handle mobile/music YouTube hosts as standard YouTube URLs, keep affected mocks current, and cap default Vitest workers to reduce local CPU spikes.
This commit is contained in:
Tommaso Casaburi
2026-06-01 12:57:17 +07:00
committed by GitHub
parent d48cf10ee8
commit 829b672053
57 changed files with 482 additions and 127 deletions
+1 -1
View File
@@ -71,7 +71,7 @@
"build:fdroid": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false VITE_APP_DISTRIBUTION=fdroid vite build",
"build:preload": "vite build --config electron/vite.preload.config.js",
"build-vercel": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build",
"test": "vitest",
"test": "vitest --maxWorkers=4",
"test:leaks": "vitest run --detectAsyncLeaks",
"preview": "vite preview",
"analyze-bundle": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && npx source-map-explorer 'dist/assets/*.js'",
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "حظر",
"timestamp": "الطابع الزمني",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "নিষেধ করুন",
"timestamp": "টাইমস্ট্যাম্প",
"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 ভিডিও"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "zakázat",
"timestamp": "časové razítko",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "forbyd",
"timestamp": "tidsstempel",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "sperren",
"timestamp": "Zeitstempel",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "απαγόρευση",
"timestamp": "χρονοσήμανση",
"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"
}
+2 -1
View File
@@ -418,5 +418,6 @@
"ban": "ban",
"timestamp": "timestamp",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "prohibir",
"timestamp": "marca de tiempo",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "مسدود کردن",
"timestamp": "مهر زمان",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "kielto",
"timestamp": "aikaleimaustiedosto",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "ipagbawal",
"timestamp": "timestamp",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "interdire",
"timestamp": "horodatage",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "חסימה",
"timestamp": "חותמת זמן",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "प्रतिबंध",
"timestamp": "टाइमस्टैंप",
"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 वीडियो"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "tiltás",
"timestamp": "időbélyeg",
"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ó"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "larangan",
"timestamp": "stempel waktu",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "vietare",
"timestamp": "marca temporale",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "ブロック",
"timestamp": "タイムスタンプ",
"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動画"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "차단",
"timestamp": "타임스탬프",
"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 동영상"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "प्रतिबंध",
"timestamp": "टाइमस्टॅम्प",
"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 व्हिडिओ"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "verbannen",
"timestamp": "tijdstempel",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "forbud",
"timestamp": "tidsstempel",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "zbanować",
"timestamp": "znacznik czasu",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "banir",
"timestamp": "marca de tempo",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "interzice",
"timestamp": "marcă de timp",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "блокировка",
"timestamp": "временная метка",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "ndalim",
"timestamp": "vulë kohe",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "förbjud",
"timestamp": "tidsstämpel",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "నిషేధం",
"timestamp": "టైమ్‌స్టాంప్",
"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 వీడియో"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "แบน",
"timestamp": "แสตมป์เวลา",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "yasakla",
"timestamp": "zaman damgası",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "заблокувати",
"timestamp": "часова мітка",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "روک",
"timestamp": "ٹائم سٹیمپ",
"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 ویڈیو"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "cấm",
"timestamp": "dấu thời gian",
"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"
}
+2 -1
View File
@@ -399,5 +399,6 @@
"ban": "封禁",
"timestamp": "时间戳",
"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 视频"
}
@@ -151,6 +151,8 @@ vi.mock('../../lib/utils/media-utils', () => ({
getDisplayMediaInfoType: (type?: string) => type ?? 'unknown',
getHasThumbnail: () => true,
getMediaDimensions: () => '100x100',
getPostMediaTypeLabel: (_commentMediaInfo: unknown, resolvedType?: string) => resolvedType ?? '',
getYouTubeEmbedPostMediaFileLink: () => undefined,
}));
vi.mock('../../lib/utils/post-utils', () => ({
@@ -171,6 +173,7 @@ vi.mock('../../lib/utils/pending-approval-moderation', () => ({
vi.mock('../../lib/utils/url-utils', () => ({
isValidURL: () => true,
parseHttpUrl: (value: string) => new URL(value),
}));
vi.mock('../../lib/utils/view-utils', () => ({
@@ -408,11 +408,11 @@ describe('CommentContent', () => {
cid: 'post-2',
content: 'ignored',
postCid: 'post-2',
reason: 'spam',
reason: 'duplicate of >>96',
removed: true,
});
expect(container.textContent).toContain('this_post_was_removed');
expect(container.textContent).toContain('Reason: "spam"');
expect(queryMarkdownText()).toEqual(['Reason: duplicate of >>96']);
await renderContent({
cid: 'post-3',
@@ -422,7 +422,7 @@ describe('CommentContent', () => {
reason: 'self-delete',
});
expect(container.textContent).toContain('user_deleted_this_post');
expect(container.textContent).toContain('Reason: "self-delete"');
expect(queryMarkdownText()).toEqual(['Reason: self-delete']);
});
it('renders pending approval, ban details, and loading or failed states', async () => {
@@ -442,6 +442,7 @@ describe('CommentContent', () => {
expect(container.textContent).toContain('pending_mod_approval');
expect(container.textContent).toContain('pending-reason:rules violation');
expect(queryMarkdownText()).toEqual(['queued body', 'pending-reason:rules violation']);
const tooltip = container.querySelector('[data-testid="tooltip"]');
expect(tooltip?.getAttribute('title')).toContain('ban:short:music-posting.eth:2024-01-01 12:00:00');
@@ -156,7 +156,14 @@ const CommentContent = ({
const failedError = getFailedCommentError(resolvedPost);
const failedErrorMessage = formatErrorMessageForDisplay(failedError);
const shouldShowUnpublishedStateDetails = !cid && (!hasFailedState || Boolean(failedError));
const reasonMessage = reason ? `${capitalize(t('reason'))}: ${reason}` : undefined;
const pendingApprovalReason = pendingApproval ? reason?.trim() : undefined;
const pendingApprovalReasonMessage = pendingApprovalReason
? t('pending_mod_approval_reason', {
reason: pendingApprovalReason,
interpolation: { escapeValue: false },
})
: undefined;
const loadingString = (
<div className={styles.stateString}>
@@ -193,21 +200,21 @@ const CommentContent = ({
{purged ? (
<span className={styles.grayEditMessage}>{capitalize(t('this_post_was_purged'))}</span>
) : removed ? (
reason ? (
reasonMessage ? (
<>
<span className={styles.redEditMessage}>({t('this_post_was_removed')})</span>
<br />
<br />
<span className={styles.grayEditMessage}>{`${capitalize(t('reason'))}: "${reason}"`}</span>
{renderContent(reasonMessage)}
</>
) : (
<span className={styles.grayEditMessage}>{capitalize(t('this_post_was_removed'))}.</span>
)
) : deleted ? (
reason ? (
reasonMessage ? (
<>
<span className={styles.grayEditMessage}>{t('user_deleted_this_post')}</span>{' '}
<span className={styles.grayEditMessage}>{`${capitalize(t('reason'))}: "${reason}"`}</span>
{renderContent(reasonMessage)}
</>
) : (
<span className={styles.grayEditMessage}>{t('user_deleted_this_post')}</span>
@@ -220,18 +227,13 @@ const CommentContent = ({
<br />
<br />
<span className={styles.pendingApproval}>({t('pending_mod_approval')})</span>
{pendingApprovalReason && (
{pendingApprovalReasonMessage ? (
<>
<br />
<br />
<span className={styles.grayEditMessage}>
{t('pending_mod_approval_reason', {
reason: pendingApprovalReason,
interpolation: { escapeValue: false },
})}
</span>
{renderContent(pendingApprovalReasonMessage)}
</>
)}
) : null}
</>
)}
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && !isPrivilegedAuthor && (
@@ -295,6 +295,21 @@ describe('CommentMedia', () => {
expect(setShowThumbnailMock).toHaveBeenCalledWith(false);
});
it('renders a youtube thumbnail before the embed is opened', async () => {
await renderMedia({
commentMediaInfo: {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
expect(container.querySelector('img[src="https://img.youtube.com/vi/abc123/0.jpg"]')).toBeTruthy();
expect(container.querySelector('[data-testid="embed"]')).toBeNull();
});
it('renders the expanded embed view with a close button on mobile', async () => {
testState.isMobile = true;
+13 -1
View File
@@ -3,7 +3,7 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Embed from '../embed';
import { canEmbed } from '../embed-utils';
import { canEmbed, getYouTubeVideoId } from '../embed-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -56,6 +56,18 @@ describe('Embed', () => {
expect(container.innerHTML).toBe('');
});
it('extracts youtube video ids for standard, short, and invidious urls', () => {
expect(getYouTubeVideoId(new URL('https://www.youtube.com/watch?v=abc123'))).toBe('abc123');
expect(getYouTubeVideoId(new URL('https://m.youtube.com/watch?v=mobile123'))).toBe('mobile123');
expect(getYouTubeVideoId(new URL('https://music.youtube.com/watch?v=music123'))).toBe('music123');
expect(getYouTubeVideoId(new URL('https://youtu.be/short123'))).toBe('short123');
expect(getYouTubeVideoId(new URL('https://www.youtube.com/shorts/short456'))).toBe('short456');
expect(getYouTubeVideoId(new URL('https://yewtu.be/invidious789'))).toBe('invidious789');
expect(getYouTubeVideoId(new URL('https://m.youtube.com/@channel'))).toBeNull();
expect(getYouTubeVideoId(new URL('https://music.youtube.com/channel/UC123'))).toBeNull();
expect(getYouTubeVideoId(new URL('https://www.youtube.com/playlist?list=PL123'))).toBeNull();
});
it('reports embeddable hosts through canEmbed and rejects unsupported reddit pages', () => {
expect(canEmbed(new URL('https://www.youtube.com/watch?v=abc123'))).toBe(true);
expect(canEmbed(new URL('https://yt.example/watch?v=abc123'))).toBe(true);
+31
View File
@@ -1,3 +1,5 @@
const STANDARD_YOUTUBE_HOSTS = new Set<string>(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtu.be', 'www.youtu.be']);
export const youtubeHosts = new Set<string>([
'youtube.com',
'www.youtube.com',
@@ -46,6 +48,35 @@ const canEmbedHosts = new Set<string>([
...spotifyHosts,
]);
export const getYouTubeVideoId = (parsedUrl: URL): string | null => {
if (parsedUrl.searchParams.has('list') && !parsedUrl.searchParams.get('v') && !parsedUrl.host.includes('youtu.be') && !parsedUrl.pathname.includes('/shorts/')) {
return null;
}
const videoIdFromQuery = parsedUrl.searchParams.get('v');
if (videoIdFromQuery) {
return videoIdFromQuery;
}
if (parsedUrl.host.includes('youtu.be')) {
return parsedUrl.pathname.slice(1).split('/')[0] || null;
}
if (parsedUrl.pathname.includes('/shorts/')) {
return parsedUrl.pathname.split('/shorts/')[1]?.split('/')[0] || null;
}
const isInvidious = !STANDARD_YOUTUBE_HOSTS.has(parsedUrl.host) && (youtubeHosts.has(parsedUrl.host) || parsedUrl.host.startsWith('yt.'));
if (isInvidious) {
if (parsedUrl.pathname.startsWith('/watch')) {
return parsedUrl.searchParams.get('v');
}
return parsedUrl.pathname.split('/').filter(Boolean).pop() || null;
}
return null;
};
export const canEmbed = (parsedUrl: URL): boolean => {
if (xHosts.has(parsedUrl.host)) {
return Boolean(getXTweetId(parsedUrl));
+3 -19
View File
@@ -10,6 +10,7 @@ import {
streamableHosts,
tiktokHosts,
twitchHosts,
getYouTubeVideoId,
xHosts,
youtubeHosts,
} from './embed-utils';
@@ -66,28 +67,11 @@ const remoteEmbedSandbox = `${srcDocSandbox} allow-forms allow-presentation allo
const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let embedSrc = '';
const isInvidious = parsedUrl.host !== 'youtube.com' && parsedUrl.host !== 'www.youtube.com' && parsedUrl.host !== 'youtu.be' && parsedUrl.host !== 'www.youtu.be';
if (parsedUrl.searchParams.has('list')) {
if (parsedUrl.searchParams.has('list') && !getYouTubeVideoId(parsedUrl)) {
const playlistId = parsedUrl.searchParams.get('list');
embedSrc = `https://www.youtube.com/embed/videoseries?list=${playlistId}`;
} else {
let videoId = parsedUrl.searchParams.get('v');
if (!videoId) {
if (parsedUrl.host.includes('youtu.be')) {
videoId = parsedUrl.pathname.substring(1);
} else if (parsedUrl.pathname.includes('/shorts/')) {
videoId = parsedUrl.pathname.split('/shorts/')[1];
} else if (isInvidious) {
if (parsedUrl.pathname.startsWith('/watch')) {
videoId = parsedUrl.searchParams.get('v');
} else {
videoId = parsedUrl.pathname.split('/').pop() || null;
}
}
}
const videoId = getYouTubeVideoId(parsedUrl);
if (videoId) {
embedSrc = `https://www.youtube.com/embed/${videoId}`;
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { default } from './embed';
export { canEmbed } from './embed-utils';
export { canEmbed, getYouTubeVideoId } from './embed-utils';
@@ -24,7 +24,9 @@ const testState = vi.hoisted(() => ({
cidToNumber: {} as Record<string, number>,
internalPathByHref: {} as Record<string, string | null>,
isMobile: false,
mediaInfoByHref: {} as Record<string, { thumbnail?: string; type: string; url: string }>,
mediaInfoByHref: {} as Record<string, { patternThumbnailUrl?: string; thumbnail?: string; type: string; url: string }>,
floatingMediaProps: null as { showThumbnail?: boolean } | null,
floatingOnOpenChange: null as ((open: boolean) => void) | null,
numberToCid: {} as Record<string, Record<number, string>>,
unavailableCids: new Set<string>(),
}));
@@ -42,20 +44,32 @@ vi.mock('@floating-ui/react', () => ({
shift: () => ({}),
size: () => ({}),
useDismiss: () => ({}),
useFloating: () => ({
context: {},
floatingStyles: {},
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
update: () => undefined,
}),
useFloating: ({ onOpenChange, open }: { onOpenChange?: (open: boolean) => void; open?: boolean }) => {
testState.floatingOnOpenChange = onOpenChange ?? null;
return {
context: {},
floatingStyles: {},
open,
onOpenChange,
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
update: () => undefined,
};
},
useFocus: () => ({}),
useHover: () => ({}),
useInteractions: () => ({
useHover: () => ({
getReferenceProps: (props?: Record<string, unknown>) => ({
...props,
onMouseEnter: () => testState.floatingOnOpenChange?.(true),
onMouseLeave: () => testState.floatingOnOpenChange?.(false),
}),
}),
useInteractions: (interactions: Array<{ getReferenceProps?: (props?: Record<string, unknown>) => Record<string, unknown> }>) => ({
getFloatingProps: (props?: Record<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) => props || {},
getReferenceProps: (props?: Record<string, unknown>) =>
interactions.reduce((mergedProps, interaction) => interaction.getReferenceProps?.(mergedProps) || mergedProps, props || {}),
}),
}));
@@ -75,8 +89,13 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
}));
vi.mock('../../../lib/utils/media-utils', () => ({
getHasThumbnail: (linkMediaInfo?: { thumbnail?: string; type?: string }, href?: string) =>
Boolean(linkMediaInfo?.thumbnail || linkMediaInfo?.type === 'image' || (href && testState.mediaInfoByHref[href]?.thumbnail)),
getHasThumbnail: (linkMediaInfo?: { patternThumbnailUrl?: string; thumbnail?: string; type?: string }, href?: string) =>
Boolean(
linkMediaInfo?.thumbnail ||
linkMediaInfo?.patternThumbnailUrl ||
linkMediaInfo?.type === 'image' ||
(href && (testState.mediaInfoByHref[href]?.thumbnail || testState.mediaInfoByHref[href]?.patternThumbnailUrl)),
),
getLinkMediaInfo: (href: string) => testState.mediaInfoByHref[href],
}));
@@ -139,8 +158,28 @@ vi.mock('../../../stores/use-post-number-store', () => ({
}));
vi.mock('../../comment-media', () => ({
default: ({ commentMediaInfo }: { commentMediaInfo?: { type?: string; url?: string } }) =>
createElement('div', { 'data-testid': 'comment-media' }, `${commentMediaInfo?.type}:${commentMediaInfo?.url}`),
default: ({
commentMediaInfo,
isFloatingEmbed,
showThumbnail,
}: {
commentMediaInfo?: { type?: string; url?: string };
isFloatingEmbed?: boolean;
showThumbnail?: boolean;
}) => {
if (isFloatingEmbed) {
testState.floatingMediaProps = { showThumbnail };
}
return createElement(
'div',
{
'data-floating': String(Boolean(isFloatingEmbed)),
'data-show-thumbnail': String(showThumbnail),
'data-testid': 'comment-media',
},
`${commentMediaInfo?.type}:${commentMediaInfo?.url}`,
);
},
}));
vi.mock('../../embed', () => ({
@@ -198,6 +237,8 @@ describe('Markdown', () => {
testState.internalPathByHref = {};
testState.isMobile = false;
testState.mediaInfoByHref = {};
testState.floatingMediaProps = null;
testState.floatingOnOpenChange = null;
testState.numberToCid = {};
testState.unavailableCids = new Set<string>();
@@ -460,6 +501,33 @@ describe('Markdown', () => {
expect(lazyLinks.map((node) => node.textContent)).toEqual(['>>42', '>>>/fit/77']);
});
it('shows youtube thumbnails in the floating hover preview instead of loading the iframe', async () => {
testState.embeddableHosts = new Set(['www.youtube.com']);
testState.mediaInfoByHref = {
'https://www.youtube.com/watch?v=abc123': {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
},
};
await renderMarkdown({
content: 'https://www.youtube.com/watch?v=abc123',
});
const embedButton = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'embed');
expect(embedButton).toBeTruthy();
await act(async () => {
testState.floatingOnOpenChange?.(true);
});
const floatingMedia = container.querySelector('[data-floating="true"]');
expect(floatingMedia).toBeTruthy();
expect(floatingMedia?.getAttribute('data-show-thumbnail')).toBe('true');
expect(testState.floatingMediaProps?.showThumbnail).toBe(true);
});
it('toggles inline media embeds for embeddable links outside catalog view', async () => {
testState.mediaInfoByHref = {
'https://cdn.example/image.png': {
+2 -2
View File
@@ -123,8 +123,8 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
disableToggle={true}
isFloatingEmbed={true}
isReply={false}
setShowThumbnail={setShowMedia}
showThumbnail={false}
setShowThumbnail={() => {}}
showThumbnail={true}
/>
</div>
)}
+12 -7
View File
@@ -5,11 +5,11 @@ import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import styles from '../../views/post/post.module.css';
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
import { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils';
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { approvePendingCommentModeration, isPendingApprovalRejected, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
import { isValidURL } from '../../lib/utils/url-utils';
import { isValidURL, parseHttpUrl } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -642,12 +642,16 @@ const PostMedia = ({
type = 'static gif';
}
const embedUrl = url && new URL(url);
const youtubeFileLink = getYouTubeEmbedPostMediaFileLink(commentMediaInfo);
const fileLinkUrl = youtubeFileLink ?? url;
const embedUrl = url ? parseHttpUrl(url) : null;
const [showThumbnail, setShowThumbnail] = useState(true);
const mediaDimensions = getMediaDimensions(commentMediaInfo);
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const fileLabel = youtubeFileLink || requirePostLinkIsMedia ? t('file') : t('link');
const mediaTypeLabel = type ? lowerCase(getPostMediaTypeLabel(commentMediaInfo, type, t)) : '';
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const displayBoardPath =
boardPath && communityAddress && boardPath !== communityAddress
@@ -666,10 +670,11 @@ const PostMedia = ({
{t('board')}: <Link to={`/${boardPath}`}>{displayBoardPath}</Link>{' '}
</>
)}
{requirePostLinkIsMedia ? t('file') : t('link')}:{' '}
<a href={url} target='_blank' rel='noopener noreferrer'>
{fileLabel}:{' '}
<a href={fileLinkUrl} target='_blank' rel='noopener noreferrer'>
{(() => {
if (spoiler) return capitalize(t('spoiler'));
if (youtubeFileLink) return truncateWithEllipsisInMiddle(youtubeFileLink);
if (requirePostLinkIsMedia && url) {
try {
const pathParts = new URL(url).pathname.split('/');
@@ -677,10 +682,10 @@ const PostMedia = ({
if (filename && /\.\w+$/.test(filename)) return truncateWithEllipsisInMiddle(filename);
} catch {}
}
return truncateWithEllipsisInMiddle(url ?? '');
return truncateWithEllipsisInMiddle(fileLinkUrl ?? '');
})()}
</a>{' '}
({type && lowerCase(getDisplayMediaInfoType(type, t))}
({mediaTypeLabel}
{mediaDimensions && `, ${mediaDimensions}`})
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
<span>
@@ -444,6 +444,7 @@ describe('PostForm', () => {
testState.directories = [
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
{ address: 'sports-posting.bso', directoryCode: 'sp', features: { hasFlags: true }, title: '/sp/ - Sports' },
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
{
address: 'flash-posting.bso',
@@ -699,6 +700,30 @@ describe('PostForm', () => {
});
});
it('publishes geographic location on /sp/ without showing a flag field', async () => {
testState.resolvedCommunityAddress = 'sports-posting.bso';
await renderPostForm('/sp');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
expect(flagSelect).toBeNull();
await dispatchInput(textarea as HTMLTextAreaElement, 'sports post');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'sports post',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it('publishes selected political flags from the post form', async () => {
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
+1 -1
View File
@@ -391,7 +391,7 @@ const PostFormFields = ({
<tr>
<td>{t('tag')}</td>
<td>
<select name='flashTag' aria-label={t('tag')} ref={flashTagRef} defaultValue=''>
<select name='flashTag' aria-label={t('tag')} className={styles.flagSelector} ref={flashTagRef} defaultValue=''>
<option value=''>{t('choose_one')}</option>
{flashTagOptions.map((option) => (
<option key={option.value} value={option.value}>
@@ -340,6 +340,18 @@ describe('ReplyModal', () => {
features: { hasFlags: true },
title: '/bant/ - International/Random',
},
'international-sfw.bso': {
address: 'international-sfw.bso',
directoryCode: 'int',
features: { hasFlags: true },
title: '/int/ - International',
},
'sports-posting.bso': {
address: 'sports-posting.bso',
directoryCode: 'sp',
features: { hasFlags: true },
title: '/sp/ - Sports',
},
'random-nsfw.bso': {
address: 'random-nsfw.bso',
features: {},
@@ -494,8 +506,12 @@ describe('ReplyModal', () => {
});
});
it('publishes geographic location on /bant/ without showing a flag selector', async () => {
await renderReplyModal('/bant/thread/post-1', 'international-nsfw.bso');
it.each([
{ boardPath: '/bant/thread/post-1', communityAddress: 'international-nsfw.bso' },
{ boardPath: '/int/thread/post-1', communityAddress: 'international-sfw.bso' },
{ boardPath: '/sp/thread/post-1', communityAddress: 'sports-posting.bso' },
])('publishes geographic location on country-only boards without showing a flag selector', async ({ boardPath, communityAddress }) => {
await renderReplyModal(boardPath, communityAddress);
expect(container.querySelector<HTMLSelectElement>('select[aria-label="flag"]')).toBeNull();
@@ -256,6 +256,73 @@ describe('P2PStatsSettings', () => {
expect(fetchMock).toHaveBeenCalledWith('https://free.freeipapi.com/api/json/91.234.199.189', expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
it('shows peer country flags for DNS6 relay hostnames with embedded IPv6 addresses', async () => {
const fetchMock = vi.fn(async (url: string | URL | Request) => {
const requestUrl = String(url);
if (requestUrl === 'https://free.freeipapi.com/api/json/2a11%3A6100%3A0%3A5e9f%3A%3A0') {
return {
ok: true,
json: async () => ({
cityName: 'Reykjavik',
countryCode: 'IS',
ipAddress: '2a11:6100:0:5e9f::0',
latitude: 64.1466,
longitude: -21.9426,
}),
};
}
return {
ok: true,
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
};
});
vi.stubGlobal('fetch', fetchMock);
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
_helia: {
libp2p: {
getConnections: () => [
{
remoteAddr: {
toString: () => '/dns6/2a11-6100-0-5e9f--0.k51qzi5uqu5djg5pdoi9a98.example/tcp/443/ws/p2p/relay-peer/p2p-circuit/p2p/12D3KooWIPv6Peer',
},
remotePeer: { toString: () => '12D3KooWIPv6Peer' },
},
],
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
getPeers: () => ['12D3KooWIPv6Peer'],
peerId: { toString: () => 'self-peer' },
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const connectedPeers = container.querySelector('[data-testid="connected-peers"]');
expect(connectedPeers?.textContent).toContain('WebSocket through relay');
expect(connectedPeers?.querySelector('img[aria-label="Iceland"]')).not.toBeNull();
expect(fetchMock).toHaveBeenCalledWith(
'https://free.freeipapi.com/api/json/2a11%3A6100%3A0%3A5e9f%3A%3A0',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('shows the own IP flag and a red precise map marker when leeching', async () => {
const fetchMock = vi.fn(async (url: string | URL | Request) => {
const requestUrl = String(url);
@@ -17,30 +17,34 @@ describe('comment-flag-selection', () => {
expect(hasCommentFlagsForDirectory({ features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' })).toBe(true);
});
it('uses geographic location as the default for country flag boards', () => {
it('uses geographic location as the default for other flag boards', () => {
expect(
getCommentFlagOptionsForDirectory({
directoryCode: 'int',
directoryCode: 'fit',
features: { hasFlags: true },
title: '/int/ - International',
title: '/fit/ - Fitness',
}),
).toEqual([{ label: 'Geographic Location', value: 'country:auto' }]);
});
it('does not expose a flag selector on /bant/ but still publishes geographic location', () => {
it.each([
{ directoryCode: 'bant', title: '/bant/ - International/Random' },
{ directoryCode: 'int', title: '/int/ - International' },
{ directoryCode: 'sp', title: '/sp/ - Sports' },
])('does not expose a flag selector on /$directoryCode/ but still publishes geographic location', ({ directoryCode, title }) => {
expect(
getCommentFlagOptionsForDirectory({
directoryCode: 'bant',
directoryCode,
features: { hasFlags: true },
title: '/bant/ - International/Random',
title,
}),
).toEqual([]);
expect(
getCommentFlagPublishOptionsForDirectory({
directoryCode: 'bant',
directoryCode,
features: { hasFlags: true },
title: '/bant/ - International/Random',
title,
}),
).toEqual({
challengeRequest: {
+32
View File
@@ -37,6 +37,13 @@ describe('extractIpv6FromAddress', () => {
expect(extractIpv6FromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
expect(extractIpFromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
});
it('extracts an IPv6 embedded with dashes in a DNS hostname', () => {
const address = '/dns6/2a11-6100-0-5e9f--0.k51qzi5uqu5djg5pdoi9a98.example/tcp/443/wss/p2p/12D3KooWExample';
expect(extractIpv6FromAddress(address)).toBe('2a11:6100:0:5e9f::0');
expect(extractIpFromAddress(address)).toBe('2a11:6100:0:5e9f::0');
});
});
describe('isPrivateOrReservedIpv4', () => {
@@ -261,6 +268,31 @@ describe('fetchPeerMapLocation', () => {
vi.unstubAllGlobals();
});
it('resolves a peer location from a DNS6 hostname with an embedded IPv6', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
cityName: 'Reykjavik',
countryCode: 'IS',
ipAddress: '2a11:6100:0:5e9f::0',
latitude: 64.1466,
longitude: -21.9426,
}),
});
vi.stubGlobal('fetch', fetchMock);
await expect(fetchPeerMapLocation('/dns6/2a11-6100-0-5e9f--0.k51qzi5uqu5djg5pdoi9a98.example/tcp/443/wss')).resolves.toMatchObject({
countryCode: 'is',
label: 'Reykjavik, IS',
lat: 64.1466,
lon: -21.9426,
source: 'geoip',
});
expect(fetchMock.mock.calls[0][0]).toBe('https://free.freeipapi.com/api/json/2a11%3A6100%3A0%3A5e9f%3A%3A0');
vi.unstubAllGlobals();
});
});
describe('getApproximateCountryCode', () => {
+1 -1
View File
@@ -38,7 +38,7 @@ const NO_FLAG_OPTION: CommentFlagSelectOption = {
};
/** Boards that always publish geographic location without showing a flag selector. */
const AUTO_GEOGRAPHIC_FLAG_DIRECTORY_CODES = new Set(['bant']);
const AUTO_GEOGRAPHIC_FLAG_DIRECTORY_CODES = new Set(['bant', 'int', 'sp']);
const getDirectoryCode = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined => {
const directoryCode = directory?.directoryCode?.trim().toLowerCase();
+11 -1
View File
@@ -284,7 +284,17 @@ export const extractIpv4FromAddress = (address: string): string | null => {
export const extractIpv6FromAddress = (address: string): string | null => {
const direct = /\/ip6\/([^/]+)/.exec(address);
return direct ? direct[1] : null;
if (direct) return direct[1];
const dns = /\/dns6\/([^/]+)/i.exec(address);
const firstLabel = dns?.[1]?.split('.')[0];
if (!firstLabel || !firstLabel.includes('-') || !/^[0-9a-f-]+$/i.test(firstLabel)) return null;
const candidate = firstLabel.replaceAll('-', ':').toLowerCase();
try {
new URL(`http://[${candidate}]/`);
return candidate;
} catch {
return null;
}
};
const parseOctets = (ip: string): number[] | null => {
+35 -4
View File
@@ -20,9 +20,13 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', ()
},
}));
vi.mock('../../../components/embed', () => ({
canEmbed: (url: URL) => testState.canEmbedHosts.has(url.hostname),
}));
vi.mock('../../../components/embed/embed-utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../components/embed/embed-utils')>();
return {
...actual,
canEmbed: (url: URL) => testState.canEmbedHosts.has(url.hostname),
};
});
vi.mock('@capacitor/core', () => ({
Capacitor: {
@@ -33,7 +37,16 @@ vi.mock('@capacitor/core', () => ({
},
}));
import { fetchWebpageThumbnailIfNeeded, getCommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getLinkMediaInfo, getMediaDimensions } from '../media-utils';
import {
fetchWebpageThumbnailIfNeeded,
getCommentMediaInfo,
getDisplayMediaInfoType,
getHasThumbnail,
getLinkMediaInfo,
getMediaDimensions,
getPostMediaTypeLabel,
getYouTubeEmbedPostMediaFileLink,
} from '../media-utils';
const clearMemoizedCache = (fn: unknown) => {
const memoized = fn as { clear?: () => void };
@@ -102,6 +115,18 @@ describe('media-utils', () => {
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
});
it('uses the youtube thumbnail url for post media file links and labels', () => {
const mediaInfo = {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
};
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();
});
it('recognizes which media types expose thumbnails', () => {
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true);
@@ -156,6 +181,12 @@ describe('media-utils', () => {
type: 'iframe',
url: 'https://yt.example/watch?v=yt123',
});
testState.canEmbedHosts = new Set(['yewtu.be']);
expect(getLinkMediaInfo('https://yewtu.be/invidious123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/0.jpg',
type: 'iframe',
url: 'https://yewtu.be/invidious123',
});
});
it('builds comment media info and strips thumbnails for blacklisted domains', () => {
+26 -12
View File
@@ -1,5 +1,5 @@
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { canEmbed } from '../../components/embed';
import { canEmbed, getYouTubeVideoId } from '../../components/embed/embed-utils';
import memoize from 'memoizee';
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
@@ -40,6 +40,31 @@ export const getDisplayMediaInfoType = (type: string, t: Translate) => {
}
};
export const getYouTubeEmbedPostMediaFileLink = (commentMediaInfo: CommentMediaInfo | undefined): string | undefined => {
if (!commentMediaInfo || commentMediaInfo.type !== 'iframe' || !commentMediaInfo.url) {
return undefined;
}
const parsedUrl = parseHttpUrl(commentMediaInfo.url);
if (!parsedUrl || !getYouTubeVideoId(parsedUrl)) {
return undefined;
}
return commentMediaInfo.patternThumbnailUrl || getPatternThumbnailUrl(parsedUrl);
};
export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undefined, resolvedType: string | undefined, t: Translate): string => {
if (getYouTubeEmbedPostMediaFileLink(commentMediaInfo)) {
return t('youtube_video');
}
if (!resolvedType) {
return '';
}
return getDisplayMediaInfoType(resolvedType, t);
};
export const getHasThumbnail = memoize(
(commentMediaInfo: CommentMediaInfo | undefined, link: string | undefined): boolean => {
if (!link || !commentMediaInfo) return false;
@@ -55,17 +80,6 @@ export const getHasThumbnail = memoize(
{ max: 1000 },
);
const getYouTubeVideoId = (url: URL): string | null => {
if (url.host.includes('youtu.be')) {
return url.pathname.slice(1);
} else if (url.pathname.includes('/shorts/')) {
return url.pathname.split('/shorts/')[1].split('/')[0];
} else if (url.searchParams.has('v')) {
return url.searchParams.get('v');
}
return null;
};
const getPatternThumbnailUrl = (url: URL): string | undefined => {
const videoId = getYouTubeVideoId(url);
if (videoId) {