File size: 13,950 Bytes
34b0226
bdd1640
34b0226
bdd1640
 
 
34b0226
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
 
 
34b0226
bdd1640
 
 
34b0226
bdd1640
 
34b0226
 
 
 
bdd1640
 
 
34b0226
 
 
 
 
 
 
bdd1640
 
 
 
 
34b0226
 
 
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
bdd1640
34b0226
 
 
bdd1640
 
 
 
fa6e4a3
 
bdd1640
 
 
34b0226
 
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
bdd1640
 
 
 
 
 
34b0226
bdd1640
34b0226
bdd1640
 
 
 
 
 
 
fa6e4a3
 
 
bdd1640
 
 
 
 
 
 
 
 
fa6e4a3
bdd1640
 
34b0226
 
bdd1640
34b0226
bdd1640
34b0226
 
 
 
bdd1640
 
 
 
 
 
 
 
 
 
34b0226
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
bdd1640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
bdd1640
34b0226
 
bdd1640
 
 
 
34b0226
bdd1640
 
34b0226
 
 
bdd1640
 
 
 
 
 
 
34b0226
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
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<Platform[]>([first]);
  const [texts, setTexts] = useState<Partial<Record<Platform, string[]>>>(() => ({
    [first]: posts.length ? posts : artifact ? prefillFor(first, artifact) : [""],
  }));
  const [attached, setAttached] = useState<PostableArtifact | null>(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<SocialAccounts | null>(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<string | null>(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 (
    <Modal title="New post" onClose={onClose} wide>
      <form onSubmit={(e) => submit(false, e)} className="stack lp-compose">
        <div className="field">
          <span className="vw-label">Post to</span>
          <div className="gen-chips lp-platforms" role="radiogroup" aria-label="Platform">
            {PLATFORMS.filter((p) => COMPOSER_PLATFORMS.includes(p.id)).map((p) => {
              const on = selected.includes(p.id);
              return (
                <button key={p.id} type="button" role="radio" className={`chip${on ? " on" : ""}`} aria-checked={on}
                        onClick={() => pick(p.id)}>
                  {p.label}
                </button>
              );
            })}
          </div>
        </div>

        {kitId && <p className="muted small lp-from-kit">Written from the launch kit: {kitTitle ?? "this kit"}</p>}
        <div className="field">
          <span className="vw-label">Attachment</span>
          {attached ? (
            <AttachmentChip a={attached} onChange={() => setPicking(true)} onRemove={() => setAttached(null)} />
          ) : (
            <button type="button" className="btn lp-attach-btn" onClick={() => setPicking(true)}>
              + Attach from your Library
            </button>
          )}
        </div>

        {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 (
            <section key={p} className="lp-platform">
              <div className="lp-platform-head">
                <strong>{meta.label}</strong>
                <span className={`small ${problem || (accountsState === "error" && CONNECTED_ONLY.includes(p)) ? "lp-warn" : "muted"}`}>
                  {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.")}
                </span>
                {accountsState === "error" && (
                  <button type="button" className="btn btn-small" onClick={loadAccounts}>Retry</button>
                )}
                {problem && canConnect && (
                  <button type="button" className="btn btn-small btn-primary" onClick={() => connect(p as "x" | "linkedin")}>
                    {accounts?.accounts.some((a) => a.platform === p) ? "Reconnect" : "Connect"} {meta.label}
                  </button>
                )}
                {problem && !canConnect && <span className="muted small">{meta.label} posting isn't set up yet.</span>}
              </div>
              {list.map((t, i) => (
                <label key={i} className="field">
                  <span className="field-row">
                    <span className="small muted">{THREADED.includes(p) && list.length > 1 ? `Post ${i + 1}` : "Post"}</span>
                    <span className={`mono small ${t.length > meta.limit ? "over" : "muted"}`}>{t.length} / {meta.limit}</span>
                  </span>
                  <textarea className="textarea" rows={THREADED.includes(p) ? 3 : 6} value={t}
                            onChange={(e) => setText(p, i, e.target.value)} />
                </label>
              ))}
              {THREADED.includes(p) && (
                <div className="row">
                  <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: [...list, ""] })}>
                    + Add to thread
                  </button>
                  {list.length > 1 && (
                    <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: list.slice(0, -1) })}>
                      Remove last
                    </button>
                  )}
                </div>
              )}
            </section>
          );
        })}

        <div className="row wrap lp-when">
          <div className="field">
            <span className="vw-label">When</span>
            <SlotPicker value={when} onChange={setWhen} />
          </div>
          {remindable && (
            <label className="check">
              <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} />
              Email me instead of posting automatically (Bluesky, Substack)
            </label>
          )}
        </div>

        {error && <p className="error-text">{error}</p>}
        <div className="row end">
          <button type="button" className="btn" onClick={onClose}>Cancel</button>
          <button type="button" className="btn" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}
                  onClick={() => submit(true)}>
            {busy === "now" ? "Posting..." : "Post now"}
          </button>
          <button className="btn btn-primary" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}>
            {busy === "schedule" ? "Scheduling..." : "Schedule"}
          </button>
        </div>
      </form>
      {picking && (
        <AttachPicker onPick={attach} onClose={() => setPicking(false)} onCreateKit={() => {
          setPicking(false);
          onClose();
          navigate("/app/launchpad/kits");
        }} />
      )}
    </Modal>
  );
}