mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(flash board): add SWF posting support (#1145)
This commit is contained in:
@@ -44,7 +44,14 @@ public class FileUploaderPlugin extends Plugin {
|
||||
}
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("*/*");
|
||||
String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"};
|
||||
String[] mimeTypes = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"application/x-shockwave-flash",
|
||||
"application/vnd.adobe.flash.movie"
|
||||
};
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
|
||||
startActivityForResult(call, intent, "pickFileResult");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/** File extensions that denote direct media URLs (mirrors src/lib/media-hosting/direct-url.ts) */
|
||||
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'];
|
||||
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv', '.swf'];
|
||||
|
||||
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
|
||||
export function isDirectMediaUrl(url) {
|
||||
|
||||
@@ -105,6 +105,11 @@ describe('media-upload-automation', () => {
|
||||
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for Flash movie extensions', () => {
|
||||
expect(isDirectMediaUrl('https://example.com/movie.swf')).toBe(true);
|
||||
expect(isDirectMediaUrl('https://example.com/movie.SWF?download=1')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips query strings and fragments before checking', () => {
|
||||
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
|
||||
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@floating-ui/react": "0.26.1",
|
||||
"@pkcprotocol/pkc-js": "0.0.37",
|
||||
"@react-spring/web": "10.0.3",
|
||||
"@ruffle-rs/ruffle": "0.2.0",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.25",
|
||||
"@types/react-dom": "18.2.10",
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "مدة الحظر بالأيام",
|
||||
"challenge_answer": "إجابة التحدي",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "ব্যান মেয়াদ দিনে",
|
||||
"challenge_answer": "চ্যালেঞ্জ উত্তর",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "doba zákazu v dnech",
|
||||
"challenge_answer": "odpověď na výzvu",
|
||||
"ban": "zakázat",
|
||||
"timestamp": "časové razítko"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "banvarighed i dage",
|
||||
"challenge_answer": "svar på udfordring",
|
||||
"ban": "forbyd",
|
||||
"timestamp": "tidsstempel"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "bannierungsdauer in tagen",
|
||||
"challenge_answer": "herausforderungsantwort",
|
||||
"ban": "sperren",
|
||||
"timestamp": "Zeitstempel"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "διάρκεια απαγόρευσης σε ημέρες",
|
||||
"challenge_answer": "απάντηση πρόκλησης",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -416,5 +416,7 @@
|
||||
"ban_duration_days": "ban duration in days",
|
||||
"challenge_answer": "challenge answer",
|
||||
"ban": "ban",
|
||||
"timestamp": "timestamp"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "duración del baneo en días",
|
||||
"challenge_answer": "respuesta del desafío",
|
||||
"ban": "prohibir",
|
||||
"timestamp": "marca de tiempo"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "مدت بن به روز",
|
||||
"challenge_answer": "پاسخ چالش",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "kiellon kesto päivissä",
|
||||
"challenge_answer": "haasteen vastaus",
|
||||
"ban": "kielto",
|
||||
"timestamp": "aikaleimaustiedosto"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "tagal ng ban sa araw",
|
||||
"challenge_answer": "sagot sa hamon",
|
||||
"ban": "ipagbawal",
|
||||
"timestamp": "timestamp"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "durée du bannissement en jours",
|
||||
"challenge_answer": "réponse au défi",
|
||||
"ban": "interdire",
|
||||
"timestamp": "horodatage"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "משך הבן בימים",
|
||||
"challenge_answer": "תשובת אתגר",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "प्रतिबंध की अवधि दिनों में",
|
||||
"challenge_answer": "चुनौती उत्तर",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "kitiltás időtartama napokban",
|
||||
"challenge_answer": "kihívás válasza",
|
||||
"ban": "tiltás",
|
||||
"timestamp": "időbélyeg"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "durasi larangan dalam hari",
|
||||
"challenge_answer": "jawaban tantangan",
|
||||
"ban": "larangan",
|
||||
"timestamp": "stempel waktu"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "durata del ban in giorni",
|
||||
"challenge_answer": "risposta alla sfida",
|
||||
"ban": "vietare",
|
||||
"timestamp": "marca temporale"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "バン期間(日数)",
|
||||
"challenge_answer": "チャレンジ回答",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "차단 기간(일)",
|
||||
"challenge_answer": "챌린지 답변",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "बंद मुदत दिवसांमध्ये",
|
||||
"challenge_answer": "आव्हान उत्तर",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "banningsduur in dagen",
|
||||
"challenge_answer": "antwoord op uitdaging",
|
||||
"ban": "verbannen",
|
||||
"timestamp": "tijdstempel"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "bannvarighet i dager",
|
||||
"challenge_answer": "svar på utfordring",
|
||||
"ban": "forbud",
|
||||
"timestamp": "tidsstempel"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "czas trwania banu w dniach",
|
||||
"challenge_answer": "odpowiedź na wyzwanie",
|
||||
"ban": "zbanować",
|
||||
"timestamp": "znacznik czasu"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "duração do banimento em dias",
|
||||
"challenge_answer": "resposta do desafio",
|
||||
"ban": "banir",
|
||||
"timestamp": "marca de tempo"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "durata banului în zile",
|
||||
"challenge_answer": "răspunsul la provocare",
|
||||
"ban": "interzice",
|
||||
"timestamp": "marcă de timp"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "продолжительность бана в днях",
|
||||
"challenge_answer": "ответ на задачу",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "kohëzgjatja e ndalimit në ditë",
|
||||
"challenge_answer": "përgjigje ndaj sfidës",
|
||||
"ban": "ndalim",
|
||||
"timestamp": "vulë kohe"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "bannlängd i dagar",
|
||||
"challenge_answer": "svar på utmaning",
|
||||
"ban": "förbjud",
|
||||
"timestamp": "tidsstämpel"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "నిషేధ వ్యవధి రోజుల్లో",
|
||||
"challenge_answer": "సవాలు సమాధానం",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "ระยะเวลาแบนในวัน",
|
||||
"challenge_answer": "คำตอบความท้าทาย",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "ban süresi gün cinsinden",
|
||||
"challenge_answer": "zorluk yanıtı",
|
||||
"ban": "yasakla",
|
||||
"timestamp": "zaman damgası"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "тривалість бану в днях",
|
||||
"challenge_answer": "відповідь на виклик",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "بین کی مدت دنوں میں",
|
||||
"challenge_answer": "چ یلنج کا جواب",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "thời gian cấm theo ngày",
|
||||
"challenge_answer": "câu trả lời thử thách",
|
||||
"ban": "cấm",
|
||||
"timestamp": "dấu thời gian"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,7 @@
|
||||
"ban_duration_days": "封禁时长(天数)",
|
||||
"challenge_answer": "挑战答案",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ const DEFAULT_METADATA = {
|
||||
|
||||
const DEFAULTS_FILE_NAME = '5chan-directories-defaults.json';
|
||||
const DIRECTORY_LIST_FILE_RE = /^5chan-.+-directory\.json$/;
|
||||
const FLASH_DIRECTORY_RULES = [
|
||||
'The tagging of uploaded files is mandatory. Improperly tagged items may be removed without notice. Abuse of the tagging system may result in temporary ban.',
|
||||
];
|
||||
const DIRECTORY_CODE_ORDER = [
|
||||
'a',
|
||||
'f',
|
||||
'co',
|
||||
'ck',
|
||||
'pol',
|
||||
@@ -58,11 +62,53 @@ const DIRECTORY_CODE_ORDER = [
|
||||
];
|
||||
const DIRECTORY_CODE_ORDER_INDEX = new Map(DIRECTORY_CODE_ORDER.map((code, index) => [code, index]));
|
||||
|
||||
const BOOTSTRAP_DIRECTORY_LISTS = [
|
||||
{
|
||||
directoryCode: 'f',
|
||||
title: '/f/ - Flash',
|
||||
description:
|
||||
'Boards competing to host the /f/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-f-directory.json',
|
||||
features: {
|
||||
pseudonymityMode: 'per-reply',
|
||||
safeForWork: false,
|
||||
hasFlags: false,
|
||||
postFlairs: true,
|
||||
requirePostFlairs: true,
|
||||
requirePostLink: true,
|
||||
requirePostLinkIsMedia: false,
|
||||
bumpLimit: 300,
|
||||
noSpoilers: true,
|
||||
noSpoilerReplies: true,
|
||||
postsPerPage: 50,
|
||||
},
|
||||
rules: FLASH_DIRECTORY_RULES,
|
||||
createdAt: 1780123897,
|
||||
updatedAt: 1780123897,
|
||||
boards: [
|
||||
{
|
||||
address: 'flash-posting.bso',
|
||||
publicKey: '12D3KooWPFckNTD8YHVJrjpa9hRYvuomvM9VsvQQkJqjWRtpLv1F',
|
||||
owner: 'plebeius.bso',
|
||||
addedAt: 1780123897,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const isRecord = (value) => typeof value === 'object' && value !== null;
|
||||
const isDirectoryListFile = (fileName) => DIRECTORY_LIST_FILE_RE.test(fileName);
|
||||
const toNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
||||
const toString = (value) => (typeof value === 'string' && value.length > 0 ? value : undefined);
|
||||
|
||||
const normalizeRules = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rules = value.filter((rule) => typeof rule === 'string' && rule.length > 0);
|
||||
return rules.length > 0 ? rules : undefined;
|
||||
};
|
||||
|
||||
const normalizeFeatures = (value) => {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
@@ -112,10 +158,12 @@ const normalizeDirectoryDefaultsEntry = (code, raw) => {
|
||||
}
|
||||
|
||||
const features = normalizeFeatures(raw.features);
|
||||
const rules = normalizeRules(raw.rules);
|
||||
return {
|
||||
directoryCode: toString(raw.directoryCode) || code,
|
||||
...(toString(raw.title) ? { title: toString(raw.title) } : {}),
|
||||
...(features ? { features } : {}),
|
||||
...(rules ? { rules } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,12 +189,14 @@ const normalizeDirectoryList = (raw, fallbackCode, defaults) => {
|
||||
const rawCode = toString(raw.directoryCode);
|
||||
const defaultEntry = defaults?.directories?.[rawCode || fallbackCode] || defaults?.directories?.[fallbackCode];
|
||||
const features = normalizeFeatures(defaultEntry?.features) || normalizeFeatures(raw.features);
|
||||
const rules = normalizeRules(raw.rules);
|
||||
|
||||
return {
|
||||
directoryCode: toString(defaultEntry?.directoryCode) || rawCode || fallbackCode,
|
||||
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry.title) } : toString(raw.title) ? { title: toString(raw.title) } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description) } : {}),
|
||||
...(features ? { features } : {}),
|
||||
...(rules ? { rules } : {}),
|
||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||
boards,
|
||||
@@ -165,8 +215,14 @@ const sortDirectoryLists = (lists) =>
|
||||
return a.directoryCode.localeCompare(b.directoryCode);
|
||||
});
|
||||
|
||||
const addBootstrapDirectoryLists = (directories) => {
|
||||
const codes = new Set(directories.map((directory) => directory.directoryCode));
|
||||
return [...directories, ...BOOTSTRAP_DIRECTORY_LISTS.filter((directory) => !codes.has(directory.directoryCode))];
|
||||
};
|
||||
|
||||
const toDirectoryListsData = (directories, fallbackMetadata = DEFAULT_METADATA) => {
|
||||
const timestamps = [fallbackMetadata.createdAt, fallbackMetadata.updatedAt, ...directories.flatMap((list) => [list.createdAt, list.updatedAt])].filter(
|
||||
const directoryLists = addBootstrapDirectoryLists(directories);
|
||||
const timestamps = [fallbackMetadata.createdAt, fallbackMetadata.updatedAt, ...directoryLists.flatMap((list) => [list.createdAt, list.updatedAt])].filter(
|
||||
(value) => typeof value === 'number',
|
||||
);
|
||||
return {
|
||||
@@ -174,7 +230,7 @@ const toDirectoryListsData = (directories, fallbackMetadata = DEFAULT_METADATA)
|
||||
description: fallbackMetadata.description || DEFAULT_METADATA.description,
|
||||
createdAt: timestamps.length > 0 ? Math.min(...timestamps) : fallbackMetadata.createdAt,
|
||||
updatedAt: timestamps.length > 0 ? Math.max(...timestamps) : fallbackMetadata.updatedAt,
|
||||
directories: sortDirectoryLists(directories),
|
||||
directories: sortDirectoryLists(directoryLists),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
|
||||
hostname: 'example.com',
|
||||
isMobile: false,
|
||||
unmuteExpandedVideoSound: false,
|
||||
ruffleLoadMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -69,6 +70,8 @@ vi.mock('../../embed', () => ({
|
||||
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
||||
}));
|
||||
|
||||
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
|
||||
@@ -90,6 +93,16 @@ describe('CommentMedia', () => {
|
||||
testState.hostname = 'example.com';
|
||||
testState.isMobile = false;
|
||||
testState.unmuteExpandedVideoSound = false;
|
||||
testState.ruffleLoadMock = vi.fn();
|
||||
const player = document.createElement('ruffle-player') as HTMLElement & {
|
||||
ruffle: () => { load: (source: string | Record<string, unknown>) => void };
|
||||
};
|
||||
player.ruffle = () => ({ load: testState.ruffleLoadMock });
|
||||
window.RufflePlayer = {
|
||||
newest: () => ({
|
||||
createPlayer: () => player,
|
||||
}),
|
||||
};
|
||||
setShowThumbnailMock = vi.fn();
|
||||
|
||||
container = document.createElement('div');
|
||||
@@ -100,6 +113,7 @@ describe('CommentMedia', () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
delete window.RufflePlayer;
|
||||
});
|
||||
|
||||
it('toggles image expansion on mobile and renders the media metadata', async () => {
|
||||
@@ -370,4 +384,52 @@ describe('CommentMedia', () => {
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.muted).toBe(true);
|
||||
});
|
||||
|
||||
it('renders collapsed SWF media as a compact placeholder and expands through Ruffle', async () => {
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
type: 'swf',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
},
|
||||
setShowThumbnail: setShowThumbnailMock,
|
||||
showThumbnail: true,
|
||||
});
|
||||
|
||||
const placeholder = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '[SWF]');
|
||||
expect(placeholder).toBeTruthy();
|
||||
expect(container.querySelector('object')).toBeNull();
|
||||
expect(container.querySelector('embed')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
placeholder?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setShowThumbnailMock).toHaveBeenCalledWith(false);
|
||||
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
type: 'swf',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
},
|
||||
setShowThumbnail: setShowThumbnailMock,
|
||||
showThumbnail: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="ruffle-player"]')).toBeTruthy();
|
||||
expect(testState.ruffleLoadMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowNetworking: 'internal',
|
||||
allowScriptAccess: false,
|
||||
autoplay: 'off',
|
||||
openUrlMode: 'confirm',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector('object')).toBeNull();
|
||||
expect(container.querySelector('embed')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,11 +147,77 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swfPlaceholder {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--post-link-text-color);
|
||||
font-family: inherit;
|
||||
font-size: 9pt;
|
||||
line-height: 1.2;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swfPlaceholder:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.content iframe {
|
||||
border: none;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.rufflePlayer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(640px, 100%);
|
||||
height: 480px;
|
||||
max-width: 100%;
|
||||
border: var(--reply-desktop-border);
|
||||
background: var(--media-thumbnail-background-color);
|
||||
box-sizing: border-box;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rufflePlayerElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rufflePlayerMount {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rufflePlayerMountHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ruffleLoading,
|
||||
.ruffleFallback {
|
||||
padding: 4px;
|
||||
color: var(--post-mobile-file-info-text-color);
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.ruffleFallback a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.ruffleFallback a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
padding-bottom: 5px;
|
||||
text-align: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import styles from './comment-media.module.css';
|
||||
import Embed, { canEmbed } from '../embed';
|
||||
import RufflePlayer from './ruffle-player';
|
||||
|
||||
interface MediaProps {
|
||||
commentMediaInfo?: CommentMediaInfo;
|
||||
@@ -143,6 +144,12 @@ const Thumbnail = ({
|
||||
) : null;
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||
} else if (type === 'swf') {
|
||||
thumbnailComponent = (
|
||||
<button type='button' className={styles.swfPlaceholder} onClick={() => setShowThumbnail(false)}>
|
||||
[SWF]
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
||||
@@ -219,6 +226,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
)
|
||||
) : type === 'video' ? (
|
||||
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
||||
) : type === 'swf' && url ? (
|
||||
<RufflePlayer url={url} />
|
||||
) : type === 'webpage' ? (
|
||||
disableToggle ? (
|
||||
<img src={thumbnail} alt='' />
|
||||
@@ -237,7 +246,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
{mediaDimensions && `, ${mediaDimensions}`})
|
||||
</div>
|
||||
)}
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio' || type === 'swf') && (
|
||||
<div className={styles.closeButton}>
|
||||
<button
|
||||
type='button'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './comment-media.module.css';
|
||||
|
||||
const RUFFLE_CONFIG = {
|
||||
allowFullscreen: false,
|
||||
allowNetworking: 'internal',
|
||||
allowScriptAccess: false,
|
||||
autoplay: 'off',
|
||||
openUrlMode: 'confirm',
|
||||
polyfills: false,
|
||||
showSwfDownload: false,
|
||||
} as const;
|
||||
|
||||
let ruffleLoadPromise: Promise<void> | undefined;
|
||||
|
||||
const getRufflePublicPath = () => new URL('ruffle/', document.baseURI).href;
|
||||
|
||||
const getRuffleConfig = () => ({
|
||||
...window.RufflePlayer?.config,
|
||||
...RUFFLE_CONFIG,
|
||||
publicPath: getRufflePublicPath(),
|
||||
});
|
||||
|
||||
const loadRuffle = async () => {
|
||||
if (!ruffleLoadPromise) {
|
||||
window.RufflePlayer = window.RufflePlayer || {};
|
||||
window.RufflePlayer.config = getRuffleConfig();
|
||||
ruffleLoadPromise = import('@ruffle-rs/ruffle')
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
ruffleLoadPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return ruffleLoadPromise;
|
||||
};
|
||||
|
||||
interface RufflePlayerProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
type RufflePlayerStatus = 'loading' | 'ready' | 'failed';
|
||||
|
||||
const RufflePlayer = ({ url }: RufflePlayerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [status, setStatus] = useState<RufflePlayerStatus>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: RufflePlayerElement | undefined;
|
||||
const mountNode = containerRef.current;
|
||||
|
||||
const mountPlayer = async () => {
|
||||
setStatus('loading');
|
||||
if (mountNode) {
|
||||
mountNode.textContent = '';
|
||||
}
|
||||
|
||||
try {
|
||||
await loadRuffle();
|
||||
|
||||
if (cancelled || !mountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ruffle = window.RufflePlayer?.newest?.();
|
||||
player = ruffle?.createPlayer?.();
|
||||
if (!player) {
|
||||
throw new Error('Ruffle player API unavailable');
|
||||
}
|
||||
|
||||
player.className = styles.rufflePlayerElement;
|
||||
player.setAttribute('data-testid', 'ruffle-player');
|
||||
mountNode.appendChild(player);
|
||||
|
||||
const playerApi = player.ruffle?.();
|
||||
if (!playerApi) {
|
||||
throw new Error('Ruffle player instance unavailable');
|
||||
}
|
||||
|
||||
await playerApi.load({
|
||||
...getRuffleConfig(),
|
||||
url,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setStatus('ready');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SWF with Ruffle:', error);
|
||||
if (!cancelled) {
|
||||
setStatus('failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void mountPlayer();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
player?.remove();
|
||||
if (mountNode) {
|
||||
mountNode.textContent = '';
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<span className={styles.rufflePlayer}>
|
||||
<span className={status === 'ready' ? styles.rufflePlayerMount : styles.rufflePlayerMountHidden} ref={containerRef} />
|
||||
{status === 'failed' ? (
|
||||
<span className={styles.ruffleFallback}>
|
||||
{t('media_failed_to_load')}.{' '}
|
||||
<a href={url} target='_blank' rel='noopener noreferrer'>
|
||||
{t('media_failed_to_load_open_source')}
|
||||
</a>
|
||||
</span>
|
||||
) : status === 'loading' ? (
|
||||
<span className={styles.ruffleLoading}>{t('loading')} SWF</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default RufflePlayer;
|
||||
@@ -0,0 +1,166 @@
|
||||
.wrapper {
|
||||
overflow-x: auto;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
min-width: 760px;
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 10px auto 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 1px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.flashListing td,
|
||||
.flashListing th {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flashListing thead th {
|
||||
background: #98e;
|
||||
border: 1px solid #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(1) {
|
||||
width: 6.5%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(2) {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(3) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(4) {
|
||||
width: 7%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(5) {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(6) {
|
||||
width: 23%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(7) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(8) {
|
||||
width: 7%;
|
||||
}
|
||||
|
||||
.flashListing thead th:nth-child(9) {
|
||||
width: 4.5%;
|
||||
}
|
||||
|
||||
.postblock {
|
||||
padding: 5px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.numberCell,
|
||||
.embedCell,
|
||||
.tagCell,
|
||||
.dateCell,
|
||||
.repliesCell,
|
||||
.replyCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileCell,
|
||||
.subjectCell {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subjectCell {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.emptyCell {
|
||||
padding: 8px 2px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
width: calc(100% - 10px);
|
||||
max-width: none;
|
||||
margin: 10px auto 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .flashListing thead th {
|
||||
background: #ea8;
|
||||
}
|
||||
|
||||
:global(body.yotsuba-b) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.futaba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.futaba) .flashListing thead th {
|
||||
background: #f0e0d6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .flashListing thead th {
|
||||
background: #c3c9e9;
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .rowOdd td {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .flashListing thead th {
|
||||
background: var(--post-form-field-title-background-color);
|
||||
color: var(--post-form-field-title-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(body.photon) .rowOdd td {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
:global(body.photon) .flashListing thead th {
|
||||
background: #ddd;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { getFlashTagOptionFromComment } from '../../lib/flash-tags';
|
||||
import { removeMarkdown } from '../../lib/utils/post-utils';
|
||||
import { getFormattedDate } from '../../lib/utils/time-utils';
|
||||
import { getPublishURLFilename } from '../../lib/utils/url-utils';
|
||||
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import styles from './flash-board-table.module.css';
|
||||
|
||||
type FlashBoardComment = Comment & {
|
||||
postNumber?: number | string;
|
||||
};
|
||||
|
||||
interface FlashBoardTableProps {
|
||||
boardBasePath: string;
|
||||
isLoading?: boolean;
|
||||
posts: FlashBoardComment[];
|
||||
}
|
||||
|
||||
const CELL_COUNT = 9;
|
||||
const MAX_CELL_TEXT_LENGTH = 40;
|
||||
const MAX_SUBJECT_LENGTH = 55;
|
||||
|
||||
const getThreadPath = (boardBasePath: string, comment: FlashBoardComment) => {
|
||||
const threadCid = comment.postCid || comment.cid;
|
||||
return threadCid ? `${boardBasePath.replace(/\/$/, '')}/thread/${threadCid}` : undefined;
|
||||
};
|
||||
|
||||
const getPostNumber = (comment: FlashBoardComment) => comment.number || comment.postNumber || '?';
|
||||
|
||||
const getDisplayName = (comment: FlashBoardComment, anonymousLabel: string) => comment.author?.displayName?.trim() || anonymousLabel;
|
||||
|
||||
const getFileLabel = (link: string | undefined) => {
|
||||
if (!link) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return truncateWithEllipsisInMiddle(getPublishURLFilename(link) || link, MAX_CELL_TEXT_LENGTH);
|
||||
};
|
||||
|
||||
const getSubjectLabel = (comment: FlashBoardComment) => {
|
||||
const title = typeof comment.title === 'string' ? removeMarkdown(comment.title).trim() : '';
|
||||
const content = typeof comment.content === 'string' ? removeMarkdown(comment.content).trim() : '';
|
||||
return truncateWithEllipsisInMiddle(title || content, MAX_SUBJECT_LENGTH);
|
||||
};
|
||||
|
||||
const getReplyCount = (comment: FlashBoardComment) => (typeof comment.replyCount === 'number' ? comment.replyCount : 0);
|
||||
|
||||
const FlashBoardTable = ({ boardBasePath, isLoading = false, posts }: FlashBoardTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const anonymousLabel = capitalize(t('anonymous'));
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<table id='flash-list' className={styles.flashListing}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
No.
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('name'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('file'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('embed'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('tag'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('subject'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('date'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock}>
|
||||
{capitalize(t('replies'))}
|
||||
</th>
|
||||
<th scope='col' className={styles.postblock} aria-label={capitalize(t('reply'))}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.length === 0 && isLoading ? (
|
||||
<tr className={styles.row}>
|
||||
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
|
||||
<LoadingEllipsis string={t('downloading_board')} />
|
||||
</td>
|
||||
</tr>
|
||||
) : posts.length === 0 ? (
|
||||
<tr className={styles.row}>
|
||||
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
|
||||
no posts
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
posts.map((post, index) => {
|
||||
const threadPath = getThreadPath(boardBasePath, post);
|
||||
const fileLabel = getFileLabel(post.link);
|
||||
const flashTag = getFlashTagOptionFromComment(post);
|
||||
const subjectLabel = getSubjectLabel(post);
|
||||
const rowClassName = `${styles.row} ${index % 2 === 0 ? styles.rowOdd : ''}`;
|
||||
|
||||
return (
|
||||
<tr key={post.cid || `flash-post-${index}`} className={rowClassName}>
|
||||
<td className={styles.numberCell}>
|
||||
{threadPath ? (
|
||||
<Link to={threadPath} className={styles.link}>
|
||||
{getPostNumber(post)}
|
||||
</Link>
|
||||
) : (
|
||||
getPostNumber(post)
|
||||
)}
|
||||
</td>
|
||||
<td>{getDisplayName(post, anonymousLabel)}</td>
|
||||
<td className={styles.fileCell} title={post.link || fileLabel}>
|
||||
{post.link ? (
|
||||
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
|
||||
{fileLabel}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={styles.embedCell}>
|
||||
{post.link ? (
|
||||
<>
|
||||
[
|
||||
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
|
||||
{capitalize(t('embed'))}
|
||||
</a>
|
||||
]
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={styles.tagCell} title={flashTag?.label}>
|
||||
{flashTag ? `[${flashTag.shortLabel}]` : ''}
|
||||
</td>
|
||||
<td className={styles.subjectCell} title={subjectLabel}>
|
||||
{subjectLabel}
|
||||
</td>
|
||||
<td className={styles.dateCell}>{typeof post.timestamp === 'number' ? getFormattedDate(post.timestamp) : ''}</td>
|
||||
<td className={styles.repliesCell}>{getReplyCount(post)}</td>
|
||||
<td className={styles.replyCell}>
|
||||
{threadPath ? (
|
||||
<>
|
||||
[
|
||||
<Link to={threadPath} className={styles.link}>
|
||||
{capitalize(t('reply'))}
|
||||
</Link>
|
||||
]
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlashBoardTable;
|
||||
@@ -32,6 +32,7 @@ import FailedPublishNotice from '../failed-publish-notice';
|
||||
import { canEmbed } from '../embed';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import PostAuthorFlags from '../post-author-flags';
|
||||
import PostFlashTag from '../post-flash-tag';
|
||||
import PostMenuDesktop from './post-menu-desktop';
|
||||
import ReplyQuotePreview from '../reply-quote-preview';
|
||||
import Tooltip from '../tooltip';
|
||||
@@ -418,6 +419,7 @@ const PostInfo = ({
|
||||
)}
|
||||
</span>
|
||||
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
|
||||
<PostFlashTag comment={post} directory={directoryEntry} />
|
||||
<span className={styles.dateTime}>
|
||||
{isInModQueueView && isOverThreshold ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getFlashTagOptionFromComment, isFlashDirectory } from '../lib/flash-tags';
|
||||
import type { DirectoryCommunity } from '../lib/utils/directory-list-utils';
|
||||
import styles from '../views/post/post.module.css';
|
||||
|
||||
interface PostFlashTagProps {
|
||||
comment: unknown;
|
||||
directory: DirectoryCommunity | undefined;
|
||||
}
|
||||
|
||||
const PostFlashTag = ({ comment, directory }: PostFlashTagProps) => {
|
||||
if (!isFlashDirectory(directory)) return null;
|
||||
|
||||
const tag = getFlashTagOptionFromComment(comment);
|
||||
if (!tag) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.flashTag} title={tag.label}>
|
||||
[{tag.shortLabel}]{' '}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostFlashTag;
|
||||
@@ -73,11 +73,23 @@ vi.mock('react-i18next', async () => {
|
||||
' before posting.',
|
||||
);
|
||||
}
|
||||
if (i18nKey === 'post_form_flash_upload_prompt') {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
'Recommended SWF host: ',
|
||||
components?.catbox ? React.cloneElement(components.catbox, {}, 'Catbox') : 'Catbox',
|
||||
'. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link.',
|
||||
);
|
||||
}
|
||||
|
||||
return i18nKey;
|
||||
},
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => (options?.domain ? `${key}:${options.domain}` : key),
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === 'choose_one') return 'Choose one:';
|
||||
return options?.domain ? `${key}:${options.domain}` : key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -433,6 +445,12 @@ describe('PostForm', () => {
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
|
||||
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
|
||||
{
|
||||
address: 'flash-posting.bso',
|
||||
directoryCode: 'f',
|
||||
features: { postFlairs: true, requirePostFlairs: true, requirePostLink: true, requirePostLinkIsMedia: false },
|
||||
title: '/f/ - Flash',
|
||||
},
|
||||
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
|
||||
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
|
||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||
@@ -704,6 +722,63 @@ describe('PostForm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows /f/ upload guidance and publishes the selected flash tag as a post flair', async () => {
|
||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||
|
||||
await renderPostForm('/f');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
const flashTagSelect = table?.querySelector<HTMLSelectElement>('select[name="flashTag"]');
|
||||
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
|
||||
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
||||
const catboxLink = table?.querySelector<HTMLAnchorElement>('a[href="https://catbox.moe/"]');
|
||||
|
||||
expect(flashTagSelect).toBeTruthy();
|
||||
expect(flashTagSelect?.value).toBe('');
|
||||
expect(Array.from(flashTagSelect?.options || []).map((option) => option.textContent)).toEqual([
|
||||
'Choose one:',
|
||||
'Hentai',
|
||||
'Porn',
|
||||
'Japanese',
|
||||
'Anime',
|
||||
'Game',
|
||||
'Loop',
|
||||
'Other',
|
||||
]);
|
||||
expect(catboxLink?.textContent).toBe('Catbox');
|
||||
expect(container.textContent).toContain('Recommended SWF host: Catbox');
|
||||
|
||||
await dispatchChange(flashTagSelect as HTMLSelectElement, 'loop');
|
||||
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
||||
content: 'flash thread',
|
||||
flairs: [{ text: 'flash:loop' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not publish a flash flair until a tag is selected', async () => {
|
||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||
|
||||
await renderPostForm('/f');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
|
||||
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
|
||||
|
||||
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
||||
content: 'flash thread',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates unsupported options and stores fortune output in post content', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
testState.resolvedCommunityAddress = 'random-nsfw.bso';
|
||||
|
||||
@@ -25,6 +25,7 @@ import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
||||
import { getBoardPath } from '../../lib/utils/route-utils';
|
||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
|
||||
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
|
||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||
@@ -51,6 +52,11 @@ import debounce from 'lodash/debounce';
|
||||
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
|
||||
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28;
|
||||
|
||||
const mergeFlairs = (...flairGroups: Array<Comment['flairs'] | undefined>): Comment['flairs'] | undefined => {
|
||||
const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : []));
|
||||
return flairs.length > 0 ? flairs : undefined;
|
||||
};
|
||||
|
||||
const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => {
|
||||
const raw = getPublishURLFilename(url) || uploadedFileName;
|
||||
if (!raw) return noFileLabel;
|
||||
@@ -130,6 +136,7 @@ interface PostFormFieldsProps {
|
||||
subjectRef: React.Ref<HTMLInputElement>;
|
||||
optionsRef: React.RefObject<HTMLInputElement>;
|
||||
flagRef: React.RefObject<HTMLSelectElement>;
|
||||
flashTagRef: React.RefObject<HTMLSelectElement>;
|
||||
textRef: React.RefObject<HTMLTextAreaElement>;
|
||||
urlRef: React.Ref<HTMLInputElement>;
|
||||
url: string;
|
||||
@@ -156,6 +163,9 @@ interface PostFormFieldsProps {
|
||||
rulesPath: string;
|
||||
requirePostLinkIsMedia: boolean;
|
||||
flagOptions: CommentFlagSelectOption[];
|
||||
flashTagOptions: FlashTagOption[];
|
||||
showFlashTagSelector: boolean;
|
||||
showFlashUploadPrompt: boolean;
|
||||
showBbcodeToolbar: boolean;
|
||||
onBbcodePreviewToggle: () => void;
|
||||
onPublishReply: () => void;
|
||||
@@ -177,6 +187,7 @@ const PostFormFields = ({
|
||||
subjectRef,
|
||||
optionsRef,
|
||||
flagRef,
|
||||
flashTagRef,
|
||||
textRef,
|
||||
urlRef,
|
||||
url,
|
||||
@@ -203,6 +214,9 @@ const PostFormFields = ({
|
||||
rulesPath,
|
||||
requirePostLinkIsMedia,
|
||||
flagOptions,
|
||||
flashTagOptions,
|
||||
showFlashTagSelector,
|
||||
showFlashUploadPrompt,
|
||||
showBbcodeToolbar,
|
||||
onBbcodePreviewToggle,
|
||||
onPublishReply,
|
||||
@@ -373,6 +387,21 @@ const PostFormFields = ({
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showFlashTagSelector && (
|
||||
<tr>
|
||||
<td>{t('tag')}</td>
|
||||
<td>
|
||||
<select name='flashTag' aria-label={t('tag')} ref={flashTagRef} defaultValue=''>
|
||||
<option value=''>{t('choose_one')}</option>
|
||||
{flashTagOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showOekakiControls && (
|
||||
<tr>
|
||||
<td>Draw</td>
|
||||
@@ -440,6 +469,16 @@ const PostFormFields = ({
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{showFlashUploadPrompt && (
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey='post_form_flash_upload_prompt'
|
||||
components={{
|
||||
catbox: <a href='https://catbox.moe/' target='_blank' rel='noopener noreferrer' aria-label='Catbox' />,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
|
||||
</ul>
|
||||
</td>
|
||||
@@ -467,6 +506,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const subjectRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<HTMLInputElement>(null);
|
||||
const flagRef = useRef<HTMLSelectElement>(null);
|
||||
const flashTagRef = useRef<HTMLSelectElement>(null);
|
||||
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
|
||||
const diceRollRef = useRef<DiceRoll | null>(null);
|
||||
const nonokoRedirectPathRef = useRef<string | null>(null);
|
||||
@@ -488,6 +528,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
|
||||
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
|
||||
|
||||
const accountCommunityAddresses = useAccountCommunityAddresses();
|
||||
const accountAddress = account?.author?.address;
|
||||
@@ -536,6 +578,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
if (flagRef.current) {
|
||||
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
|
||||
}
|
||||
if (flashTagRef.current) {
|
||||
flashTagRef.current.value = '';
|
||||
}
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
fortuneEntryRef.current = null;
|
||||
@@ -597,9 +642,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}
|
||||
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
|
||||
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
|
||||
const publishOptions = {
|
||||
...flagPublishOptions,
|
||||
...(flairs ? { flairs } : {}),
|
||||
};
|
||||
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||
publishPost({ content: publishContent, ...flagPublishOptions });
|
||||
publishPost({ content: publishContent, ...publishOptions });
|
||||
};
|
||||
|
||||
// redirect to pending page when pending comment is created
|
||||
@@ -785,6 +836,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
subjectRef={subjectRef}
|
||||
optionsRef={optionsRef}
|
||||
flagRef={flagRef}
|
||||
flashTagRef={flashTagRef}
|
||||
textRef={textRef}
|
||||
urlRef={urlRef}
|
||||
url={url}
|
||||
@@ -811,6 +863,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
rulesPath={rulesPath}
|
||||
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
||||
flagOptions={flagOptions}
|
||||
flashTagOptions={FLASH_TAG_OPTIONS}
|
||||
showFlashTagSelector={showFlashTagSelector}
|
||||
showFlashUploadPrompt={showFlashUploadPrompt}
|
||||
showBbcodeToolbar={showBbcodeToolbar}
|
||||
onBbcodePreviewToggle={handleBbcodePreviewToggle}
|
||||
onPublishReply={onPublishReply}
|
||||
|
||||
@@ -29,6 +29,7 @@ import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
|
||||
import FailedPublishNotice from '../failed-publish-notice';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import PostAuthorFlags from '../post-author-flags';
|
||||
import PostFlashTag from '../post-flash-tag';
|
||||
import PostMenuMobile from './post-menu-mobile';
|
||||
import ReplyQuotePreview from '../reply-quote-preview';
|
||||
import Tooltip from '../tooltip';
|
||||
@@ -351,6 +352,7 @@ const PostInfoAndMedia = ({
|
||||
</>
|
||||
)}
|
||||
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
|
||||
<PostFlashTag comment={resolvedPost} directory={directoryEntry} />
|
||||
{pinned && (
|
||||
<span className={styles.stickyIconWrapper}>
|
||||
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "5chan directories",
|
||||
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
||||
"createdAt": 1779182014,
|
||||
"updatedAt": 1780054375,
|
||||
"updatedAt": 1780123897,
|
||||
"directories": [
|
||||
{
|
||||
"directoryCode": "a",
|
||||
@@ -30,6 +30,37 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"directoryCode": "f",
|
||||
"title": "/f/ - Flash",
|
||||
"description": "Boards competing to host the /f/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-f-directory.json",
|
||||
"features": {
|
||||
"pseudonymityMode": "per-reply",
|
||||
"safeForWork": false,
|
||||
"hasFlags": false,
|
||||
"postFlairs": true,
|
||||
"requirePostFlairs": true,
|
||||
"requirePostLink": true,
|
||||
"requirePostLinkIsMedia": false,
|
||||
"bumpLimit": 300,
|
||||
"noSpoilers": true,
|
||||
"noSpoilerReplies": true,
|
||||
"postsPerPage": 50
|
||||
},
|
||||
"rules": [
|
||||
"The tagging of uploaded files is mandatory. Improperly tagged items may be removed without notice. Abuse of the tagging system may result in temporary ban."
|
||||
],
|
||||
"createdAt": 1780123897,
|
||||
"updatedAt": 1780123897,
|
||||
"boards": [
|
||||
{
|
||||
"address": "flash-posting.bso",
|
||||
"publicKey": "12D3KooWPFckNTD8YHVJrjpa9hRYvuomvM9VsvQQkJqjWRtpLv1F",
|
||||
"owner": "plebeius.bso",
|
||||
"addedAt": 1780123897
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"directoryCode": "co",
|
||||
"title": "/co/ - Comics & Cartoons",
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('use-directories', () => {
|
||||
updatedAt: 2,
|
||||
communities: [
|
||||
{ address: 'music-posting.bso', title: '/mu/ - Cached Music', nsfw: false },
|
||||
{ address: 'flash.bso', title: '/f/ - Flash', nsfw: true },
|
||||
{ address: 'flash-posting.bso', title: '/f/ - Flash', nsfw: true },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -225,8 +225,8 @@ describe('use-directories', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(latestSnapshot?.state.loading).toBe(false);
|
||||
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash.bso']);
|
||||
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
|
||||
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash-posting.bso']);
|
||||
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash-posting.bso']);
|
||||
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
|
||||
expect(latestSnapshot?.metadata).toEqual({
|
||||
title: 'Cached directories',
|
||||
|
||||
@@ -157,6 +157,7 @@ describe('useFileUpload', () => {
|
||||
await act(async () => {
|
||||
uploadPromise = hook().handleUpload();
|
||||
});
|
||||
expect((document.querySelector('input[type="file"]') as HTMLInputElement | null)?.accept).toContain('.swf');
|
||||
await selectFileFromHiddenInput(selectedFile);
|
||||
await act(async () => {
|
||||
await uploadPromise;
|
||||
|
||||
@@ -111,7 +111,7 @@ function selectFileViaInput(): Promise<File | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,video/mp4,video/webm';
|
||||
input.accept = 'image/jpeg,image/png,video/mp4,video/webm,.swf,application/x-shockwave-flash,application/vnd.adobe.flash.movie';
|
||||
input.style.display = 'none';
|
||||
let resolved = false;
|
||||
let focusTimeoutId: number | null = null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FLASH_TAG_OPTIONS, getFlashTagOption, getFlashTagOptionFromComment, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectory } from '../flash-tags';
|
||||
|
||||
describe('flash-tags', () => {
|
||||
it('defines the classic /f/ tag options with text-only post flairs', () => {
|
||||
expect(FLASH_TAG_OPTIONS.map((option) => option.label)).toEqual(['Hentai', 'Porn', 'Japanese', 'Anime', 'Game', 'Loop', 'Other']);
|
||||
expect(FLASH_TAG_OPTIONS.map((option) => option.flair)).toEqual([
|
||||
{ text: 'flash:hentai' },
|
||||
{ text: 'flash:porn' },
|
||||
{ text: 'flash:japanese' },
|
||||
{ text: 'flash:anime' },
|
||||
{ text: 'flash:game' },
|
||||
{ text: 'flash:loop' },
|
||||
{ text: 'flash:other' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes a flash tag only on /f/', () => {
|
||||
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'loop')).toEqual({
|
||||
flairs: [{ text: 'flash:loop' }],
|
||||
});
|
||||
expect(getFlashTagPublishOptionsForDirectoryCode('b', 'loop')).toEqual({
|
||||
flairs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not publish a flair for missing or invalid selections', () => {
|
||||
expect(getFlashTagOption(undefined)).toBeUndefined();
|
||||
expect(getFlashTagOption('bad')).toBeUndefined();
|
||||
expect(getFlashTagPublishOptionsForDirectoryCode('f', undefined)).toEqual({ flairs: undefined });
|
||||
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'bad')).toEqual({ flairs: undefined });
|
||||
});
|
||||
|
||||
it('detects flash directories from code or title', () => {
|
||||
expect(isFlashDirectory({ directoryCode: 'f' })).toBe(true);
|
||||
expect(isFlashDirectory({ title: '/f/ - Flash' })).toBe(true);
|
||||
expect(isFlashDirectory({ directoryCode: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts display tags from comment flairs', () => {
|
||||
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:loop' }] })?.shortLabel).toBe('L');
|
||||
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flag:country:US' }] })).toBeUndefined();
|
||||
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:bad' }] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { DirectoryCommunity } from './utils/directory-list-utils';
|
||||
|
||||
export type FlashTagCode = 'hentai' | 'porn' | 'japanese' | 'anime' | 'game' | 'loop' | 'other';
|
||||
|
||||
export interface FlashTagOption {
|
||||
value: FlashTagCode;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
flair: {
|
||||
text: `flash:${FlashTagCode}`;
|
||||
};
|
||||
}
|
||||
|
||||
export const FLASH_TAG_OPTIONS: FlashTagOption[] = [
|
||||
{ value: 'hentai', label: 'Hentai', shortLabel: 'H', flair: { text: 'flash:hentai' } },
|
||||
{ value: 'porn', label: 'Porn', shortLabel: 'P', flair: { text: 'flash:porn' } },
|
||||
{ value: 'japanese', label: 'Japanese', shortLabel: 'J', flair: { text: 'flash:japanese' } },
|
||||
{ value: 'anime', label: 'Anime', shortLabel: 'A', flair: { text: 'flash:anime' } },
|
||||
{ value: 'game', label: 'Game', shortLabel: 'G', flair: { text: 'flash:game' } },
|
||||
{ value: 'loop', label: 'Loop', shortLabel: 'L', flair: { text: 'flash:loop' } },
|
||||
{ value: 'other', label: 'Other', shortLabel: '?', flair: { text: 'flash:other' } },
|
||||
];
|
||||
|
||||
const FLASH_TAG_OPTIONS_BY_CODE = new Map(FLASH_TAG_OPTIONS.map((option) => [option.value, option]));
|
||||
|
||||
const getDirectoryCode = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined => {
|
||||
const directoryCode = directory?.directoryCode?.trim().toLowerCase();
|
||||
return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
|
||||
};
|
||||
|
||||
export const isFlashDirectoryCode = (directoryCode: string | undefined): boolean => directoryCode?.toLowerCase() === 'f';
|
||||
|
||||
export const isFlashDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): boolean =>
|
||||
isFlashDirectoryCode(getDirectoryCode(directory));
|
||||
|
||||
export const getFlashTagOption = (value: string | undefined): FlashTagOption | undefined => FLASH_TAG_OPTIONS_BY_CODE.get(value as FlashTagCode);
|
||||
|
||||
export const getFlashTagPublishOptionsForDirectoryCode = (directoryCode: string | undefined, value: string | undefined) => {
|
||||
if (!isFlashDirectoryCode(directoryCode)) {
|
||||
return { flairs: undefined };
|
||||
}
|
||||
|
||||
const option = getFlashTagOption(value);
|
||||
return { flairs: option ? [option.flair] : undefined };
|
||||
};
|
||||
|
||||
export const getFlashTagOptionFromComment = (comment: unknown): FlashTagOption | undefined => {
|
||||
const flairs = comment && typeof comment === 'object' && Array.isArray((comment as { flairs?: unknown }).flairs) ? (comment as { flairs: unknown[] }).flairs : [];
|
||||
|
||||
for (const flair of flairs) {
|
||||
if (!flair || typeof flair !== 'object') continue;
|
||||
const text = (flair as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') continue;
|
||||
const match = text.match(/^flash:([a-z]+)$/);
|
||||
if (!match) continue;
|
||||
const option = FLASH_TAG_OPTIONS_BY_CODE.get(match[1] as FlashTagCode);
|
||||
if (option) return option;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -21,6 +21,11 @@ describe('direct-url', () => {
|
||||
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for Flash movie extensions', () => {
|
||||
expect(isDirectMediaUrl('https://example.com/movie.swf')).toBe(true);
|
||||
expect(isDirectMediaUrl('https://example.com/movie.SWF?download=1')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips query strings and fragments before checking', () => {
|
||||
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
|
||||
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** File extensions that denote direct media URLs (images + videos) */
|
||||
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'] as const;
|
||||
/** File extensions that denote direct media URLs (images, videos, and Flash movies) */
|
||||
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv', '.swf'] as const;
|
||||
|
||||
/** Returns true if the URL appears to point to a direct media file (image or video) */
|
||||
/** Returns true if the URL appears to point to a direct media file */
|
||||
export function isDirectMediaUrl(url: string): boolean {
|
||||
try {
|
||||
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeDirectoryList, sortDirectoryBoardsByRank } from '../directory-list-utils';
|
||||
import { normalizeDirectoryList, sortDirectoryBoardsByRank, sortDirectoryLists } from '../directory-list-utils';
|
||||
|
||||
describe('directory-list-utils', () => {
|
||||
it('preserves board scores and uses them for ranking', () => {
|
||||
@@ -20,4 +20,36 @@ describe('directory-list-utils', () => {
|
||||
]);
|
||||
expect(sortDirectoryBoardsByRank(list?.boards ?? [])[0]?.address).toBe('higher-score.bso');
|
||||
});
|
||||
|
||||
it('orders the Flash directory with the classic board set', () => {
|
||||
expect(
|
||||
sortDirectoryLists([
|
||||
{ directoryCode: 'co', boards: [{ address: 'comics.bso' }] },
|
||||
{ directoryCode: 'f', boards: [{ address: 'flash-posting.bso' }] },
|
||||
{ directoryCode: 'a', boards: [{ address: 'anime.bso' }] },
|
||||
]).map((list) => list.directoryCode),
|
||||
).toEqual(['a', 'f', 'co']);
|
||||
});
|
||||
|
||||
it('preserves rules from the directory list', () => {
|
||||
const list = normalizeDirectoryList(
|
||||
{
|
||||
directoryCode: 'f',
|
||||
rules: ['Tag uploaded files.'],
|
||||
boards: [{ address: 'flash-posting.bso' }],
|
||||
},
|
||||
'f',
|
||||
{
|
||||
directories: {
|
||||
f: {
|
||||
directoryCode: 'f',
|
||||
title: '/f/ - Flash',
|
||||
rules: ['Ignored because defaults rules are not vendored.'],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(list?.rules).toEqual(['Tag uploaded files.']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ describe('media-utils', () => {
|
||||
expect(getDisplayMediaInfoType('iframe', t)).toBe('translated:iframe');
|
||||
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
|
||||
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
|
||||
expect(getDisplayMediaInfoType('swf', t)).toBe('SWF');
|
||||
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
|
||||
});
|
||||
|
||||
@@ -107,6 +108,7 @@ describe('media-utils', () => {
|
||||
expect(getHasThumbnail({ type: 'video', url: 'https://example.com/file.mp4' }, 'https://example.com/file.mp4')).toBe(true);
|
||||
expect(getHasThumbnail({ type: 'audio', url: 'https://example.com/file.mp3' }, 'https://example.com/file.mp3')).toBe(true);
|
||||
expect(getHasThumbnail({ type: 'gif', url: 'https://example.com/file.gif' }, 'https://example.com/file.gif')).toBe(true);
|
||||
expect(getHasThumbnail({ type: 'swf', url: 'https://example.com/file.swf' }, 'https://example.com/file.swf')).toBe(true);
|
||||
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
|
||||
expect(
|
||||
getHasThumbnail(
|
||||
@@ -132,6 +134,7 @@ describe('media-utils', () => {
|
||||
expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' });
|
||||
expect(getLinkMediaInfo('https://example.com/file.mp4')).toMatchObject({ type: 'video' });
|
||||
expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' });
|
||||
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
|
||||
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
|
||||
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
@@ -201,6 +204,7 @@ describe('media-utils', () => {
|
||||
expect(getMediaDimensions({ type: 'audio', url: 'https://example.com/file.mp3' })).toBe('700x240');
|
||||
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'image', url: 'https://example.com/file.png' })).toBe('640x480');
|
||||
expect(getMediaDimensions({ linkHeight: 720, linkWidth: 1280, type: 'video', url: 'https://example.com/file.mp4' })).toBe('1280x720');
|
||||
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'swf', url: 'https://example.com/file.swf' })).toBe('640x480');
|
||||
expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe('');
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface DirectoryList {
|
||||
title?: string;
|
||||
description?: string;
|
||||
features?: DirectoryFeatures;
|
||||
rules?: string[];
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
boards: DirectoryListBoard[];
|
||||
@@ -54,6 +55,7 @@ interface DirectoryDefaultsEntry {
|
||||
directoryCode?: string;
|
||||
title?: string;
|
||||
features?: DirectoryFeatures;
|
||||
rules?: string[];
|
||||
}
|
||||
|
||||
export interface DirectoryDefaultsData {
|
||||
@@ -66,6 +68,7 @@ export interface DirectoryDefaultsData {
|
||||
|
||||
const DIRECTORY_CODE_ORDER = [
|
||||
'a',
|
||||
'f',
|
||||
'co',
|
||||
'ck',
|
||||
'pol',
|
||||
@@ -121,6 +124,15 @@ const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
|
||||
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
|
||||
};
|
||||
|
||||
const normalizeRules = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rules = value.filter((rule): rule is string => typeof rule === 'string' && rule.length > 0);
|
||||
return rules.length > 0 ? rules : undefined;
|
||||
};
|
||||
|
||||
const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => {
|
||||
if (!isRecord(raw)) {
|
||||
return { directoryCode: code };
|
||||
@@ -128,10 +140,12 @@ const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryD
|
||||
|
||||
const directoryCode = toString(raw.directoryCode) ?? code;
|
||||
const features = normalizeFeatures(raw.features);
|
||||
const rules = normalizeRules(raw.rules);
|
||||
return {
|
||||
directoryCode,
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(features ? { features } : {}),
|
||||
...(rules ? { rules } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -218,12 +232,14 @@ export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defau
|
||||
const defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode];
|
||||
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
|
||||
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
|
||||
const rules = normalizeRules(raw.rules);
|
||||
|
||||
return {
|
||||
directoryCode,
|
||||
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(features ? { features } : {}),
|
||||
...(rules ? { rules } : {}),
|
||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||
boards,
|
||||
|
||||
@@ -33,6 +33,8 @@ export const getDisplayMediaInfoType = (type: string, t: Translate) => {
|
||||
return t('video');
|
||||
case 'audio':
|
||||
return t('audio');
|
||||
case 'swf':
|
||||
return 'SWF';
|
||||
default:
|
||||
return t('webpage');
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export const getHasThumbnail = memoize(
|
||||
|
||||
const { type, thumbnail, patternThumbnailUrl } = commentMediaInfo;
|
||||
|
||||
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif') return true;
|
||||
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif' || type === 'swf') return true;
|
||||
if (type === 'webpage' && thumbnail) return true;
|
||||
if (type === 'iframe' && (patternThumbnailUrl || thumbnail)) return true;
|
||||
|
||||
@@ -79,7 +81,8 @@ const getPatternThumbnailUrl = (url: URL): string | undefined => {
|
||||
const KNOWN_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'];
|
||||
const KNOWN_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v'];
|
||||
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
|
||||
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS]);
|
||||
const KNOWN_SWF_EXTENSIONS = ['swf'];
|
||||
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
|
||||
|
||||
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
|
||||
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
|
||||
@@ -154,6 +157,8 @@ export const getLinkMediaInfo = memoize(
|
||||
type = 'video';
|
||||
} else if (KNOWN_AUDIO_EXTENSIONS.includes(extension)) {
|
||||
type = 'audio';
|
||||
} else if (KNOWN_SWF_EXTENSIONS.includes(extension)) {
|
||||
type = 'swf';
|
||||
}
|
||||
// Unknown extensions remain as 'webpage'
|
||||
|
||||
@@ -297,7 +302,7 @@ export const getMediaDimensions = memoize(
|
||||
}
|
||||
} else if (type === 'audio') {
|
||||
return '700x240';
|
||||
} else if (type === 'image' || type === 'video' || type === 'gif') {
|
||||
} else if (type === 'image' || type === 'video' || type === 'gif' || type === 'swf') {
|
||||
if (linkWidth && linkHeight) {
|
||||
return `${linkWidth}x${linkHeight}`;
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
declare module '@ruffle-rs/ruffle' {
|
||||
const ruffle: unknown;
|
||||
export default ruffle;
|
||||
}
|
||||
|
||||
interface RufflePlayerElement extends HTMLElement {
|
||||
ruffle?: () => {
|
||||
load: (source: string | Record<string, unknown>) => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
interface RuffleSource {
|
||||
createPlayer?: () => RufflePlayerElement;
|
||||
}
|
||||
|
||||
interface RufflePlayerApi {
|
||||
config?: Record<string, unknown>;
|
||||
newest?: () => RuffleSource | null;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
RufflePlayer?: RufflePlayerApi;
|
||||
}
|
||||
@@ -10,17 +10,26 @@ import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../.
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
type TestComment = {
|
||||
author?: {
|
||||
displayName?: string;
|
||||
};
|
||||
cid?: string;
|
||||
content?: string;
|
||||
flairs?: Array<{ text?: string }>;
|
||||
index?: number;
|
||||
link?: string;
|
||||
number?: number | string;
|
||||
parentCid?: string;
|
||||
pinned?: boolean;
|
||||
postNumber?: number | string;
|
||||
communityAddress?: string;
|
||||
deleted?: boolean;
|
||||
postCid?: string;
|
||||
replyCount?: number;
|
||||
removed?: boolean;
|
||||
state?: string;
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type TestCommunity = {
|
||||
@@ -42,7 +51,7 @@ const testState = vi.hoisted(() => ({
|
||||
address: 'music-posting.eth',
|
||||
features: { postsPerPage: 2 },
|
||||
},
|
||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
|
||||
feed: [] as TestComment[],
|
||||
feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
||||
feedState: undefined as string | undefined,
|
||||
@@ -539,6 +548,120 @@ describe('Board', () => {
|
||||
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('renders flash board posts as table rows instead of the normal feed', async () => {
|
||||
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
|
||||
testState.directoryByAddress = {
|
||||
'flash-posting.bso': {
|
||||
address: 'flash-posting.bso',
|
||||
directoryCode: 'f',
|
||||
features: { postsPerPage: 50 },
|
||||
title: '/f/ - Flash',
|
||||
},
|
||||
};
|
||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'flash-posting.bso',
|
||||
state: 'ready',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
testState.communitySnapshot = {
|
||||
shortAddress: 'flash-posting.bso',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
testState.hasMore = true;
|
||||
testState.feed = [
|
||||
{
|
||||
author: { displayName: 'FlashAnon' },
|
||||
cid: 'flash-cid',
|
||||
communityAddress: 'flash-posting.bso',
|
||||
flairs: [{ text: 'flash:game' }],
|
||||
link: 'https://files.catbox.moe/movie.swf',
|
||||
number: 3524333,
|
||||
postCid: 'flash-cid',
|
||||
replyCount: 4,
|
||||
timestamp: 1_704_067_200,
|
||||
title: 'Flash game',
|
||||
},
|
||||
];
|
||||
|
||||
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
||||
|
||||
const table = container.querySelector('#flash-list');
|
||||
expect(table).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="post"]')).toBeNull();
|
||||
expect(table?.querySelectorAll('tbody tr').length).toBe(1);
|
||||
expect(table?.textContent).toContain('3524333');
|
||||
expect(table?.textContent).toContain('FlashAnon');
|
||||
expect(table?.textContent).toContain('movie.swf');
|
||||
expect(table?.textContent).toContain('[G]');
|
||||
expect(table?.textContent).toContain('Flash game');
|
||||
expect(table?.textContent).toContain('4');
|
||||
expect(table?.querySelector('a[href="/f/thread/flash-cid"]')?.textContent).toBe('3524333');
|
||||
expect(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders an empty flash table when the board has no posts', async () => {
|
||||
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
|
||||
testState.directoryByAddress = {
|
||||
'flash-posting.bso': {
|
||||
address: 'flash-posting.bso',
|
||||
directoryCode: 'f',
|
||||
features: { postsPerPage: 50 },
|
||||
title: '/f/ - Flash',
|
||||
},
|
||||
};
|
||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'flash-posting.bso',
|
||||
state: 'succeeded',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
testState.communitySnapshot = {
|
||||
shortAddress: 'flash-posting.bso',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
|
||||
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
||||
|
||||
const table = container.querySelector('#flash-list');
|
||||
expect(table).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="post"]')).toBeNull();
|
||||
expect(table?.textContent).toContain('no posts');
|
||||
});
|
||||
|
||||
it('keeps the flash table in loading state until the empty board feed finishes syncing', async () => {
|
||||
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
|
||||
testState.directoryByAddress = {
|
||||
'flash-posting.bso': {
|
||||
address: 'flash-posting.bso',
|
||||
directoryCode: 'f',
|
||||
features: { postsPerPage: 50 },
|
||||
title: '/f/ - Flash',
|
||||
},
|
||||
};
|
||||
testState.resolvedCommunityAddress = 'flash-posting.bso';
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'flash-posting.bso',
|
||||
state: 'ready',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
testState.communitySnapshot = {
|
||||
shortAddress: 'flash-posting.bso',
|
||||
title: '/f/ - Flash',
|
||||
};
|
||||
testState.hasMore = true;
|
||||
|
||||
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
|
||||
|
||||
const table = container.querySelector('#flash-list');
|
||||
expect(table).toBeTruthy();
|
||||
expect(table?.textContent).not.toContain('no posts');
|
||||
expect(table?.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board');
|
||||
});
|
||||
|
||||
it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
testState.feed = [
|
||||
|
||||
@@ -28,7 +28,9 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
|
||||
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
||||
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||
import { isFlashDirectory, isFlashDirectoryCode } from '../../lib/flash-tags';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import FlashBoardTable from '../../components/flash-board-table/flash-board-table';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import BoardPagination from '../../components/board-pagination';
|
||||
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
||||
@@ -193,12 +195,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
const communities = useCommunityIdentifiers(communityAddresses);
|
||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||
|
||||
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
||||
const requestedBoardIdentifier = boardIdentifierProp || params.boardIdentifier;
|
||||
const shouldUseFlashTable = !isMultiboardView && (isFlashDirectoryCode(requestedBoardIdentifier) || isFlashDirectory(communityDirectory));
|
||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
|
||||
const isMobile = useIsMobile();
|
||||
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
||||
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
||||
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
||||
const effectiveInfiniteScroll = !shouldUseFlashTable && (enableInfiniteScroll || isForcedInfiniteScroll);
|
||||
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
||||
|
||||
const excludeArchivedFilter = useMemo(
|
||||
@@ -524,7 +528,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasMore && !effectiveInfiniteScroll && (
|
||||
{hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
|
||||
<div className={mobileFooterStyles.mobileFooterButtons}>
|
||||
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
|
||||
{t('load_more')}
|
||||
@@ -553,6 +557,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
subscriptions?.length,
|
||||
accountCommunityAddresses?.length,
|
||||
effectiveInfiniteScroll,
|
||||
shouldUseFlashTable,
|
||||
isForcedInfiniteScroll,
|
||||
paginationBasePath,
|
||||
currentPage,
|
||||
@@ -635,6 +640,10 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
communityIdentifier.publicKey.length > 0 &&
|
||||
communityData?.nameResolved === false;
|
||||
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
|
||||
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
|
||||
const isFeedSucceeded = feedState === 'succeeded';
|
||||
const shouldShowFlashTableLoading =
|
||||
shouldUseFlashTable && displayFeed.length === 0 && !(isLoadedCommunityState && isFeedSucceeded) && communityState !== 'failed' && feedState !== 'failed';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -646,7 +655,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
</div>
|
||||
)}
|
||||
{shouldShowUnverifiedAddressWarning && <output className={styles.addressWarning}>{t('board_address_unverified_warning')}</output>}
|
||||
{effectiveInfiniteScroll ? (
|
||||
{shouldUseFlashTable ? (
|
||||
<>
|
||||
<FlashBoardTable boardBasePath={paginationBasePath} isLoading={shouldShowFlashTableLoading} posts={displayFeed} />
|
||||
<footerComponents.Footer />
|
||||
</>
|
||||
) : effectiveInfiniteScroll ? (
|
||||
<Virtuoso
|
||||
defaultItemHeight={defaultBoardItemHeight}
|
||||
{...boardSizingProps}
|
||||
|
||||
@@ -148,6 +148,10 @@
|
||||
vertical-align: -1px;
|
||||
}
|
||||
|
||||
.flashTag {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.authorFlag {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
+47
-4
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
@@ -56,6 +56,8 @@ const baselineAppShellUrls = new Set([
|
||||
'manifest-icon-192x192.png',
|
||||
'manifest-icon-512x512.png',
|
||||
]);
|
||||
const ruffleRuntimeDirectory = new URL('./node_modules/@ruffle-rs/ruffle/', import.meta.url);
|
||||
const ruffleRuntimeFilePattern = /^(?:ruffle\.js|core\.ruffle\.[\da-f]+\.js|[\da-f]+\.wasm)$/;
|
||||
|
||||
function readGitRef(args) {
|
||||
try {
|
||||
@@ -192,6 +194,48 @@ function appVersionMetadataPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
function getRuffleRuntimeFileNames() {
|
||||
return readdirSync(ruffleRuntimeDirectory).filter((fileName) => ruffleRuntimeFilePattern.test(fileName));
|
||||
}
|
||||
|
||||
function getRuffleRuntimeContentType(fileName) {
|
||||
return fileName.endsWith('.wasm') ? 'application/wasm' : 'application/javascript; charset=utf-8';
|
||||
}
|
||||
|
||||
function ruffleRuntimeAssetsPlugin() {
|
||||
return {
|
||||
name: 'fivechan-ruffle-runtime-assets',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/ruffle/', (req, res, next) => {
|
||||
const fileName = new URL(req.url || '', 'https://example.invalid/').pathname.split('/').pop() || '';
|
||||
|
||||
if (!ruffleRuntimeFilePattern.test(fileName)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = readFileSync(new URL(fileName, ruffleRuntimeDirectory));
|
||||
res.setHeader('Content-Type', getRuffleRuntimeContentType(fileName));
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.end(source);
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
generateBundle() {
|
||||
for (const fileName of getRuffleRuntimeFileNames()) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `ruffle/${fileName}`,
|
||||
source: readFileSync(new URL(fileName, ruffleRuntimeDirectory)),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getVercelContentSecurityPolicy() {
|
||||
const vercelConfig = JSON.parse(readFileSync(new URL('./vercel.json', import.meta.url), 'utf8'));
|
||||
const cspHeader = vercelConfig.headers
|
||||
@@ -228,9 +272,7 @@ function verifyVercelCspHashesPlugin() {
|
||||
closeBundle() {
|
||||
const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8');
|
||||
const contentSecurityPolicy = getVercelContentSecurityPolicy();
|
||||
const missingHashes = getInlineScriptHashes(indexHtml).filter(
|
||||
(hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash),
|
||||
);
|
||||
const missingHashes = getInlineScriptHashes(indexHtml).filter((hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash));
|
||||
|
||||
if (missingHashes.length > 0) {
|
||||
const plural = missingHashes.length === 1 ? '' : 'es';
|
||||
@@ -280,6 +322,7 @@ function adaptReactPluginForRolldown(plugin) {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
appVersionMetadataPlugin(),
|
||||
ruffleRuntimeAssetsPlugin(),
|
||||
...react({
|
||||
babel: {
|
||||
plugins: [
|
||||
|
||||
@@ -28,6 +28,7 @@ __metadata:
|
||||
"@pkcprotocol/pkc-js": "npm:0.0.37"
|
||||
"@react-spring/web": "npm:10.0.3"
|
||||
"@reforged/maker-appimage": "npm:5.1.1"
|
||||
"@ruffle-rs/ruffle": "npm:0.2.0"
|
||||
"@types/lodash": "npm:4.17.24"
|
||||
"@types/memoizee": "npm:0.4.9"
|
||||
"@types/node": "npm:20.19.37"
|
||||
@@ -6386,6 +6387,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ruffle-rs/ruffle@npm:0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "@ruffle-rs/ruffle@npm:0.2.0"
|
||||
checksum: 10c0/5fc32eebc111743f6273ca238bee18fb7dd510ca05f81a3f6d6530d8c1b91250d0fcf2a9a23225be1d5d0ffd6000fc62b9531b59719b937399fc11fc088020cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@scure/base@npm:~1.2.5":
|
||||
version: 1.2.6
|
||||
resolution: "@scure/base@npm:1.2.6"
|
||||
|
||||
Reference in New Issue
Block a user