mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'master' of github.com:bitsocialnet/5chan
This commit is contained in:
@@ -44,7 +44,14 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||||
intent.setType("*/*");
|
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);
|
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
|
||||||
startActivityForResult(call, intent, "pickFileResult");
|
startActivityForResult(call, intent, "pickFileResult");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
|||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
/** File extensions that denote direct media URLs (mirrors src/lib/media-hosting/direct-url.ts) */
|
/** 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. */
|
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
|
||||||
export function isDirectMediaUrl(url) {
|
export function isDirectMediaUrl(url) {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ describe('media-upload-automation', () => {
|
|||||||
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
|
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', () => {
|
it('strips query strings and fragments before checking', () => {
|
||||||
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
|
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
|
||||||
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
|
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@floating-ui/react": "0.26.1",
|
"@floating-ui/react": "0.26.1",
|
||||||
"@pkcprotocol/pkc-js": "0.0.37",
|
"@pkcprotocol/pkc-js": "0.0.37",
|
||||||
"@react-spring/web": "10.0.3",
|
"@react-spring/web": "10.0.3",
|
||||||
|
"@ruffle-rs/ruffle": "0.2.0",
|
||||||
"@types/node": "20.19.37",
|
"@types/node": "20.19.37",
|
||||||
"@types/react": "18.2.25",
|
"@types/react": "18.2.25",
|
||||||
"@types/react-dom": "18.2.10",
|
"@types/react-dom": "18.2.10",
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "مدة الحظر بالأيام",
|
"ban_duration_days": "مدة الحظر بالأيام",
|
||||||
"challenge_answer": "إجابة التحدي",
|
"challenge_answer": "إجابة التحدي",
|
||||||
"ban": "حظر",
|
"ban": "حظر",
|
||||||
"timestamp": "الطابع الزمني"
|
"timestamp": "الطابع الزمني",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "ব্যান মেয়াদ দিনে",
|
"ban_duration_days": "ব্যান মেয়াদ দিনে",
|
||||||
"challenge_answer": "চ্যালেঞ্জ উত্তর",
|
"challenge_answer": "চ্যালেঞ্জ উত্তর",
|
||||||
"ban": "নিষেধ করুন",
|
"ban": "নিষেধ করুন",
|
||||||
"timestamp": "টাইমস্ট্যাম্প"
|
"timestamp": "টাইমস্ট্যাম্প",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "doba zákazu v dnech",
|
"ban_duration_days": "doba zákazu v dnech",
|
||||||
"challenge_answer": "odpověď na výzvu",
|
"challenge_answer": "odpověď na výzvu",
|
||||||
"ban": "zakázat",
|
"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",
|
"ban_duration_days": "banvarighed i dage",
|
||||||
"challenge_answer": "svar på udfordring",
|
"challenge_answer": "svar på udfordring",
|
||||||
"ban": "forbyd",
|
"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",
|
"ban_duration_days": "bannierungsdauer in tagen",
|
||||||
"challenge_answer": "herausforderungsantwort",
|
"challenge_answer": "herausforderungsantwort",
|
||||||
"ban": "sperren",
|
"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": "διάρκεια απαγόρευσης σε ημέρες",
|
"ban_duration_days": "διάρκεια απαγόρευσης σε ημέρες",
|
||||||
"challenge_answer": "απάντηση πρόκλησης",
|
"challenge_answer": "απάντηση πρόκλησης",
|
||||||
"ban": "απαγόρευση",
|
"ban": "απαγόρευση",
|
||||||
"timestamp": "χρονοσήμανση"
|
"timestamp": "χρονοσήμανση",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -416,5 +416,7 @@
|
|||||||
"ban_duration_days": "ban duration in days",
|
"ban_duration_days": "ban duration in days",
|
||||||
"challenge_answer": "challenge answer",
|
"challenge_answer": "challenge answer",
|
||||||
"ban": "ban",
|
"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",
|
"ban_duration_days": "duración del baneo en días",
|
||||||
"challenge_answer": "respuesta del desafío",
|
"challenge_answer": "respuesta del desafío",
|
||||||
"ban": "prohibir",
|
"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": "مدت بن به روز",
|
"ban_duration_days": "مدت بن به روز",
|
||||||
"challenge_answer": "پاسخ چالش",
|
"challenge_answer": "پاسخ چالش",
|
||||||
"ban": "مسدود کردن",
|
"ban": "مسدود کردن",
|
||||||
"timestamp": "مهر زمان"
|
"timestamp": "مهر زمان",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "kiellon kesto päivissä",
|
"ban_duration_days": "kiellon kesto päivissä",
|
||||||
"challenge_answer": "haasteen vastaus",
|
"challenge_answer": "haasteen vastaus",
|
||||||
"ban": "kielto",
|
"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",
|
"ban_duration_days": "tagal ng ban sa araw",
|
||||||
"challenge_answer": "sagot sa hamon",
|
"challenge_answer": "sagot sa hamon",
|
||||||
"ban": "ipagbawal",
|
"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",
|
"ban_duration_days": "durée du bannissement en jours",
|
||||||
"challenge_answer": "réponse au défi",
|
"challenge_answer": "réponse au défi",
|
||||||
"ban": "interdire",
|
"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": "משך הבן בימים",
|
"ban_duration_days": "משך הבן בימים",
|
||||||
"challenge_answer": "תשובת אתגר",
|
"challenge_answer": "תשובת אתגר",
|
||||||
"ban": "חסימה",
|
"ban": "חסימה",
|
||||||
"timestamp": "חותמת זמן"
|
"timestamp": "חותמת זמן",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "प्रतिबंध की अवधि दिनों में",
|
"ban_duration_days": "प्रतिबंध की अवधि दिनों में",
|
||||||
"challenge_answer": "चुनौती उत्तर",
|
"challenge_answer": "चुनौती उत्तर",
|
||||||
"ban": "प्रतिबंध",
|
"ban": "प्रतिबंध",
|
||||||
"timestamp": "टाइमस्टैंप"
|
"timestamp": "टाइमस्टैंप",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "kitiltás időtartama napokban",
|
"ban_duration_days": "kitiltás időtartama napokban",
|
||||||
"challenge_answer": "kihívás válasza",
|
"challenge_answer": "kihívás válasza",
|
||||||
"ban": "tiltás",
|
"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",
|
"ban_duration_days": "durasi larangan dalam hari",
|
||||||
"challenge_answer": "jawaban tantangan",
|
"challenge_answer": "jawaban tantangan",
|
||||||
"ban": "larangan",
|
"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",
|
"ban_duration_days": "durata del ban in giorni",
|
||||||
"challenge_answer": "risposta alla sfida",
|
"challenge_answer": "risposta alla sfida",
|
||||||
"ban": "vietare",
|
"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": "バン期間(日数)",
|
"ban_duration_days": "バン期間(日数)",
|
||||||
"challenge_answer": "チャレンジ回答",
|
"challenge_answer": "チャレンジ回答",
|
||||||
"ban": "ブロック",
|
"ban": "ブロック",
|
||||||
"timestamp": "タイムスタンプ"
|
"timestamp": "タイムスタンプ",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "차단 기간(일)",
|
"ban_duration_days": "차단 기간(일)",
|
||||||
"challenge_answer": "챌린지 답변",
|
"challenge_answer": "챌린지 답변",
|
||||||
"ban": "차단",
|
"ban": "차단",
|
||||||
"timestamp": "타임스탬프"
|
"timestamp": "타임스탬프",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "बंद मुदत दिवसांमध्ये",
|
"ban_duration_days": "बंद मुदत दिवसांमध्ये",
|
||||||
"challenge_answer": "आव्हान उत्तर",
|
"challenge_answer": "आव्हान उत्तर",
|
||||||
"ban": "प्रतिबंध",
|
"ban": "प्रतिबंध",
|
||||||
"timestamp": "टाइमस्टॅम्प"
|
"timestamp": "टाइमस्टॅम्प",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "banningsduur in dagen",
|
"ban_duration_days": "banningsduur in dagen",
|
||||||
"challenge_answer": "antwoord op uitdaging",
|
"challenge_answer": "antwoord op uitdaging",
|
||||||
"ban": "verbannen",
|
"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",
|
"ban_duration_days": "bannvarighet i dager",
|
||||||
"challenge_answer": "svar på utfordring",
|
"challenge_answer": "svar på utfordring",
|
||||||
"ban": "forbud",
|
"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",
|
"ban_duration_days": "czas trwania banu w dniach",
|
||||||
"challenge_answer": "odpowiedź na wyzwanie",
|
"challenge_answer": "odpowiedź na wyzwanie",
|
||||||
"ban": "zbanować",
|
"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",
|
"ban_duration_days": "duração do banimento em dias",
|
||||||
"challenge_answer": "resposta do desafio",
|
"challenge_answer": "resposta do desafio",
|
||||||
"ban": "banir",
|
"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",
|
"ban_duration_days": "durata banului în zile",
|
||||||
"challenge_answer": "răspunsul la provocare",
|
"challenge_answer": "răspunsul la provocare",
|
||||||
"ban": "interzice",
|
"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": "продолжительность бана в днях",
|
"ban_duration_days": "продолжительность бана в днях",
|
||||||
"challenge_answer": "ответ на задачу",
|
"challenge_answer": "ответ на задачу",
|
||||||
"ban": "блокировка",
|
"ban": "блокировка",
|
||||||
"timestamp": "временная метка"
|
"timestamp": "временная метка",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "kohëzgjatja e ndalimit në ditë",
|
"ban_duration_days": "kohëzgjatja e ndalimit në ditë",
|
||||||
"challenge_answer": "përgjigje ndaj sfidës",
|
"challenge_answer": "përgjigje ndaj sfidës",
|
||||||
"ban": "ndalim",
|
"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",
|
"ban_duration_days": "bannlängd i dagar",
|
||||||
"challenge_answer": "svar på utmaning",
|
"challenge_answer": "svar på utmaning",
|
||||||
"ban": "förbjud",
|
"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": "నిషేధ వ్యవధి రోజుల్లో",
|
"ban_duration_days": "నిషేధ వ్యవధి రోజుల్లో",
|
||||||
"challenge_answer": "సవాలు సమాధానం",
|
"challenge_answer": "సవాలు సమాధానం",
|
||||||
"ban": "నిషేధం",
|
"ban": "నిషేధం",
|
||||||
"timestamp": "టైమ్స్టాంప్"
|
"timestamp": "టైమ్స్టాంప్",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "ระยะเวลาแบนในวัน",
|
"ban_duration_days": "ระยะเวลาแบนในวัน",
|
||||||
"challenge_answer": "คำตอบความท้าทาย",
|
"challenge_answer": "คำตอบความท้าทาย",
|
||||||
"ban": "แบน",
|
"ban": "แบน",
|
||||||
"timestamp": "แสตมป์เวลา"
|
"timestamp": "แสตมป์เวลา",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "ban süresi gün cinsinden",
|
"ban_duration_days": "ban süresi gün cinsinden",
|
||||||
"challenge_answer": "zorluk yanıtı",
|
"challenge_answer": "zorluk yanıtı",
|
||||||
"ban": "yasakla",
|
"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": "тривалість бану в днях",
|
"ban_duration_days": "тривалість бану в днях",
|
||||||
"challenge_answer": "відповідь на виклик",
|
"challenge_answer": "відповідь на виклик",
|
||||||
"ban": "заблокувати",
|
"ban": "заблокувати",
|
||||||
"timestamp": "часова мітка"
|
"timestamp": "часова мітка",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "بین کی مدت دنوں میں",
|
"ban_duration_days": "بین کی مدت دنوں میں",
|
||||||
"challenge_answer": "چ یلنج کا جواب",
|
"challenge_answer": "چ یلنج کا جواب",
|
||||||
"ban": "روک",
|
"ban": "روک",
|
||||||
"timestamp": "ٹائم سٹیمپ"
|
"timestamp": "ٹائم سٹیمپ",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,7 @@
|
|||||||
"ban_duration_days": "thời gian cấm theo ngày",
|
"ban_duration_days": "thời gian cấm theo ngày",
|
||||||
"challenge_answer": "câu trả lời thử thách",
|
"challenge_answer": "câu trả lời thử thách",
|
||||||
"ban": "cấm",
|
"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": "封禁时长(天数)",
|
"ban_duration_days": "封禁时长(天数)",
|
||||||
"challenge_answer": "挑战答案",
|
"challenge_answer": "挑战答案",
|
||||||
"ban": "封禁",
|
"ban": "封禁",
|
||||||
"timestamp": "时间戳"
|
"timestamp": "时间戳",
|
||||||
|
"tag": "tag",
|
||||||
|
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ const DEFAULT_METADATA = {
|
|||||||
|
|
||||||
const DEFAULTS_FILE_NAME = '5chan-directories-defaults.json';
|
const DEFAULTS_FILE_NAME = '5chan-directories-defaults.json';
|
||||||
const DIRECTORY_LIST_FILE_RE = /^5chan-.+-directory\.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 = [
|
const DIRECTORY_CODE_ORDER = [
|
||||||
'a',
|
'a',
|
||||||
|
'f',
|
||||||
'co',
|
'co',
|
||||||
'ck',
|
'ck',
|
||||||
'pol',
|
'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 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 isRecord = (value) => typeof value === 'object' && value !== null;
|
||||||
const isDirectoryListFile = (fileName) => DIRECTORY_LIST_FILE_RE.test(fileName);
|
const isDirectoryListFile = (fileName) => DIRECTORY_LIST_FILE_RE.test(fileName);
|
||||||
const toNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
const toNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
||||||
const toString = (value) => (typeof value === 'string' && value.length > 0 ? 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) => {
|
const normalizeFeatures = (value) => {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -112,10 +158,12 @@ const normalizeDirectoryDefaultsEntry = (code, raw) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const features = normalizeFeatures(raw.features);
|
const features = normalizeFeatures(raw.features);
|
||||||
|
const rules = normalizeRules(raw.rules);
|
||||||
return {
|
return {
|
||||||
directoryCode: toString(raw.directoryCode) || code,
|
directoryCode: toString(raw.directoryCode) || code,
|
||||||
...(toString(raw.title) ? { title: toString(raw.title) } : {}),
|
...(toString(raw.title) ? { title: toString(raw.title) } : {}),
|
||||||
...(features ? { features } : {}),
|
...(features ? { features } : {}),
|
||||||
|
...(rules ? { rules } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,12 +189,14 @@ const normalizeDirectoryList = (raw, fallbackCode, defaults) => {
|
|||||||
const rawCode = toString(raw.directoryCode);
|
const rawCode = toString(raw.directoryCode);
|
||||||
const defaultEntry = defaults?.directories?.[rawCode || fallbackCode] || defaults?.directories?.[fallbackCode];
|
const defaultEntry = defaults?.directories?.[rawCode || fallbackCode] || defaults?.directories?.[fallbackCode];
|
||||||
const features = normalizeFeatures(defaultEntry?.features) || normalizeFeatures(raw.features);
|
const features = normalizeFeatures(defaultEntry?.features) || normalizeFeatures(raw.features);
|
||||||
|
const rules = normalizeRules(raw.rules);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
directoryCode: toString(defaultEntry?.directoryCode) || rawCode || fallbackCode,
|
directoryCode: toString(defaultEntry?.directoryCode) || rawCode || fallbackCode,
|
||||||
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry.title) } : toString(raw.title) ? { title: toString(raw.title) } : {}),
|
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry.title) } : toString(raw.title) ? { title: toString(raw.title) } : {}),
|
||||||
...(toString(raw.description) ? { description: toString(raw.description) } : {}),
|
...(toString(raw.description) ? { description: toString(raw.description) } : {}),
|
||||||
...(features ? { features } : {}),
|
...(features ? { features } : {}),
|
||||||
|
...(rules ? { rules } : {}),
|
||||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||||
boards,
|
boards,
|
||||||
@@ -165,8 +215,14 @@ const sortDirectoryLists = (lists) =>
|
|||||||
return a.directoryCode.localeCompare(b.directoryCode);
|
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 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',
|
(value) => typeof value === 'number',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -174,7 +230,7 @@ const toDirectoryListsData = (directories, fallbackMetadata = DEFAULT_METADATA)
|
|||||||
description: fallbackMetadata.description || DEFAULT_METADATA.description,
|
description: fallbackMetadata.description || DEFAULT_METADATA.description,
|
||||||
createdAt: timestamps.length > 0 ? Math.min(...timestamps) : fallbackMetadata.createdAt,
|
createdAt: timestamps.length > 0 ? Math.min(...timestamps) : fallbackMetadata.createdAt,
|
||||||
updatedAt: timestamps.length > 0 ? Math.max(...timestamps) : fallbackMetadata.updatedAt,
|
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',
|
hostname: 'example.com',
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
unmuteExpandedVideoSound: false,
|
unmuteExpandedVideoSound: false,
|
||||||
|
ruffleLoadMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
@@ -69,6 +70,8 @@ vi.mock('../../embed', () => ({
|
|||||||
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
|
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
|
||||||
@@ -90,6 +93,16 @@ describe('CommentMedia', () => {
|
|||||||
testState.hostname = 'example.com';
|
testState.hostname = 'example.com';
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
testState.unmuteExpandedVideoSound = 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();
|
setShowThumbnailMock = vi.fn();
|
||||||
|
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
@@ -100,6 +113,7 @@ describe('CommentMedia', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
|
delete window.RufflePlayer;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles image expansion on mobile and renders the media metadata', async () => {
|
it('toggles image expansion on mobile and renders the media metadata', async () => {
|
||||||
@@ -370,4 +384,52 @@ describe('CommentMedia', () => {
|
|||||||
expect(video).toBeTruthy();
|
expect(video).toBeTruthy();
|
||||||
expect(video?.muted).toBe(true);
|
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;
|
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 {
|
.content iframe {
|
||||||
border: none;
|
border: none;
|
||||||
color-scheme: light;
|
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 {
|
.fileInfo {
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
|||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import styles from './comment-media.module.css';
|
import styles from './comment-media.module.css';
|
||||||
import Embed, { canEmbed } from '../embed';
|
import Embed, { canEmbed } from '../embed';
|
||||||
|
import RufflePlayer from './ruffle-player';
|
||||||
|
|
||||||
interface MediaProps {
|
interface MediaProps {
|
||||||
commentMediaInfo?: CommentMediaInfo;
|
commentMediaInfo?: CommentMediaInfo;
|
||||||
@@ -143,6 +144,12 @@ const Thumbnail = ({
|
|||||||
) : null;
|
) : null;
|
||||||
} else if (type === 'audio') {
|
} else if (type === 'audio') {
|
||||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
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;
|
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
||||||
@@ -219,6 +226,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
|||||||
)
|
)
|
||||||
) : type === 'video' ? (
|
) : type === 'video' ? (
|
||||||
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
||||||
|
) : type === 'swf' && url ? (
|
||||||
|
<RufflePlayer url={url} />
|
||||||
) : type === 'webpage' ? (
|
) : type === 'webpage' ? (
|
||||||
disableToggle ? (
|
disableToggle ? (
|
||||||
<img src={thumbnail} alt='' />
|
<img src={thumbnail} alt='' />
|
||||||
@@ -237,7 +246,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
|||||||
{mediaDimensions && `, ${mediaDimensions}`})
|
{mediaDimensions && `, ${mediaDimensions}`})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio' || type === 'swf') && (
|
||||||
<div className={styles.closeButton}>
|
<div className={styles.closeButton}>
|
||||||
<button
|
<button
|
||||||
type='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 { canEmbed } from '../embed';
|
||||||
import LoadingEllipsis from '../loading-ellipsis';
|
import LoadingEllipsis from '../loading-ellipsis';
|
||||||
import PostAuthorFlags from '../post-author-flags';
|
import PostAuthorFlags from '../post-author-flags';
|
||||||
|
import PostFlashTag from '../post-flash-tag';
|
||||||
import PostMenuDesktop from './post-menu-desktop';
|
import PostMenuDesktop from './post-menu-desktop';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview';
|
||||||
import Tooltip from '../tooltip';
|
import Tooltip from '../tooltip';
|
||||||
@@ -418,6 +419,7 @@ const PostInfo = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
|
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
|
||||||
|
<PostFlashTag comment={post} directory={directoryEntry} />
|
||||||
<span className={styles.dateTime}>
|
<span className={styles.dateTime}>
|
||||||
{isInModQueueView && isOverThreshold ? (
|
{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.',
|
' 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;
|
return i18nKey;
|
||||||
},
|
},
|
||||||
useTranslation: () => ({
|
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: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||||
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
|
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
|
||||||
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
|
{ 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: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
|
||||||
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
|
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
|
||||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
{ 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 () => {
|
it('validates unsupported options and stores fortune output in post content', async () => {
|
||||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||||
testState.resolvedCommunityAddress = 'random-nsfw.bso';
|
testState.resolvedCommunityAddress = 'random-nsfw.bso';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
|||||||
import { getBoardPath } from '../../lib/utils/route-utils';
|
import { getBoardPath } from '../../lib/utils/route-utils';
|
||||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
|
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 { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
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 FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
|
||||||
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28;
|
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 getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => {
|
||||||
const raw = getPublishURLFilename(url) || uploadedFileName;
|
const raw = getPublishURLFilename(url) || uploadedFileName;
|
||||||
if (!raw) return noFileLabel;
|
if (!raw) return noFileLabel;
|
||||||
@@ -130,6 +136,7 @@ interface PostFormFieldsProps {
|
|||||||
subjectRef: React.Ref<HTMLInputElement>;
|
subjectRef: React.Ref<HTMLInputElement>;
|
||||||
optionsRef: React.RefObject<HTMLInputElement>;
|
optionsRef: React.RefObject<HTMLInputElement>;
|
||||||
flagRef: React.RefObject<HTMLSelectElement>;
|
flagRef: React.RefObject<HTMLSelectElement>;
|
||||||
|
flashTagRef: React.RefObject<HTMLSelectElement>;
|
||||||
textRef: React.RefObject<HTMLTextAreaElement>;
|
textRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
urlRef: React.Ref<HTMLInputElement>;
|
urlRef: React.Ref<HTMLInputElement>;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -156,6 +163,9 @@ interface PostFormFieldsProps {
|
|||||||
rulesPath: string;
|
rulesPath: string;
|
||||||
requirePostLinkIsMedia: boolean;
|
requirePostLinkIsMedia: boolean;
|
||||||
flagOptions: CommentFlagSelectOption[];
|
flagOptions: CommentFlagSelectOption[];
|
||||||
|
flashTagOptions: FlashTagOption[];
|
||||||
|
showFlashTagSelector: boolean;
|
||||||
|
showFlashUploadPrompt: boolean;
|
||||||
showBbcodeToolbar: boolean;
|
showBbcodeToolbar: boolean;
|
||||||
onBbcodePreviewToggle: () => void;
|
onBbcodePreviewToggle: () => void;
|
||||||
onPublishReply: () => void;
|
onPublishReply: () => void;
|
||||||
@@ -177,6 +187,7 @@ const PostFormFields = ({
|
|||||||
subjectRef,
|
subjectRef,
|
||||||
optionsRef,
|
optionsRef,
|
||||||
flagRef,
|
flagRef,
|
||||||
|
flashTagRef,
|
||||||
textRef,
|
textRef,
|
||||||
urlRef,
|
urlRef,
|
||||||
url,
|
url,
|
||||||
@@ -203,6 +214,9 @@ const PostFormFields = ({
|
|||||||
rulesPath,
|
rulesPath,
|
||||||
requirePostLinkIsMedia,
|
requirePostLinkIsMedia,
|
||||||
flagOptions,
|
flagOptions,
|
||||||
|
flashTagOptions,
|
||||||
|
showFlashTagSelector,
|
||||||
|
showFlashUploadPrompt,
|
||||||
showBbcodeToolbar,
|
showBbcodeToolbar,
|
||||||
onBbcodePreviewToggle,
|
onBbcodePreviewToggle,
|
||||||
onPublishReply,
|
onPublishReply,
|
||||||
@@ -373,6 +387,21 @@ const PostFormFields = ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 && (
|
{showOekakiControls && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Draw</td>
|
<td>Draw</td>
|
||||||
@@ -440,6 +469,16 @@ const PostFormFields = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</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}
|
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
|
||||||
</ul>
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
@@ -467,6 +506,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const subjectRef = useRef<HTMLInputElement>(null);
|
const subjectRef = useRef<HTMLInputElement>(null);
|
||||||
const optionsRef = useRef<HTMLInputElement>(null);
|
const optionsRef = useRef<HTMLInputElement>(null);
|
||||||
const flagRef = useRef<HTMLSelectElement>(null);
|
const flagRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const flashTagRef = useRef<HTMLSelectElement>(null);
|
||||||
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
|
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
|
||||||
const diceRollRef = useRef<DiceRoll | null>(null);
|
const diceRollRef = useRef<DiceRoll | null>(null);
|
||||||
const nonokoRedirectPathRef = useRef<string | 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 requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||||
|
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
|
||||||
|
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
|
||||||
|
|
||||||
const accountCommunityAddresses = useAccountCommunityAddresses();
|
const accountCommunityAddresses = useAccountCommunityAddresses();
|
||||||
const accountAddress = account?.author?.address;
|
const accountAddress = account?.author?.address;
|
||||||
@@ -536,6 +578,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
if (flagRef.current) {
|
if (flagRef.current) {
|
||||||
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
|
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
|
||||||
}
|
}
|
||||||
|
if (flashTagRef.current) {
|
||||||
|
flashTagRef.current.value = '';
|
||||||
|
}
|
||||||
checkContentLength.cancel();
|
checkContentLength.cancel();
|
||||||
checkPostOptions.cancel();
|
checkPostOptions.cancel();
|
||||||
fortuneEntryRef.current = null;
|
fortuneEntryRef.current = null;
|
||||||
@@ -597,9 +642,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
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;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
publishPost({ content: publishContent, ...flagPublishOptions });
|
publishPost({ content: publishContent, ...publishOptions });
|
||||||
};
|
};
|
||||||
|
|
||||||
// redirect to pending page when pending comment is created
|
// redirect to pending page when pending comment is created
|
||||||
@@ -785,6 +836,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
subjectRef={subjectRef}
|
subjectRef={subjectRef}
|
||||||
optionsRef={optionsRef}
|
optionsRef={optionsRef}
|
||||||
flagRef={flagRef}
|
flagRef={flagRef}
|
||||||
|
flashTagRef={flashTagRef}
|
||||||
textRef={textRef}
|
textRef={textRef}
|
||||||
urlRef={urlRef}
|
urlRef={urlRef}
|
||||||
url={url}
|
url={url}
|
||||||
@@ -811,6 +863,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
rulesPath={rulesPath}
|
rulesPath={rulesPath}
|
||||||
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
||||||
flagOptions={flagOptions}
|
flagOptions={flagOptions}
|
||||||
|
flashTagOptions={FLASH_TAG_OPTIONS}
|
||||||
|
showFlashTagSelector={showFlashTagSelector}
|
||||||
|
showFlashUploadPrompt={showFlashUploadPrompt}
|
||||||
showBbcodeToolbar={showBbcodeToolbar}
|
showBbcodeToolbar={showBbcodeToolbar}
|
||||||
onBbcodePreviewToggle={handleBbcodePreviewToggle}
|
onBbcodePreviewToggle={handleBbcodePreviewToggle}
|
||||||
onPublishReply={onPublishReply}
|
onPublishReply={onPublishReply}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
|
|||||||
import FailedPublishNotice from '../failed-publish-notice';
|
import FailedPublishNotice from '../failed-publish-notice';
|
||||||
import LoadingEllipsis from '../loading-ellipsis';
|
import LoadingEllipsis from '../loading-ellipsis';
|
||||||
import PostAuthorFlags from '../post-author-flags';
|
import PostAuthorFlags from '../post-author-flags';
|
||||||
|
import PostFlashTag from '../post-flash-tag';
|
||||||
import PostMenuMobile from './post-menu-mobile';
|
import PostMenuMobile from './post-menu-mobile';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview';
|
||||||
import Tooltip from '../tooltip';
|
import Tooltip from '../tooltip';
|
||||||
@@ -351,6 +352,7 @@ const PostInfoAndMedia = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
|
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
|
||||||
|
<PostFlashTag comment={resolvedPost} directory={directoryEntry} />
|
||||||
{pinned && (
|
{pinned && (
|
||||||
<span className={styles.stickyIconWrapper}>
|
<span className={styles.stickyIconWrapper}>
|
||||||
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
|
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"title": "5chan directories",
|
"title": "5chan directories",
|
||||||
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
||||||
"createdAt": 1779182014,
|
"createdAt": 1779182014,
|
||||||
"updatedAt": 1780054375,
|
"updatedAt": 1780123897,
|
||||||
"directories": [
|
"directories": [
|
||||||
{
|
{
|
||||||
"directoryCode": "a",
|
"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",
|
"directoryCode": "co",
|
||||||
"title": "/co/ - Comics & Cartoons",
|
"title": "/co/ - Comics & Cartoons",
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ describe('use-directories', () => {
|
|||||||
updatedAt: 2,
|
updatedAt: 2,
|
||||||
communities: [
|
communities: [
|
||||||
{ address: 'music-posting.bso', title: '/mu/ - Cached Music', nsfw: false },
|
{ 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(fetchMock).toHaveBeenCalled();
|
||||||
expect(latestSnapshot?.state.loading).toBe(false);
|
expect(latestSnapshot?.state.loading).toBe(false);
|
||||||
expect(latestSnapshot?.state.communities.map((community) => community.address)).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.bso']);
|
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash-posting.bso']);
|
||||||
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
|
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
|
||||||
expect(latestSnapshot?.metadata).toEqual({
|
expect(latestSnapshot?.metadata).toEqual({
|
||||||
title: 'Cached directories',
|
title: 'Cached directories',
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ describe('useFileUpload', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
uploadPromise = hook().handleUpload();
|
uploadPromise = hook().handleUpload();
|
||||||
});
|
});
|
||||||
|
expect((document.querySelector('input[type="file"]') as HTMLInputElement | null)?.accept).toContain('.swf');
|
||||||
await selectFileFromHiddenInput(selectedFile);
|
await selectFileFromHiddenInput(selectedFile);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await uploadPromise;
|
await uploadPromise;
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function selectFileViaInput(): Promise<File | null> {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
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';
|
input.style.display = 'none';
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
let focusTimeoutId: number | null = null;
|
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);
|
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', () => {
|
it('strips query strings and fragments before checking', () => {
|
||||||
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
|
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
|
||||||
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
|
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) */
|
/** 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'] as const;
|
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 {
|
export function isDirectMediaUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
|
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('directory-list-utils', () => {
|
||||||
it('preserves board scores and uses them for ranking', () => {
|
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');
|
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('iframe', t)).toBe('translated:iframe');
|
||||||
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
|
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
|
||||||
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
|
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
|
||||||
|
expect(getDisplayMediaInfoType('swf', t)).toBe('SWF');
|
||||||
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
|
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: '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: '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: '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({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
getHasThumbnail(
|
getHasThumbnail(
|
||||||
@@ -132,6 +134,7 @@ describe('media-utils', () => {
|
|||||||
expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' });
|
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.mp4')).toMatchObject({ type: 'video' });
|
||||||
expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' });
|
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://example.com/path')).toMatchObject({ type: 'webpage' });
|
||||||
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
|
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
|
||||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
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({ 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: 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: 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('');
|
expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface DirectoryList {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
features?: DirectoryFeatures;
|
features?: DirectoryFeatures;
|
||||||
|
rules?: string[];
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
boards: DirectoryListBoard[];
|
boards: DirectoryListBoard[];
|
||||||
@@ -54,6 +55,7 @@ interface DirectoryDefaultsEntry {
|
|||||||
directoryCode?: string;
|
directoryCode?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
features?: DirectoryFeatures;
|
features?: DirectoryFeatures;
|
||||||
|
rules?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryDefaultsData {
|
export interface DirectoryDefaultsData {
|
||||||
@@ -66,6 +68,7 @@ export interface DirectoryDefaultsData {
|
|||||||
|
|
||||||
const DIRECTORY_CODE_ORDER = [
|
const DIRECTORY_CODE_ORDER = [
|
||||||
'a',
|
'a',
|
||||||
|
'f',
|
||||||
'co',
|
'co',
|
||||||
'ck',
|
'ck',
|
||||||
'pol',
|
'pol',
|
||||||
@@ -121,6 +124,15 @@ const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
|
|||||||
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : 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 => {
|
const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => {
|
||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
return { directoryCode: code };
|
return { directoryCode: code };
|
||||||
@@ -128,10 +140,12 @@ const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryD
|
|||||||
|
|
||||||
const directoryCode = toString(raw.directoryCode) ?? code;
|
const directoryCode = toString(raw.directoryCode) ?? code;
|
||||||
const features = normalizeFeatures(raw.features);
|
const features = normalizeFeatures(raw.features);
|
||||||
|
const rules = normalizeRules(raw.rules);
|
||||||
return {
|
return {
|
||||||
directoryCode,
|
directoryCode,
|
||||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||||
...(features ? { features } : {}),
|
...(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 defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode];
|
||||||
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
|
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
|
||||||
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
|
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
|
||||||
|
const rules = normalizeRules(raw.rules);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
directoryCode,
|
directoryCode,
|
||||||
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||||
...(features ? { features } : {}),
|
...(features ? { features } : {}),
|
||||||
|
...(rules ? { rules } : {}),
|
||||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||||
boards,
|
boards,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const getDisplayMediaInfoType = (type: string, t: Translate) => {
|
|||||||
return t('video');
|
return t('video');
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return t('audio');
|
return t('audio');
|
||||||
|
case 'swf':
|
||||||
|
return 'SWF';
|
||||||
default:
|
default:
|
||||||
return t('webpage');
|
return t('webpage');
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,7 @@ export const getHasThumbnail = memoize(
|
|||||||
|
|
||||||
const { type, thumbnail, patternThumbnailUrl } = commentMediaInfo;
|
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 === 'webpage' && thumbnail) return true;
|
||||||
if (type === 'iframe' && (patternThumbnailUrl || 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_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_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v'];
|
||||||
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
|
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
|
// 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'];
|
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
|
||||||
@@ -154,6 +157,8 @@ export const getLinkMediaInfo = memoize(
|
|||||||
type = 'video';
|
type = 'video';
|
||||||
} else if (KNOWN_AUDIO_EXTENSIONS.includes(extension)) {
|
} else if (KNOWN_AUDIO_EXTENSIONS.includes(extension)) {
|
||||||
type = 'audio';
|
type = 'audio';
|
||||||
|
} else if (KNOWN_SWF_EXTENSIONS.includes(extension)) {
|
||||||
|
type = 'swf';
|
||||||
}
|
}
|
||||||
// Unknown extensions remain as 'webpage'
|
// Unknown extensions remain as 'webpage'
|
||||||
|
|
||||||
@@ -297,7 +302,7 @@ export const getMediaDimensions = memoize(
|
|||||||
}
|
}
|
||||||
} else if (type === 'audio') {
|
} else if (type === 'audio') {
|
||||||
return '700x240';
|
return '700x240';
|
||||||
} else if (type === 'image' || type === 'video' || type === 'gif') {
|
} else if (type === 'image' || type === 'video' || type === 'gif' || type === 'swf') {
|
||||||
if (linkWidth && linkHeight) {
|
if (linkWidth && linkHeight) {
|
||||||
return `${linkWidth}x${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>;
|
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
type TestComment = {
|
type TestComment = {
|
||||||
|
author?: {
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
cid?: string;
|
cid?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
flairs?: Array<{ text?: string }>;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
link?: string;
|
||||||
|
number?: number | string;
|
||||||
parentCid?: string;
|
parentCid?: string;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
|
postNumber?: number | string;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
postCid?: string;
|
postCid?: string;
|
||||||
|
replyCount?: number;
|
||||||
removed?: boolean;
|
removed?: boolean;
|
||||||
state?: string;
|
state?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestCommunity = {
|
type TestCommunity = {
|
||||||
@@ -42,7 +51,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
features: { postsPerPage: 2 },
|
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[],
|
feed: [] as TestComment[],
|
||||||
feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
||||||
feedState: undefined as string | undefined,
|
feedState: undefined as string | undefined,
|
||||||
@@ -539,6 +548,120 @@ describe('Board', () => {
|
|||||||
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
|
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 () => {
|
it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => {
|
||||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||||
testState.feed = [
|
testState.feed = [
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
|||||||
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
|
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
|
||||||
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
||||||
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
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 ErrorDisplay from '../../components/error-display/error-display';
|
||||||
|
import FlashBoardTable from '../../components/flash-board-table/flash-board-table';
|
||||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||||
import BoardPagination from '../../components/board-pagination';
|
import BoardPagination from '../../components/board-pagination';
|
||||||
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
||||||
@@ -193,12 +195,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
const communities = useCommunityIdentifiers(communityAddresses);
|
const communities = useCommunityIdentifiers(communityAddresses);
|
||||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
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 enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||||
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
|
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
||||||
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
const effectiveInfiniteScroll = !shouldUseFlashTable && (enableInfiniteScroll || isForcedInfiniteScroll);
|
||||||
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
|
||||||
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
||||||
|
|
||||||
const excludeArchivedFilter = useMemo(
|
const excludeArchivedFilter = useMemo(
|
||||||
@@ -524,7 +528,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasMore && !effectiveInfiniteScroll && (
|
{hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
|
||||||
<div className={mobileFooterStyles.mobileFooterButtons}>
|
<div className={mobileFooterStyles.mobileFooterButtons}>
|
||||||
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
|
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
|
||||||
{t('load_more')}
|
{t('load_more')}
|
||||||
@@ -553,6 +557,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
subscriptions?.length,
|
subscriptions?.length,
|
||||||
accountCommunityAddresses?.length,
|
accountCommunityAddresses?.length,
|
||||||
effectiveInfiniteScroll,
|
effectiveInfiniteScroll,
|
||||||
|
shouldUseFlashTable,
|
||||||
isForcedInfiniteScroll,
|
isForcedInfiniteScroll,
|
||||||
paginationBasePath,
|
paginationBasePath,
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -635,6 +640,10 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
communityIdentifier.publicKey.length > 0 &&
|
communityIdentifier.publicKey.length > 0 &&
|
||||||
communityData?.nameResolved === false;
|
communityData?.nameResolved === false;
|
||||||
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -646,7 +655,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shouldShowUnverifiedAddressWarning && <output className={styles.addressWarning}>{t('board_address_unverified_warning')}</output>}
|
{shouldShowUnverifiedAddressWarning && <output className={styles.addressWarning}>{t('board_address_unverified_warning')}</output>}
|
||||||
{effectiveInfiniteScroll ? (
|
{shouldUseFlashTable ? (
|
||||||
|
<>
|
||||||
|
<FlashBoardTable boardBasePath={paginationBasePath} isLoading={shouldShowFlashTableLoading} posts={displayFeed} />
|
||||||
|
<footerComponents.Footer />
|
||||||
|
</>
|
||||||
|
) : effectiveInfiniteScroll ? (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
defaultItemHeight={defaultBoardItemHeight}
|
defaultItemHeight={defaultBoardItemHeight}
|
||||||
{...boardSizingProps}
|
{...boardSizingProps}
|
||||||
|
|||||||
@@ -148,6 +148,10 @@
|
|||||||
vertical-align: -1px;
|
vertical-align: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flashTag {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.authorFlag {
|
.authorFlag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
+47
-4
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { readFileSync } from 'fs';
|
import { readdirSync, readFileSync } from 'fs';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
@@ -56,6 +56,8 @@ const baselineAppShellUrls = new Set([
|
|||||||
'manifest-icon-192x192.png',
|
'manifest-icon-192x192.png',
|
||||||
'manifest-icon-512x512.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) {
|
function readGitRef(args) {
|
||||||
try {
|
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() {
|
function getVercelContentSecurityPolicy() {
|
||||||
const vercelConfig = JSON.parse(readFileSync(new URL('./vercel.json', import.meta.url), 'utf8'));
|
const vercelConfig = JSON.parse(readFileSync(new URL('./vercel.json', import.meta.url), 'utf8'));
|
||||||
const cspHeader = vercelConfig.headers
|
const cspHeader = vercelConfig.headers
|
||||||
@@ -228,9 +272,7 @@ function verifyVercelCspHashesPlugin() {
|
|||||||
closeBundle() {
|
closeBundle() {
|
||||||
const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8');
|
const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8');
|
||||||
const contentSecurityPolicy = getVercelContentSecurityPolicy();
|
const contentSecurityPolicy = getVercelContentSecurityPolicy();
|
||||||
const missingHashes = getInlineScriptHashes(indexHtml).filter(
|
const missingHashes = getInlineScriptHashes(indexHtml).filter((hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash));
|
||||||
(hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingHashes.length > 0) {
|
if (missingHashes.length > 0) {
|
||||||
const plural = missingHashes.length === 1 ? '' : 'es';
|
const plural = missingHashes.length === 1 ? '' : 'es';
|
||||||
@@ -280,6 +322,7 @@ function adaptReactPluginForRolldown(plugin) {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
appVersionMetadataPlugin(),
|
appVersionMetadataPlugin(),
|
||||||
|
ruffleRuntimeAssetsPlugin(),
|
||||||
...react({
|
...react({
|
||||||
babel: {
|
babel: {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ __metadata:
|
|||||||
"@pkcprotocol/pkc-js": "npm:0.0.37"
|
"@pkcprotocol/pkc-js": "npm:0.0.37"
|
||||||
"@react-spring/web": "npm:10.0.3"
|
"@react-spring/web": "npm:10.0.3"
|
||||||
"@reforged/maker-appimage": "npm:5.1.1"
|
"@reforged/maker-appimage": "npm:5.1.1"
|
||||||
|
"@ruffle-rs/ruffle": "npm:0.2.0"
|
||||||
"@types/lodash": "npm:4.17.24"
|
"@types/lodash": "npm:4.17.24"
|
||||||
"@types/memoizee": "npm:0.4.9"
|
"@types/memoizee": "npm:0.4.9"
|
||||||
"@types/node": "npm:20.19.37"
|
"@types/node": "npm:20.19.37"
|
||||||
@@ -6386,6 +6387,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@scure/base@npm:~1.2.5":
|
||||||
version: 1.2.6
|
version: 1.2.6
|
||||||
resolution: "@scure/base@npm:1.2.6"
|
resolution: "@scure/base@npm:1.2.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user