From beea8a5f97d222b3a1793a7c33976f2013433b5f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 23 Jun 2026 14:30:38 -0300 Subject: [PATCH] feat(posts): block combining an image and a video in one post A Bluesky post embed is images XOR video, so a post that carries both can't be published there. Enforce it per selected platform: - Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject image+video in ContentTypeCompatibleWithMedia when the type forbids it. - Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance) so the editor blocks scheduling with an inline reason before submit, like every other per-platform compatibility check. - Add the no_mixed_media message in all three locales (en/es/pt-BR). - Cover the rule and enum, including the GIF-counts-as-image case. --- app/Enums/PostPlatform/ContentType.php | 14 +++++++ app/Rules/ContentTypeCompatibleWithMedia.php | 4 ++ lang/en/posts.php | 1 + lang/es/posts.php | 1 + lang/pt-BR/posts.php | 1 + resources/js/composables/useMediaRules.ts | 6 ++- resources/js/composables/usePostCompliance.ts | 1 + tests/Unit/Enums/ContentTypeTest.php | 8 ++++ .../ContentTypeCompatibleWithMediaTest.php | 42 +++++++++++++++++++ 9 files changed, 76 insertions(+), 2 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0c525df9..02a27e9b 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -216,6 +216,20 @@ public function supportsImage(): bool }; } + /** + * Whether a single post may carry images and a video together. Most + * targets accept only one or the other; this is conservative and lists + * only the types we've confirmed reject mixed media (e.g. a Bluesky post + * embed is `images` XOR `video`, never both). + */ + public function supportsMixedMedia(): bool + { + return match ($this) { + self::BlueskyPost => false, + default => true, + }; + } + /** * Whether this content type carries a text caption visible to viewers. * Stories are image-overlay only — viewers don't see a separate caption. diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index d0c8df5f..2fd2e5f1 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -61,6 +61,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if ($hasVideo && ! $contentType->supportsVideo()) { $fail("{$contentType->label()} does not support videos."); } + + if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) { + $fail("{$contentType->label()} can't combine an image and a video in the same post."); + } } /** diff --git a/lang/en/posts.php b/lang/en/posts.php index 09e66b58..d3f3f7dd 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Add at least :min files for this format.', 'no_videos' => 'Only images are allowed for this format.', 'no_images' => 'Only videos are allowed for this format.', + 'no_mixed_media' => "Images and videos can't be combined in the same post.", 'no_gifs' => 'GIFs are not supported here.', 'video_too_large' => 'Video exceeds the size limit for this platform.', 'video_too_long' => 'Video must be under :seconds seconds for this format.', diff --git a/lang/es/posts.php b/lang/es/posts.php index cbe0df58..c52e8895 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Agrega al menos :min archivos para este formato.', 'no_videos' => 'Solo se permiten imágenes en este formato.', 'no_images' => 'Solo se permiten videos en este formato.', + 'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.', 'no_gifs' => 'Los GIFs no son compatibles aquí.', 'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.', 'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index ade99dd0..3b580dcb 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.', 'no_videos' => 'Apenas imagens são permitidas neste formato.', 'no_images' => 'Apenas vídeos são permitidos neste formato.', + 'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.', 'no_gifs' => 'GIFs não são suportados aqui.', 'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.', 'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.', diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 8c3b08e9..aa5624d5 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -7,6 +7,7 @@ export interface MediaRules { acceptVideos: boolean; requiresMedia: boolean; acceptsGif: boolean; + forbidsMixedMedia?: boolean; maxImageBytes?: number; maxVideoBytes?: number; maxVideoDurationSec?: number; @@ -131,10 +132,11 @@ const CONTENT_TYPE_RULES: Record = { maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, }, - // Bluesky — accepts GIF; tight image size (auto-resized by backend) + // Bluesky — accepts GIF; tight image size (auto-resized by backend). + // The embed is images XOR video, so the two can't be combined in one post. bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, + acceptsGif: true, forbidsMixedMedia: true, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, }, diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 9a855247..fed0cfed 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -99,6 +99,7 @@ export const getMediaIncompatibilityReason = ( if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media'); if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos'); if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images'); + if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media'); if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs'); if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) }); if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 7a771504..4928fc42 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -86,6 +86,14 @@ expect(ContentType::YouTubeShort->supportsImage())->toBeFalse(); }); +test('content type supports mixed media correctly', function () { + // A Bluesky embed is images XOR video, so it can't carry both. + expect(ContentType::BlueskyPost->supportsMixedMedia())->toBeFalse(); + // Multi-attachment messengers can mix an image and a video. + expect(ContentType::DiscordMessage->supportsMixedMedia())->toBeTrue(); + expect(ContentType::TelegramPost->supportsMixedMedia())->toBeTrue(); +}); + test('content type requires media correctly', function () { expect(ContentType::InstagramReel->requiresMedia())->toBeTrue(); expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue(); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index 81fd06a0..2c4dfa20 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -94,6 +94,48 @@ function runMediaRule(string $contentType, array $media): array expect($errors)->toHaveCount(1); }); +test('bluesky rejects an image and a video in the same post', function () { + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + $errors = runMediaRule(ContentType::BlueskyPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain("can't combine an image and a video"); +}); + +test('bluesky still accepts an image-only or video-only post', function () { + $image = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']]; + $video = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']]; + + expect(runMediaRule(ContentType::BlueskyPost->value, $image))->toBe([]); + expect(runMediaRule(ContentType::BlueskyPost->value, $video))->toBe([]); +}); + +test('bluesky rejects an animated gif combined with a video', function () { + // A GIF counts as an image, so gif + video is still mixed media. + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/gif'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + $errors = runMediaRule(ContentType::BlueskyPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain("can't combine an image and a video"); +}); + +test('a mixed-media content type accepts an image and a video together', function () { + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]); +}); + test('does nothing for invalid content type values', function () { expect(runMediaRule('not_a_real_content_type', []))->toBe([]); });