diff --git a/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php b/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php index 629196de..09b2776b 100644 --- a/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php +++ b/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Api\Post; +use App\Support\PostMediaRules; use Illuminate\Foundation\Http\FormRequest; class AttachMediaFromUrlRequest extends FormRequest @@ -20,7 +21,8 @@ public function rules(): array { return [ 'urls' => ['required', 'array', 'min:1', 'max:10'], - 'urls.*' => ['url:http,https', 'active_url'], + 'urls.*.url' => ['required', 'url:http,https', 'active_url'], + 'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH], ]; } } diff --git a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php index dcebfe95..94a32afb 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php @@ -8,6 +8,7 @@ use App\Models\Media; use App\Models\Post; use App\Models\Workspace; +use App\Support\PostMediaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -23,6 +24,7 @@ public function handle(Request $request): Response|ResponseFactory $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'upload_token' => ['required', 'uuid'], + 'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH], ]); $workspaceId = $request->user()->current_workspace_id; @@ -48,14 +50,20 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('No enabled platform on this post accepts this media type.'); } - $post->appendMedia([[ + $item = [ 'id' => $media->id, 'path' => $media->path, 'url' => $media->url, 'type' => $media->type, 'mime_type' => $media->mime_type, 'original_filename' => $media->original_filename, - ]]); + ]; + + if (($alt = data_get($validated, 'alt')) !== null && $media->isImage()) { + $item['meta'] = ['alt_text' => $alt]; + } + + $post->appendMedia([$item]); $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); @@ -69,6 +77,7 @@ public function schema(JsonSchema $schema): array return [ 'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'), 'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'), + 'alt' => $schema->string()->description('Optional accessibility alt text for the media (applies to images).'), ]; } } diff --git a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php index faab44f5..c8dc4432 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php @@ -7,6 +7,7 @@ use App\Http\Resources\Api\PostResource; use App\Models\Post; use App\Services\Post\MediaAttacher; +use App\Support\PostMediaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'urls' => ['required', 'array', 'min:1', 'max:10'], - 'urls.*' => ['url:http,https', 'active_url'], + 'urls.*.url' => ['required', 'url:http,https', 'active_url'], + 'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH], ]); $post = Post::where('workspace_id', $request->user()->current_workspace_id) @@ -51,9 +53,12 @@ public function schema(JsonSchema $schema): array return [ 'post_id' => $schema->string()->required()->description('UUID of the post to attach media to.'), 'urls' => $schema->array() - ->items($schema->string()) + ->items($schema->object(fn ($u) => [ + 'url' => $u->string()->required()->description('Public HTTP/HTTPS URL of an image, video, or PDF.'), + 'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'), + ])) ->required() - ->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), + ->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), ]; } } diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 3319eb63..37a71267 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -25,7 +25,7 @@ class MediaAttacher { /** - * @param array $urls + * @param array $urls * @return array{attached: array>, failed: array} */ public function attachFromUrls(Post $post, array $urls): array @@ -33,10 +33,22 @@ public function attachFromUrls(Post $post, array $urls): array $attached = []; $failed = []; - foreach ($urls as $url) { - ($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null - ? $failed[] = $url - : $attached[] = $item; + foreach ($urls as $entry) { + $url = (string) data_get($entry, 'url', ''); + $item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url); + + if ($item === null) { + $failed[] = $url; + + continue; + } + + if (($alt = data_get($entry, 'alt')) !== null + && MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) { + $item['meta'] = ['alt_text' => $alt]; + } + + $attached[] = $item; } if ($attached !== []) { @@ -77,6 +89,10 @@ public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, ar continue; } + if (($meta = data_get($item, 'meta')) !== null) { + $hosted['meta'] = $meta; + } + $media[] = $hosted; $hostedIds[] = data_get($hosted, 'id'); } diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 4fba3190..1fef3ffa 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -121,12 +121,13 @@ private function publishPost(?string $content, $media): array $payload = $this->basePayload($content); if ($media->isNotEmpty()) { - $mediaUrn = $this->uploadMedia($media->first()); + $item = $media->first(); + $mediaUrn = $this->uploadMedia($item); if ($mediaUrn) { $payload['content'] = ['media' => array_filter([ 'id' => $mediaUrn, - 'altText' => $media->first()->altTextFor($this->platform()), + 'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null, ], fn ($v) => $v !== null)]; } } diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php index 3cbbb4c4..cb58c7b3 100644 --- a/app/Services/Social/Discord/DiscordPublisher.php +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -129,7 +129,7 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me $attachment = ['id' => $index, 'filename' => $filename]; - $alt = $item->altTextFor(Platform::Discord); + $alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null; if ($alt !== null) { $attachment['description'] = $alt; diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index ec5799df..57350e87 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array // Upload media first (max 4) foreach ($medias->take(4) as $media) { - $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon)); + $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null); if ($mediaId) { $mediaIds[] = $mediaId; } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 61b7e3bd..29cfd466 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -16,6 +16,7 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Throwable; class XPublisher { @@ -110,24 +111,37 @@ private function getHttpClient(): PendingRequest /** * Sets the image's accessibility description on X via the v2 media - * metadata endpoint. Skipped entirely when no alt text was provided. + * metadata endpoint. Best-effort: only images carry alt text, and a failure + * here never blocks the tweet — the media already uploaded and the post + * should still go out without the description. */ private function uploadAltText(string $mediaId, MediaItem $mediaItem): void { + if (! $mediaItem->isImage()) { + return; + } + $alt = $mediaItem->altTextFor(Platform::X); if ($alt === null) { return; } - $this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [ - 'id' => $mediaId, - 'metadata' => [ - 'alt_text' => [ - 'text' => $alt, + try { + $this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [ + 'id' => $mediaId, + 'metadata' => [ + 'alt_text' => [ + 'text' => $alt, + ], ], - ], - ]); + ]); + } catch (Throwable $e) { + Log::warning('X alt text upload failed; posting the tweet without it', [ + 'media_id' => $mediaId, + 'error' => $e->getMessage(), + ]); + } } private function uploadMedia($mediaItem): ?array diff --git a/app/Support/PostMediaRules.php b/app/Support/PostMediaRules.php index fd177f7f..4d192778 100644 --- a/app/Support/PostMediaRules.php +++ b/app/Support/PostMediaRules.php @@ -5,6 +5,7 @@ namespace App\Support; use App\Enums\Media\Source; +use Closure; use Illuminate\Validation\Rule; /** @@ -15,6 +16,12 @@ */ class PostMediaRules { + /** + * Maximum stored length (characters) for a media item's alt text. Publishers + * truncate further to each platform's own cap via Platform::altTextMaxLength(). + */ + public const ALT_TEXT_MAX_LENGTH = 2000; + /** * @param bool $hosted true (web): items must already be hosted (id + path * required); false (API): a bare external `url` is @@ -34,8 +41,23 @@ public static function rules(bool $hosted): array 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], 'media.*.size' => ['sometimes', 'nullable', 'integer'], - 'media.*.meta' => ['sometimes', 'nullable', 'array'], - 'media.*.meta.alt_text' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'media.*.meta' => ['sometimes', 'nullable', 'array', static function (string $attribute, mixed $value, Closure $fail): void { + $altText = data_get($value, 'alt_text'); + + if ($altText === null) { + return; + } + + if (! is_string($altText)) { + $fail('validation.string')->translate(['attribute' => trans('posts.edit.alt_text.label')]); + + return; + } + + if (mb_strlen($altText) > self::ALT_TEXT_MAX_LENGTH) { + $fail('validation.max.string')->translate(['attribute' => trans('posts.edit.alt_text.label'), 'max' => self::ALT_TEXT_MAX_LENGTH]); + } + }], 'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))], 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], ]; diff --git a/resources/js/components/ImagePreviewDialog.vue b/resources/js/components/ImagePreviewDialog.vue index a51e5b75..100dd5f0 100644 --- a/resources/js/components/ImagePreviewDialog.vue +++ b/resources/js/components/ImagePreviewDialog.vue @@ -100,6 +100,7 @@ defineExpose({ open, openCollection, close }); v-else-if="currentItem && currentItem.type === 'video'" :key="currentItem.url" :src="currentItem.url" + data-testid="lightbox-video" class="max-h-[85vh] max-w-full rounded-2xl bg-black" controls autoplay diff --git a/resources/js/components/posts/editor/AltTextDialog.vue b/resources/js/components/posts/editor/AltTextDialog.vue index 6fdf72c9..f5abb5ec 100644 --- a/resources/js/components/posts/editor/AltTextDialog.vue +++ b/resources/js/components/posts/editor/AltTextDialog.vue @@ -1,5 +1,5 @@