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 WriteStudio island: editor-first, publish deferred

+358
+85
src/components/WriteStudio.test.tsx
··· 1 + // src/components/WriteStudio.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 + 6 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 + 8 + // Stub the heavy editor: render a marker, ignore props. 9 + vi.mock( './SkyEditor', () => ( { default: () => createElement( 'div', { 'data-testid': 'sky-editor' } ) } ) ); 10 + // Stub AppBar to avoid pulling chrome we don't assert on. 11 + vi.mock( './AppBar', () => ( { default: () => null } ) ); 12 + 13 + const auth = vi.hoisted( () => ( { value: {} as Record< string, unknown > } ) ); 14 + vi.mock( '../lib/auth/AuthProvider', () => ( { 15 + AuthProvider: ( { children }: { children: unknown } ) => children, 16 + } ) ); 17 + vi.mock( '../lib/auth/useAuth', () => ( { useAuth: () => auth.value } ) ); 18 + 19 + const draft = vi.hoisted( () => ( { 20 + store: { 21 + load: vi.fn( async () => null ), 22 + save: vi.fn( async () => {} ), 23 + clear: vi.fn( async () => {} ), 24 + setPublishIntent: vi.fn(), 25 + consumePublishIntent: vi.fn( () => false ), 26 + }, 27 + } ) ); 28 + vi.mock( '../lib/write/draft-store', () => ( { 29 + createDraftStore: () => draft.store, 30 + } ) ); 31 + vi.mock( '../lib/publish/publications', () => ( { 32 + listPublications: vi.fn( async () => [] ), 33 + } ) ); 34 + 35 + import WriteStudio from './WriteStudio'; 36 + 37 + function render() { 38 + const container = document.createElement( 'div' ); 39 + document.body.appendChild( container ); 40 + const root = createRoot( container ); 41 + return { container, root }; 42 + } 43 + 44 + beforeEach( () => { 45 + draft.store.consumePublishIntent.mockReturnValue( false ); 46 + draft.store.load.mockResolvedValue( null ); 47 + } ); 48 + 49 + describe( 'WriteStudio', () => { 50 + it( 'signed out: renders the editor AND a sign-in affordance (no gate)', async () => { 51 + auth.value = { status: 'signed-out', agent: null, did: null, handle: null, signIn: vi.fn(), signOut: vi.fn() }; 52 + const { container, root } = render(); 53 + await act( async () => { 54 + root.render( createElement( WriteStudio ) ); 55 + } ); 56 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 57 + expect( container.textContent?.toLowerCase() ).toContain( 'sign in' ); 58 + } ); 59 + 60 + it( 'signed in: renders the editor AND the account pill', async () => { 61 + auth.value = { 62 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 63 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 64 + }; 65 + const { container, root } = render(); 66 + await act( async () => { 67 + root.render( createElement( WriteStudio ) ); 68 + } ); 69 + expect( container.querySelector( '[data-testid="sky-editor"]' ) ).not.toBe( null ); 70 + expect( container.querySelector( '.account-pill' ) ).not.toBe( null ); 71 + } ); 72 + 73 + it( 'resume: a publish intent on a signed-in load opens the publish flow', async () => { 74 + draft.store.consumePublishIntent.mockReturnValue( true ); 75 + auth.value = { 76 + status: 'signed-in', agent: {}, did: 'did:plc:me', handle: 'me.test', 77 + displayName: 'Me', avatar: null, pdsUrl: 'https://pds', signIn: vi.fn(), signOut: vi.fn(), 78 + }; 79 + const { container, root } = render(); 80 + await act( async () => { 81 + root.render( createElement( WriteStudio ) ); 82 + } ); 83 + expect( container.querySelector( '.writeflow' ) ).not.toBe( null ); 84 + } ); 85 + } );
+273
src/components/WriteStudio.tsx
··· 1 + // src/components/WriteStudio.tsx 2 + import { useEffect, useMemo, useRef, useState } from 'react'; 3 + import type { BlockInstance } from '@wordpress/blocks'; 4 + import { AuthProvider } from '../lib/auth/AuthProvider'; 5 + import { useAuth } from '../lib/auth/useAuth'; 6 + import SkyEditor from './SkyEditor'; 7 + import AppBar from './AppBar'; 8 + import PublishedPill from './PublishedPill'; 9 + import CoverImagePicker from './CoverImagePicker'; 10 + import SignInPanel from './SignInPanel'; 11 + import AccountPill from './AccountPill'; 12 + import WritePublishFlow from './WritePublishFlow'; 13 + import { createDeferredMediaUpload } from '../lib/write/deferred-media'; 14 + import { createDraftStore, type WriteDraft } from '../lib/write/draft-store'; 15 + import { listPublications, type Publication } from '../lib/publish/publications'; 16 + import { normalizeBlocks } from '../lib/publish/records'; 17 + import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 18 + import { displayNameFor } from '../lib/auth/profile'; 19 + import type { BlockNode } from '../lib/blocks/render'; 20 + import type { BlobRefJson } from '../lib/media/blob'; 21 + 22 + /** Read a file into a `data:` URL for a held (not-yet-uploaded) cover preview. */ 23 + function readAsDataUrl( file: File ): Promise< string > { 24 + return new Promise( ( resolve, reject ) => { 25 + const reader = new FileReader(); 26 + reader.onload = () => resolve( reader.result as string ); 27 + reader.onerror = () => reject( reader.error ?? new Error( 'Could not read the file.' ) ); 28 + reader.readAsDataURL( file ); 29 + } ); 30 + } 31 + 32 + /** A placeholder ref for a held cover — only `previewUrl` (a data URL) is used before publish. */ 33 + function deferredCoverRef( file: File ): BlobRefJson { 34 + return { $type: 'blob', ref: { $link: '' }, mimeType: file.type, size: file.size }; 35 + } 36 + 37 + function WriteSurface() { 38 + const { status, agent, did, handle, displayName, avatar, pdsUrl, error, signIn, signOut } = 39 + useAuth(); 40 + 41 + const draftStore = useMemo( () => createDraftStore(), [] ); 42 + const mediaUpload = useMemo( () => createDeferredMediaUpload(), [] ); 43 + 44 + const [ title, setTitle ] = useState( '' ); 45 + const [ lede, setLede ] = useState( '' ); 46 + const [ blocks, setBlocks ] = useState< BlockNode[] >( [] ); 47 + const [ cover, setCover ] = useState< CoverUpload | null >( null ); 48 + const [ publications, setPublications ] = useState< Publication[] | null >( null ); 49 + const [ flowOpen, setFlowOpen ] = useState( false ); 50 + const [ pendingPublish, setPendingPublish ] = useState( false ); 51 + const [ signinOpen, setSigninOpen ] = useState( false ); 52 + const [ published, setPublished ] = useState< { url: string } | null >( null ); 53 + const [ editorKey, setEditorKey ] = useState( 0 ); 54 + 55 + // Restored content fed to the editor canvas once, captured so a later `blocks` 56 + // change can't remount SkyEditor (which would wipe the canvas). 57 + const initialBlocksRef = useRef< BlockNode[] >( [] ); 58 + const restoredRef = useRef( false ); 59 + const intentRef = useRef( false ); 60 + 61 + // One-shot restore + intent read on mount. 62 + useEffect( () => { 63 + let cancelled = false; 64 + intentRef.current = draftStore.consumePublishIntent(); 65 + draftStore.load().then( ( d: WriteDraft | null ) => { 66 + if ( cancelled || ! d ) { 67 + return; 68 + } 69 + restoredRef.current = true; 70 + initialBlocksRef.current = d.blocks; 71 + setTitle( d.title ); 72 + setLede( d.lede ); 73 + setBlocks( d.blocks ); 74 + if ( d.coverDataUrl ) { 75 + setCover( { 76 + ref: { $type: 'blob', ref: { $link: '' }, mimeType: '', size: 0 }, 77 + previewUrl: d.coverDataUrl, 78 + } ); 79 + } 80 + setEditorKey( ( k ) => k + 1 ); 81 + } ); 82 + return () => { 83 + cancelled = true; 84 + }; 85 + }, [ draftStore ] ); 86 + 87 + // Load publications once signed in. 88 + useEffect( () => { 89 + if ( ! agent || ! did ) { 90 + return; 91 + } 92 + let cancelled = false; 93 + listPublications( agent, did ) 94 + .then( ( list ) => ! cancelled && setPublications( list ) ) 95 + .catch( () => ! cancelled && setPublications( [] ) ); 96 + return () => { 97 + cancelled = true; 98 + }; 99 + }, [ agent, did ] ); 100 + 101 + // Resume: a publish intent + a signed-in session auto-opens the publish flow. 102 + useEffect( () => { 103 + if ( status === 'signed-in' && intentRef.current ) { 104 + intentRef.current = false; 105 + setFlowOpen( true ); 106 + } 107 + }, [ status ] ); 108 + 109 + const reloadPublications = () => { 110 + if ( agent && did ) { 111 + listPublications( agent, did ) 112 + .then( setPublications ) 113 + .catch( () => setPublications( [] ) ); 114 + } 115 + }; 116 + 117 + async function persistDraft() { 118 + await draftStore.save( { 119 + title, 120 + lede, 121 + blocks, 122 + coverDataUrl: cover?.previewUrl ?? null, 123 + } ); 124 + } 125 + 126 + async function onSignIn( value: string ) { 127 + await persistDraft(); 128 + if ( pendingPublish ) { 129 + draftStore.setPublishIntent(); 130 + } 131 + await signIn( value ); // full-page redirect; never resolves 132 + } 133 + 134 + function onPublishClicked() { 135 + if ( status === 'signed-in' ) { 136 + void persistDraft(); 137 + setFlowOpen( true ); 138 + return; 139 + } 140 + // Signed out → reveal the sign-in panel framed around publishing. 141 + setPendingPublish( true ); 142 + setSigninOpen( true ); 143 + } 144 + 145 + async function uploadCover( file: File ): Promise< CoverUpload > { 146 + const message = validateCoverFile( file ); 147 + if ( message ) { 148 + throw new Error( message ); 149 + } 150 + const previewUrl = await readAsDataUrl( file ); 151 + return { ref: deferredCoverRef( file ), previewUrl }; 152 + } 153 + 154 + const canPublish = title.trim().length > 0 && blocks.length > 0; 155 + const signedIn = status === 'signed-in' && agent && did; 156 + 157 + return ( 158 + <> 159 + <AppBar current="editor" /> 160 + 161 + <div className="write-corner"> 162 + { signedIn ? ( 163 + <AccountPill 164 + displayName={ displayName ? displayNameFor( { did: did!, handle, displayName, avatar } ) : ( handle ?? did! ) } 165 + handle={ handle } 166 + avatar={ avatar } 167 + onSignOut={ () => void signOut() } 168 + /> 169 + ) : ( 170 + <button 171 + type="button" 172 + className="write-corner__signin" 173 + onClick={ () => { 174 + setPendingPublish( false ); 175 + setSigninOpen( true ); 176 + } } 177 + > 178 + Sign in 179 + </button> 180 + ) } 181 + </div> 182 + 183 + { published && <PublishedPill url={ published.url } isEditing={ false } /> } 184 + 185 + { ! signedIn && signinOpen && ( 186 + <div className="write-signin"> 187 + <SignInPanel 188 + forPublish={ pendingPublish } 189 + error={ error } 190 + onSubmit={ ( value ) => void onSignIn( value ) } 191 + onCancel={ () => setSigninOpen( false ) } 192 + /> 193 + </div> 194 + ) } 195 + 196 + { signedIn && flowOpen && ( 197 + <WritePublishFlow 198 + agent={ agent! } 199 + identity={ { did: did!, handle } } 200 + pdsUrl={ pdsUrl ?? '' } 201 + blocks={ blocks } 202 + coverDataUrl={ cover?.previewUrl ?? null } 203 + title={ title } 204 + description={ lede } 205 + publications={ publications } 206 + onReloadPublications={ reloadPublications } 207 + onPublished={ ( result ) => { 208 + setFlowOpen( false ); 209 + setPublished( { url: result.articleUrl } ); 210 + void draftStore.clear(); 211 + // Reset to a fresh new article. 212 + setTitle( '' ); 213 + setLede( '' ); 214 + setBlocks( [] ); 215 + setCover( null ); 216 + initialBlocksRef.current = []; 217 + setEditorKey( ( k ) => k + 1 ); 218 + } } 219 + onCancel={ () => setFlowOpen( false ) } 220 + /> 221 + ) } 222 + 223 + <div key={ editorKey }> 224 + <div className="write-header"> 225 + <textarea 226 + className="studio__title" 227 + rows={ 1 } 228 + placeholder="Add title" 229 + aria-label="Article title" 230 + value={ title } 231 + onKeyDown={ ( event ) => event.key === 'Enter' && event.preventDefault() } 232 + onChange={ ( event ) => { 233 + setPublished( null ); 234 + setTitle( event.target.value ); 235 + } } 236 + /> 237 + <button 238 + type="button" 239 + className="write-publish" 240 + disabled={ ! canPublish } 241 + onClick={ onPublishClicked } 242 + > 243 + Publish… 244 + </button> 245 + </div> 246 + <textarea 247 + className="studio__lede" 248 + rows={ 1 } 249 + maxLength={ 3000 } 250 + placeholder="Add a subtitle…" 251 + aria-label="Subtitle" 252 + value={ lede } 253 + onChange={ ( event ) => setLede( event.target.value ) } 254 + /> 255 + <SkyEditor 256 + onChange={ ( instances: BlockInstance[] ) => setBlocks( normalizeBlocks( instances ) ) } 257 + mediaUpload={ mediaUpload } 258 + initialBlocks={ initialBlocksRef.current } 259 + /> 260 + <CoverImagePicker cover={ cover } onUpload={ uploadCover } onChange={ setCover } /> 261 + </div> 262 + </> 263 + ); 264 + } 265 + 266 + /** The writing-first island: editor for every auth status, publish deferred (design 2026-06-17). */ 267 + export default function WriteStudio() { 268 + return ( 269 + <AuthProvider> 270 + <WriteSurface /> 271 + </AuthProvider> 272 + ); 273 + }