From 009a551d746248b6532afc04a40907bedc529bbd Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 09:11:01 +0200 Subject: [PATCH] Publish every attached image, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer takes `multiple` files, stores a media row per image and shows a thumbnail for each — then publishing sent `media[0]` and dropped the rest, on every platform, with no warning. Same class as the single-image drop, one level up. Each platform now carries the whole set, using the shape its own contract defines: X up to 4 media_media_ids — one staged upload per image LinkedIn up to 20 images[] of broker-staged descriptors Instagram 1-10 one image = a plain container, 2+ = a carousel Facebook — FACEBOOK_CREATE_MULTI_PHOTO_POST for 2+ photos TikTok 1-35 photo_images[], first image is the cover Bluesky up to 4 one blob upload per image into the embed The Instagram path also moves off two actions Composio marks deprecated (INSTAGRAM_CREATE_MEDIA_CONTAINER, INSTAGRAM_CREATE_POST) onto INSTAGRAM_POST_IG_USER_MEDIA / _PUBLISH — the carousel container has no deprecated publish partner, so the whole case had to move anyway. Attaching more images than a platform accepts fails that channel with the reason, rather than posting a truncated set: shipping fewer images than the user attached, silently, is the bug this path exists to close, and the per-channel delivery model means the other channels still go out. The composer warns while the images are still on screen and removable. The limits live in src/shared/platforms.ts because the server enforces the same table the composer warns on — two copies would drift, and the copy that drifts is the one that only fails at send time. Caps verified against Composio's live v3.1 tool schemas and the atproto app.bsky.embed.images lexicon, not recalled. Facebook is deliberately uncapped: photo_urls declares minItems and no maximum, so there is no stated number to enforce and Facebook's own rejection is the error we surface. --- README.md | 28 +++-- src/client/components/post-composer.tsx | 24 ++++- src/client/types.ts | 62 +++-------- src/server/bluesky.ts | 22 ++-- src/server/index.ts | 138 ++++++++++++++++-------- src/shared/platforms.ts | 94 ++++++++++++++++ 6 files changed, 254 insertions(+), 114 deletions(-) create mode 100644 src/shared/platforms.ts diff --git a/README.md b/README.md index a1cc9c6..ff4f5c0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful - **Drafts** -- save unfinished posts and come back to them later - **Channel management** -- add your social media accounts with platform detection and color coding - **Labels** -- categorize posts with colored labels for organization -- **Media attachments** -- upload images to posts +- **Media attachments** -- attach several images to a post; every platform publishes the whole set (carousel, gallery or multi-photo, whichever that platform calls it) - **Analytics** -- bar charts showing posts per channel, per label, and daily activity - **Dashboard** -- at-a-glance stats, upcoming posts, and recent drafts - **Native previews** -- see the post as it will look on each selected platform before it goes out @@ -23,16 +23,20 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful ### Supported Platforms -| Platform | Character Limit | Publishing | -|----------|----------------|------------| -| X / Twitter | 280 | text or photo | -| LinkedIn | 3,000 | text or photo | -| Instagram | 2,200 | photo + caption (Business or Creator account, picked up from the connection) | -| Facebook | 63,206 | text or photo, as a Page you manage | -| TikTok | 2,200 | photo post | -| Bluesky | 300 | text, with an image and link cards | -| Mastodon | 500 | coming soon | -| Threads | 500 | coming soon | +| Platform | Character Limit | Images | Publishing | +|----------|----------------|--------|------------| +| X / Twitter | 280 | up to 4 | text, or a post with up to four images | +| LinkedIn | 3,000 | up to 20 | text, or a post with up to twenty images | +| Instagram | 2,200 | 1-10 | photo + caption; two or more images publish as a carousel (Business or Creator account, picked up from the connection) | +| Facebook | 63,206 | no stated cap | text, one photo, or one feed post with several photos, as a Page you manage | +| TikTok | 2,200 | 1-35 | photo post; the first image is the cover | +| Bluesky | 300 | up to 4 | text, with images and link cards | +| Mastodon | 500 | -- | coming soon | +| Threads | 500 | -- | coming soon | + +Attaching more images than a platform accepts fails **that channel only**, with the +reason on the post, rather than quietly publishing a subset -- the other channels in +the fan-out still go out. The composer warns you before you get there. Connect each account once in Clawnify (Settings → Integrations), then add it as a channel here. Facebook channels are Pages: the channel form lists the Pages the connected account manages so you can pick one. @@ -75,6 +79,8 @@ Publishing runs through the accounts connected in Clawnify -- no API keys in the ``` src/ + shared/ + platforms.ts -- Platform limits, colours, labels; the media caps the server enforces and the composer warns on server/ index.ts -- Hono API with D1 + credentials middleware db.ts -- D1-native database adapter diff --git a/src/client/components/post-composer.tsx b/src/client/components/post-composer.tsx index 0ed8484..4a6c02b 100644 --- a/src/client/components/post-composer.tsx +++ b/src/client/components/post-composer.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from "preact/hooks"; import { Send, Save, ArrowLeft, Image, X, Upload, Loader2 } from "lucide-preact"; import { useApp } from "../context"; -import { PLATFORM_LIMITS, PLATFORM_LABELS } from "../types"; +import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaLimitError } from "../types"; import type { Platform } from "../types"; import { PostPreview, PreviewChannelTabs } from "./previews"; @@ -57,6 +57,21 @@ export function PostComposer({ editId, navigate }: Props) { const overLimit = charLimit !== null && content.length > charLimit; + // Selected channels whose platform can't carry this many images. Publishing + // fails those channels rather than posting a truncated set, so say it here — + // while the images are still on screen and removable — instead of letting it + // surface as a delivery error after the fact. + const mediaWarnings = useMemo(() => { + const messages = new Set(); + for (const c of channels) { + if (!selectedChannels.includes(c.id)) continue; + const msg = mediaLimitError(c.platform, mediaUrls.length); + // Two channels on the same platform share one message. + if (msg) messages.add(msg); + } + return [...messages]; + }, [selectedChannels, channels, mediaUrls.length]); + // Selected channels, in selection order, for the preview switcher. const previewChannels = useMemo( () => selectedChannels.map((id) => channels.find((c) => c.id === id)).filter(Boolean) as typeof channels, @@ -207,6 +222,13 @@ export function PostComposer({ editId, navigate }: Props) { Add + {mediaWarnings.length > 0 && ( +
+ {mediaWarnings.map((msg) => ( +

{msg}

+ ))} +
+ )} {mediaUrls.length > 0 && (
{mediaUrls.map((url, i) => ( diff --git a/src/client/types.ts b/src/client/types.ts index 7ddb6e7..ccf725c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -3,22 +3,19 @@ export type PostStatus = "draft" | "scheduled" | "published" | "partial" | "fail // Per-channel delivery state on a post's channel (Postiz-style). Present on the // Channel objects returned inside a Post; absent in the standalone channel list. export type DeliveryStatus = "pending" | "published" | "failed"; -export type Platform = "twitter" | "linkedin" | "instagram" | "facebook" | "bluesky" | "mastodon" | "threads" | "tiktok"; - -// The platforms the server can actually publish to — the single source the -// channel picker offers. It MUST track the cases in publishToChannel() -// (src/server/index.ts): a platform here with no server case would let a user -// schedule posts that only fail at send time (the mastodon/threads trap this -// list closes); a server case missing here is simply not offered. -// mastodon/threads stay out until they have a publish path. -export const PUBLISHABLE_PLATFORMS: Platform[] = [ - "twitter", - "linkedin", - "instagram", - "facebook", - "tiktok", - "bluesky", -]; +// Platform metadata (limits, colours, labels, the publishable list) lives in +// src/shared/platforms.ts because the server enforces the same tables. Re-exported +// here so client code keeps importing platform facts from one place. +export { + PUBLISHABLE_PLATFORMS, + PLATFORM_LIMITS, + PLATFORM_MEDIA_LIMITS, + PLATFORM_COLORS, + PLATFORM_LABELS, + mediaLimitError, +} from "../shared/platforms"; +export type { Platform } from "../shared/platforms"; +import type { Platform } from "../shared/platforms"; // A Facebook Page the connected account manages (GET /api/platforms/facebook/pages). export interface FacebookPage { @@ -27,39 +24,6 @@ export interface FacebookPage { username: string | null; } -export const PLATFORM_LIMITS: Record = { - twitter: 280, - linkedin: 3000, - instagram: 2200, - facebook: 63206, - bluesky: 300, - mastodon: 500, - threads: 500, - tiktok: 2200, -}; - -export const PLATFORM_COLORS: Record = { - twitter: "#1da1f2", - linkedin: "#0a66c2", - instagram: "#e4405f", - facebook: "#1877f2", - bluesky: "#0085ff", - mastodon: "#6364ff", - threads: "#000000", - tiktok: "#00f2ea", -}; - -export const PLATFORM_LABELS: Record = { - twitter: "X / Twitter", - linkedin: "LinkedIn", - instagram: "Instagram", - facebook: "Facebook", - bluesky: "Bluesky", - mastodon: "Mastodon", - threads: "Threads", - tiktok: "TikTok", -}; - export interface Channel { id: number; name: string; diff --git a/src/server/bluesky.ts b/src/server/bluesky.ts index f68fa16..5352deb 100644 --- a/src/server/bluesky.ts +++ b/src/server/bluesky.ts @@ -128,11 +128,11 @@ async function uploadBlob( return { blob: data.blob }; } -// Publish one post: createSession → (optional) uploadBlob → createRecord. +// Publish one post: createSession → (optional) uploadBlob per image → createRecord. export async function publishToBluesky( creds: BlueskyCreds, content: string, - imageUrl?: string, + imageUrls: string[] = [], ): Promise { const base = trimBase(creds.service); const session = await createSession(creds); @@ -146,13 +146,17 @@ export async function publishToBluesky( const facets = detectFacets(content); if (facets.length) record.facets = facets; - if (imageUrl) { - const up = await uploadBlob(base, session.accessJwt, imageUrl); - if ("error" in up) return { success: false, error: up.error }; - record.embed = { - $type: "app.bsky.embed.images", - images: [{ image: up.blob, alt: "" }], - }; + if (imageUrls.length) { + // One blob upload per image, in order — the lexicon caps the embed at 4, + // which the caller has already checked. A single failed blob fails the + // whole post rather than shipping the rest without it. + const images: Array<{ image: unknown; alt: string }> = []; + for (const url of imageUrls) { + const up = await uploadBlob(base, session.accessJwt, url); + if ("error" in up) return { success: false, error: up.error }; + images.push({ image: up.blob, alt: "" }); + } + record.embed = { $type: "app.bsky.embed.images", images }; } const res = await fetch(`${base}/xrpc/com.atproto.repo.createRecord`, { diff --git a/src/server/index.ts b/src/server/index.ts index 582fcb1..e56f948 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import type { CredentialServiceBinding, StagedFile } from "./credentials"; import { publishToBluesky, blueskyProfile, type BlueskyCreds } from "./bluesky"; import { scheduleDelivery, cancelDelivery, verifyDelivery } from "./queue"; import { initUploads, uploadsEnabled, putUpload, getUpload, makeKey } from "./uploads"; +import { mediaLimitError } from "../shared/platforms"; type Env = { Bindings: { @@ -47,11 +48,14 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P [id], ); const media = await query("SELECT * FROM media WHERE post_id = ? ORDER BY id ASC", [id]); - const firstImage = media[0]?.url as string | undefined; + // Every attached image, in the order the composer shows them. Each platform + // decides how many it can carry (see mediaLimitError) — none of them silently + // gets a subset. + const imageUrls = media.map((m: any) => m.url as string).filter(Boolean); const results: PublishResult[] = []; for (const channel of channels) { - const r = await publishToChannel(channel, post.content, firstImage); + const r = await publishToChannel(channel, post.content, imageUrls); // Persist this channel's delivery outcome on its post_channels row. await run( `UPDATE post_channels @@ -87,18 +91,30 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P return { published: delivered > 0, results }; } -async function publishToChannel(channel: any, content: string, imageUrl?: string): Promise { +async function publishToChannel(channel: any, content: string, imageUrls: string[]): Promise { const base = { channelId: channel.id as number, channel: channel.name as string, platform: channel.platform as string }; + + // More images than this platform accepts fails the channel rather than + // posting a truncated set. Sending fewer images than the user attached, + // silently, is the bug the whole media path exists to avoid — and the + // per-channel delivery model means the other channels still go out. + const overLimit = mediaLimitError(channel.platform, imageUrls.length); + if (overLimit) return { ...base, success: false, error: overLimit }; + switch (channel.platform) { case "twitter": { // Composio execute (raw tokens are permanently redacted post-incident). - // An image is a second call first: stage it with the broker, hand the - // descriptor to TWITTER_UPLOAD_MEDIA, then attach the media id it mints. + // Images are separate calls first: stage each with the broker, hand the + // descriptor to TWITTER_UPLOAD_MEDIA, then attach every media id it mints. let mediaIds: string[] | undefined; - if (imageUrl) { - const up = await uploadTwitterMedia(imageUrl); - if ("error" in up) return { ...base, success: false, error: up.error }; - mediaIds = [up.mediaId]; + if (imageUrls.length) { + const ids: string[] = []; + for (const url of imageUrls) { + const up = await uploadTwitterMedia(url); + if ("error" in up) return { ...base, success: false, error: up.error }; + ids.push(up.mediaId); + } + mediaIds = ids; } const r = await executeTool("twitter", "TWITTER_CREATION_OF_A_POST", { text: content, @@ -116,17 +132,22 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string if (!me?.successful) return { ...base, success: false, error: me?.error || "LinkedIn not connected" }; const id = (me.data as { id?: string } | null)?.id; if (!id) return { ...base, success: false, error: "could not resolve LinkedIn member id" }; - // LinkedIn's action uploads the image itself, but only from a file + // LinkedIn's action uploads the images itself, but only from files // staged through the broker — a URL in `images` is not a shape it takes. + // The action carries 1-20 of them; slide order follows this array. let images: StagedFile[] | undefined; - if (imageUrl) { - const staged = await stageFile("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", imageUrl); - // No staging available at all: off-platform, or a runtime older than - // the broker's stageFile. Either way the image cannot go out, and the - // channel fails rather than quietly posting the text on its own. - if (!staged) return { ...base, success: false, error: "Couldn't upload the image to LinkedIn. Reconnect LinkedIn in Clawnify." }; - if (!staged.descriptor) return { ...base, success: false, error: `LinkedIn image upload failed: ${staged.error}` }; - images = [staged.descriptor]; + if (imageUrls.length) { + const staged: StagedFile[] = []; + for (const url of imageUrls) { + const s = await stageFile("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", url); + // No staging available at all: off-platform, or a runtime older than + // the broker's stageFile. Either way the image cannot go out, and the + // channel fails rather than quietly posting the text on its own. + if (!s) return { ...base, success: false, error: "Couldn't upload the images to LinkedIn. Reconnect LinkedIn in Clawnify." }; + if (!s.descriptor) return { ...base, success: false, error: `LinkedIn image upload failed: ${s.error}` }; + staged.push(s.descriptor); + } + images = staged; } const r = await executeTool("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", { author: `urn:li:person:${id}`, @@ -143,20 +164,34 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // Composio execute, two-step: create media container → publish it. // IG requires a Business account, an image, and the IG Business Account // ID (resolved from the connection, see resolveInstagramAccountId). - if (!imageUrl) return { ...base, success: false, error: "Instagram requires an image." }; + // + // One image is a plain container; two or more is a carousel, which takes + // its children as URLs directly (no per-child container round-trip). Both + // publish through the same media_publish call. + // + // INSTAGRAM_CREATE_MEDIA_CONTAINER / INSTAGRAM_CREATE_POST — what this + // used to call — are both marked deprecated in Composio's catalogue, and + // the carousel container has no deprecated publish partner anyway. + if (!imageUrls.length) return { ...base, success: false, error: "Instagram requires an image." }; const igUserId = await resolveInstagramAccountId(channel); if (!igUserId) return { ...base, success: false, error: "No Instagram credentials. Connect Instagram in Clawnify." }; - const container = await executeTool("instagram", "INSTAGRAM_CREATE_MEDIA_CONTAINER", { - ig_user_id: igUserId, - image_url: imageUrl, - caption: content, - content_type: "photo", - }); + const container = + imageUrls.length === 1 + ? await executeTool("instagram", "INSTAGRAM_POST_IG_USER_MEDIA", { + ig_user_id: igUserId, + image_url: imageUrls[0], + caption: content, + }) + : await executeTool("instagram", "INSTAGRAM_CREATE_CAROUSEL_CONTAINER", { + ig_user_id: igUserId, + child_image_urls: imageUrls, + caption: content, + }); if (!container) return { ...base, success: false, error: "No Instagram credentials. Connect Instagram in Clawnify." }; if (!container.successful) return { ...base, success: false, error: container.error || "Instagram container failed" }; const creationId = (container.data as { id?: string } | null)?.id; if (!creationId) return { ...base, success: false, error: "Instagram: no creation_id returned" }; - const pub = await executeTool("instagram", "INSTAGRAM_CREATE_POST", { + const pub = await executeTool("instagram", "INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH", { ig_user_id: igUserId, creation_id: creationId, }); @@ -166,10 +201,11 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string case "tiktok": { // Composio execute, single-step photo post (TikTok Content Posting API). // Image-only for now, matching this app's photo-first media model. - if (!imageUrl) return { ...base, success: false, error: "TikTok requires an image." }; + // photo_images carries the whole set (1-35); the first is the cover. + if (!imageUrls.length) return { ...base, success: false, error: "TikTok requires an image." }; const r = await executeTool("tiktok", "TIKTOK_POST_PHOTO", { post_mode: "DIRECT_POST", - photo_images: [imageUrl], + photo_images: imageUrls, photo_cover_index: 0, title: content.slice(0, 90), description: content, @@ -198,22 +234,36 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // through /feed (Postiz's facebook.provider.ts splits the same way). const pageId = accountId(channel); if (!pageId) return { ...base, success: false, error: "Facebook channel has no Page selected." }; - const r = imageUrl - ? await executeTool("facebook", "FACEBOOK_CREATE_PHOTO_POST", { - page_id: pageId, - url: imageUrl, - message: content, - published: true, - }) - : await executeTool("facebook", "FACEBOOK_CREATE_POST", { - page_id: pageId, - message: content, - published: true, - }); + // Several photos need the unpublished-upload + attached_media dance; + // FACEBOOK_CREATE_MULTI_PHOTO_POST does the whole thing in one call and + // fails the post outright if any upload fails, so a partial set never + // ships. One photo keeps the plain photo post. + const r = + imageUrls.length > 1 + ? await executeTool("facebook", "FACEBOOK_CREATE_MULTI_PHOTO_POST", { + page_id: pageId, + photo_urls: imageUrls, + message: content, + }) + : imageUrls.length === 1 + ? await executeTool("facebook", "FACEBOOK_CREATE_PHOTO_POST", { + page_id: pageId, + url: imageUrls[0], + message: content, + published: true, + }) + : await executeTool("facebook", "FACEBOOK_CREATE_POST", { + page_id: pageId, + message: content, + published: true, + }); if (!r) return { ...base, success: false, error: "No Facebook credentials. Connect Facebook in Clawnify." }; - // Composio wraps the Graph response as data.response_data. /feed returns - // { id: "_" }; /photos returns { id: , post_id: - // "_" } — the post id is the one that has a permalink. + // Composio wraps the raw Graph response as data.response_data. /feed + // returns { id: "_" }; /photos returns { id: , + // post_id: "_" } — the post id is the one that has a + // permalink. The multi-photo action is Composio-authored and returns a + // typed { post_id } with no response_data wrapper, which the same + // `response_data ?? data` then `post_id || id` read already covers. const d = (((r.data as any)?.response_data ?? r.data) || {}) as { id?: string; post_id?: string }; const ref = d.post_id || d.id; const url = ref ? facebookPostUrl(ref) : undefined; @@ -227,7 +277,7 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // OAuth channels above. const creds = await resolveBlueskyCreds(); if (!creds) return { ...base, success: false, error: "No Bluesky credentials. Connect Bluesky in Clawnify." }; - const r = await publishToBluesky(creds, content, imageUrl); + const r = await publishToBluesky(creds, content, imageUrls); return { ...base, success: r.success, error: r.error, ref: r.ref, url: r.url }; } default: diff --git a/src/shared/platforms.ts b/src/shared/platforms.ts new file mode 100644 index 0000000..3dab9f8 --- /dev/null +++ b/src/shared/platforms.ts @@ -0,0 +1,94 @@ +// Platform metadata shared by the server (publishing) and the client (composer, +// previews, channel picker). +// +// It lives here rather than in client/types.ts because the server enforces the +// same media limits it warns about: two copies of this table would drift, and +// the copy that drifts is the one that only fails at send time. + +export type Platform = + | "twitter" + | "linkedin" + | "instagram" + | "facebook" + | "bluesky" + | "mastodon" + | "threads" + | "tiktok"; + +// The platforms the server can actually publish to — the single source the +// channel picker offers. It MUST track the cases in publishToChannel() +// (src/server/index.ts): a platform here with no server case would let a user +// schedule posts that only fail at send time (the mastodon/threads trap this +// list closes); a server case missing here is simply not offered. +// mastodon/threads stay out until they have a publish path. +export const PUBLISHABLE_PLATFORMS: Platform[] = [ + "twitter", + "linkedin", + "instagram", + "facebook", + "tiktok", + "bluesky", +]; + +export const PLATFORM_LIMITS: Record = { + twitter: 280, + linkedin: 3000, + instagram: 2200, + facebook: 63206, + bluesky: 300, + mastodon: 500, + threads: 500, + tiktok: 2200, +}; + +// How many images one post may carry, per platform. Only platforms whose own +// contract states a maximum appear here — an absent entry means "the platform +// publishes as many as it accepts, and its own rejection is the error we +// surface" (the stance bluesky.ts already takes on text length). +// +// Each number is the platform's stated cap, not folklore: +// twitter 4 — TWITTER_CREATION_OF_A_POST.media_media_ids ("Up to 4 Media IDs") +// linkedin 20 — LINKEDIN_CREATE_LINKED_IN_POST.images (maxItems: 20) +// instagram 10 — a carousel is 2-10 items (INSTAGRAM_CREATE_CAROUSEL_CONTAINER) +// tiktok 35 — TIKTOK_POST_PHOTO.photo_images (maxItems: 35) +// bluesky 4 — app.bsky.embed.images.images (lexicon maxLength: 4) +// facebook is deliberately absent: FACEBOOK_CREATE_MULTI_PHOTO_POST.photo_urls +// declares minItems but no maximum, so there is no stated number to enforce. +export const PLATFORM_MEDIA_LIMITS: Partial> = { + twitter: 4, + linkedin: 20, + instagram: 10, + tiktok: 35, + bluesky: 4, +}; + +export const PLATFORM_COLORS: Record = { + twitter: "#1da1f2", + linkedin: "#0a66c2", + instagram: "#e4405f", + facebook: "#1877f2", + bluesky: "#0085ff", + mastodon: "#6364ff", + threads: "#000000", + tiktok: "#00f2ea", +}; + +export const PLATFORM_LABELS: Record = { + twitter: "X / Twitter", + linkedin: "LinkedIn", + instagram: "Instagram", + facebook: "Facebook", + bluesky: "Bluesky", + mastodon: "Mastodon", + threads: "Threads", + tiktok: "TikTok", +}; + +// Why a channel can't take this many images, in the user's words. Null when the +// count is fine (or the platform states no cap). +export function mediaLimitError(platform: string, count: number): string | null { + const max = PLATFORM_MEDIA_LIMITS[platform as Platform]; + if (max === undefined || count <= max) return null; + const label = PLATFORM_LABELS[platform as Platform] ?? platform; + return `${label} takes at most ${max} image${max === 1 ? "" : "s"}; this post has ${count}.`; +}