import { useEffect, useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { launchpadApi } from "../api/endpoints"; import type { CalendarItem, Platform, PostableArtifact, SocialAccounts } from "../api/types"; import { AttachmentChip, AttachPicker } from "./launchpad/Attachment"; import { nextSlot, SlotPicker, toWall, userTimeZone, wallToDate } from "./launchpad/SlotPicker"; import { errorMessage, Modal } from "./ui"; export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [ { id: "x", label: "X", limit: 280, auto: true }, { id: "linkedin", label: "LinkedIn", limit: 3000, auto: true }, { id: "bluesky", label: "Bluesky", limit: 300, auto: true }, { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false }, ]; /** Only X and LinkedIn take images and videos; the others get the text. */ const MEDIA_PLATFORMS: Platform[] = ["x", "linkedin"]; const THREADED: Platform[] = ["x", "bluesky"]; /** These post only through a live connection: no email fallback. */ const CONNECTED_ONLY: Platform[] = ["x", "linkedin"]; /** What the composer offers for now: LinkedIn only (X posting is switched off). */ const COMPOSER_PLATFORMS: Platform[] = ["linkedin"]; /** The attachment's starting text for a platform: a thread for X and Bluesky, one post otherwise. */ const prefillFor = (p: Platform, a: PostableArtifact): string[] => THREADED.includes(p) ? [...a.prefill.x] : [a.prefill.linkedin]; /** The composer: one post to any of X, LinkedIn, Bluesky and Substack Notes (one calendar item each), with * something from the Library attached (its images or video go to X and LinkedIn). Scheduled or posted now. */ export function ScheduleModal({ platform: initialPlatform, posts, artifactId, artifact, kitId, kitTitle, documentId, onClose, onScheduled, }: { platform: Platform; posts: string[]; artifactId?: string | null; /** The attachment, when the caller already has it (else it is looked up from artifactId). */ artifact?: PostableArtifact | null; /** The Launch Kit the post is written from: tracked with it, never attached. */ kitId?: string | null; kitTitle?: string | null; documentId?: string | null; onClose: () => void; onScheduled?: (item: CalendarItem) => void; }) { // One platform per post. A caller asking for one the composer doesn't offer starts on LinkedIn with that text. const first: Platform = COMPOSER_PLATFORMS.includes(initialPlatform) ? initialPlatform : "linkedin"; const [selected, setSelected] = useState([first]); const [texts, setTexts] = useState>>(() => ({ [first]: posts.length ? posts : artifact ? prefillFor(first, artifact) : [""], })); const [attached, setAttached] = useState(artifact?.type === "launch_kit" ? null : artifact ?? null); const navigate = useNavigate(); const [picking, setPicking] = useState(false); const [when, setWhen] = useState(() => toWall(nextSlot())); const [accounts, setAccounts] = useState(null); // The connections are checked before anything says one is missing: loading isn't "not connected". const [accountsState, setAccountsState] = useState<"loading" | "ready" | "error">("loading"); const loadAccounts = () => { setAccountsState("loading"); launchpadApi.accounts().then((a) => { setAccounts(a); setAccountsState("ready"); }, () => setAccountsState("error")); }; const [remind, setRemind] = useState(false); const [busy, setBusy] = useState<"schedule" | "now" | null>(null); const [error, setError] = useState(null); useEffect(() => { loadAccounts(); if (artifactId && !artifact) { launchpadApi.postable().then((list) => { const found = list.find((a) => a.id === artifactId); // a kit is never an attachment, and audio can't be posted (X and LinkedIn take no audio files) if (found && found.type !== "launch_kit" && found.media !== "audio") attach(found); }, () => undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const accountFor = (p: Platform) => accounts?.accounts.find((a) => a.platform === p && a.status === "active") ?? null; const carriesMedia = !!attached && attached.media !== "text"; /** Why a ticked X / LinkedIn can't be scheduled yet (no connection, connection down, can't post media). */ const blocker = (p: Platform): string | null => { if (!CONNECTED_ONLY.includes(p) || accountsState !== "ready") return null; const label = PLATFORMS.find((x) => x.id === p)!.label; const any = accounts?.accounts.find((a) => a.platform === p); const live = accountFor(p); if (!live) return any ? `Your ${label} connection is down. Reconnect ${label}.` : `Connect ${label} to post there.`; if (carriesMedia && !live.can_post_media) return `Reconnect ${label} to post images and videos.`; return null; }; const blocked = selected.map(blocker).filter(Boolean) as string[]; const checking = accountsState !== "ready" && selected.some((p) => CONNECTED_ONLY.includes(p)); const remindable = selected.some((p) => !CONNECTED_ONLY.includes(p)); /** Switch to another platform (one per post); it starts from the attachment's text, or the text written so far. */ const pick = (p: Platform) => { if (selected.includes(p)) return; setSelected([p]); if (!texts[p]?.some((t) => t.trim())) { const from = selected.map((s) => texts[s] ?? []).find((t) => t.some((x) => x.trim())) ?? [""]; const start = attached ? prefillFor(p, attached) : THREADED.includes(p) ? from : [from.join("\n\n")]; setTexts((cur) => ({ ...cur, [p]: start })); } }; /** Attach: empty texts take its suggested text. */ const attach = (a: PostableArtifact) => { setAttached(a); setPicking(false); setTexts((cur) => { const next = { ...cur }; for (const p of selected) if (!next[p]?.some((t) => t.trim())) next[p] = prefillFor(p, a); return next; }); }; const setText = (p: Platform, i: number, value: string) => setTexts((cur) => ({ ...cur, [p]: (cur[p] ?? [""]).map((t, j) => (j === i ? value : t)) })); const connect = async (p: "x" | "linkedin") => { try { window.location.href = (await launchpadApi.startOAuth(p)).url; } catch (e) { setError(errorMessage(e)); } }; const submit = async (now: boolean, e?: FormEvent) => { e?.preventDefault(); if (!selected.length) return setError("Pick where to post."); if (blocked.length) return setError(blocked[0]); if (!now && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead."); setBusy(now ? "now" : "schedule"); setError(null); const done: string[] = []; try { for (const p of PLATFORMS.map((x) => x.id).filter((id) => selected.includes(id))) { const meta = PLATFORMS.find((x) => x.id === p)!; const clean = (texts[p] ?? []).map((t) => t.trim()).filter(Boolean); const merged = THREADED.includes(p) ? clean : [clean.join("\n\n")]; const account = meta.auto ? accountFor(p) : null; const item = await launchpadApi.create({ platform: p, // Post now: published by this very request (the server takes the time). Scheduled: the half hour picked, in // the user's zone (the backend makes it UTC). ...(now ? { publish_now: true } : { local_time: when, timezone: userTimeZone() }), content: merged[0] ?? "", thread: merged.slice(1), // Images and videos only go to X and LinkedIn; text attachments go anywhere. artifact_id: attached && (!carriesMedia || MEDIA_PLATFORMS.includes(p)) ? attached.id : null, document_id: documentId ?? null, kit_id: kitId ?? null, social_account_id: account?.id ?? null, remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account, }); onScheduled?.(item); done.push(meta.label); } onClose(); } catch (err) { setError(done.length ? `${done.join(" and ")} done; then: ${errorMessage(err)}` : errorMessage(err)); } finally { setBusy(null); } }; return (
submit(false, e)} className="stack lp-compose">
Post to
{PLATFORMS.filter((p) => COMPOSER_PLATFORMS.includes(p.id)).map((p) => { const on = selected.includes(p.id); return ( ); })}
{kitId &&

Written from the launch kit: {kitTitle ?? "this kit"}

}
Attachment {attached ? ( setPicking(true)} onRemove={() => setAttached(null)} /> ) : ( )}
{PLATFORMS.filter((p) => selected.includes(p.id)).map((meta) => { const p = meta.id; const list = texts[p] ?? [""]; const account = meta.auto ? accountFor(p) : null; const canConnect = (p === "x" || p === "linkedin") && accounts?.available[p]; const problem = blocker(p); return (
{meta.label} {problem ?? (!meta.auto ? "No posting API: we email you the text at send time." : accountsState === "loading" ? "Checking your connection..." : accountsState === "error" ? "Couldn't check your connections." : account ? `Posting as ${account.handle}` : "Not connected: we email you a reminder.")} {accountsState === "error" && ( )} {problem && canConnect && ( )} {problem && !canConnect && {meta.label} posting isn't set up yet.}
{list.map((t, i) => (