A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add WritePublishFlow stepper (branch, confirm, upload, publish)

+278
+87
src/components/WritePublishFlow.test.tsx
··· 1 + // src/components/WritePublishFlow.test.tsx 2 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 + import { act, createElement } from 'react'; 4 + import { createRoot } from 'react-dom/client'; 5 + import type { Agent } from '@atproto/api'; 6 + 7 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 8 + 9 + const { publish, uploadHeldAssets } = vi.hoisted( () => ( { 10 + publish: vi.fn( async () => ( { articleUrl: 'https://x/a' } ) ), 11 + uploadHeldAssets: vi.fn( async () => ( { blocks: [], coverImage: undefined } ) ), 12 + } ) ); 13 + vi.mock( '../lib/publish/publisher', () => ( { publish } ) ); 14 + vi.mock( '../lib/write/upload-held', () => ( { uploadHeldAssets } ) ); 15 + 16 + import WritePublishFlow from './WritePublishFlow'; 17 + import type { BlockNode } from '../lib/blocks/render'; 18 + 19 + const PUB = ( uri: string, name: string ) => ( { 20 + uri, cid: 'cid', rkey: uri.split( '/' ).pop()!, slug: name, name, 21 + } ); 22 + const BLOCKS: BlockNode[] = [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ]; 23 + 24 + function mount( publications: unknown ) { 25 + const onPublished = vi.fn(); 26 + const container = document.createElement( 'div' ); 27 + document.body.appendChild( container ); 28 + const root = createRoot( container ); 29 + act( () => { 30 + root.render( 31 + createElement( WritePublishFlow, { 32 + agent: {} as Agent, 33 + identity: { did: 'did:plc:me', handle: 'me.test' }, 34 + pdsUrl: 'https://pds', 35 + blocks: BLOCKS, 36 + coverDataUrl: null, 37 + title: 'Title', 38 + description: 'Lede', 39 + publications, 40 + onReloadPublications: vi.fn(), 41 + onPublished, 42 + onCancel: vi.fn(), 43 + } as never ) 44 + ); 45 + } ); 46 + return { container, root, onPublished }; 47 + } 48 + 49 + beforeEach( () => { 50 + publish.mockClear(); 51 + uploadHeldAssets.mockClear(); 52 + } ); 53 + 54 + describe( 'WritePublishFlow', () => { 55 + it( 'single publication: shows a confirm naming it, never auto-publishes', () => { 56 + const { container } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 57 + expect( container.textContent ).toContain( 'Solo' ); 58 + expect( container.textContent?.toLowerCase() ).toContain( 'bluesky' ); 59 + expect( publish ).not.toHaveBeenCalled(); 60 + } ); 61 + 62 + it( 'confirming uploads held assets then publishes', async () => { 63 + const { container, onPublished } = mount( [ PUB( 'at://me/site.standard.publication/p1', 'Solo' ) ] ); 64 + const confirm = [ ...container.querySelectorAll( 'button' ) ].find( ( b ) => 65 + /post to bluesky/i.test( b.textContent ?? '' ) 66 + )!; 67 + await act( async () => confirm.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 68 + expect( uploadHeldAssets ).toHaveBeenCalledTimes( 1 ); 69 + expect( publish ).toHaveBeenCalledTimes( 1 ); 70 + expect( onPublished ).toHaveBeenCalledWith( { articleUrl: 'https://x/a' } ); 71 + } ); 72 + 73 + it( 'zero publications: renders the inline create-publication form', () => { 74 + const { container } = mount( [] ); 75 + expect( container.textContent ).toContain( 'New publication' ); 76 + } ); 77 + 78 + it( 'many publications: renders a picker listing each', () => { 79 + const { container } = mount( [ 80 + PUB( 'at://me/site.standard.publication/p1', 'One' ), 81 + PUB( 'at://me/site.standard.publication/p2', 'Two' ), 82 + ] ); 83 + expect( container.querySelector( 'select' ) ).not.toBe( null ); 84 + expect( container.textContent ).toContain( 'One' ); 85 + expect( container.textContent ).toContain( 'Two' ); 86 + } ); 87 + } );
+191
src/components/WritePublishFlow.tsx
··· 1 + // src/components/WritePublishFlow.tsx 2 + import { useMemo, useState } from 'react'; 3 + import type { Agent } from '@atproto/api'; 4 + import { publish, type Identity } from '../lib/publish/publisher'; 5 + import type { Publication } from '../lib/publish/publications'; 6 + import { computePostPreview } from './PublishPanel'; 7 + import PublicationForm from './PublicationForm'; 8 + import { uploadHeldAssets } from '../lib/write/upload-held'; 9 + import type { BlockNode } from '../lib/blocks/render'; 10 + 11 + interface 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 + 27 + type 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 + */ 35 + export 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 &amp; post to Bluesky 177 + </button> 178 + <button type="button" className="writeflow__cancel" onClick={ onCancel }> 179 + Keep editing 180 + </button> 181 + </div> 182 + ) } 183 + 184 + { phase === 'error' && error && ( 185 + <p className="writeflow__error" role="alert"> 186 + Publish failed: { error } 187 + </p> 188 + ) } 189 + </section> 190 + ); 191 + }