diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php
index 5a6fdbea7..dfc00e71e 100644
--- a/app/Http/Controllers/Auth/InstagramController.php
+++ b/app/Http/Controllers/Auth/InstagramController.php
@@ -25,6 +25,8 @@ class InstagramController extends SocialController
'instagram_business_basic',
'instagram_business_content_publish',
'instagram_business_manage_insights',
+ // first comment after publish (FirstCommentPoster)
+ 'instagram_business_manage_comments',
];
public function connect(Request $request): Response
diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php
index 6f2a2af94..273428054 100644
--- a/app/Http/Controllers/Auth/InstagramFacebookController.php
+++ b/app/Http/Controllers/Auth/InstagramFacebookController.php
@@ -49,6 +49,8 @@ class InstagramFacebookController extends MetaController
'instagram_basic',
'instagram_content_publish',
'instagram_manage_insights',
+ // first comment after publish (FirstCommentPoster)
+ 'instagram_manage_comments',
];
public function connect(Request $request): Response
diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php
index 096463a89..066ec7fbf 100644
--- a/app/Jobs/PublishToSocialPlatform.php
+++ b/app/Jobs/PublishToSocialPlatform.php
@@ -29,6 +29,7 @@
use App\Services\Social\Telegram\TelegramPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
+use App\Services\Social\FirstCommentPoster;
use App\Services\Social\XPublisher;
use App\Services\Social\YouTubePublisher;
use App\Support\Social\GoogleBusinessDerivativeCleaner;
@@ -126,6 +127,15 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);
$this->recordPublishResult($result);
+
+ // The post is live; a failed first comment must never fail it —
+ // FirstCommentPoster logs and swallows its own errors. Posts
+ // held in pending review (Google Business) get no comment.
+ if ($this->postPlatform->status === PostPlatformStatus::Published
+ && ($externalId = (string) data_get($result, 'id')) !== '') {
+ app(FirstCommentPoster::class)->post($this->postPlatform, $externalId);
+ }
+
break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php
index 0183b2663..67081ad1b 100644
--- a/app/Mcp/Tools/Post/CreatePostTool.php
+++ b/app/Mcp/Tools/Post/CreatePostTool.php
@@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
- 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'),
+ 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description. YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100, overrides the content-derived title), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php
index 0b2af698e..7188ef7e5 100644
--- a/app/Mcp/Tools/Post/UpdatePostTool.php
+++ b/app/Mcp/Tools/Post/UpdatePostTool.php
@@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
- 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'),
+ 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description. YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100, overrides the content-derived title), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php
index c0b0ffbf4..e94ac9d40 100644
--- a/app/Models/PostPlatform.php
+++ b/app/Models/PostPlatform.php
@@ -60,6 +60,20 @@ protected function casts(): array
];
}
+ /**
+ * The text this platform row publishes: the per-platform `meta.content`
+ * override when the user provided one (a short copy for X next to a long
+ * Instagram caption, without splitting the post), otherwise the post's
+ * shared content. Publishers and publish-time length validation must read
+ * content through this method, never `post->content` directly.
+ */
+ public function resolvedContent(): ?string
+ {
+ $override = trim((string) data_get($this->meta, 'content'));
+
+ return $override !== '' ? $override : $this->post?->content;
+ }
+
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php
index a541df9f8..de4588937 100644
--- a/app/Services/Social/AbstractLinkedInPublisher.php
+++ b/app/Services/Social/AbstractLinkedInPublisher.php
@@ -76,8 +76,8 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;
$this->account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php
index e20d5caf6..17c194775 100644
--- a/app/Services/Social/BlueskyPublisher.php
+++ b/app/Services/Social/BlueskyPublisher.php
@@ -51,7 +51,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
@@ -100,6 +100,10 @@ public function publish(PostPlatform $postPlatform): array
'$type' => BlueskyLexicon::EMBED_VIDEO,
'video' => $videoBlob,
];
+
+ if (($alt = $video->altTextFor(Platform::Bluesky)) !== null) {
+ $embed['alt'] = $alt;
+ }
}
}
}
diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php
index 3977eee17..540eb348d 100644
--- a/app/Services/Social/Concerns/HasSocialHttpClient.php
+++ b/app/Services/Social/Concerns/HasSocialHttpClient.php
@@ -22,7 +22,7 @@ trait HasSocialHttpClient
*/
protected function validateContentLength(PostPlatform $postPlatform): void
{
- $raw = $postPlatform->post->content ?? '';
+ $raw = $postPlatform->resolvedContent() ?? '';
$content = app(ContentSanitizer::class)->displayText($raw, $postPlatform->platform);
if ($postPlatform->platform->contentOverflow($content) === 0) {
diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php
index 44b6a0150..1adca5936 100644
--- a/app/Services/Social/Discord/DiscordPublisher.php
+++ b/app/Services/Social/Discord/DiscordPublisher.php
@@ -52,8 +52,8 @@ public function publish(PostPlatform $postPlatform): array
// bot is in (including another workspace's).
$this->guardChannelBelongsToGuild($guildId, $channelId);
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: '';
$content = $this->appendMentions($content, $postPlatform);
diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php
index c62bc57b6..6e1fc0253 100644
--- a/app/Services/Social/FacebookPublisher.php
+++ b/app/Services/Social/FacebookPublisher.php
@@ -37,6 +37,12 @@ class FacebookPublisher
private string $baseUrl;
+ /**
+ * Facebook place ID from the platform row's `meta.location_id` — tags feed
+ * and photo posts with a location. Stories do not accept it.
+ */
+ private ?string $placeId = null;
+
public function __construct()
{
$this->baseUrl = config('trypost.platforms.facebook.graph_api');
@@ -53,6 +59,7 @@ public function publish(PostPlatform $postPlatform): array
$pageId = $account->platform_user_id;
$accessToken = $account->access_token;
$content = $this->sanitizedContent($postPlatform);
+ $this->placeId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;
$media = $postPlatform->post->mediaItems;
$contentType = $postPlatform->content_type;
@@ -149,11 +156,11 @@ private function postTextWithLink(string $pageId, string $accessToken, string $c
*/
private function postTextToFeed(string $pageId, string $accessToken, string $content, ?string $link, bool $reportFailure = true): Response
{
- return $this->postToGraph("{$pageId}/feed", [
+ return $this->postToGraph("{$pageId}/feed", $this->withPlace([
'message' => $content,
'access_token' => $accessToken,
...$this->optionalField('link', $link),
- ], 'text post', $reportFailure);
+ ]), 'text post', $reportFailure);
}
/**
@@ -161,12 +168,12 @@ private function postTextToFeed(string $pageId, string $accessToken, string $con
*/
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media, ?string $aspectRatio): array
{
- $response = $this->postToGraph("{$pageId}/photos", [
+ $response = $this->postToGraph("{$pageId}/photos", $this->withPlace([
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'access_token' => $accessToken,
...$this->optionalField('message', $content),
...$this->altText($media),
- ], 'single image post');
+ ]), 'single image post');
$data = $response->json();
@@ -196,13 +203,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
);
}
- $response = $this->postToGraph("{$pageId}/feed", [
+ $response = $this->postToGraph("{$pageId}/feed", $this->withPlace([
'access_token' => $accessToken,
...$this->optionalField('message', $content),
...$photoIds
->mapWithKeys(fn (string $photoId, int $index): array => ["attached_media[{$index}]" => json_encode(['media_fbid' => $photoId])])
->all(),
- ], 'multi-image post');
+ ]), 'multi-image post');
return $this->feedPostResult(data_get($response->json(), 'id'));
}
@@ -489,7 +496,7 @@ private function requireVideo(?MediaItem $media, string $format): MediaItem
private function sanitizedContent(PostPlatform $postPlatform): ?string
{
- $content = $postPlatform->post->content;
+ $content = $postPlatform->resolvedContent();
return filled($content)
? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform)
@@ -505,6 +512,20 @@ private function facebookHttp(): PendingRequest
return $this->socialHttp()->asForm();
}
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function withPlace(array $payload): array
+ {
+ if ($this->placeId !== null) {
+ $payload['place'] = $this->placeId;
+ }
+
+ return $payload;
+ }
+
/**
* POST a form payload to a Graph edge and turn any failure into the typed
* exception, logging the redacted body under `$label` first.
diff --git a/app/Services/Social/FirstCommentPoster.php b/app/Services/Social/FirstCommentPoster.php
new file mode 100644
index 000000000..b0a7b32a4
--- /dev/null
+++ b/app/Services/Social/FirstCommentPoster.php
@@ -0,0 +1,102 @@
+meta, 'first_comment'));
+
+ if ($comment === '') {
+ return;
+ }
+
+ try {
+ match ($postPlatform->platform) {
+ Platform::YouTube => $this->postYouTubeComment($postPlatform, $externalId, $comment),
+ Platform::Instagram, Platform::InstagramFacebook => $this->postInstagramComment($postPlatform, $externalId, $comment),
+ default => Log::warning('First comment is not supported for this platform', [
+ 'platform' => $postPlatform->platform->value,
+ 'post_platform_id' => $postPlatform->id,
+ ]),
+ };
+ } catch (\Throwable $e) {
+ Log::warning('First comment failed (post itself is published)', [
+ 'platform' => $postPlatform->platform->value,
+ 'post_platform_id' => $postPlatform->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * commentThreads.insert needs the youtube.force-ssl scope — requested at
+ * connect time since the platform was added, so every account has it.
+ */
+ private function postYouTubeComment(PostPlatform $postPlatform, string $videoId, string $comment): void
+ {
+ $account = $postPlatform->socialAccount;
+ $api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');
+
+ $response = $this->socialHttp()
+ ->withToken($account->access_token)
+ ->post("{$api}/commentThreads?part=snippet", [
+ 'snippet' => [
+ 'videoId' => $videoId,
+ 'topLevelComment' => [
+ 'snippet' => ['textOriginal' => $comment],
+ ],
+ ],
+ ]);
+
+ if ($response->failed()) {
+ Log::warning('YouTube first comment failed', [
+ 'post_platform_id' => $postPlatform->id,
+ 'status' => $response->status(),
+ 'body' => $this->redactResponseBody($response->body()),
+ ]);
+ }
+ }
+
+ /**
+ * Needs instagram_business_manage_comments (direct) / instagram_manage_comments
+ * (via Facebook Page) — accounts connected before the scope was requested
+ * get a 4xx here, which is logged and ignored; reconnecting fixes it.
+ */
+ private function postInstagramComment(PostPlatform $postPlatform, string $mediaId, string $comment): void
+ {
+ $account = $postPlatform->socialAccount;
+ $baseUrl = $account->platform->instagramGraphBaseUrl();
+
+ $response = $this->socialHttp()->post("{$baseUrl}/{$mediaId}/comments", [
+ 'message' => $comment,
+ 'access_token' => $account->access_token,
+ ]);
+
+ if ($response->failed()) {
+ Log::warning('Instagram first comment failed', [
+ 'post_platform_id' => $postPlatform->id,
+ 'status' => $response->status(),
+ 'body' => $this->redactResponseBody($response->body()),
+ ]);
+ }
+ }
+}
diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php
index 8be62d24b..463f4fe0e 100644
--- a/app/Services/Social/InstagramPublisher.php
+++ b/app/Services/Social/InstagramPublisher.php
@@ -38,6 +38,13 @@ class InstagramPublisher
private const string WORKFLOW_FINAL_CONTAINER = 'final_container';
+ /**
+ * Facebook place ID from the platform row's `meta.location_id` — tags the
+ * post with a location. Applied to feed/reel/carousel containers; the API
+ * does not accept it on stories or carousel children.
+ */
+ private ?string $locationId = null;
+
public function publish(PostPlatform $postPlatform): array
{
$this->postPlatform = $postPlatform;
@@ -45,6 +52,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
+ $this->locationId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
@@ -53,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array
$instagramId = $account->platform_user_id;
$accessToken = $account->access_token;
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$pendingWorkflow = PublishCheckpoint::instagramWorkflow($postPlatform->error_context);
@@ -101,6 +109,19 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia, $aspectRatio);
}
+ /**
+ * @param array $params
+ * @return array
+ */
+ private function withLocation(array $params): array
+ {
+ if ($this->locationId !== null) {
+ $params['location_id'] = $this->locationId;
+ }
+
+ return $params;
+ }
+
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
@@ -117,19 +138,19 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
$params['alt_text'] = $alt;
}
- $containerId = $this->createContainer($instagramId, $params, 'container');
+ $containerId = $this->createContainer($instagramId, $this->withLocation($params), 'container');
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array
{
- $containerId = $this->createContainer($instagramId, [
+ $containerId = $this->createContainer($instagramId, $this->withLocation([
'video_url' => $media->url,
'caption' => $content,
'media_type' => 'REELS',
'access_token' => $accessToken,
- ], 'reel container');
+ ]), 'reel container');
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
@@ -233,12 +254,12 @@ private function finishCarousel(string $instagramId, string $accessToken, ?strin
$this->waitForMediaProcessing($childId, $accessToken, $workflow);
}
- $carouselId = $this->createContainer($instagramId, [
+ $carouselId = $this->createContainer($instagramId, $this->withLocation([
'media_type' => 'CAROUSEL',
'caption' => $content,
'children' => implode(',', $childContainers),
'access_token' => $accessToken,
- ], 'carousel container');
+ ]), 'carousel container');
return $this->finishContainer($instagramId, $accessToken, $carouselId);
}
diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php
index ee3a2b2e1..b6831af0d 100644
--- a/app/Services/Social/MastodonPublisher.php
+++ b/app/Services/Social/MastodonPublisher.php
@@ -24,7 +24,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
@@ -34,7 +34,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->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
+ $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon));
if ($mediaId) {
$mediaIds[] = $mediaId;
}
diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php
index 3cb6df412..65408e7b8 100644
--- a/app/Services/Social/PinterestPublisher.php
+++ b/app/Services/Social/PinterestPublisher.php
@@ -43,8 +43,8 @@ public function publish(PostPlatform $postPlatform): array
app(ConnectionVerifier::class)->refreshToken($account);
}
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;
return match ($postPlatform->content_type) {
diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php
index 7692f22cd..61260f929 100644
--- a/app/Services/Social/Telegram/TelegramPublisher.php
+++ b/app/Services/Social/Telegram/TelegramPublisher.php
@@ -33,8 +33,8 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$chatId = (string) data_get($account->meta, 'chat_id');
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: '';
$media = $postPlatform->post->mediaItems->take(self::ALBUM_CHUNK);
diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php
index 8585cfcad..30f981d1a 100644
--- a/app/Services/Social/ThreadsPublisher.php
+++ b/app/Services/Social/ThreadsPublisher.php
@@ -35,7 +35,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php
index 46e1c486b..8e4bf6f28 100644
--- a/app/Services/Social/TikTokPublisher.php
+++ b/app/Services/Social/TikTokPublisher.php
@@ -47,7 +47,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php
index f2e7a2f2c..03210786f 100644
--- a/app/Services/Social/XPublisher.php
+++ b/app/Services/Social/XPublisher.php
@@ -37,7 +37,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php
index 94d110891..514795289 100644
--- a/app/Services/Social/YouTubePublisher.php
+++ b/app/Services/Social/YouTubePublisher.php
@@ -14,7 +14,9 @@
use Google\Client as GoogleClient;
use Google\Service\Exception;
use Google\Service\YouTube;
+use Google\Service\YouTube\GeoPoint;
use Google\Service\YouTube\Video;
+use Google\Service\YouTube\VideoRecordingDetails;
use Google\Service\YouTube\VideoSnippet;
use Google\Service\YouTube\VideoStatus;
use Google_Http_MediaFileUpload;
@@ -31,8 +33,8 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;
$description = $this->resolveDescription($postPlatform, $content);
@@ -60,7 +62,7 @@ public function publish(PostPlatform $postPlatform): array
);
}
- return $this->publishShort($firstMedia, $account, $content, $description);
+ return $this->publishShort($postPlatform, $firstMedia, $account, $content, $description);
}
private function createGoogleClient(SocialAccount $account): GoogleClient
@@ -88,7 +90,7 @@ private function createGoogleClient(SocialAccount $account): GoogleClient
return $client;
}
- private function publishShort(MediaItem $media, SocialAccount $account, ?string $content, string $description): array
+ private function publishShort(PostPlatform $postPlatform, MediaItem $media, SocialAccount $account, ?string $content, string $description): array
{
if (empty($content)) {
throw new YouTubePublishException(
@@ -108,7 +110,7 @@ private function publishShort(MediaItem $media, SocialAccount $account, ?string
try {
$this->downloadVideo($media, $tempFile);
- $video = $this->uploadVideo($media, $account, $tempFile, $this->buildVideo($content, $description));
+ $video = $this->uploadVideo($media, $account, $tempFile, $this->buildVideo($postPlatform, $content, $description));
$videoId = $video->getId();
return [
@@ -169,7 +171,7 @@ private function uploadVideo(MediaItem $media, SocialAccount $account, string $p
$youtube = new YouTube($client);
$mediaUpload = new Google_Http_MediaFileUpload(
$client,
- $youtube->videos->insert('snippet,status', $video),
+ $youtube->videos->insert($video->getRecordingDetails() !== null ? 'snippet,status,recordingDetails' : 'snippet,status', $video),
$media->mime_type ?: 'video/mp4',
null,
true,
@@ -214,12 +216,24 @@ private function resolveDescription(PostPlatform $postPlatform, ?string $content
return $description;
}
- private function buildVideo(string $content, string $description): Video
+ private function buildVideo(PostPlatform $postPlatform, string $content, string $description): Video
{
$snippet = new VideoSnippet;
- $snippet->setTitle($this->buildTitle($content));
+ $snippet->setTitle($this->resolveTitle($postPlatform, $content));
$snippet->setDescription($description);
- $snippet->setCategoryId('22');
+ $snippet->setCategoryId((string) (data_get($postPlatform->meta, 'category_id') ?: '22'));
+
+ $tags = array_values(array_filter(array_map(
+ fn ($tag) => trim((string) $tag),
+ (array) data_get($postPlatform->meta, 'tags', []),
+ )));
+ if ($tags !== []) {
+ $snippet->setTags($tags);
+ }
+
+ if (filled($language = data_get($postPlatform->meta, 'default_language'))) {
+ $snippet->setDefaultLanguage((string) $language);
+ }
$status = new VideoStatus;
$status->setPrivacyStatus('public');
@@ -229,9 +243,36 @@ private function buildVideo(string $content, string $description): Video
$video->setSnippet($snippet);
$video->setStatus($status);
+ $recording = data_get($postPlatform->meta, 'recording_location');
+ if (is_array($recording) && isset($recording['lat'], $recording['lng'])) {
+ $location = new GeoPoint;
+ $location->setLatitude((float) $recording['lat']);
+ $location->setLongitude((float) $recording['lng']);
+
+ $details = new VideoRecordingDetails;
+ $details->setLocation($location);
+ if (filled($recording['description'] ?? null)) {
+ $details->setLocationDescription((string) $recording['description']);
+ }
+
+ $video->setRecordingDetails($details);
+ }
+
return $video;
}
+ /**
+ * The video title: the per-platform `meta.title` when set (YouTube caps
+ * titles at 100 chars — the shared `title` meta rule enforces that),
+ * otherwise derived from the first line of the content as before.
+ */
+ private function resolveTitle(PostPlatform $postPlatform, string $content): string
+ {
+ $title = trim((string) data_get($postPlatform->meta, 'title'));
+
+ return $title !== '' ? $title : $this->buildTitle($content);
+ }
+
private function buildTitle(string $content): string
{
$maxLength = 100;
diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php
index f7608db84..151958df7 100644
--- a/app/Support/PostPlatformMetaRules.php
+++ b/app/Support/PostPlatformMetaRules.php
@@ -53,6 +53,32 @@ public static function rules(): array
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
+ // First comment posted right after a successful publish (YouTube and
+ // Instagram). Capped at Instagram's comment limit, the stricter of
+ // the two.
+ 'platforms.*.meta.first_comment' => ['sometimes', 'nullable', 'string', 'max:2200'],
+
+ // Any platform — caption override for this platform row only. The
+ // platform's own hard cap is enforced at publish time against the
+ // resolved text (PostPlatform::resolvedContent()).
+ 'platforms.*.meta.content' => ['sometimes', 'nullable', 'string', 'max:16000'],
+
+ // Instagram / Facebook — place tag. Meta closed place search to
+ // third parties, so the ID is entered by hand; location_name is
+ // only for display in the editor.
+ 'platforms.*.meta.location_id' => ['sometimes', 'nullable', 'string', 'max:64'],
+ 'platforms.*.meta.location_name' => ['sometimes', 'nullable', 'string', 'max:200'],
+
+ // YouTube (title reuses the shared 100-char `title` rule below)
+ 'platforms.*.meta.tags' => ['sometimes', 'nullable', 'array', 'max:30'],
+ 'platforms.*.meta.tags.*' => ['required', 'string', 'max:100'],
+ 'platforms.*.meta.category_id' => ['sometimes', 'nullable', 'string', 'max:10'],
+ 'platforms.*.meta.default_language' => ['sometimes', 'nullable', 'string', 'max:12'],
+ 'platforms.*.meta.recording_location' => ['sometimes', 'nullable', 'array'],
+ 'platforms.*.meta.recording_location.lat' => ['required_with:platforms.*.meta.recording_location', 'numeric', 'between:-90,90'],
+ 'platforms.*.meta.recording_location.lng' => ['required_with:platforms.*.meta.recording_location', 'numeric', 'between:-180,180'],
+ 'platforms.*.meta.recording_location.description' => ['sometimes', 'nullable', 'string', 'max:200'],
+
// Pinterest
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'],
@@ -105,6 +131,7 @@ public static function messages(): array
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'),
'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'),
+ 'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
];
}
@@ -121,6 +148,7 @@ public static function attributes(): array
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'),
'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'),
+ 'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
];
}
diff --git a/lang/ar/posts.php b/lang/ar/posts.php
index 1005d7b03..f69f074bf 100644
--- a/lang/ar/posts.php
+++ b/lang/ar/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'عنوان المستند',
'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك',
],
+ 'custom_caption' => [
+ 'label' => 'نص مخصص لـ :platform',
+ 'active' => 'مفعّل',
+ 'hint' => 'اتركه فارغًا لاستخدام نص المنشور المشترك.',
+ 'placeholder' => 'نص لهذه المنصة فقط…',
+ 'over_limit' => 'يتجاوز حد هذه المنصة (:limit حرفًا).',
+ ],
+ 'location' => [
+ 'label' => 'الموقع',
+ 'id_placeholder' => 'معرّف المكان',
+ 'name_placeholder' => 'الاسم (للعرض)',
+ 'hint' => 'معرّف مكان فيسبوك؛ البحث عن الأماكن مغلق للتطبيقات الخارجية — أدخل المعرّف مرة واحدة وأعد استخدامه.',
+ ],
'youtube' => [
'settings' => 'إعدادات YouTube',
'posting_to' => 'النشر على',
@@ -150,6 +163,25 @@
'description_max' => 'يجب ألا يتجاوز وصف YouTube 5000 بايت.',
'description_invalid' => 'يجب أن يكون وصف YouTube نصًا صالحًا.',
'description_bytes' => ':used / :limit بايت',
+ 'title' => 'العنوان',
+ 'title_placeholder' => 'عنوان الفيديو (بدل السطر الأول من المنشور)',
+ 'title_hint' => 'حتى 100 حرف؛ يُضاف " #Shorts" تلقائيًا عند اشتقاقه من النص.',
+ 'tags' => 'الوسوم',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'مفصولة بفواصل؛ تساعد البحث والتوصيات.',
+ 'category' => 'معرّف الفئة',
+ 'language' => 'اللغة',
+ 'location' => 'مكان التصوير',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'خط العرض',
+ 'lng' => 'خط الطول',
+ ],
+ 'first_comment' => [
+ 'label' => 'التعليق الأول',
+ 'placeholder' => 'رابط وإضافات للتعليق الأول…',
+ 'hint' => 'يُنشر باسم قناتك فور نشر الفيديو.',
+ 'hint_instagram' => 'يُنشر فور النشر — المكان الكلاسيكي للروابط التي لا تكون قابلة للنقر في وصف Instagram. أعد ربط الحساب مرة واحدة لمنح إذن التعليقات.',
+ 'max' => 'لا يمكن أن يتجاوز التعليق الأول 2200 حرف.',
],
'pinterest' => [
'settings' => 'إعدادات Pinterest',
diff --git a/lang/de/posts.php b/lang/de/posts.php
index 0bd362f66..e0f3292c9 100644
--- a/lang/de/posts.php
+++ b/lang/de/posts.php
@@ -144,6 +144,19 @@
'document_title' => 'Dokumenttitel',
'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt',
],
+ 'custom_caption' => [
+ 'label' => 'Eigener Text für :platform',
+ 'active' => 'an',
+ 'hint' => 'Leer lassen, um den gemeinsamen Beitragstext zu verwenden.',
+ 'placeholder' => 'Text nur für diese Plattform…',
+ 'over_limit' => 'Länger als das Limit dieser Plattform (:limit Zeichen).',
+ ],
+ 'location' => [
+ 'label' => 'Standort',
+ 'id_placeholder' => 'Orts-ID',
+ 'name_placeholder' => 'Name (zur Anzeige)',
+ 'hint' => 'Facebook-Orts-ID; die Ortssuche ist für Dritt-Apps geschlossen — ID einmal ermitteln und wiederverwenden.',
+ ],
'youtube' => [
'settings' => 'YouTube-Einstellungen',
'posting_to' => 'Veröffentlichen auf',
@@ -152,6 +165,25 @@
'description_max' => 'Die YouTube-Beschreibung darf 5.000 Bytes nicht überschreiten.',
'description_invalid' => 'Die YouTube-Beschreibung muss gültiger Text sein.',
'description_bytes' => ':used / :limit Bytes',
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Videotitel (statt der ersten Zeile des Beitrags)',
+ 'title_hint' => 'Bis zu 100 Zeichen; „ #Shorts" wird automatisch angehängt, wenn der Titel aus dem Text stammt.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, stadtkarte',
+ 'tags_hint' => 'Kommagetrennt; hilft Suche und Empfehlungen.',
+ 'category' => 'Kategorie-ID',
+ 'language' => 'Sprache',
+ 'location' => 'Aufnahmeort',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Breitengrad',
+ 'lng' => 'Längengrad',
+ ],
+ 'first_comment' => [
+ 'label' => 'Erster Kommentar',
+ 'placeholder' => 'Link und Extras für den ersten Kommentar…',
+ 'hint' => 'Wird direkt nach der Veröffentlichung vom Kanal gepostet.',
+ 'hint_instagram' => 'Wird direkt nach dem Beitrag gepostet — der klassische Ort für Links, die in Instagram-Beschreibungen nicht klickbar sind. Konto einmal neu verbinden, um die Kommentar-Berechtigung zu erteilen.',
+ 'max' => 'Der erste Kommentar darf 2200 Zeichen nicht überschreiten.',
],
'pinterest' => [
'settings' => 'Pinterest-Einstellungen',
diff --git a/lang/el/posts.php b/lang/el/posts.php
index 6d8516056..6fdedc38f 100644
--- a/lang/el/posts.php
+++ b/lang/el/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Τίτλος εγγράφου',
'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας',
],
+ 'custom_caption' => [
+ 'label' => 'Ξεχωριστό κείμενο για :platform',
+ 'active' => 'ενεργό',
+ 'hint' => 'Κενό: χρησιμοποιείται το κοινό κείμενο της ανάρτησης.',
+ 'placeholder' => 'Κείμενο μόνο για αυτήν την πλατφόρμα…',
+ 'over_limit' => 'Υπερβαίνει το όριο της πλατφόρμας (:limit χαρακτήρες).',
+ ],
+ 'location' => [
+ 'label' => 'Τοποθεσία',
+ 'id_placeholder' => 'ID τοποθεσίας',
+ 'name_placeholder' => 'Όνομα (εμφάνιση)',
+ 'hint' => 'Facebook place ID· η αναζήτηση τοποθεσιών είναι κλειστή για τρίτους — βρείτε το ID μία φορά και επαναχρησιμοποιήστε το.',
+ ],
'youtube' => [
'settings' => 'Ρυθμίσεις YouTube',
'posting_to' => 'Δημοσίευση σε',
@@ -150,6 +163,25 @@
'description_max' => 'Η περιγραφή YouTube δεν πρέπει να υπερβαίνει τα 5.000 byte.',
'description_invalid' => 'Η περιγραφή YouTube πρέπει να είναι έγκυρο κείμενο.',
'description_bytes' => ':used / :limit byte',
+ 'title' => 'Τίτλος',
+ 'title_placeholder' => 'Τίτλος βίντεο (αντί της πρώτης γραμμής)',
+ 'title_hint' => 'Έως 100 χαρακτήρες· το " #Shorts" προστίθεται αυτόματα όταν προκύπτει από το κείμενο.',
+ 'tags' => 'Ετικέτες',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'Χωρισμένες με κόμματα· βοηθούν αναζήτηση και προτάσεις.',
+ 'category' => 'ID κατηγορίας',
+ 'language' => 'Γλώσσα',
+ 'location' => 'Τοποθεσία λήψης',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Γεωγρ. πλάτος',
+ 'lng' => 'Γεωγρ. μήκος',
+ ],
+ 'first_comment' => [
+ 'label' => 'Πρώτο σχόλιο',
+ 'placeholder' => 'Σύνδεσμος και πρόσθετα για το πρώτο σχόλιο…',
+ 'hint' => 'Δημοσιεύεται από το κανάλι σας αμέσως μετά τη δημοσίευση του βίντεο.',
+ 'hint_instagram' => 'Δημοσιεύεται αμέσως μετά την ανάρτηση — το κλασικό σημείο για συνδέσμους που οι λεζάντες του Instagram δεν κάνουν κλικαρίσιμους. Επανασυνδέστε τον λογαριασμό μία φορά για να δώσετε το δικαίωμα σχολίων.',
+ 'max' => 'Το πρώτο σχόλιο δεν μπορεί να υπερβαίνει τους 2200 χαρακτήρες.',
],
'pinterest' => [
'settings' => 'Ρυθμίσεις Pinterest',
diff --git a/lang/en/posts.php b/lang/en/posts.php
index c97d9ca3e..67459fa30 100644
--- a/lang/en/posts.php
+++ b/lang/en/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Document title',
'document_title_placeholder' => 'Shown on your PDF document post',
],
+ 'custom_caption' => [
+ 'label' => 'Custom text for :platform',
+ 'active' => 'on',
+ 'hint' => 'Leave empty to use the shared post text.',
+ 'placeholder' => 'Text for this platform only…',
+ 'over_limit' => 'Longer than this platform allows (:limit characters).',
+ ],
+ 'location' => [
+ 'label' => 'Location',
+ 'id_placeholder' => 'Place ID',
+ 'name_placeholder' => 'Name (for display)',
+ 'hint' => 'Facebook place ID; place search is closed to third-party apps, so enter the ID once and reuse it.',
+ ],
'youtube' => [
'settings' => 'YouTube settings',
'posting_to' => 'Posting to',
@@ -150,6 +163,25 @@
'description_max' => 'The YouTube description must not exceed 5,000 bytes.',
'description_invalid' => 'The YouTube description must be valid text.',
'description_bytes' => ':used / :limit bytes',
+ 'title' => 'Title',
+ 'title_placeholder' => 'Video title (instead of the first line of the post)',
+ 'title_hint' => 'Up to 100 characters; " #Shorts" is appended automatically when derived from the post text.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, city map',
+ 'tags_hint' => 'Comma-separated; helps search and recommendations.',
+ 'category' => 'Category ID',
+ 'language' => 'Language',
+ 'location' => 'Recording location',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
+ ],
+ 'first_comment' => [
+ 'label' => 'First comment',
+ 'placeholder' => 'Link and extras for the first comment…',
+ 'hint' => 'Posted by your channel right after the video is published.',
+ 'hint_instagram' => 'Posted right after publishing — the classic spot for links Instagram captions can\'t make clickable. Requires reconnecting the account once to grant the comments permission.',
+ 'max' => 'First comment may not exceed 2200 characters.',
],
'pinterest' => [
'settings' => 'Pinterest Settings',
diff --git a/lang/es/posts.php b/lang/es/posts.php
index b0f6ffd60..edcf98466 100644
--- a/lang/es/posts.php
+++ b/lang/es/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Título del documento',
'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texto propio para :platform',
+ 'active' => 'sí',
+ 'hint' => 'Vacío: se usa el texto común de la publicación.',
+ 'placeholder' => 'Texto solo para esta plataforma…',
+ 'over_limit' => 'Supera el límite de esta plataforma (:limit caracteres).',
+ ],
+ 'location' => [
+ 'label' => 'Ubicación',
+ 'id_placeholder' => 'ID del lugar',
+ 'name_placeholder' => 'Nombre (visual)',
+ 'hint' => 'ID de lugar de Facebook; la búsqueda de lugares está cerrada a terceros: introduce el ID una vez y reutilízalo.',
+ ],
'youtube' => [
'settings' => 'Configuración de YouTube',
'posting_to' => 'Publicando en',
@@ -150,6 +163,25 @@
'description_max' => 'La descripción de YouTube no puede superar los 5.000 bytes.',
'description_invalid' => 'La descripción de YouTube debe ser texto válido.',
'description_bytes' => ':used / :limit bytes',
+ 'title' => 'Título',
+ 'title_placeholder' => 'Título del vídeo (en lugar de la primera línea)',
+ 'title_hint' => 'Hasta 100 caracteres; " #Shorts" se añade automáticamente si se deriva del texto.',
+ 'tags' => 'Etiquetas',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Separadas por comas; ayudan a la búsqueda y recomendaciones.',
+ 'category' => 'ID de categoría',
+ 'language' => 'Idioma',
+ 'location' => 'Lugar de grabación',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitud',
+ 'lng' => 'Longitud',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primer comentario',
+ 'placeholder' => 'Enlace y extras para el primer comentario…',
+ 'hint' => 'Se publica desde tu canal justo después de publicar el vídeo.',
+ 'hint_instagram' => 'Se publica justo después de la publicación: el lugar clásico para enlaces que Instagram no hace clicables en la descripción. Reconecta la cuenta una vez para conceder el permiso de comentarios.',
+ 'max' => 'El primer comentario no puede superar los 2200 caracteres.',
],
'pinterest' => [
'settings' => 'Configuración de Pinterest',
diff --git a/lang/fr/posts.php b/lang/fr/posts.php
index 4cb63393d..0debbb3bc 100644
--- a/lang/fr/posts.php
+++ b/lang/fr/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Titre du document',
'document_title_placeholder' => 'Affiché sur votre publication de document PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texte spécifique pour :platform',
+ 'active' => 'actif',
+ 'hint' => 'Vide : le texte commun de la publication est utilisé.',
+ 'placeholder' => 'Texte pour cette plateforme uniquement…',
+ 'over_limit' => 'Dépasse la limite de cette plateforme (:limit caractères).',
+ ],
+ 'location' => [
+ 'label' => 'Lieu',
+ 'id_placeholder' => 'ID du lieu',
+ 'name_placeholder' => 'Nom (affichage)',
+ 'hint' => 'ID de lieu Facebook ; la recherche de lieux est fermée aux apps tierces — saisissez l’ID une fois et réutilisez-le.',
+ ],
'youtube' => [
'settings' => 'Paramètres YouTube',
'posting_to' => 'Publication sur',
@@ -150,6 +163,25 @@
'description_max' => 'La description YouTube ne doit pas dépasser 5 000 octets.',
'description_invalid' => 'La description YouTube doit être un texte valide.',
'description_bytes' => ':used / :limit octets',
+ 'title' => 'Titre',
+ 'title_placeholder' => 'Titre de la vidéo (au lieu de la première ligne)',
+ 'title_hint' => 'Jusqu’à 100 caractères ; « #Shorts » est ajouté automatiquement s’il provient du texte.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, carte',
+ 'tags_hint' => 'Séparés par des virgules ; aide la recherche et les recommandations.',
+ 'category' => 'ID de catégorie',
+ 'language' => 'Langue',
+ 'location' => 'Lieu de tournage',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
+ ],
+ 'first_comment' => [
+ 'label' => 'Premier commentaire',
+ 'placeholder' => 'Lien et compléments pour le premier commentaire…',
+ 'hint' => 'Publié par votre chaîne juste après la mise en ligne de la vidéo.',
+ 'hint_instagram' => 'Publié juste après la publication — l\'endroit classique pour les liens que les légendes Instagram ne rendent pas cliquables. Reconnectez le compte une fois pour accorder l\'autorisation de commenter.',
+ 'max' => 'Le premier commentaire ne peut pas dépasser 2200 caractères.',
],
'pinterest' => [
'settings' => 'Paramètres Pinterest',
diff --git a/lang/it/posts.php b/lang/it/posts.php
index 3f3aa3316..ef987cae0 100644
--- a/lang/it/posts.php
+++ b/lang/it/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Titolo del documento',
'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Testo dedicato per :platform',
+ 'active' => 'attivo',
+ 'hint' => 'Vuoto: viene usato il testo comune del post.',
+ 'placeholder' => 'Testo solo per questa piattaforma…',
+ 'over_limit' => 'Supera il limite di questa piattaforma (:limit caratteri).',
+ ],
+ 'location' => [
+ 'label' => 'Posizione',
+ 'id_placeholder' => 'ID del luogo',
+ 'name_placeholder' => 'Nome (visuale)',
+ 'hint' => 'ID luogo di Facebook; la ricerca dei luoghi è chiusa alle app di terzi: inserisci l’ID una volta e riusalo.',
+ ],
'youtube' => [
'settings' => 'Impostazioni YouTube',
'posting_to' => 'Pubblicazione su',
@@ -150,6 +163,25 @@
'description_max' => 'La descrizione YouTube non deve superare 5.000 byte.',
'description_invalid' => 'La descrizione YouTube deve essere testo valido.',
'description_bytes' => ':used / :limit byte',
+ 'title' => 'Titolo',
+ 'title_placeholder' => 'Titolo del video (invece della prima riga)',
+ 'title_hint' => 'Fino a 100 caratteri; " #Shorts" viene aggiunto automaticamente se derivato dal testo.',
+ 'tags' => 'Tag',
+ 'tags_placeholder' => 'brisbane, pixel art, mappa',
+ 'tags_hint' => 'Separati da virgole; aiutano ricerca e consigli.',
+ 'category' => 'ID categoria',
+ 'language' => 'Lingua',
+ 'location' => 'Luogo di ripresa',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitudine',
+ 'lng' => 'Longitudine',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primo commento',
+ 'placeholder' => 'Link ed extra per il primo commento…',
+ 'hint' => 'Pubblicato dal tuo canale subito dopo la pubblicazione del video.',
+ 'hint_instagram' => 'Pubblicato subito dopo il post: il posto classico per i link che le didascalie di Instagram non rendono cliccabili. Ricollega l\'account una volta per concedere il permesso sui commenti.',
+ 'max' => 'Il primo commento non può superare i 2200 caratteri.',
],
'pinterest' => [
'settings' => 'Impostazioni Pinterest',
diff --git a/lang/ja/posts.php b/lang/ja/posts.php
index c2a011a02..61333bc35 100644
--- a/lang/ja/posts.php
+++ b/lang/ja/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'ドキュメントタイトル',
'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます',
],
+ 'custom_caption' => [
+ 'label' => ':platform 専用テキスト',
+ 'active' => '有効',
+ 'hint' => '空の場合は投稿の共通テキストが使われます。',
+ 'placeholder' => 'このプラットフォームだけのテキスト…',
+ 'over_limit' => 'このプラットフォームの上限(:limit文字)を超えています。',
+ ],
+ 'location' => [
+ 'label' => '位置情報',
+ 'id_placeholder' => 'プレイスID',
+ 'name_placeholder' => '名称(表示用)',
+ 'hint' => 'FacebookのプレイスID。場所検索はサードパーティに閉鎖されているため、IDを一度調べて再利用してください。',
+ ],
'youtube' => [
'settings' => 'YouTube設定',
'posting_to' => '投稿先',
@@ -150,6 +163,25 @@
'description_max' => 'YouTubeの説明は5,000バイト以内にしてください。',
'description_invalid' => 'YouTubeの説明には有効なテキストを入力してください。',
'description_bytes' => ':used / :limit バイト',
+ 'title' => 'タイトル',
+ 'title_placeholder' => '動画タイトル(投稿の1行目の代わり)',
+ 'title_hint' => '最大100文字。本文から生成される場合は「 #Shorts」が自動で付きます。',
+ 'tags' => 'タグ',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'カンマ区切り。検索とおすすめに役立ちます。',
+ 'category' => 'カテゴリID',
+ 'language' => '言語',
+ 'location' => '撮影場所',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '緯度',
+ 'lng' => '経度',
+ ],
+ 'first_comment' => [
+ 'label' => '最初のコメント',
+ 'placeholder' => '最初のコメントに入れるリンクや補足…',
+ 'hint' => '動画公開直後にチャンネルから投稿されます。',
+ 'hint_instagram' => '投稿直後にコメントされます。Instagramのキャプションでクリックできないリンクの定番の置き場所です。コメント権限を付与するには一度アカウントを再連携してください。',
+ 'max' => '最初のコメントは2200文字を超えられません。',
],
'pinterest' => [
'settings' => 'Pinterest 設定',
diff --git a/lang/ko/posts.php b/lang/ko/posts.php
index bf42e246a..4a2a4534f 100644
--- a/lang/ko/posts.php
+++ b/lang/ko/posts.php
@@ -142,6 +142,19 @@
'document_title' => '문서 제목',
'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다',
],
+ 'custom_caption' => [
+ 'label' => ':platform 전용 텍스트',
+ 'active' => '사용',
+ 'hint' => '비워두면 게시물의 공통 텍스트가 사용됩니다.',
+ 'placeholder' => '이 플랫폼만을 위한 텍스트…',
+ 'over_limit' => '이 플랫폼의 한도(:limit자)를 초과했습니다.',
+ ],
+ 'location' => [
+ 'label' => '위치',
+ 'id_placeholder' => '장소 ID',
+ 'name_placeholder' => '이름(표시용)',
+ 'hint' => 'Facebook 장소 ID. 장소 검색은 서드파티에 닫혀 있으므로 ID를 한 번 찾아 재사용하세요.',
+ ],
'youtube' => [
'settings' => 'YouTube 설정',
'posting_to' => '게시 대상',
@@ -150,6 +163,25 @@
'description_max' => 'YouTube 설명은 5,000바이트를 초과할 수 없습니다.',
'description_invalid' => 'YouTube 설명은 유효한 텍스트여야 합니다.',
'description_bytes' => ':used / :limit 바이트',
+ 'title' => '제목',
+ 'title_placeholder' => '동영상 제목(게시물 첫 줄 대신)',
+ 'title_hint' => '최대 100자. 본문에서 생성되면 " #Shorts"가 자동으로 붙습니다.',
+ 'tags' => '태그',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => '쉼표로 구분. 검색과 추천에 도움이 됩니다.',
+ 'category' => '카테고리 ID',
+ 'language' => '언어',
+ 'location' => '촬영 위치',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '위도',
+ 'lng' => '경도',
+ ],
+ 'first_comment' => [
+ 'label' => '첫 댓글',
+ 'placeholder' => '첫 댓글에 넣을 링크와 추가 내용…',
+ 'hint' => '동영상 게시 직후 채널 명의로 작성됩니다.',
+ 'hint_instagram' => '게시 직후 작성됩니다 — Instagram 캡션에서 클릭되지 않는 링크를 넣는 고전적인 방법입니다. 댓글 권한을 부여하려면 계정을 한 번 다시 연결하세요.',
+ 'max' => '첫 댓글은 2200자를 초과할 수 없습니다.',
],
'pinterest' => [
'settings' => 'Pinterest 설정',
diff --git a/lang/nl/posts.php b/lang/nl/posts.php
index da32a0986..9e7648893 100644
--- a/lang/nl/posts.php
+++ b/lang/nl/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Documenttitel',
'document_title_placeholder' => 'Getoond op je PDF-documentpost',
],
+ 'custom_caption' => [
+ 'label' => 'Eigen tekst voor :platform',
+ 'active' => 'aan',
+ 'hint' => 'Leeg: de gedeelde berichttekst wordt gebruikt.',
+ 'placeholder' => 'Tekst alleen voor dit platform…',
+ 'over_limit' => 'Langer dan de limiet van dit platform (:limit tekens).',
+ ],
+ 'location' => [
+ 'label' => 'Locatie',
+ 'id_placeholder' => 'Plaats-ID',
+ 'name_placeholder' => 'Naam (weergave)',
+ 'hint' => 'Facebook-plaats-ID; plaats zoeken is gesloten voor externe apps — voer de ID één keer in en hergebruik.',
+ ],
'youtube' => [
'settings' => 'YouTube-instellingen',
'posting_to' => 'Publiceren op',
@@ -150,6 +163,25 @@
'description_max' => 'De YouTube-beschrijving mag niet langer zijn dan 5.000 bytes.',
'description_invalid' => 'De YouTube-beschrijving moet geldige tekst zijn.',
'description_bytes' => ':used / :limit bytes',
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Videotitel (in plaats van de eerste regel)',
+ 'title_hint' => 'Tot 100 tekens; " #Shorts" wordt automatisch toegevoegd als de titel uit de tekst komt.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, kaart',
+ 'tags_hint' => 'Kommagescheiden; helpt zoeken en aanbevelingen.',
+ 'category' => 'Categorie-ID',
+ 'language' => 'Taal',
+ 'location' => 'Opnamelocatie',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Breedtegraad',
+ 'lng' => 'Lengtegraad',
+ ],
+ 'first_comment' => [
+ 'label' => 'Eerste reactie',
+ 'placeholder' => 'Link en extra\'s voor de eerste reactie…',
+ 'hint' => 'Direct na publicatie door je kanaal geplaatst.',
+ 'hint_instagram' => 'Direct na het bericht geplaatst — dé plek voor links die Instagram-bijschriften niet klikbaar maken. Verbind het account één keer opnieuw om de reactietoestemming te geven.',
+ 'max' => 'De eerste reactie mag niet langer zijn dan 2200 tekens.',
],
'pinterest' => [
'settings' => 'Pinterest-instellingen',
diff --git a/lang/pl/posts.php b/lang/pl/posts.php
index 3393ecca7..9d5dc8d33 100644
--- a/lang/pl/posts.php
+++ b/lang/pl/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Tytuł dokumentu',
'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Własny tekst dla :platform',
+ 'active' => 'wł.',
+ 'hint' => 'Puste — używany jest wspólny tekst posta.',
+ 'placeholder' => 'Tekst tylko dla tej platformy…',
+ 'over_limit' => 'Przekracza limit tej platformy (:limit znaków).',
+ ],
+ 'location' => [
+ 'label' => 'Lokalizacja',
+ 'id_placeholder' => 'ID miejsca',
+ 'name_placeholder' => 'Nazwa (podgląd)',
+ 'hint' => 'ID miejsca Facebooka; wyszukiwanie miejsc jest zamknięte dla aplikacji zewnętrznych — wpisz ID raz i używaj ponownie.',
+ ],
'youtube' => [
'settings' => 'Ustawienia YouTube',
'posting_to' => 'Publikowanie na',
@@ -150,6 +163,25 @@
'description_max' => 'Opis YouTube nie może przekraczać 5000 bajtów.',
'description_invalid' => 'Opis YouTube musi być poprawnym tekstem.',
'description_bytes' => ':used / :limit bajtów',
+ 'title' => 'Tytuł',
+ 'title_placeholder' => 'Tytuł filmu (zamiast pierwszej linii posta)',
+ 'title_hint' => 'Do 100 znaków; „ #Shorts" dodawane automatycznie, gdy tytuł pochodzi z tekstu.',
+ 'tags' => 'Tagi',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Rozdzielone przecinkami; pomagają wyszukiwarce i rekomendacjom.',
+ 'category' => 'ID kategorii',
+ 'language' => 'Język',
+ 'location' => 'Miejsce nagrania',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Szerokość',
+ 'lng' => 'Długość',
+ ],
+ 'first_comment' => [
+ 'label' => 'Pierwszy komentarz',
+ 'placeholder' => 'Link i dodatki do pierwszego komentarza…',
+ 'hint' => 'Publikowany przez Twój kanał zaraz po opublikowaniu filmu.',
+ 'hint_instagram' => 'Publikowany zaraz po poście — klasyczne miejsce na linki, których opisy na Instagramie nie czynią klikalnymi. Połącz konto ponownie, aby nadać uprawnienie do komentarzy.',
+ 'max' => 'Pierwszy komentarz nie może przekraczać 2200 znaków.',
],
'pinterest' => [
'settings' => 'Ustawienia Pinterest',
diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php
index 5cb562e66..c46e63132 100644
--- a/lang/pt-BR/posts.php
+++ b/lang/pt-BR/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Título do documento',
'document_title_placeholder' => 'Aparece no seu post de documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texto próprio para :platform',
+ 'active' => 'ativo',
+ 'hint' => 'Vazio: o texto comum da publicação é usado.',
+ 'placeholder' => 'Texto apenas para esta plataforma…',
+ 'over_limit' => 'Excede o limite desta plataforma (:limit caracteres).',
+ ],
+ 'location' => [
+ 'label' => 'Localização',
+ 'id_placeholder' => 'ID do lugar',
+ 'name_placeholder' => 'Nome (exibição)',
+ 'hint' => 'ID de lugar do Facebook; a busca de lugares está fechada para terceiros — informe o ID uma vez e reutilize.',
+ ],
'youtube' => [
'settings' => 'Configurações do YouTube',
'posting_to' => 'Publicando em',
@@ -150,6 +163,25 @@
'description_max' => 'A descrição do YouTube não pode ultrapassar 5.000 bytes.',
'description_invalid' => 'A descrição do YouTube deve ser um texto válido.',
'description_bytes' => ':used / :limit bytes',
+ 'title' => 'Título',
+ 'title_placeholder' => 'Título do vídeo (em vez da primeira linha)',
+ 'title_hint' => 'Até 100 caracteres; " #Shorts" é adicionado automaticamente quando derivado do texto.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Separadas por vírgulas; ajudam busca e recomendações.',
+ 'category' => 'ID da categoria',
+ 'language' => 'Idioma',
+ 'location' => 'Local de gravação',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primeiro comentário',
+ 'placeholder' => 'Link e extras para o primeiro comentário…',
+ 'hint' => 'Publicado pelo seu canal logo após o vídeo ir ao ar.',
+ 'hint_instagram' => 'Publicado logo após a postagem — o lugar clássico para links que as legendas do Instagram não tornam clicáveis. Reconecte a conta uma vez para conceder a permissão de comentários.',
+ 'max' => 'O primeiro comentário não pode exceder 2200 caracteres.',
],
'pinterest' => [
'settings' => 'Configurações do Pinterest',
diff --git a/lang/ru/posts.php b/lang/ru/posts.php
index b64e8164b..a193981f8 100644
--- a/lang/ru/posts.php
+++ b/lang/ru/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Название документа',
'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом',
],
+ 'custom_caption' => [
+ 'label' => 'Свой текст для :platform',
+ 'active' => 'вкл',
+ 'hint' => 'Пусто — используется общий текст поста.',
+ 'placeholder' => 'Текст только для этой платформы…',
+ 'over_limit' => 'Длиннее лимита платформы (:limit символов).',
+ ],
+ 'location' => [
+ 'label' => 'Локация',
+ 'id_placeholder' => 'ID места',
+ 'name_placeholder' => 'Название (для себя)',
+ 'hint' => 'ID места Facebook; поиск мест закрыт для сторонних приложений — найдите ID один раз и переиспользуйте.',
+ ],
'youtube' => [
'settings' => 'Настройки YouTube',
'posting_to' => 'Публикация в',
@@ -150,6 +163,25 @@
'description_max' => 'Описание YouTube не должно превышать 5000 байт.',
'description_invalid' => 'Описание YouTube должно быть корректным текстом.',
'description_bytes' => ':used / :limit байт',
+ 'title' => 'Заголовок',
+ 'title_placeholder' => 'Заголовок ролика (вместо первой строки поста)',
+ 'title_hint' => 'До 100 символов; « #Shorts» добавляется автоматически, если заголовок берётся из текста поста.',
+ 'tags' => 'Теги',
+ 'tags_placeholder' => 'брисбен, пиксель-арт, карта города',
+ 'tags_hint' => 'Через запятую; помогают поиску и рекомендациям.',
+ 'category' => 'ID категории',
+ 'language' => 'Язык',
+ 'location' => 'Место съёмки',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Широта',
+ 'lng' => 'Долгота',
+ ],
+ 'first_comment' => [
+ 'label' => 'Первый комментарий',
+ 'placeholder' => 'Ссылка и дополнения для первого комментария…',
+ 'hint' => 'Публикуется от имени канала сразу после выхода ролика.',
+ 'hint_instagram' => 'Публикуется сразу после поста — классическое место для ссылок, которые в описании Instagram некликабельны. Требуется один раз переподключить аккаунт, чтобы выдать право на комментарии.',
+ 'max' => 'Первый комментарий не может быть длиннее 2200 знаков.',
],
'pinterest' => [
'settings' => 'Настройки Pinterest',
diff --git a/lang/tr/posts.php b/lang/tr/posts.php
index 52f68be2d..f61fc828e 100644
--- a/lang/tr/posts.php
+++ b/lang/tr/posts.php
@@ -144,6 +144,19 @@
'document_title' => 'Belge başlığı',
'document_title_placeholder' => 'PDF belge gönderinizde gösterilir',
],
+ 'custom_caption' => [
+ 'label' => ':platform için özel metin',
+ 'active' => 'açık',
+ 'hint' => 'Boşsa gönderinin ortak metni kullanılır.',
+ 'placeholder' => 'Yalnızca bu platform için metin…',
+ 'over_limit' => 'Bu platformun sınırını aşıyor (:limit karakter).',
+ ],
+ 'location' => [
+ 'label' => 'Konum',
+ 'id_placeholder' => 'Yer ID',
+ 'name_placeholder' => 'Ad (görünüm)',
+ 'hint' => 'Facebook yer ID’si; yer arama üçüncü taraflara kapalı — ID’yi bir kez bulun ve yeniden kullanın.',
+ ],
'youtube' => [
'settings' => 'YouTube ayarları',
'posting_to' => 'Yayınlanacak hesap',
@@ -152,6 +165,25 @@
'description_max' => 'YouTube açıklaması 5.000 baytı aşmamalıdır.',
'description_invalid' => 'YouTube açıklaması geçerli bir metin olmalıdır.',
'description_bytes' => ':used / :limit bayt',
+ 'title' => 'Başlık',
+ 'title_placeholder' => 'Video başlığı (gönderinin ilk satırı yerine)',
+ 'title_hint' => 'En fazla 100 karakter; metinden türetildiğinde " #Shorts" otomatik eklenir.',
+ 'tags' => 'Etiketler',
+ 'tags_placeholder' => 'brisbane, pixel art, harita',
+ 'tags_hint' => 'Virgülle ayrılır; arama ve önerilere yardımcı olur.',
+ 'category' => 'Kategori ID',
+ 'language' => 'Dil',
+ 'location' => 'Çekim yeri',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Enlem',
+ 'lng' => 'Boylam',
+ ],
+ 'first_comment' => [
+ 'label' => 'İlk yorum',
+ 'placeholder' => 'İlk yorum için bağlantı ve ekler…',
+ 'hint' => 'Video yayınlandıktan hemen sonra kanalınız tarafından gönderilir.',
+ 'hint_instagram' => 'Gönderiden hemen sonra paylaşılır — Instagram açıklamalarında tıklanamayan bağlantılar için klasik yer. Yorum iznini vermek için hesabı bir kez yeniden bağlayın.',
+ 'max' => 'İlk yorum 2200 karakteri aşamaz.',
],
'pinterest' => [
'settings' => 'Pinterest Ayarları',
diff --git a/lang/uk/posts.php b/lang/uk/posts.php
index 293a047a7..189b0580e 100644
--- a/lang/uk/posts.php
+++ b/lang/uk/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Назва документа',
'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом',
],
+ 'custom_caption' => [
+ 'label' => 'Свій текст для :platform',
+ 'active' => 'увімк',
+ 'hint' => 'Порожньо — використовується спільний текст поста.',
+ 'placeholder' => 'Текст лише для цієї платформи…',
+ 'over_limit' => 'Довше за ліміт платформи (:limit символів).',
+ ],
+ 'location' => [
+ 'label' => 'Локація',
+ 'id_placeholder' => 'ID місця',
+ 'name_placeholder' => 'Назва (для себе)',
+ 'hint' => 'ID місця Facebook; пошук місць закритий для сторонніх застосунків — знайдіть ID один раз і повторно використовуйте.',
+ ],
'youtube' => [
'settings' => 'Налаштування YouTube',
'posting_to' => 'Публікація в',
@@ -150,6 +163,25 @@
'description_max' => 'Опис YouTube не повинен перевищувати 5000 байтів.',
'description_invalid' => 'Опис YouTube має бути коректним текстом.',
'description_bytes' => ':used / :limit байтів',
+ 'title' => 'Заголовок',
+ 'title_placeholder' => 'Заголовок відео (замість першого рядка поста)',
+ 'title_hint' => 'До 100 символів; « #Shorts» додається автоматично, якщо береться з тексту поста.',
+ 'tags' => 'Теги',
+ 'tags_placeholder' => 'брисбен, піксель-арт, мапа міста',
+ 'tags_hint' => 'Через кому; допомагають пошуку та рекомендаціям.',
+ 'category' => 'ID категорії',
+ 'language' => 'Мова',
+ 'location' => 'Місце зйомки',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Широта',
+ 'lng' => 'Довгота',
+ ],
+ 'first_comment' => [
+ 'label' => 'Перший коментар',
+ 'placeholder' => 'Посилання та доповнення для першого коментаря…',
+ 'hint' => 'Публікується від імені каналу одразу після виходу відео.',
+ 'hint_instagram' => 'Публікується одразу після поста — класичне місце для посилань, які в описі Instagram неклікабельні. Потрібно один раз перепідключити акаунт, щоб надати право на коментарі.',
+ 'max' => 'Перший коментар не може перевищувати 2200 знаків.',
],
'pinterest' => [
'settings' => 'Налаштування Pinterest',
diff --git a/lang/zh/posts.php b/lang/zh/posts.php
index f42ea1bcb..c0846d072 100644
--- a/lang/zh/posts.php
+++ b/lang/zh/posts.php
@@ -142,6 +142,19 @@
'document_title' => '文档标题',
'document_title_placeholder' => '显示在你的 PDF 文档帖子上',
],
+ 'custom_caption' => [
+ 'label' => '为 :platform 定制文本',
+ 'active' => '启用',
+ 'hint' => '留空则使用帖子共用文本。',
+ 'placeholder' => '仅用于此平台的文本…',
+ 'over_limit' => '超过该平台上限(:limit 个字符)。',
+ ],
+ 'location' => [
+ 'label' => '位置',
+ 'id_placeholder' => '地点 ID',
+ 'name_placeholder' => '名称(展示用)',
+ 'hint' => 'Facebook 地点 ID;地点搜索已对第三方关闭,请查一次 ID 后重复使用。',
+ ],
'youtube' => [
'settings' => 'YouTube 设置',
'posting_to' => '发布到',
@@ -150,6 +163,25 @@
'description_max' => 'YouTube 描述不得超过 5,000 个字节。',
'description_invalid' => 'YouTube 描述必须是有效文本。',
'description_bytes' => ':used / :limit 字节',
+ 'title' => '标题',
+ 'title_placeholder' => '视频标题(代替帖子的第一行)',
+ 'title_hint' => '最多 100 个字符;由正文生成时会自动附加" #Shorts"。',
+ 'tags' => '标签',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => '用逗号分隔;有助于搜索和推荐。',
+ 'category' => '类别 ID',
+ 'language' => '语言',
+ 'location' => '拍摄地点',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '纬度',
+ 'lng' => '经度',
+ ],
+ 'first_comment' => [
+ 'label' => '首条评论',
+ 'placeholder' => '首条评论中的链接和补充内容…',
+ 'hint' => '视频发布后立即以频道名义发表。',
+ 'hint_instagram' => '发布后立即评论——这是放置 Instagram 描述中无法点击的链接的经典位置。需重新连接一次账号以授予评论权限。',
+ 'max' => '首条评论不能超过 2200 个字符。',
],
'pinterest' => [
'settings' => 'Pinterest 设置',
diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue
index 1c8706b4f..2377db34f 100644
--- a/resources/js/components/ChannelConfigurator.vue
+++ b/resources/js/components/ChannelConfigurator.vue
@@ -2,6 +2,7 @@
import { IconAlertCircle, IconBan, IconCircleCheck, IconExternalLink, IconHourglass } from '@tabler/icons-vue';
import { computed } from 'vue';
+import ChannelCaptionOverride from '@/components/posts/editor/ChannelCaptionOverride.vue';
import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import GoogleBusinessSettings from '@/components/posts/editor/GoogleBusinessSettings.vue';
@@ -185,6 +186,12 @@ const settingsProps = (channel: Channel) => ({
@update:meta="emit('update:meta', channel.id, $event)"
/>
+
diff --git a/resources/js/components/ImagePreviewDialog.vue b/resources/js/components/ImagePreviewDialog.vue
index 400bd67a7..a547c88f4 100644
--- a/resources/js/components/ImagePreviewDialog.vue
+++ b/resources/js/components/ImagePreviewDialog.vue
@@ -139,7 +139,7 @@ defineExpose({ open, openCollection, close });
+import { IconChevronDown, IconChevronUp, IconPencil } from '@tabler/icons-vue';
+import { computed, ref } from 'vue';
+
+import { Textarea } from '@/components/ui/textarea';
+import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
+import type { Channel } from '@/types/channel';
+
+const props = withDefaults(defineProps<{
+ channel: Channel;
+ disabled?: boolean;
+ previewOnly?: boolean;
+}>(), {
+ disabled: false,
+ previewOnly: false,
+});
+
+const emit = defineEmits<{
+ 'update:meta': [value: Record
];
+}>();
+
+const override = computed({
+ get: () => (props.channel.meta?.content as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.channel.meta, content: value || null }),
+});
+
+// Раскрыта сразу, если переопределение уже задано.
+const open = ref(override.value !== '');
+
+const limit = computed(() => props.channel.contentLimit ?? null);
+const overLimit = computed(() => limit.value !== null && override.value.length > limit.value);
+
+
+
+
+
+
+
+
+
{{ $t('posts.form.custom_caption.hint') }}
+
+ {{ override.length }}/{{ limit }}
+
+
+
+
+ {{ $t('posts.form.custom_caption.over_limit', { limit: limit ?? 0 }) }}
+
+
+
+
diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue
index 214bb74cf..8af18339d 100644
--- a/resources/js/components/posts/editor/FacebookSettings.vue
+++ b/resources/js/components/posts/editor/FacebookSettings.vue
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue';
import MediaRulesWarning from '@/components/posts/editor/MediaRulesWarning.vue';
import { Avatar } from '@/components/ui/avatar';
+import { Input } from '@/components/ui/input';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@@ -55,6 +56,16 @@ const aspectRatios = [
const isFeed = computed(() => props.contentType === ContentType.FacebookPost);
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? 'original');
+const locationId = computed({
+ get: () => (props.meta?.location_id as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_id: value.trim() || null }),
+});
+
+const locationName = computed({
+ get: () => (props.meta?.location_name as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_name: value || null }),
+});
+
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
@@ -139,6 +150,15 @@ const pickAspectRatio = (value: string) => {
+
+
{{ $t('posts.form.location.label') }}
+
+
+
+
+
{{ $t('posts.form.location.hint') }}
+
+
diff --git a/resources/js/components/posts/editor/InstagramSettings.vue b/resources/js/components/posts/editor/InstagramSettings.vue
index 8b9803985..78e9c1c65 100644
--- a/resources/js/components/posts/editor/InstagramSettings.vue
+++ b/resources/js/components/posts/editor/InstagramSettings.vue
@@ -4,6 +4,8 @@ import { computed, ref } from 'vue';
import MediaRulesWarning from '@/components/posts/editor/MediaRulesWarning.vue';
import { Avatar } from '@/components/ui/avatar';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { AspectRatio, type AspectRatioValue } from '@/types/aspect-ratio';
import { ContentType } from '@/types/content-type';
@@ -65,6 +67,23 @@ const pickAspectRatio = (value: AspectRatioValue) => {
if (props.disabled) return;
emit('update:meta', { ...props.meta, aspect_ratio: value });
};
+
+const FIRST_COMMENT_MAX = 2200;
+
+const locationId = computed({
+ get: () => (props.meta?.location_id as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_id: value.trim() || null }),
+});
+
+const locationName = computed({
+ get: () => (props.meta?.location_name as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_name: value || null }),
+});
+
+const firstComment = computed({
+ get: () => (props.meta?.first_comment as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, first_comment: value || null }),
+});
@@ -141,6 +160,32 @@ const pickAspectRatio = (value: AspectRatioValue) => {
+
+
{{ $t('posts.form.location.label') }}
+
+
+
+
+
{{ $t('posts.form.location.hint') }}
+
+
+
+
+
{{ $t('posts.form.first_comment.label') }}
+
+ {{ firstComment.length }}/{{ FIRST_COMMENT_MAX }}
+
+
+
+
{{ $t('posts.form.first_comment.hint_instagram') }}
+
+
diff --git a/resources/js/components/posts/editor/PostEditorComposer.vue b/resources/js/components/posts/editor/PostEditorComposer.vue
index 7336ea663..18da5ab4c 100644
--- a/resources/js/components/posts/editor/PostEditorComposer.vue
+++ b/resources/js/components/posts/editor/PostEditorComposer.vue
@@ -86,7 +86,7 @@ const openPreview = (item: MediaItem) => {
media.value.map((m) => ({
url: m.url,
type: classify(m) ?? MediaType.Image,
- altText: isImage(m) ? m.meta?.alt_text : undefined,
+ altText: isImage(m) || isVideo(m) ? m.meta?.alt_text : undefined,
})),
idx,
);
@@ -306,7 +306,7 @@ const onAltTextSave = (alt: string): void => {
{{ date.formatClock(item.meta.duration) }}
@@ -362,7 +362,7 @@ const onAltTextSave = (alt: string): void => {
+
+
+
+ {{ $t('posts.form.youtube.title') }}
+
+
{{ title.length }}/{{ TITLE_MAX }}
+
+
+
+ {{ $t('posts.form.youtube.title_hint') }}
+
+
+
+
+ {{ $t('posts.form.youtube.tags') }}
+
+
+
+ {{ $t('posts.form.youtube.tags_hint') }}
+
+
+
+
+
+ {{ $t('posts.form.youtube.category') }}
+
+
+
+
+
+ {{ $t('posts.form.youtube.language') }}
+
+
+
+
+
+
+
+
+
+ {{ firstComment.length }}/{{ FIRST_COMMENT_MAX }}
+
+
+ {{ $t('posts.form.first_comment.hint') }}
+
+
+
diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts
index 93d310cb5..e6670252c 100644
--- a/resources/js/types/channel.ts
+++ b/resources/js/types/channel.ts
@@ -40,4 +40,5 @@ export interface Channel {
creatorInfo?: ChannelTikTokCreatorInfo | null;
boards?: PinterestBoard[];
boardsTruncated?: boolean;
+ contentLimit?: number | null;
}
diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php
index c1a1e3efd..02a33ea91 100644
--- a/tests/Feature/Api/PostApiPlatformMetaTest.php
+++ b/tests/Feature/Api/PostApiPlatformMetaTest.php
@@ -546,6 +546,114 @@
Queue::assertPushed(PublishPost::class);
});
+it('persists YouTube first_comment meta on store', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'first_comment' => 'More details: https://example.com',
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'first_comment'))->toBe('More details: https://example.com');
+});
+
+it('rejects a first_comment over the instagram comment limit', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => ['first_comment' => str_repeat('a', 2201)],
+ ]],
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['platforms.0.meta.first_comment']);
+});
+
+it('persists YouTube SEO meta and recording location on store', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'title' => 'Pixel Brisbane — Story Bridge',
+ 'tags' => ['brisbane', 'pixel art'],
+ 'category_id' => '19',
+ 'default_language' => 'en',
+ 'recording_location' => ['lat' => -27.4679, 'lng' => 153.0281, 'description' => 'Brisbane QLD'],
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'title'))->toBe('Pixel Brisbane — Story Bridge')
+ ->and(data_get($meta, 'tags'))->toBe(['brisbane', 'pixel art'])
+ ->and(data_get($meta, 'category_id'))->toBe('19')
+ ->and(data_get($meta, 'default_language'))->toBe('en')
+ ->and(data_get($meta, 'recording_location.lat'))->toBe(-27.4679)
+ ->and(data_get($meta, 'recording_location.description'))->toBe('Brisbane QLD');
+});
+
+it('rejects an out-of-range recording location latitude', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => ['recording_location' => ['lat' => 120, 'lng' => 10]],
+ ]],
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['platforms.0.meta.recording_location.lat']);
+});
+
+it('persists a per-platform content override and instagram location meta on store', function () {
+ $instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Shared caption',
+ 'platforms' => [[
+ 'social_account_id' => $instagram->id,
+ 'content_type' => ContentType::InstagramFeed->value,
+ 'meta' => [
+ 'content' => 'Instagram-only caption with #hashtags',
+ 'location_id' => '106190712750250',
+ 'location_name' => 'Brisbane',
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $row = PostPlatform::where('social_account_id', $instagram->id)->sole();
+
+ expect(data_get($row->meta, 'content'))->toBe('Instagram-only caption with #hashtags')
+ ->and(data_get($row->meta, 'location_id'))->toBe('106190712750250')
+ ->and($row->resolvedContent())->toBe('Instagram-only caption with #hashtags');
+});
+
it('persists Pinterest title and link meta on store', function () {
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php
index 000525ed4..25547357a 100644
--- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php
+++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php
@@ -514,6 +514,28 @@
Queue::assertPushed(PublishPost::class);
});
+test('create post persists YouTube first_comment meta', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $response = TryPostServer::actingAs($this->user)
+ ->tool(CreatePostTool::class, [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'first_comment' => 'More details: https://example.com',
+ ],
+ ]],
+ ]);
+
+ $response->assertOk();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'first_comment'))->toBe('More details: https://example.com');
+});
+
test('create post persists Pinterest title and link meta', function () {
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
diff --git a/tests/Feature/Services/Social/FirstCommentPosterTest.php b/tests/Feature/Services/Social/FirstCommentPosterTest.php
new file mode 100644
index 000000000..ffe119634
--- /dev/null
+++ b/tests/Feature/Services/Social/FirstCommentPosterTest.php
@@ -0,0 +1,109 @@
+user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
+ $this->post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ 'content' => 'Post body',
+ ]);
+ $this->poster = new FirstCommentPoster;
+});
+
+function makePlatformRow(Workspace $workspace, Post $post, SocialAccount $account, ContentType $contentType, ?array $meta): PostPlatform
+{
+ return PostPlatform::factory()->create([
+ 'post_id' => $post->id,
+ 'social_account_id' => $account->id,
+ 'platform' => $account->platform,
+ 'content_type' => $contentType,
+ 'meta' => $meta,
+ ]);
+}
+
+test('posts a youtube first comment via commentThreads.insert', function () {
+ $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::YouTubeShort, [
+ 'first_comment' => 'Link: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['id' => 'thread-1'], 200)]);
+
+ $this->poster->post($row, 'video-123');
+
+ $api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');
+
+ Http::assertSent(function ($request) use ($api) {
+ return str_starts_with($request->url(), "{$api}/commentThreads")
+ && data_get($request->data(), 'snippet.videoId') === 'video-123'
+ && data_get($request->data(), 'snippet.topLevelComment.snippet.textOriginal') === 'Link: https://example.com';
+ });
+});
+
+test('posts an instagram first comment on the published media', function () {
+ $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::InstagramFeed, [
+ 'first_comment' => 'More: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['id' => 'comment-1'], 200)]);
+
+ $this->poster->post($row, 'media-456');
+
+ $base = $account->platform->instagramGraphBaseUrl();
+
+ Http::assertSent(function ($request) use ($base) {
+ return str_starts_with($request->url(), "{$base}/media-456/comments")
+ && $request['message'] === 'More: https://example.com';
+ });
+});
+
+test('does nothing when first_comment meta is empty', function () {
+ $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::YouTubeShort, ['first_comment' => ' ']);
+
+ Http::fake();
+
+ $this->poster->post($row, 'video-123');
+
+ Http::assertNothingSent();
+});
+
+test('a failed first comment never throws', function () {
+ $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::InstagramFeed, [
+ 'first_comment' => 'More: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['error' => ['message' => 'Missing permission']], 403)]);
+
+ $this->poster->post($row, 'media-456');
+
+ expect(true)->toBeTrue();
+});
+
+test('unsupported platforms are skipped without requests', function () {
+ $account = SocialAccount::factory()->x()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::XPost, [
+ 'first_comment' => 'ignored',
+ ]);
+
+ Http::fake();
+
+ $this->poster->post($row, 'tweet-1');
+
+ Http::assertNothingSent();
+});
diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php
index a71097da0..45958fd11 100644
--- a/tests/Feature/Services/Social/InstagramPublisherTest.php
+++ b/tests/Feature/Services/Social/InstagramPublisherTest.php
@@ -2051,3 +2051,31 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
&& ! str_contains($request->url(), 'media_publish')
&& data_get($request->data(), 'caption') === 'New post: https://acme.com/blog');
});
+
+test('instagram publisher tags the container with the meta location', function () {
+ $this->post->update([
+ 'media' => [[
+ 'id' => 'test-media-id',
+ 'path' => 'media/2026-01/test-image.jpg',
+ 'url' => 'https://example.com/media/2026-01/test-image.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'original_filename' => 'test.jpg',
+ ]],
+ ]);
+ $this->postPlatform->update(['meta' => ['location_id' => '106190712750250']]);
+ $this->postPlatform->refresh();
+
+ Http::fake([
+ 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
+ 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
+ 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-789'], 200),
+ 'https://graph.instagram.com/v25.0/media-789*' => Http::response(['permalink' => 'https://instagram.com/p/x'], 200),
+ ]);
+
+ $this->publisher->publish($this->postPlatform);
+
+ Http::assertSent(function ($request) {
+ return str_ends_with(parse_url($request->url(), PHP_URL_PATH), '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/ig_123456789/media')
+ && ($request['location_id'] ?? null) === '106190712750250';
+ });
+});
diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php
index 37f527d57..2b1d20b6b 100644
--- a/tests/Feature/Services/Social/MastodonPublisherTest.php
+++ b/tests/Feature/Services/Social/MastodonPublisherTest.php
@@ -261,7 +261,7 @@
@unlink($optimizedFile);
});
-test('mastodon publisher does not send a description for a non-image even if it carries alt text', function () {
+test('mastodon publisher sends a video alt text as the media description', function () {
$this->post->update([
'media' => [
[
@@ -270,7 +270,7 @@
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
- 'meta' => ['alt_text' => 'alt must not be sent for a video'],
+ 'meta' => ['alt_text' => 'A pixel-art flyover of Brisbane'],
],
],
]);
@@ -303,7 +303,10 @@
return false;
}
- return collect($request->data())->firstWhere('name', 'description') === null;
+ // Mastodon accepts descriptions on any media type, video included.
+ $description = collect($request->data())->firstWhere('name', 'description');
+
+ return $description !== null && $description['contents'] === 'A pixel-art flyover of Brisbane';
});
});
@@ -580,3 +583,21 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/statuses')
&& $request['status'] === 'New post: https://acme.com/blog');
});
+
+test('mastodon publisher uses the per-platform content override', function () {
+ $this->postPlatform->update(['meta' => ['content' => 'Short Mastodon-only version']]);
+ $this->postPlatform->refresh();
+
+ Http::fake([
+ 'https://mastodon.social/api/v1/statuses' => Http::response([
+ 'id' => '42', 'url' => 'https://mastodon.social/@testuser/42',
+ ], 200),
+ ]);
+
+ $this->publisher->publish($this->postPlatform);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/api/v1/statuses')
+ && $request['status'] === 'Short Mastodon-only version';
+ });
+});
diff --git a/tests/Feature/Services/Social/YouTubePublisherTest.php b/tests/Feature/Services/Social/YouTubePublisherTest.php
index d0a505b09..9f3e6e904 100644
--- a/tests/Feature/Services/Social/YouTubePublisherTest.php
+++ b/tests/Feature/Services/Social/YouTubePublisherTest.php
@@ -404,3 +404,18 @@ function fakeYouTubeUpload(array $responses = []): YouTubePublisher
->and(strlen($accented))->toBeGreaterThan(92)
->and($method->invoke($publisher, $accented))->toBe($accented.' #Shorts');
});
+
+test('youtube publisher resolves title from platform meta with content fallback', function () {
+ $publisher = new YouTubePublisher;
+ $reflection = new ReflectionClass($publisher);
+ $method = $reflection->getMethod('resolveTitle');
+ $method->setAccessible(true);
+
+ $this->postPlatform->meta = null;
+ expect($method->invoke($publisher, $this->postPlatform, 'My awesome short video'))
+ ->toBe('My awesome short video #Shorts');
+
+ $this->postPlatform->meta = ['title' => 'Exact custom title'];
+ expect($method->invoke($publisher, $this->postPlatform, 'My awesome short video'))
+ ->toBe('Exact custom title');
+});
diff --git a/tests/Unit/PostPlatformMetaRulesTest.php b/tests/Unit/PostPlatformMetaRulesTest.php
index 5a533d45b..48f91c0a9 100644
--- a/tests/Unit/PostPlatformMetaRulesTest.php
+++ b/tests/Unit/PostPlatformMetaRulesTest.php
@@ -8,13 +8,14 @@
use App\Support\PostPlatformMetaRules;
use Illuminate\Support\Facades\Validator;
-test('custom meta messages only cover pinterest title and link', function () {
+test('custom meta messages cover pinterest, google business and first-comment fields', function () {
expect(PostPlatformMetaRules::messages())->toBe([
'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'),
'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'),
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'),
'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'),
+ 'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
]);
});
@@ -25,6 +26,7 @@
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'),
'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'),
+ 'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
]);
});
@@ -51,6 +53,7 @@
'platforms.*.meta.channel_id',
'platforms.*.meta.title',
'platforms.*.meta.link',
+ 'platforms.*.meta.first_comment',
]);
});