···6161 there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern.
6262- **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write)
6363 render no action bar — there is no Bluesky thread to act on.
6464+- **Deleted companion post** (the `bskyPostRef` exists, but the post it points at was later
6565+ deleted on Bluesky): the island confirms the post is live before showing anything, and
6666+ renders **nothing** when it is gone — no buttons, note, or thread link, since none would
6767+ work. The check is client-side and three-state: signed-in readers learn it from the
6868+ authenticated `getPosts` (a `null` result ⇒ gone); signed-out readers from an
6969+ unauthenticated `fetchPostExists` against the public AppView. It is **optimistic** — the
7070+ bar shows by default and only hides on a *definitive* "gone", so a transient network error
7171+ never hides a live post (fail open). `getPosts` can't distinguish *deleted* from
7272+ *not-yet-indexed*, so a just-published post viewed before AppView indexing is briefly
7373+ hidden too (self-heals on reload).
6474- **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in
6575 states, that actions are public and happen on Bluesky.
6676
+118
src/components/PostActions.presence.test.tsx
···11+import { describe, it, expect, vi, beforeEach } from 'vitest';
22+import { act, createElement } from 'react';
33+import { createRoot } from 'react-dom/client';
44+import type { Agent } from '@atproto/api';
55+66+( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true;
77+88+// Mock the orchestration layer so we control whether the companion post "exists".
99+const { fetchPostState, fetchPostExists } = vi.hoisted( () => ( {
1010+ fetchPostState: vi.fn(),
1111+ fetchPostExists: vi.fn(),
1212+} ) );
1313+vi.mock( '../lib/social/interactions', () => ( { fetchPostState, fetchPostExists } ) );
1414+1515+import { ActionsGate } from './PostActions';
1616+import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider';
1717+1818+const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' };
1919+2020+function authValue( overrides: Partial< AuthContextValue > ): AuthContextValue {
2121+ return {
2222+ status: 'signed-out',
2323+ agent: null,
2424+ did: null,
2525+ handle: null,
2626+ displayName: null,
2727+ avatar: null,
2828+ pdsUrl: null,
2929+ error: null,
3030+ signIn: async () => {},
3131+ signOut: async () => {},
3232+ ...overrides,
3333+ };
3434+}
3535+3636+/** Mount ActionsGate under a hand-rolled auth context and flush its effects. */
3737+async function mountGate( auth: Partial< AuthContextValue > ): Promise< { html: string; cleanup: () => void } > {
3838+ const container = document.createElement( 'div' );
3939+ document.body.appendChild( container );
4040+ const root = createRoot( container );
4141+ await act( async () => {
4242+ root.render(
4343+ createElement(
4444+ AuthContext.Provider,
4545+ { value: authValue( auth ) },
4646+ createElement( ActionsGate, PROPS )
4747+ )
4848+ );
4949+ } );
5050+ return {
5151+ html: container.innerHTML,
5252+ cleanup: () => {
5353+ act( () => {
5454+ root.unmount();
5555+ } );
5656+ container.remove();
5757+ },
5858+ };
5959+}
6060+6161+beforeEach( () => {
6262+ fetchPostState.mockReset();
6363+ fetchPostExists.mockReset();
6464+} );
6565+6666+describe( 'PostActions presence gate', () => {
6767+ it( 'renders nothing when the companion post is gone (signed in)', async () => {
6868+ fetchPostState.mockResolvedValue( null ); // getPosts found no post
6969+ const { html, cleanup } = await mountGate( {
7070+ status: 'signed-in',
7171+ agent: {} as Agent,
7272+ did: 'did:plc:reader',
7373+ handle: 'reader.test',
7474+ } );
7575+ expect( html ).toBe( '' );
7676+ cleanup();
7777+ } );
7878+7979+ it( 'renders nothing when the companion post is gone (signed out)', async () => {
8080+ fetchPostExists.mockResolvedValue( false ); // public AppView says: deleted
8181+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
8282+ expect( html ).toBe( '' );
8383+ cleanup();
8484+ } );
8585+8686+ it( 'keeps the action bar when the post exists (signed in)', async () => {
8787+ fetchPostState.mockResolvedValue( {
8888+ likeCount: 0,
8989+ repostCount: 0,
9090+ replyCount: 0,
9191+ viewerLikeUri: null,
9292+ viewerRepostUri: null,
9393+ } );
9494+ const { html, cleanup } = await mountGate( {
9595+ status: 'signed-in',
9696+ agent: {} as Agent,
9797+ did: 'did:plc:reader',
9898+ handle: 'reader.test',
9999+ } );
100100+ expect( html ).toContain( 'Like' );
101101+ expect( html ).toContain( 'Reply' );
102102+ cleanup();
103103+ } );
104104+105105+ it( 'keeps the sign-in prompt when the post exists (signed out)', async () => {
106106+ fetchPostExists.mockResolvedValue( true );
107107+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
108108+ expect( html ).toContain( 'Sign in to react' );
109109+ cleanup();
110110+ } );
111111+112112+ it( 'keeps the action bar when existence is undetermined (signed out, network error → fail open)', async () => {
113113+ fetchPostExists.mockResolvedValue( null );
114114+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
115115+ expect( html ).toContain( 'Sign in to react' );
116116+ cleanup();
117117+ } );
118118+} );
+37-8
src/components/PostActions.tsx
···99 postReply,
1010 postQuote,
1111 fetchPostState,
1212+ fetchPostExists,
1213 type PostState,
1314} from '../lib/social/interactions';
1415import {
···4647 const subject: StrongRef = { uri: postUri, cid: postCid };
47484849 const [ state, setState ] = useState< PostState | null >( null );
5050+ // Whether the companion Bluesky post still exists. Optimistic: we show the bar and only
5151+ // hide it once a read confirms the post is gone — a transient error must never hide a
5252+ // live post, and this keeps the common (post present) case flicker-free.
5353+ const [ present, setPresent ] = useState( true );
4954 const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null );
5055 const [ composer, setComposer ] = useState< Composer >( null );
5156 const [ text, setText ] = useState( '' );
···6065 mounted.current = false;
6166 }, [] );
62676363- // Load counts + viewer state once signed in (and refresh after each write).
6868+ // Resolve the companion post once auth settles: signed-in readers get counts + viewer
6969+ // state from an authenticated read (a `null` result means the post is gone); signed-out
7070+ // readers get an unauthenticated existence check (only a definitive `false` hides the
7171+ // bar — `true`/`null` keep it, so a transient error fails open).
6472 useEffect( () => {
6565- if ( status !== 'signed-in' || ! agent ) {
6666- return;
7373+ if ( status === 'loading' ) {
7474+ return; // wait for auth before choosing the read path
6775 }
6876 let cancelled = false;
6969- fetchPostState( agent, postUri )
7070- .then( ( next ) => ! cancelled && next && setState( next ) )
7171- .catch( () => {
7272- /* counts are best-effort; the action buttons still work */
7373- } );
7777+ if ( status === 'signed-in' && agent ) {
7878+ fetchPostState( agent, postUri )
7979+ .then( ( next ) => {
8080+ if ( cancelled ) {
8181+ return;
8282+ }
8383+ if ( next ) {
8484+ setState( next );
8585+ } else {
8686+ setPresent( false ); // getPosts found no post → deleted/unindexed
8787+ }
8888+ } )
8989+ .catch( () => {
9090+ /* counts are best-effort; fail open — the action buttons still work */
9191+ } );
9292+ } else {
9393+ fetchPostExists( postUri )
9494+ .then( ( exists ) => ! cancelled && exists === false && setPresent( false ) )
9595+ .catch( () => {} );
9696+ }
7497 return () => {
7598 cancelled = true;
7699 };
···158181159182 if ( status === 'loading' ) {
160183 return <div className="post-actions post-actions--loading">Loading actions…</div>;
184184+ }
185185+186186+ // The companion post is gone — there's no live Bluesky thread to act on, so render
187187+ // nothing rather than buttons / a sign-in prompt / a dead thread link that won't work.
188188+ if ( ! present ) {
189189+ return null;
161190 }
162191163192 const threadUrl = bskyPostWebUrl( postUri );
+37
src/lib/social/interactions.test.ts
···77 postReply,
88 postQuote,
99 fetchPostState,
1010+ fetchPostExists,
1011} from './interactions';
1112import type { StrongRef } from './records';
1213···149150 expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull();
150151 } );
151152} );
153153+154154+describe( 'fetchPostExists', () => {
155155+ const ok = ( body: unknown ) =>
156156+ ( { ok: true, json: async () => body } ) as unknown as Response;
157157+ const notOk = () => ( { ok: false, json: async () => ( {} ) } ) as unknown as Response;
158158+159159+ it( 'queries the public AppView getPosts endpoint with the encoded uri', async () => {
160160+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) );
161161+ await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch );
162162+ expect( fetchImpl ).toHaveBeenCalledWith(
163163+ `https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent(
164164+ SUBJECT.uri
165165+ ) }`
166166+ );
167167+ } );
168168+169169+ it( 'returns true when the post is present', async () => {
170170+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) );
171171+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( true );
172172+ } );
173173+174174+ it( 'returns false when the post is gone (no posts returned)', async () => {
175175+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [] } ) );
176176+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( false );
177177+ } );
178178+179179+ it( 'returns null (undetermined) on a non-ok response', async () => {
180180+ const fetchImpl = vi.fn().mockResolvedValue( notOk() );
181181+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull();
182182+ } );
183183+184184+ it( 'returns null (undetermined) when the network throws', async () => {
185185+ const fetchImpl = vi.fn().mockRejectedValue( new Error( 'offline' ) );
186186+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull();
187187+ } );
188188+} );
+32
src/lib/social/interactions.ts
···1919const REPOST_COLLECTION = 'app.bsky.feed.repost';
2020const POST_COLLECTION = 'app.bsky.feed.post';
21212222+/** The public, unauthenticated Bluesky AppView (same host as the landing actor lookup). */
2323+const APPVIEW = 'https://public.api.bsky.app';
2424+2225/** createRecord types `record` as an open index signature; our records are precise. */
2326function asRecord( value: object ): Record< string, unknown > {
2427 return value as Record< string, unknown >;
···128131 viewerRepostUri: post.viewer?.repost ?? null,
129132 };
130133}
134134+135135+/**
136136+ * Does the companion post still exist on Bluesky? An UNAUTHENTICATED read of the public
137137+ * AppView (no OAuth, no agent), so signed-out readers can also tell when a post is gone.
138138+ * Mirrors `landing/actor-lookup.ts`: fixed host, the post AT-URI URL-encoded as the only
139139+ * input, and a deliberate three-state result —
140140+ *
141141+ * - `true` — the post is present,
142142+ * - `false` — the post is gone (deleted, or not yet indexed),
143143+ * - `null` — undetermined (network/HTTP error), so callers can fail OPEN and keep the
144144+ * action bar rather than hide a live post on a transient blip.
145145+ */
146146+export async function fetchPostExists(
147147+ postUri: string,
148148+ fetchImpl: typeof fetch = fetch
149149+): Promise< boolean | null > {
150150+ try {
151151+ const res = await fetchImpl(
152152+ `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( postUri ) }`
153153+ );
154154+ if ( ! res.ok ) {
155155+ return null;
156156+ }
157157+ const data = ( await res.json() ) as { posts?: unknown[] };
158158+ return Array.isArray( data?.posts ) && data.posts.length > 0;
159159+ } catch {
160160+ return null;
161161+ }
162162+}