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.

Fix the image block: disable wp.media, preview via object URL

The Image block was unusable: clicking "Media Library" threw
`can't access property "media", … is undefined`, and uploads logged
404 and 500 network errors. Three separate defects in the Gutenberg ↔
atproto bridge:

1. isolated-block-editor bundles @wordpress/editor's media hook, which
registers the @wordpress/media-utils MediaUpload (the wp.media
Backbone frame) on the editor.MediaUpload filter. SkyPress never
loads the wp.media global and never overrode the filter, so the
"Media Library" button crashed. SkyPress has no media library —
images upload to the PDS as blobs — so register a filter returning
an inert component, removing the button everywhere (placeholder,
Replace flow). The Upload/drop-zone path is unaffected.

2. mediaUpload handed the block `id: cid`, so it treated the CID as a
WP attachment and fetched /wp/v2/media/<cid> → 404. PDS blobs aren't
attachments; omit the id so the block treats it as a URL image.

3. Preview used the live getBlob URL, but a just-uploaded blob is
unreferenced (temporary on the PDS) until a record commits it, so
com.atproto.sync.getBlob 500s for it. Preview from a local object
URL instead; attachBlobRefs rewrites that transient blob: URL to the
canonical, portable getBlob URL at publish, keeping stored records
clean.

Decision 0006 and the SP3 spec are corrected to match the object-URL
preview strategy.

