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.

Merge branch 'hide-reactions-no-bsky-post' into trunk

+234 -8
+10
docs/decisions/0015-social-actions-on-posts.md
··· 61 61 there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern. 62 62 - **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write) 63 63 render no action bar — there is no Bluesky thread to act on. 64 + - **Deleted companion post** (the `bskyPostRef` exists, but the post it points at was later 65 + deleted on Bluesky): the island confirms the post is live before showing anything, and 66 + renders **nothing** when it is gone — no buttons, note, or thread link, since none would 67 + work. The check is client-side and three-state: signed-in readers learn it from the 68 + authenticated `getPosts` (a `null` result ⇒ gone); signed-out readers from an 69 + unauthenticated `fetchPostExists` against the public AppView. It is **optimistic** — the 70 + bar shows by default and only hides on a *definitive* "gone", so a transient network error 71 + never hides a live post (fail open). `getPosts` can't distinguish *deleted* from 72 + *not-yet-indexed*, so a just-published post viewed before AppView indexing is briefly 73 + hidden too (self-heals on reload). 64 74 - **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in 65 75 states, that actions are public and happen on Bluesky. 66 76
+118
src/components/PostActions.presence.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + import type { Agent } from '@atproto/api'; 5 + 6 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 + 8 + // Mock the orchestration layer so we control whether the companion post "exists". 9 + const { fetchPostState, fetchPostExists } = vi.hoisted( () => ( { 10 + fetchPostState: vi.fn(), 11 + fetchPostExists: vi.fn(), 12 + } ) ); 13 + vi.mock( '../lib/social/interactions', () => ( { fetchPostState, fetchPostExists } ) ); 14 + 15 + import { ActionsGate } from './PostActions'; 16 + import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider'; 17 + 18 + const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' }; 19 + 20 + function authValue( overrides: Partial< AuthContextValue > ): AuthContextValue { 21 + return { 22 + status: 'signed-out', 23 + agent: null, 24 + did: null, 25 + handle: null, 26 + displayName: null, 27 + avatar: null, 28 + pdsUrl: null, 29 + error: null, 30 + signIn: async () => {}, 31 + signOut: async () => {}, 32 + ...overrides, 33 + }; 34 + } 35 + 36 + /** Mount ActionsGate under a hand-rolled auth context and flush its effects. */ 37 + async function mountGate( auth: Partial< AuthContextValue > ): Promise< { html: string; cleanup: () => void } > { 38 + const container = document.createElement( 'div' ); 39 + document.body.appendChild( container ); 40 + const root = createRoot( container ); 41 + await act( async () => { 42 + root.render( 43 + createElement( 44 + AuthContext.Provider, 45 + { value: authValue( auth ) }, 46 + createElement( ActionsGate, PROPS ) 47 + ) 48 + ); 49 + } ); 50 + return { 51 + html: container.innerHTML, 52 + cleanup: () => { 53 + act( () => { 54 + root.unmount(); 55 + } ); 56 + container.remove(); 57 + }, 58 + }; 59 + } 60 + 61 + beforeEach( () => { 62 + fetchPostState.mockReset(); 63 + fetchPostExists.mockReset(); 64 + } ); 65 + 66 + describe( 'PostActions presence gate', () => { 67 + it( 'renders nothing when the companion post is gone (signed in)', async () => { 68 + fetchPostState.mockResolvedValue( null ); // getPosts found no post 69 + const { html, cleanup } = await mountGate( { 70 + status: 'signed-in', 71 + agent: {} as Agent, 72 + did: 'did:plc:reader', 73 + handle: 'reader.test', 74 + } ); 75 + expect( html ).toBe( '' ); 76 + cleanup(); 77 + } ); 78 + 79 + it( 'renders nothing when the companion post is gone (signed out)', async () => { 80 + fetchPostExists.mockResolvedValue( false ); // public AppView says: deleted 81 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 82 + expect( html ).toBe( '' ); 83 + cleanup(); 84 + } ); 85 + 86 + it( 'keeps the action bar when the post exists (signed in)', async () => { 87 + fetchPostState.mockResolvedValue( { 88 + likeCount: 0, 89 + repostCount: 0, 90 + replyCount: 0, 91 + viewerLikeUri: null, 92 + viewerRepostUri: null, 93 + } ); 94 + const { html, cleanup } = await mountGate( { 95 + status: 'signed-in', 96 + agent: {} as Agent, 97 + did: 'did:plc:reader', 98 + handle: 'reader.test', 99 + } ); 100 + expect( html ).toContain( 'Like' ); 101 + expect( html ).toContain( 'Reply' ); 102 + cleanup(); 103 + } ); 104 + 105 + it( 'keeps the sign-in prompt when the post exists (signed out)', async () => { 106 + fetchPostExists.mockResolvedValue( true ); 107 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 108 + expect( html ).toContain( 'Sign in to react' ); 109 + cleanup(); 110 + } ); 111 + 112 + it( 'keeps the action bar when existence is undetermined (signed out, network error → fail open)', async () => { 113 + fetchPostExists.mockResolvedValue( null ); 114 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 115 + expect( html ).toContain( 'Sign in to react' ); 116 + cleanup(); 117 + } ); 118 + } );
+37 -8
src/components/PostActions.tsx
··· 9 9 postReply, 10 10 postQuote, 11 11 fetchPostState, 12 + fetchPostExists, 12 13 type PostState, 13 14 } from '../lib/social/interactions'; 14 15 import { ··· 46 47 const subject: StrongRef = { uri: postUri, cid: postCid }; 47 48 48 49 const [ state, setState ] = useState< PostState | null >( null ); 50 + // Whether the companion Bluesky post still exists. Optimistic: we show the bar and only 51 + // hide it once a read confirms the post is gone — a transient error must never hide a 52 + // live post, and this keeps the common (post present) case flicker-free. 53 + const [ present, setPresent ] = useState( true ); 49 54 const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null ); 50 55 const [ composer, setComposer ] = useState< Composer >( null ); 51 56 const [ text, setText ] = useState( '' ); ··· 60 65 mounted.current = false; 61 66 }, [] ); 62 67 63 - // Load counts + viewer state once signed in (and refresh after each write). 68 + // Resolve the companion post once auth settles: signed-in readers get counts + viewer 69 + // state from an authenticated read (a `null` result means the post is gone); signed-out 70 + // readers get an unauthenticated existence check (only a definitive `false` hides the 71 + // bar — `true`/`null` keep it, so a transient error fails open). 64 72 useEffect( () => { 65 - if ( status !== 'signed-in' || ! agent ) { 66 - return; 73 + if ( status === 'loading' ) { 74 + return; // wait for auth before choosing the read path 67 75 } 68 76 let cancelled = false; 69 - fetchPostState( agent, postUri ) 70 - .then( ( next ) => ! cancelled && next && setState( next ) ) 71 - .catch( () => { 72 - /* counts are best-effort; the action buttons still work */ 73 - } ); 77 + if ( status === 'signed-in' && agent ) { 78 + fetchPostState( agent, postUri ) 79 + .then( ( next ) => { 80 + if ( cancelled ) { 81 + return; 82 + } 83 + if ( next ) { 84 + setState( next ); 85 + } else { 86 + setPresent( false ); // getPosts found no post → deleted/unindexed 87 + } 88 + } ) 89 + .catch( () => { 90 + /* counts are best-effort; fail open — the action buttons still work */ 91 + } ); 92 + } else { 93 + fetchPostExists( postUri ) 94 + .then( ( exists ) => ! cancelled && exists === false && setPresent( false ) ) 95 + .catch( () => {} ); 96 + } 74 97 return () => { 75 98 cancelled = true; 76 99 }; ··· 158 181 159 182 if ( status === 'loading' ) { 160 183 return <div className="post-actions post-actions--loading">Loading actions…</div>; 184 + } 185 + 186 + // The companion post is gone — there's no live Bluesky thread to act on, so render 187 + // nothing rather than buttons / a sign-in prompt / a dead thread link that won't work. 188 + if ( ! present ) { 189 + return null; 161 190 } 162 191 163 192 const threadUrl = bskyPostWebUrl( postUri );
+37
src/lib/social/interactions.test.ts
··· 7 7 postReply, 8 8 postQuote, 9 9 fetchPostState, 10 + fetchPostExists, 10 11 } from './interactions'; 11 12 import type { StrongRef } from './records'; 12 13 ··· 149 150 expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull(); 150 151 } ); 151 152 } ); 153 + 154 + describe( 'fetchPostExists', () => { 155 + const ok = ( body: unknown ) => 156 + ( { ok: true, json: async () => body } ) as unknown as Response; 157 + const notOk = () => ( { ok: false, json: async () => ( {} ) } ) as unknown as Response; 158 + 159 + it( 'queries the public AppView getPosts endpoint with the encoded uri', async () => { 160 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) ); 161 + await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ); 162 + expect( fetchImpl ).toHaveBeenCalledWith( 163 + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( 164 + SUBJECT.uri 165 + ) }` 166 + ); 167 + } ); 168 + 169 + it( 'returns true when the post is present', async () => { 170 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) ); 171 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( true ); 172 + } ); 173 + 174 + it( 'returns false when the post is gone (no posts returned)', async () => { 175 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [] } ) ); 176 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( false ); 177 + } ); 178 + 179 + it( 'returns null (undetermined) on a non-ok response', async () => { 180 + const fetchImpl = vi.fn().mockResolvedValue( notOk() ); 181 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull(); 182 + } ); 183 + 184 + it( 'returns null (undetermined) when the network throws', async () => { 185 + const fetchImpl = vi.fn().mockRejectedValue( new Error( 'offline' ) ); 186 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull(); 187 + } ); 188 + } );
+32
src/lib/social/interactions.ts
··· 19 19 const REPOST_COLLECTION = 'app.bsky.feed.repost'; 20 20 const POST_COLLECTION = 'app.bsky.feed.post'; 21 21 22 + /** The public, unauthenticated Bluesky AppView (same host as the landing actor lookup). */ 23 + const APPVIEW = 'https://public.api.bsky.app'; 24 + 22 25 /** createRecord types `record` as an open index signature; our records are precise. */ 23 26 function asRecord( value: object ): Record< string, unknown > { 24 27 return value as Record< string, unknown >; ··· 128 131 viewerRepostUri: post.viewer?.repost ?? null, 129 132 }; 130 133 } 134 + 135 + /** 136 + * Does the companion post still exist on Bluesky? An UNAUTHENTICATED read of the public 137 + * AppView (no OAuth, no agent), so signed-out readers can also tell when a post is gone. 138 + * Mirrors `landing/actor-lookup.ts`: fixed host, the post AT-URI URL-encoded as the only 139 + * input, and a deliberate three-state result — 140 + * 141 + * - `true` — the post is present, 142 + * - `false` — the post is gone (deleted, or not yet indexed), 143 + * - `null` — undetermined (network/HTTP error), so callers can fail OPEN and keep the 144 + * action bar rather than hide a live post on a transient blip. 145 + */ 146 + export async function fetchPostExists( 147 + postUri: string, 148 + fetchImpl: typeof fetch = fetch 149 + ): Promise< boolean | null > { 150 + try { 151 + const res = await fetchImpl( 152 + `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( postUri ) }` 153 + ); 154 + if ( ! res.ok ) { 155 + return null; 156 + } 157 + const data = ( await res.json() ) as { posts?: unknown[] }; 158 + return Array.isArray( data?.posts ) && data.posts.length > 0; 159 + } catch { 160 + return null; 161 + } 162 + }