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.
This commit is contained in:
Paulo Castellano 2026-06-23 14:30:38 -03:00
parent 7e28d17d2c
commit beea8a5f97
9 changed files with 76 additions and 2 deletions

View file

@ -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.

View file

@ -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.");
}
}
/**

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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<string, MediaRules> = {
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,
},

View file

@ -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) });

View file

@ -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();

View file

@ -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([]);
});