+223 -31
+24 -6
docs/decisions/0006-image-blob-pipeline.md
··· 19 19 20 20 1. Upload to the writer's PDS: `agent.uploadBlob(file, { encoding: file.type })` → 21 21 `BlobRef`. 22 - 2. Build a fetchable URL — `getBlob` on the writer's PDS for their DID + the blob CID — 23 - and hand it back to the editor via `onFileChange([{ id: cid, url, alt }])` so the 24 - image previews immediately. 25 - 3. **Retain the full blob ref** in an in-memory registry keyed by that URL. 22 + 2. Hand the editor a **local object URL** (`URL.createObjectURL(file)`) via 23 + `onFileChange([{ url, alt }])` so the image previews immediately. **No `id`** — PDS 24 + blobs aren't WP attachments; an `id` makes the Image block fetch `/wp/v2/media/<id>`, 25 + which 404s. 26 + 3. **Retain the blob ref + the canonical `getBlob` URL** in an in-memory registry keyed 27 + by the object URL. 28 + 29 + ### Preview via object URL, not the live `getBlob` URL (corrected 2026-06-08) 30 + A just-uploaded blob is **unreferenced** (temporary on the PDS) until a record commits a 31 + reference to it; `com.atproto.sync.getBlob` fails for it (observed: **500** on a 32 + bsky.network PDS). So the original "preview from the live `getBlob` URL" plan below is 33 + wrong for in-editor preview — it only resolves *after* publish. We preview from a local 34 + object URL instead, and `attachBlobRefs` rewrites that transient `blob:` URL to the 35 + canonical `getBlob` URL at publish (so the stored record stays portable, never a dead 36 + object URL). The reader (SP4) still reconstructs the URL from `skypressBlob` + the 37 + author's current PDS. 38 + 39 + The "Media Library" button (Gutenberg's `editor.MediaUpload`, which opens the legacy 40 + `wp.media` Backbone frame) is **disabled** via a filter override — SkyPress has no media 41 + library; uploads go straight to the PDS through the Upload/drop-zone path. See 42 + `src/lib/media/registerMediaUpload.ts`. 26 43 27 44 At **publish**, `attachBlobRefs` walks the block tree and, for every image whose `url` is 28 45 one we uploaded, writes the blob ref (`BlobRef.ipld()` → ··· 38 55 resolving the writer's *current* PDS — so the image survives a PDS migration. 39 56 40 57 ### In-editor URL strategy 41 - Live `getBlob` URL (not a SkyPress media proxy) for v1 — simplest, proves the upload. A 42 - caching/resizing proxy through the edge is a later optimisation (brief §5). 58 + Local object URL for preview; the canonical `getBlob` URL (not a SkyPress media proxy) is 59 + persisted at publish. A caching/resizing proxy through the edge is a later optimisation 60 + (brief §5). 43 61 44 62 ### Externally-URL'd images 45 63 Allowed **as-is** for v1 (no `skypressBlob`, just their `url`). Only files added through
+4 -2
docs/specs/sp3-image-blob-pipeline.md
··· 11 11 ## Success criteria 12 12 13 13 1. Inserting an image in the editor uploads it to the writer's PDS and previews via a 14 - `getBlob` URL. 14 + local object URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet 15 + — see Decision 0006). 15 16 2. On publish, image blocks carry a proper `skypressBlob` ref (`{$type:'blob',…}`) in the 16 17 stored `content`, keeping the blob in-use (not GC'd) and portable. 17 18 3. Pure helpers (`getBlob` URL builder, blob-ref attach transform) unit-tested. ··· 26 27 blob.test.ts Vitest unit tests (written first) 27 28 pds.ts browser: resolvePdsUrl(did) from the DID document 28 29 mediaUpload.ts browser: createMediaUpload({ agent, did, pdsUrl, registry }) 30 + registerMediaUpload.ts browser: disables the wp.media "Media Library" button 29 31 src/lib/auth/AuthProvider.tsx resolves + exposes `pdsUrl` on sign-in 30 32 src/components/Studio.tsx owns the blob registry; wires mediaUpload + publish 31 33 src/components/SkyEditor.tsx accepts `mediaUpload`, sets settings.editor.mediaUpload ··· 50 52 | Check | Result | 51 53 |---|---| 52 54 | 25 unit tests (incl. blob helpers) | pass | 53 - | Editor upload | `mediaUpload` → `uploadBlob` → image previews via the PDS `getBlob` URL | 55 + | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a local object URL | 54 56 | Stored content | image block carries `skypressBlob` = `{$type:'blob', ref:{$link:'bafkrei…'}, mimeType:'image/png', size:70}` | 55 57 | Blob served | `getBlob` returns the bytes (200, 70 bytes) | 56 58 | **Blob in-use** | the CID appears in `com.atproto.sync.listBlobs` — referenced by the record, so **not garbage-collected** (the load-bearing reason to store a typed ref, Decision 0006) |
+3
src/components/SkyEditor.tsx
··· 1 1 import { useCallback, useState } from 'react'; 2 2 import IsolatedBlockEditor from '@automattic/isolated-block-editor'; 3 + // Side-effect import: must run AFTER isolated-block-editor so our editor.MediaUpload 4 + // filter registers last and replaces the bundled wp.media-based one (see the module). 5 + import '../lib/media/registerMediaUpload'; 3 6 import { createBlock, type BlockInstance } from '@wordpress/blocks'; 4 7 import type { BlockNode } from '../lib/blocks/render'; 5 8
+15 -5
src/lib/media/blob.test.ts
··· 30 30 } ); 31 31 32 32 describe( 'attachBlobRefs', () => { 33 - const lookup = ( url: string ) => ( url === 'blob://uploaded' ? REF : undefined ); 33 + // The editor previews via a transient object URL (`blob:…`); publish looks that key 34 + // up and persists the canonical, portable getBlob URL instead. 35 + const CANONICAL = 36 + 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafycid123'; 37 + const lookup = ( url: string ) => 38 + url === 'blob:preview-1' ? { ref: REF, url: CANONICAL } : undefined; 34 39 35 - it( 'attaches skypressBlob to an uploaded image, recursing inner blocks', () => { 40 + it( 'attaches skypressBlob and rewrites the url to the canonical getBlob URL, recursing inner blocks', () => { 36 41 const blocks: BlockNode[] = [ 37 - { name: 'core/image', attributes: { url: 'blob://uploaded', alt: 'x' }, innerBlocks: [] }, 42 + { name: 'core/image', attributes: { url: 'blob:preview-1', alt: 'x' }, innerBlocks: [] }, 38 43 { 39 44 name: 'core/gallery', 40 45 attributes: {}, 41 46 innerBlocks: [ 42 - { name: 'core/image', attributes: { url: 'blob://uploaded' }, innerBlocks: [] }, 47 + { name: 'core/image', attributes: { url: 'blob:preview-1' }, innerBlocks: [] }, 43 48 ], 44 49 }, 45 50 ]; 46 51 const out = attachBlobRefs( blocks, lookup ); 47 52 expect( out[ 0 ].attributes?.skypressBlob ).toEqual( REF ); 53 + expect( out[ 0 ].attributes?.url ).toBe( CANONICAL ); 54 + expect( out[ 0 ].attributes?.alt ).toBe( 'x' ); 48 55 expect( out[ 1 ].innerBlocks?.[ 0 ].attributes?.skypressBlob ).toEqual( REF ); 56 + expect( out[ 1 ].innerBlocks?.[ 0 ].attributes?.url ).toBe( CANONICAL ); 49 57 } ); 50 58 51 59 it( 'leaves external images (no matching upload) untouched', () => { ··· 54 62 ]; 55 63 const out = attachBlobRefs( blocks, lookup ); 56 64 expect( 'skypressBlob' in ( out[ 0 ].attributes ?? {} ) ).toBe( false ); 65 + expect( out[ 0 ].attributes?.url ).toBe( 'https://example.com/cat.jpg' ); 57 66 } ); 58 67 59 68 it( 'does not mutate the input blocks', () => { 60 69 const blocks: BlockNode[] = [ 61 - { name: 'core/image', attributes: { url: 'blob://uploaded' }, innerBlocks: [] }, 70 + { name: 'core/image', attributes: { url: 'blob:preview-1' }, innerBlocks: [] }, 62 71 ]; 63 72 attachBlobRefs( blocks, lookup ); 64 73 expect( 'skypressBlob' in ( blocks[ 0 ].attributes ?? {} ) ).toBe( false ); 74 + expect( blocks[ 0 ].attributes?.url ).toBe( 'blob:preview-1' ); 65 75 } ); 66 76 } ); 67 77
+17 -5
src/lib/media/blob.ts
··· 11 11 size: number; 12 12 } 13 13 14 + /** 15 + * What a session upload records for each previewed image. The editor previews from a 16 + * transient object URL (`blob:…`); `attachBlobRefs` looks that key up at publish and 17 + * persists the portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`). 18 + */ 19 + export interface BlobUpload { 20 + ref: BlobRefJson; 21 + url: string; 22 + } 23 + 14 24 /** Block names whose `url` attribute may reference an uploaded blob. */ 15 25 const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 16 26 ··· 49 59 50 60 /** 51 61 * Attach the stored blob ref (`skypressBlob`) to every image block whose `url` was 52 - * uploaded this session (resolved via `lookup`). Returns a new tree; does not mutate 62 + * uploaded this session (resolved via `lookup`), and rewrite that `url` to the canonical, 63 + * portable `getBlob` URL — the editor previewed from a transient object URL which would 64 + * otherwise be persisted as a dead `blob:` string. Returns a new tree; does not mutate 53 65 * the input. Images without a matching upload (e.g. external URLs) are left as-is, so 54 66 * the reader keeps their `url`. 55 67 */ 56 68 export function attachBlobRefs( 57 69 blocks: BlockNode[], 58 - lookup: ( url: string ) => BlobRefJson | undefined 70 + lookup: ( url: string ) => BlobUpload | undefined 59 71 ): BlockNode[] { 60 72 return blocks.map( ( block ) => { 61 73 const url = block.attributes?.url; 62 - const ref = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string' 74 + const upload = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string' 63 75 ? lookup( url ) 64 76 : undefined; 65 77 return { 66 78 name: block.name, 67 - attributes: ref 68 - ? { ...block.attributes, skypressBlob: ref } 79 + attributes: upload 80 + ? { ...block.attributes, skypressBlob: upload.ref, url: upload.url } 69 81 : { ...block.attributes }, 70 82 innerBlocks: attachBlobRefs( block.innerBlocks ?? [], lookup ), 71 83 };
+88
src/lib/media/mediaUpload.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { createMediaUpload, type BlobRegistry } from './mediaUpload'; 4 + 5 + /** A fake `uploadBlob` response shaped like the atproto BlobRef the agent returns. */ 6 + function fakeUploadResult( cid: string, mimeType: string, size: number ) { 7 + return { data: { blob: { ref: { toString: () => cid }, mimeType, size } } }; 8 + } 9 + 10 + describe( 'createMediaUpload', () => { 11 + beforeEach( () => { 12 + // jsdom doesn't implement object URLs; stub a deterministic one per call. 13 + let n = 0; 14 + vi.stubGlobal( 'URL', { 15 + ...URL, 16 + createObjectURL: vi.fn( () => `blob:preview-${ ++n }` ), 17 + } ); 18 + } ); 19 + afterEach( () => vi.unstubAllGlobals() ); 20 + 21 + function setup() { 22 + const registry: BlobRegistry = new Map(); 23 + const uploadBlob = vi 24 + .fn() 25 + .mockResolvedValue( fakeUploadResult( 'bafycid123', 'image/png', 70 ) ); 26 + const agent = { uploadBlob } as unknown as Agent; 27 + const handler = createMediaUpload( { 28 + agent, 29 + did: 'did:plc:abc', 30 + pdsUrl: 'https://pds.example.com', 31 + registry, 32 + } ); 33 + return { registry, uploadBlob, handler }; 34 + } 35 + 36 + it( 'uploads to the PDS, previews via an object URL, and omits any attachment id', async () => { 37 + const { uploadBlob, handler } = setup(); 38 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 39 + const onFileChange = vi.fn(); 40 + 41 + await handler( { filesList: [ file ], onFileChange } ); 42 + 43 + expect( uploadBlob ).toHaveBeenCalledWith( file, { encoding: 'image/png' } ); 44 + expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 45 + const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 46 + // Preview src is the local object URL — never the live getBlob URL (unreferenced 47 + // blobs 500 on com.atproto.sync.getBlob until a record commits them). 48 + expect( media.url ).toBe( 'blob:preview-1' ); 49 + // No `id`: PDS blobs are not WP attachments, so the image block must not try to 50 + // fetch /wp/v2/media/<id> (that 404s). 51 + expect( 'id' in media ).toBe( false ); 52 + } ); 53 + 54 + it( 'registers the blob ref + canonical getBlob URL keyed by the preview URL', async () => { 55 + const { registry, handler } = setup(); 56 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 57 + 58 + await handler( { filesList: [ file ], onFileChange: vi.fn() } ); 59 + 60 + expect( registry.get( 'blob:preview-1' ) ).toEqual( { 61 + ref: { 62 + $type: 'blob', 63 + ref: { $link: 'bafycid123' }, 64 + mimeType: 'image/png', 65 + size: 70, 66 + }, 67 + url: 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafycid123', 68 + } ); 69 + } ); 70 + 71 + it( 'surfaces oversize files via onError and does not upload them', async () => { 72 + const { uploadBlob, handler } = setup(); 73 + const big = new File( [ 'x' ], 'big.png', { type: 'image/png' } ); 74 + Object.defineProperty( big, 'size', { value: 5_000_000 } ); 75 + const onError = vi.fn(); 76 + 77 + await handler( { 78 + filesList: [ big ], 79 + onFileChange: vi.fn(), 80 + onError, 81 + maxUploadFileSize: 1_000_000, 82 + } ); 83 + 84 + expect( uploadBlob ).not.toHaveBeenCalled(); 85 + expect( onError ).toHaveBeenCalledTimes( 1 ); 86 + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); 87 + } ); 88 + } );
+22 -13
src/lib/media/mediaUpload.ts
··· 1 1 import type { Agent } from '@atproto/api'; 2 - import { buildGetBlobUrl, type BlobRefJson } from './blob'; 2 + import { buildGetBlobUrl, type BlobUpload } from './blob'; 3 3 4 - /** Maps a returned `getBlob` URL → its stored blob ref (filled during this session). */ 5 - export type BlobRegistry = Map< string, BlobRefJson >; 4 + /** Maps a preview (object) URL → its blob ref + canonical getBlob URL for this session. */ 5 + export type BlobRegistry = Map< string, BlobUpload >; 6 6 7 7 interface MediaFile { 8 - id: string; 9 8 url: string; 10 9 alt: string; 11 10 } ··· 20 19 21 20 /** 22 21 * A Gutenberg `mediaUpload` handler backed by atproto blobs (Decision 0006): 23 - * upload each file to the writer's PDS, hand back a `getBlob` URL for preview, and 24 - * record the full blob ref in `registry` so the publish step can persist it. 22 + * upload each file to the writer's PDS, hand back a local object URL for preview, and 23 + * record the blob ref + canonical `getBlob` URL in `registry` so publish can persist them. 24 + * 25 + * Preview uses a local object URL rather than the live `getBlob` URL because a 26 + * just-uploaded blob is unreferenced (temporary on the PDS) until a record commits it — 27 + * `com.atproto.sync.getBlob` fails for it, so an inline `getBlob` preview would 500. 25 28 */ 26 29 export type MediaUploadHandler = ( args: MediaUploadArgs ) => Promise< void >; 27 30 ··· 48 51 const res = await agent.uploadBlob( file, { encoding: file.type } ); 49 52 const { blob } = res.data; 50 53 const cid = blob.ref.toString(); 51 - const url = buildGetBlobUrl( pdsUrl, did, cid ); 52 - registry.set( url, { 53 - $type: 'blob', 54 - ref: { $link: cid }, 55 - mimeType: blob.mimeType, 56 - size: blob.size, 54 + // Preview from a local object URL; persist the portable getBlob URL on publish. 55 + const previewUrl = URL.createObjectURL( file ); 56 + registry.set( previewUrl, { 57 + ref: { 58 + $type: 'blob', 59 + ref: { $link: cid }, 60 + mimeType: blob.mimeType, 61 + size: blob.size, 62 + }, 63 + url: buildGetBlobUrl( pdsUrl, did, cid ), 57 64 } ); 58 - onFileChange( [ { id: cid, url, alt: '' } ] ); 65 + // No `id`: PDS blobs aren't WP attachments, so the image block must not try 66 + // to resolve one via /wp/v2/media/<id> (that 404s). 67 + onFileChange( [ { url: previewUrl, alt: '' } ] ); 59 68 } catch ( error ) { 60 69 onError?.( error instanceof Error ? error : new Error( String( error ) ) ); 61 70 }
+17
src/lib/media/registerMediaUpload.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { applyFilters } from '@wordpress/hooks'; 3 + // Side-effect import: registers the editor.MediaUpload override. 4 + import './registerMediaUpload'; 5 + 6 + describe( 'registerMediaUpload', () => { 7 + it( 'replaces editor.MediaUpload with an inert, non-rendering component', () => { 8 + // A stand-in for whatever component was registered before us (in the app, the 9 + // wp.media-based one from isolated-block-editor). 10 + const Bundled = () => 'wp.media frame'; 11 + const Filtered = applyFilters( 'editor.MediaUpload', Bundled ) as () => unknown; 12 + 13 + expect( Filtered ).not.toBe( Bundled ); 14 + // Renders nothing → no "Media Library" button, no wp.media access. 15 + expect( Filtered() ).toBeNull(); 16 + } ); 17 + } );
+23
src/lib/media/registerMediaUpload.ts
··· 1 + import { addFilter } from '@wordpress/hooks'; 2 + 3 + /** 4 + * Disable Gutenberg's WordPress media library in the SkyPress editor. 5 + * 6 + * `@automattic/isolated-block-editor` bundles `@wordpress/editor`'s media-upload hook, 7 + * which registers the `@wordpress/media-utils` `MediaUpload` component on the 8 + * `editor.MediaUpload` filter. That component opens the legacy Backbone media frame via 9 + * `wp.media(...)` — a global SkyPress never loads — so the Image block's "Media Library" 10 + * button throws (`can't access property "media", … is undefined`). 11 + * 12 + * SkyPress has no media library: images upload straight to the writer's PDS as blobs 13 + * (Decision 0006) through the "Upload" / drop-zone path, which uses the custom 14 + * `settings.editor.mediaUpload` handler and is independent of this component. So we 15 + * override the filter with a no-render component, removing the broken (and inapplicable) 16 + * "Media Library" button everywhere it appears (placeholder, Replace flow). 17 + * 18 + * Importing this module registers the filter as a side effect. It must be imported AFTER 19 + * `isolated-block-editor` so our filter runs last and wins. 20 + */ 21 + const NoMediaLibrary = (): null => null; 22 + 23 + addFilter( 'editor.MediaUpload', 'skypress/disable-media-library', () => NoMediaLibrary );
+10
src/types/wordpress.d.ts
··· 28 28 declare module '@wordpress/block-library' { 29 29 export function registerCoreBlocks(): void; 30 30 } 31 + 32 + declare module '@wordpress/hooks' { 33 + export function addFilter( 34 + hookName: string, 35 + namespace: string, 36 + callback: ( ...args: unknown[] ) => unknown, 37 + priority?: number 38 + ): void; 39 + export function applyFilters( hookName: string, value: unknown, ...args: unknown[] ): unknown; 40 + }