fix(post-form): enforce required post link board rules

Validate link and media requirements from community/directory features in
post and reply forms, with shared helpers and translated alert messages.
This commit is contained in:
Tommaso Casaburi
2026-06-28 15:44:36 +07:00
parent b974be7b2f
commit 6b2b85f51e
41 changed files with 443 additions and 72 deletions
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "ترقية الإعدادات متاحة",
"settings_upgrade_review_button": "مراجعة",
"settings_upgrade_never_show_again": "لا تظهر مرة أخرى",
"site_legal_meta_contributors_link": "المساهمون"
"site_legal_meta_contributors_link": "المساهمون",
"post_link_required_alert": "يجب أن تنشر رابطًا.",
"post_media_link_required_alert": "يجب أن تنشر رابط صورة أو فيديو."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "সেটিংস আপগ্রেড উপলব্ধ",
"settings_upgrade_review_button": "পর্যালোচনা",
"settings_upgrade_never_show_again": "আর দেখাবেন না",
"site_legal_meta_contributors_link": "অবদানকারীরা"
"site_legal_meta_contributors_link": "অবদানকারীরা",
"post_link_required_alert": "আপনাকে একটি লিঙ্ক পোস্ট করতে হবে।",
"post_media_link_required_alert": "আপনাকে একটি ছবি বা ভিডিওর লিঙ্ক পোস্ট করতে হবে।"
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "upgrade nastavení k dispozici",
"settings_upgrade_review_button": "zkontrolovat",
"settings_upgrade_never_show_again": "už nezobrazovat",
"site_legal_meta_contributors_link": "Přispěvatelé"
"site_legal_meta_contributors_link": "Přispěvatelé",
"post_link_required_alert": "Musíte přidat odkaz.",
"post_media_link_required_alert": "Musíte přidat odkaz na obrázek nebo video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "indstillingsopgradering tilgængelig",
"settings_upgrade_review_button": "gennemgå",
"settings_upgrade_never_show_again": "vis aldrig igen",
"site_legal_meta_contributors_link": "Bidragydere"
"site_legal_meta_contributors_link": "Bidragydere",
"post_link_required_alert": "Du skal poste et link.",
"post_media_link_required_alert": "Du skal poste et billed- eller videolink."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "Einstellungs-Upgrade verfügbar",
"settings_upgrade_review_button": "überprüfen",
"settings_upgrade_never_show_again": "nie wieder anzeigen",
"site_legal_meta_contributors_link": "Mitwirkende"
"site_legal_meta_contributors_link": "Mitwirkende",
"post_link_required_alert": "Du musst einen Link posten.",
"post_media_link_required_alert": "Du musst einen Bild- oder Videolink posten."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "αναβάθμιση ρυθμίσεων διαθέσιμη",
"settings_upgrade_review_button": "έλεγχος",
"settings_upgrade_never_show_again": "να μην εμφανιστεί ξανά",
"site_legal_meta_contributors_link": "Συνεισφέροντες"
"site_legal_meta_contributors_link": "Συνεισφέροντες",
"post_link_required_alert": "Πρέπει να δημοσιεύσετε έναν σύνδεσμο.",
"post_media_link_required_alert": "Πρέπει να δημοσιεύσετε σύνδεσμο εικόνας ή βίντεο."
}
+3 -1
View File
@@ -462,5 +462,7 @@
"settings_upgrade_review_notice": "settings upgrade available",
"settings_upgrade_review_button": "review",
"settings_upgrade_never_show_again": "never show again",
"site_legal_meta_contributors_link": "Contributors"
"site_legal_meta_contributors_link": "Contributors",
"post_link_required_alert": "You must post a link.",
"post_media_link_required_alert": "You must post an image or video link."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "actualización de configuración disponible",
"settings_upgrade_review_button": "revisar",
"settings_upgrade_never_show_again": "no volver a mostrar",
"site_legal_meta_contributors_link": "Colaboradores"
"site_legal_meta_contributors_link": "Colaboradores",
"post_link_required_alert": "Debes publicar un enlace.",
"post_media_link_required_alert": "Debes publicar un enlace a una imagen o un video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "ارتقای تنظیمات موجود است",
"settings_upgrade_review_button": "بررسی",
"settings_upgrade_never_show_again": "دیگر نشان نده",
"site_legal_meta_contributors_link": "مشارکت‌کنندگان"
"site_legal_meta_contributors_link": "مشارکت‌کنندگان",
"post_link_required_alert": "باید یک پیوند ارسال کنید.",
"post_media_link_required_alert": "باید پیوند یک تصویر یا ویدیو را ارسال کنید."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "asetuspäivitys saatavilla",
"settings_upgrade_review_button": "tarkista",
"settings_upgrade_never_show_again": "älä näytä uudelleen",
"site_legal_meta_contributors_link": "Avustajat"
"site_legal_meta_contributors_link": "Avustajat",
"post_link_required_alert": "Sinun on julkaistava linkki.",
"post_media_link_required_alert": "Sinun on julkaistava kuva- tai videolinkki."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "may upgrade ng setting",
"settings_upgrade_review_button": "suriin",
"settings_upgrade_never_show_again": "huwag nang ipakita muli",
"site_legal_meta_contributors_link": "Mga Kontribyutor"
"site_legal_meta_contributors_link": "Mga Kontribyutor",
"post_link_required_alert": "Kailangan mong mag-post ng link.",
"post_media_link_required_alert": "Kailangan mong mag-post ng link ng larawan o video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "mise à jour des paramètres disponible",
"settings_upgrade_review_button": "examiner",
"settings_upgrade_never_show_again": "ne plus afficher",
"site_legal_meta_contributors_link": "Contributeurs"
"site_legal_meta_contributors_link": "Contributeurs",
"post_link_required_alert": "Vous devez publier un lien.",
"post_media_link_required_alert": "Vous devez publier un lien vers une image ou une vidéo."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "שדרוג הגדרות זמין",
"settings_upgrade_review_button": "סקור",
"settings_upgrade_never_show_again": "לא להציג שוב",
"site_legal_meta_contributors_link": "תורמים"
"site_legal_meta_contributors_link": "תורמים",
"post_link_required_alert": "עליך לפרסם קישור.",
"post_media_link_required_alert": "עליך לפרסם קישור לתמונה או לסרטון."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "सेटिंग्स अपग्रेड उपलब्ध",
"settings_upgrade_review_button": "समीक्षा करें",
"settings_upgrade_never_show_again": "फिर कभी न दिखाएँ",
"site_legal_meta_contributors_link": "योगदानकर्ता"
"site_legal_meta_contributors_link": "योगदानकर्ता",
"post_link_required_alert": "आपको एक लिंक पोस्ट करना होगा।",
"post_media_link_required_alert": "आपको एक छवि या वीडियो लिंक पोस्ट करना होगा।"
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "beállításfrissítés elérhető",
"settings_upgrade_review_button": "áttekintés",
"settings_upgrade_never_show_again": "ne jelenjen meg újra",
"site_legal_meta_contributors_link": "Közreműködők"
"site_legal_meta_contributors_link": "Közreműködők",
"post_link_required_alert": "Közzé kell tenned egy linket.",
"post_media_link_required_alert": "Közzé kell tenned egy kép- vagy videólinket."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "pembaruan pengaturan tersedia",
"settings_upgrade_review_button": "tinjau",
"settings_upgrade_never_show_again": "jangan tampilkan lagi",
"site_legal_meta_contributors_link": "Kontributor"
"site_legal_meta_contributors_link": "Kontributor",
"post_link_required_alert": "Anda harus memposting tautan.",
"post_media_link_required_alert": "Anda harus memposting tautan gambar atau video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "aggiornamento impostazioni disponibile",
"settings_upgrade_review_button": "rivedi",
"settings_upgrade_never_show_again": "non mostrare più",
"site_legal_meta_contributors_link": "Collaboratori"
"site_legal_meta_contributors_link": "Collaboratori",
"post_link_required_alert": "Devi pubblicare un link.",
"post_media_link_required_alert": "Devi pubblicare un link a un'immagine o a un video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "設定のアップグレードが利用可能",
"settings_upgrade_review_button": "確認",
"settings_upgrade_never_show_again": "今後表示しない",
"site_legal_meta_contributors_link": "貢献者"
"site_legal_meta_contributors_link": "貢献者",
"post_link_required_alert": "リンクを投稿する必要があります。",
"post_media_link_required_alert": "画像または動画のリンクを投稿する必要があります。"
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "설정 업그레이드 사용 가능",
"settings_upgrade_review_button": "검토",
"settings_upgrade_never_show_again": "다시 표시하지 않기",
"site_legal_meta_contributors_link": "기여자"
"site_legal_meta_contributors_link": "기여자",
"post_link_required_alert": "링크를 게시해야 합니다.",
"post_media_link_required_alert": "이미지 또는 동영상 링크를 게시해야 합니다."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "सेटिंग्ज अपग्रेड उपलब्ध",
"settings_upgrade_review_button": "पुनरावलोकन",
"settings_upgrade_never_show_again": "पुन्हा दाखवू नका",
"site_legal_meta_contributors_link": "योगदानकर्ते"
"site_legal_meta_contributors_link": "योगदानकर्ते",
"post_link_required_alert": "तुम्हाला एक लिंक पोस्ट करावी लागेल.",
"post_media_link_required_alert": "तुम्हाला प्रतिमा किंवा व्हिडिओची लिंक पोस्ट करावी लागेल."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "instellingenupgrade beschikbaar",
"settings_upgrade_review_button": "bekijken",
"settings_upgrade_never_show_again": "niet meer tonen",
"site_legal_meta_contributors_link": "Bijdragers"
"site_legal_meta_contributors_link": "Bijdragers",
"post_link_required_alert": "Je moet een link plaatsen.",
"post_media_link_required_alert": "Je moet een afbeelding- of videolink plaatsen."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "innstillingsoppgradering tilgjengelig",
"settings_upgrade_review_button": "se gjennom",
"settings_upgrade_never_show_again": "vis aldri igjen",
"site_legal_meta_contributors_link": "Bidragsytere"
"site_legal_meta_contributors_link": "Bidragsytere",
"post_link_required_alert": "Du må poste en lenke.",
"post_media_link_required_alert": "Du må poste en bilde- eller videolenke."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "aktualizacja ustawień dostępna",
"settings_upgrade_review_button": "przejrzyj",
"settings_upgrade_never_show_again": "nie pokazuj ponownie",
"site_legal_meta_contributors_link": "Współtwórcy"
"site_legal_meta_contributors_link": "Współtwórcy",
"post_link_required_alert": "Musisz opublikować link.",
"post_media_link_required_alert": "Musisz opublikować link do obrazu lub filmu."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "atualização de configurações disponível",
"settings_upgrade_review_button": "revisar",
"settings_upgrade_never_show_again": "não mostrar novamente",
"site_legal_meta_contributors_link": "Colaboradores"
"site_legal_meta_contributors_link": "Colaboradores",
"post_link_required_alert": "Você deve postar um link.",
"post_media_link_required_alert": "Você deve postar um link de imagem ou vídeo."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "actualizare setări disponibilă",
"settings_upgrade_review_button": "revizuiește",
"settings_upgrade_never_show_again": "nu mai afișa",
"site_legal_meta_contributors_link": "Contributori"
"site_legal_meta_contributors_link": "Contributori",
"post_link_required_alert": "Trebuie să postezi un link.",
"post_media_link_required_alert": "Trebuie să postezi un link către o imagine sau un videoclip."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "обновление настроек доступно",
"settings_upgrade_review_button": "просмотреть",
"settings_upgrade_never_show_again": "больше не показывать",
"site_legal_meta_contributors_link": "Участники"
"site_legal_meta_contributors_link": "Участники",
"post_link_required_alert": "Вы должны опубликовать ссылку.",
"post_media_link_required_alert": "Вы должны опубликовать ссылку на изображение или видео."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "përmirësim i cilësimeve i disponueshëm",
"settings_upgrade_review_button": "shqyrto",
"settings_upgrade_never_show_again": "mos e shfaq më",
"site_legal_meta_contributors_link": "Kontribuesit"
"site_legal_meta_contributors_link": "Kontribuesit",
"post_link_required_alert": "Duhet të postosh një lidhje.",
"post_media_link_required_alert": "Duhet të postosh një lidhje imazhi ose videoje."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "inställningsuppgradering tillgänglig",
"settings_upgrade_review_button": "granska",
"settings_upgrade_never_show_again": "visa inte igen",
"site_legal_meta_contributors_link": "Bidragsgivare"
"site_legal_meta_contributors_link": "Bidragsgivare",
"post_link_required_alert": "Du måste posta en länk.",
"post_media_link_required_alert": "Du måste posta en bild- eller videolänk."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "సెట్టింగ్‌ల అప్‌గ్రేడ్ అందుబాటులో ఉంది",
"settings_upgrade_review_button": "సమీక్షించు",
"settings_upgrade_never_show_again": "మళ్లీ చూపించవద్దు",
"site_legal_meta_contributors_link": "సహకారులు"
"site_legal_meta_contributors_link": "సహకారులు",
"post_link_required_alert": "మీరు ఒక లింక్ పోస్ట్ చేయాలి.",
"post_media_link_required_alert": "మీరు చిత్రం లేదా వీడియో లింక్ పోస్ట్ చేయాలి."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "มีการอัปเกรดการตั้งค่า",
"settings_upgrade_review_button": "ตรวจสอบ",
"settings_upgrade_never_show_again": "ไม่ต้องแสดงอีก",
"site_legal_meta_contributors_link": "ผู้มีส่วนร่วม"
"site_legal_meta_contributors_link": "ผู้มีส่วนร่วม",
"post_link_required_alert": "คุณต้องโพสต์ลิงก์",
"post_media_link_required_alert": "คุณต้องโพสต์ลิงก์รูปภาพหรือวิดีโอ"
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "ayar yükseltmesi mevcut",
"settings_upgrade_review_button": "incele",
"settings_upgrade_never_show_again": "bir daha gösterme",
"site_legal_meta_contributors_link": "Katkıda Bulunanlar"
"site_legal_meta_contributors_link": "Katkıda Bulunanlar",
"post_link_required_alert": "Bir bağlantı paylaşmalısınız.",
"post_media_link_required_alert": "Bir görsel veya video bağlantısı paylaşmalısınız."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "оновлення налаштувань доступне",
"settings_upgrade_review_button": "переглянути",
"settings_upgrade_never_show_again": "більше не показувати",
"site_legal_meta_contributors_link": "Учасники"
"site_legal_meta_contributors_link": "Учасники",
"post_link_required_alert": "Потрібно опублікувати посилання.",
"post_media_link_required_alert": "Потрібно опублікувати посилання на зображення або відео."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "ترتیبات اپ گریڈ دستیاب",
"settings_upgrade_review_button": "جائزہ لیں",
"settings_upgrade_never_show_again": "دوبارہ نہ دکھائیں",
"site_legal_meta_contributors_link": "شراکت کنندگان"
"site_legal_meta_contributors_link": "شراکت کنندگان",
"post_link_required_alert": "آپ کو ایک لنک پوسٹ کرنا ہوگا۔",
"post_media_link_required_alert": "آپ کو تصویر یا ویڈیو کا لنک پوسٹ کرنا ہوگا۔"
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "nâng cấp cài đặt có sẵn",
"settings_upgrade_review_button": "xem lại",
"settings_upgrade_never_show_again": "không hiển thị lại",
"site_legal_meta_contributors_link": "Người đóng góp"
"site_legal_meta_contributors_link": "Người đóng góp",
"post_link_required_alert": "Bạn phải đăng một liên kết.",
"post_media_link_required_alert": "Bạn phải đăng liên kết hình ảnh hoặc video."
}
+3 -1
View File
@@ -443,5 +443,7 @@
"settings_upgrade_review_notice": "设置升级可用",
"settings_upgrade_review_button": "查看",
"settings_upgrade_never_show_again": "不再显示",
"site_legal_meta_contributors_link": "贡献者"
"site_legal_meta_contributors_link": "贡献者",
"post_link_required_alert": "你必须发布一个链接。",
"post_media_link_required_alert": "你必须发布图片或视频链接。"
}
@@ -170,8 +170,17 @@ vi.mock('../../../hooks/use-resolved-community-address', () => ({
}));
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => T) =>
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: Record<string, unknown> & { roles?: Record<string, { role?: string }> }) => T) => {
const community = communityAddress ? (testState.communities[communityAddress] as Record<string, unknown> | undefined) : undefined;
return selector(
communityAddress
? ({
...community,
roles: testState.rolesByCommunity[communityAddress],
} as Record<string, unknown> & { roles?: Record<string, { role?: string }> })
: undefined,
);
},
}));
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
@@ -765,6 +774,109 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
});
it('requires a link when live community features require post links', async () => {
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true },
};
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
await dispatchInput(textarea, 'Link required thread');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: post_link_required_alert');
const linkRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_link_required_alert');
expect(linkRequiredError?.className).toContain('error');
expect(linkRequiredError?.className).toContain('formError');
expect(linkRequiredError?.closest('tfoot')).toBeTruthy();
expect(testState.publishPostMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/thread');
await clickByText(table, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe('https://example.com/thread');
});
it('requires a media link when live community features require post links to be media', async () => {
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
};
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
await dispatchInput(textarea, 'Media required thread');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: post_media_link_required_alert');
const mediaRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_media_link_required_alert');
expect(mediaRequiredError?.className).toContain('error');
expect(mediaRequiredError?.className).toContain('formError');
expect(mediaRequiredError?.closest('tfoot')).toBeTruthy();
expect(testState.publishPostMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/page');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(testState.publishPostMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/image.jpg');
await clickByText(table, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe('https://example.com/image.jpg');
});
it('does not require an empty link when only media links are constrained', async () => {
testState.resolvedCommunityAddress = 'current-news.bso';
testState.directories.push({
address: 'current-news.bso',
directoryCode: 'news',
features: { requirePostLink: false, requirePostLinkIsMedia: true },
title: '/news/ - Current News',
});
testState.communities['current-news.bso'] = {
address: 'current-news.bso',
features: { requirePostLink: false, requirePostLinkIsMedia: true },
};
await renderPostForm('/news');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
await dispatchInput(textarea, 'News body with source in text');
await clickByText(table, 'post');
expect(container.textContent).not.toContain('post_media_link_required_alert');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBeUndefined();
testState.publishPostMock.mockClear();
await dispatchInput(linkInput, 'https://example.com/article');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(testState.publishPostMock).not.toHaveBeenCalled();
});
it('ignores duplicate post clicks while youtube thumbnail resolution is pending', async () => {
const youtubeLink = 'https://www.youtube.com/watch?v=slow123';
const thumbnailLink = 'https://img.youtube.com/vi/slow123/maxresdefault.jpg';
+23 -22
View File
@@ -6,13 +6,8 @@ import { Comment, setAccount, useAccount, useAccountComment, useEditedComment }
import getShortAddress from '../../lib/get-short-address';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import {
getExpiringMediaLinkAlert,
getPublishFileDisplayName,
getPublishLinkOptions,
isPublishFileMediaLink,
isPublishFileMediaType,
} from '../../lib/utils/media-link-validation-utils';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions, isPublishFileMediaType } from '../../lib/utils/media-link-validation-utils';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../../lib/utils/publish-link-requirements';
import {
type DiceRoll,
type FortuneEntry,
@@ -27,7 +22,7 @@ import {
isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
@@ -577,8 +572,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const communityFeatures = useCommunityField(effectiveBoardAddress, (community) => community?.features);
const publishLinkFeatures = getEffectivePublishLinkFeatures(communityFeatures, directoryEntry?.features);
const requirePostLink = getRequirePostLink(publishLinkFeatures);
const requirePostLinkIsMedia = getRequirePostLinkIsMedia(publishLinkFeatures, isInAllView || isInSubscriptionsView);
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
@@ -680,12 +677,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
const linkValidationError = getPublishLinkValidationError({
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
t,
});
if (linkValidationError) {
setFormError(linkValidationError);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
@@ -809,12 +808,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
const linkValidationError = getPublishLinkValidationError({
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
t,
});
if (linkValidationError) {
setFormError(linkValidationError);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
@@ -61,7 +61,7 @@ const testState = vi.hoisted(() => ({
'music-posting.eth': {
address: 'music-posting.eth',
},
} as Record<string, { address: string }>,
} as Record<string, { address: string; features?: Record<string, unknown> }>,
fetchMock: vi.fn(),
showUploadControls: true,
uploadComplete: undefined as ((url: string) => void) | undefined,
@@ -187,8 +187,18 @@ vi.mock('../../../hooks/use-resolved-community-address', () => ({
}));
vi.mock('../../../hooks/use-stable-community', () => ({
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => T) =>
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
useCommunityField: <T,>(
communityAddress: string | undefined,
selector: (community?: { features?: Record<string, unknown>; roles?: Record<string, { role?: string }> }) => T,
) =>
selector(
communityAddress
? {
...testState.communities[communityAddress],
roles: testState.rolesByCommunity[communityAddress],
}
: undefined,
),
}));
vi.mock('../../../hooks/use-publish-reply', async () => {
@@ -755,6 +765,86 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires a link when live community features require post links', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).toContain('error: post_link_required_alert');
const linkRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_link_required_alert');
expect(linkRequiredError?.className).toContain('error');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://example.com/reply');
await clickButtonByText('post');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/reply' });
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires a media link when live community features require post links to be media', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).toContain('error: post_media_link_required_alert');
const mediaRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_media_link_required_alert');
expect(mediaRequiredError?.className).toContain('error');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://example.com/page');
await clickButtonByText('post');
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/reply.png');
await clickButtonByText('post');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/reply.png' });
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('does not require an empty link when only media links are constrained', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: false, requirePostLinkIsMedia: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).not.toContain('post_media_link_required_alert');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
testState.publishReplyMock.mockClear();
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://example.com/page');
await clickButtonByText('post');
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('publishes twimg query-format reply links with a path extension', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
+14 -10
View File
@@ -3,8 +3,9 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions, isPublishFileMediaLink } from '../../lib/utils/media-link-validation-utils';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions } from '../../lib/utils/media-link-validation-utils';
import { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../../lib/utils/publish-link-requirements';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
import {
type DiceRoll,
@@ -18,7 +19,6 @@ import {
hasNonokoOption,
isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils';
import { isValidPublishURL } from '../../lib/utils/url-utils';
import { isMathDirectoryCode } from '../../lib/math-tags';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
@@ -78,8 +78,10 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const showTexButton = isMathDirectoryCode(postOptionsDirectoryCode) || isMathDirectoryCode(directoryEntry?.directoryCode);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const communityFeatures = useCommunityField(communityAddress, (community) => community?.features);
const publishLinkFeatures = getEffectivePublishLinkFeatures(communityFeatures, directoryEntry?.features);
const requirePostLink = getRequirePostLink(publishLinkFeatures);
const requirePostLinkIsMedia = getRequirePostLinkIsMedia(publishLinkFeatures, isInAllView || isInSubscriptionsView);
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
usePublishReply({
@@ -184,12 +186,14 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setError(t('error') + ': ' + t('invalid_url_alert'));
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setError(t('error') + ': ' + t('link_not_image_or_video_alert'));
const linkValidationError = getPublishLinkValidationError({
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
t,
});
if (linkValidationError) {
setError(linkValidationError);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import type { TFunction } from 'i18next';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../publish-link-requirements';
const t = ((key: string) => key) as TFunction;
describe('getEffectivePublishLinkFeatures', () => {
it('uses live community feature values before directory defaults', () => {
expect(getEffectivePublishLinkFeatures({ requirePostLink: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
requirePostLink: false,
requirePostLinkIsMedia: true,
});
});
it('falls back per feature when live community features are partial', () => {
expect(getEffectivePublishLinkFeatures({ requirePostLinkIsMedia: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
requirePostLink: true,
requirePostLinkIsMedia: false,
});
});
});
describe('getPublishLinkValidationError', () => {
it('does not require an empty link when only media links are enabled', () => {
expect(getPublishLinkValidationError({ link: '', requireLink: false, requireMedia: true, t })).toBeNull();
});
it('requires a media link when both link and media requirements are enabled', () => {
expect(getPublishLinkValidationError({ link: '', requireLink: true, requireMedia: true, t })).toBe('error: post_media_link_required_alert');
});
});
describe('publish link feature helpers', () => {
it('does not treat media-only validation as a required link', () => {
const features = getEffectivePublishLinkFeatures({ requirePostLink: false, requirePostLinkIsMedia: true }, undefined);
expect(getRequirePostLink(features)).toBe(false);
expect(getRequirePostLinkIsMedia(features, false)).toBe(true);
});
});
@@ -0,0 +1,54 @@
import type { TFunction } from 'i18next';
import { isPublishFileMediaLink } from './media-link-validation-utils';
import { isValidPublishURL } from './url-utils';
interface PublishLinkFeatures {
requirePostLink?: unknown;
requirePostLinkIsMedia?: unknown;
}
export const getEffectivePublishLinkFeatures = (
communityFeatures: PublishLinkFeatures | undefined,
directoryFeatures: PublishLinkFeatures | undefined,
): PublishLinkFeatures | undefined => {
const requirePostLink = communityFeatures?.requirePostLink ?? directoryFeatures?.requirePostLink;
const requirePostLinkIsMedia = communityFeatures?.requirePostLinkIsMedia ?? directoryFeatures?.requirePostLinkIsMedia;
if (requirePostLink === undefined && requirePostLinkIsMedia === undefined) {
return undefined;
}
return {
requirePostLink,
requirePostLinkIsMedia,
};
};
export const getRequirePostLink = (features: PublishLinkFeatures | undefined): boolean => Boolean(features?.requirePostLink);
export const getRequirePostLinkIsMedia = (features: PublishLinkFeatures | undefined, useMediaDefault: boolean): boolean =>
Boolean(features?.requirePostLinkIsMedia) || (features?.requirePostLinkIsMedia === undefined && useMediaDefault);
export const getPublishLinkValidationError = ({
link,
requireLink,
requireMedia,
t,
}: {
link: string;
requireLink: boolean;
requireMedia: boolean;
t: TFunction;
}): string | null => {
if (!link && requireLink) {
return `${t('error')}: ${t(requireMedia ? 'post_media_link_required_alert' : 'post_link_required_alert')}`;
}
if (link && !isValidPublishURL(link)) {
return `${t('error')}: ${t('invalid_url_alert')}`;
}
if (link && requireMedia && !isPublishFileMediaLink(link)) {
return `${t('error')}: ${t('link_not_image_or_video_alert')}`;
}
return null;
};