A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1// src/components/WritePublishFlow.tsx
2import { useMemo, useState } from 'react';
3import type { Agent } from '@atproto/api';
4import { publish, type Identity } from '../lib/publish/publisher';
5import type { Publication } from '../lib/publish/publications';
6import { computePostPreview } from './PublishPanel';
7import PublicationForm from './PublicationForm';
8import { uploadHeldAssets } from '../lib/write/upload-held';
9import type { BlockNode } from '../lib/blocks/render';
10
11interface Props {
12 agent: Agent;
13 identity: Identity;
14 pdsUrl: string;
15 blocks: BlockNode[];
16 coverDataUrl: string | null;
17 title: string;
18 description: string;
19 /** `null` while still loading; `[]` when the writer has none yet. */
20 publications: Publication[] | null;
21 /** Ask the parent to re-list publications (after an inline create). */
22 onReloadPublications: () => void;
23 onPublished: ( result: { articleUrl: string } ) => void;
24 onCancel: () => void;
25}
26
27type Phase = 'pick' | 'working' | 'error';
28
29/**
30 * The writing-first publish stepper (design 2026-06-17). Branches on how many publications the
31 * writer owns: zero → inline create; one → confirm; many → pick then confirm. The confirm step
32 * always discloses the public Bluesky post (brief §10) and blocks an over-limit post. On confirm
33 * it uploads the held images/cover, then reuses `publish()` (document + Bluesky post).
34 */
35export default function WritePublishFlow( {
36 agent,
37 identity,
38 pdsUrl,
39 blocks,
40 coverDataUrl,
41 title,
42 description,
43 publications,
44 onReloadPublications,
45 onPublished,
46 onCancel,
47}: Props ) {
48 const pubs = publications ?? [];
49 const [ targetUri, setTargetUri ] = useState( () => pubs[ 0 ]?.uri ?? '' );
50 const [ phase, setPhase ] = useState< Phase >( 'pick' );
51 const [ error, setError ] = useState< string | null >( null );
52
53 const target = pubs.find( ( p ) => p.uri === targetUri ) ?? pubs[ 0 ];
54
55 const preview = useMemo(
56 () =>
57 target
58 ? computePostPreview( {
59 title,
60 lede: description,
61 blocks,
62 handle: identity.handle ?? identity.did,
63 slug: target.slug,
64 } )
65 : null,
66 [ target, title, description, blocks, identity ]
67 );
68
69 if ( publications === null ) {
70 return (
71 <section className="writeflow" aria-label="Publish">
72 <p className="writeflow__status">Loading your publications…</p>
73 </section>
74 );
75 }
76
77 // Zero publications → inline "create your first publication".
78 if ( pubs.length === 0 ) {
79 return (
80 <section className="writeflow" aria-label="Create your first publication">
81 <p className="writeflow__lede">
82 You don't have a publication yet — create one to publish into.
83 </p>
84 <PublicationForm
85 agent={ agent }
86 did={ identity.did }
87 pdsUrl={ pdsUrl }
88 handle={ identity.handle ?? identity.did }
89 onSaved={ ( pub ) => {
90 setTargetUri( pub.uri );
91 onReloadPublications();
92 } }
93 onCancel={ onCancel }
94 />
95 </section>
96 );
97 }
98
99 async function run() {
100 if ( ! target ) {
101 return;
102 }
103 setPhase( 'working' );
104 setError( null );
105 try {
106 const prepared = await uploadHeldAssets( agent, {
107 blocks,
108 coverDataUrl,
109 did: identity.did,
110 pdsUrl,
111 } );
112 const res = await publish( agent, identity, {
113 title: title.trim(),
114 description,
115 blocks: prepared.blocks,
116 publicationUri: target.uri,
117 publicationCid: target.cid,
118 publicationSlug: target.slug,
119 coverImage: prepared.coverImage,
120 } );
121 onPublished( { articleUrl: res.articleUrl } );
122 } catch ( err ) {
123 setError( err instanceof Error ? err.message : String( err ) );
124 setPhase( 'error' );
125 }
126 }
127
128 const overLimit = Boolean( preview?.overLimit );
129
130 return (
131 <section className="writeflow" aria-label="Publish">
132 { pubs.length > 1 && (
133 <label className="writeflow__target">
134 <span>Publish to</span>
135 <select
136 value={ target?.uri ?? '' }
137 onChange={ ( e ) => setTargetUri( e.target.value ) }
138 disabled={ phase === 'working' }
139 >
140 { pubs.map( ( p ) => (
141 <option key={ p.uri } value={ p.uri }>
142 { p.name }
143 </option>
144 ) ) }
145 </select>
146 </label>
147 ) }
148
149 <p className="writeflow__warning">
150 Publishing saves this article to <strong>your PDS</strong> and also creates a{ ' ' }
151 <strong>public Bluesky post</strong> linking to it
152 { target ? <> in <strong>{ target.name }</strong></> : null }. Everyone following you
153 will see it.
154 { preview && preview.handles.length > 0 && (
155 <> It will notify <strong>{ preview.handles.join( ', ' ) }</strong>.</>
156 ) }
157 </p>
158
159 { overLimit && (
160 <p className="writeflow__count" aria-live="polite">
161 Bluesky post: { preview!.graphemes }/300 — too long to publish; shorten the
162 subtitle or remove a mention.
163 </p>
164 ) }
165
166 { phase === 'working' ? (
167 <p className="writeflow__status">Publishing…</p>
168 ) : (
169 <div className="writeflow__actions">
170 <button
171 type="button"
172 className="writeflow__publish"
173 disabled={ overLimit || ! target }
174 onClick={ () => void run() }
175 >
176 Publish & post to Bluesky
177 </button>
178 </div>
179 ) }
180
181 { phase === 'error' && error && (
182 <p className="writeflow__error" role="alert">
183 Publish failed: { error }
184 </p>
185 ) }
186 </section>
187 );
188}