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 'social-actions-on-posts' into trunk

+1353 -2
+70
docs/decisions/0015-social-actions-on-posts.md
··· 1 + # 0015 — Reader social actions write `app.bsky.feed.*` (not `site.standard.*`) 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** The first write-path feature on the *reading* side — like / repost / quote / 6 + reply on a published article (design 7 + `docs/superpowers/specs/2026-06-09-social-actions-on-posts-design.md`). 8 + 9 + ## Context 10 + 11 + The brief asked whether reader replies could be **unlimited-length, block-editor-authored**, 12 + and written as a `site.standard.*` record (the lexicon family used for publications + 13 + documents), while *also* appearing as a reply under the companion Bluesky post that publish 14 + already creates (Decision 0005 / 0013). 15 + 16 + Research against the canonical lexicons (2026-06-09) showed these goals are mutually 17 + exclusive: 18 + 19 + 1. **Only `app.bsky.*` records thread / federate on Bluesky.** The Bluesky AppView is a 20 + Lexicon-specific indexer; it materialises only `app.bsky.*` collections into threads, 21 + like/repost counts, and notifications. A `site.standard.*` (or any non-`app.bsky`) record 22 + is never indexed into a bsky thread — which is exactly why `site.standard` has **no** 23 + comment/reply type and instead carries `bskyPostRef` to delegate comments to a companion 24 + `app.bsky.feed.post`. 25 + 2. **`app.bsky.feed.post.text` is hard-capped at `maxGraphemes: 300`.** There is no 26 + sanctioned threaded longform — whitewind / leaflet longform records don't thread either; 27 + they live off-thread behind a short companion post. 28 + 29 + ## Decision 30 + 31 + Reader actions are **native `app.bsky.feed.*` records targeting the article's companion 32 + post** by its strongRef (`bskyPostRef`, Decision 0013). A reply that should show on Bluesky 33 + **must** be an `app.bsky.feed.post`; it is therefore capped at 300 graphemes, and the block 34 + editor is **not** reused for replies — a plain composer with an `Intl.Segmenter` grapheme 35 + counter is used instead. 36 + 37 + - **like** → `app.bsky.feed.like` `{ subject, createdAt }` 38 + - **repost** → `app.bsky.feed.repost` `{ subject, createdAt }` 39 + - **reply** → `app.bsky.feed.post` `{ text, createdAt, reply: { root, parent } }` — the 40 + companion post is the thread origin, so `root === parent === bskyPostRef`. 41 + - **quote** → `app.bsky.feed.post` `{ text, createdAt, embed: { $type: 42 + 'app.bsky.embed.record', record: bskyPostRef } }` — a standalone post, not threaded. 43 + 44 + `unlike` / `unrepost` delete the viewer's own like/repost record (atproto has no native 45 + "unlike"); the record URI to delete comes from `app.bsky.feed.getPosts`' `viewer.like` / 46 + `viewer.repost`, which also drives counts + active button state. 47 + 48 + No SkyPress lexicon change — these are Bluesky-native records, not part of `site.standard.*` 49 + or `blog.skypress.*`. 50 + 51 + ## Consequences 52 + 53 + - **No new OAuth scope:** the existing `transition:generic` (`OAUTH_SCOPE`) already 54 + authorizes writing `app.bsky.feed.*`. Granular `repo:app.bsky.feed.*` scopes are a later 55 + tightening (Decision 0005's scope-narrowing note). 56 + - **Auth on the read path:** the article page mounts a `client:only` `PostActions` island 57 + wrapping the existing `AuthProvider`; the OAuth redirect returns to the article URL. The 58 + read path still imports **no** `@wordpress/*` (Decision 0003) — the island uses only 59 + `@atproto/api` + the OAuth client. 60 + - **No CSRF surface added:** all writes go browser → the reader's own PDS over OAuth/DPoP; 61 + there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern. 62 + - **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write) 63 + render no action bar — there is no Bluesky thread to act on. 64 + - **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in 65 + states, that actions are public and happen on Bluesky. 66 + 67 + ## Out of scope (v1) 68 + 69 + Inline rendering of the reply thread (we link out to Bluesky), rich-text facets (clickable 70 + links/mentions) in replies, and the granular `repo:app.bsky.feed.*` scopes.
+158
docs/superpowers/specs/2026-06-09-social-actions-on-posts-design.md
··· 1 + # Social actions on published articles (like / repost / quote / reply) — design 2 + 3 + - **Date:** 2026-06-09 4 + - **Scope:** The public reader article page (`src/pages/[author]/[slug]/[rkey].astro`). 5 + Adds the first **write-path / interactive** feature on the reading side; reading pages 6 + were anonymous and read-only until now. 7 + - **Decision doc:** `docs/decisions/0015-social-actions-on-posts.md` (to be written with 8 + the code) — records the record-type / unlimited-length resolution below. 9 + 10 + ## Problem 11 + 12 + Readers can read a SkyPress article but cannot react to it. We want like, repost, 13 + quote-repost, and reply surfaced at the bottom of every post, and a reader's reply should 14 + appear on Bluesky as a reply under the **companion `app.bsky.feed.post`** that publishing 15 + already creates alongside each document (Decision 0005 / 0013). 16 + 17 + ## Research that settled the open questions (2026-06-09, verified against canonical lexicons) 18 + 19 + The brief asked whether replies could be unlimited-length, block-editor-authored, and 20 + written as a `site.standard.*` record. Research closed all three: 21 + 22 + 1. **Only `app.bsky.*` records thread/federate on Bluesky.** The Bluesky AppView is a 23 + Lexicon-specific indexer; it materialises only `app.bsky.*` collections into threads, 24 + like/repost counts, and notifications. A `site.standard.*` (or any non-`app.bsky`) 25 + record is never indexed into a bsky thread — which is exactly why `site.standard` has 26 + **no** comment/reply type and instead carries `bskyPostRef` to delegate comments to a 27 + companion `app.bsky.feed.post`. So a reply that should appear on Bluesky **must** be an 28 + `app.bsky.feed.post`. 29 + 2. **`app.bsky.feed.post.text` is hard-capped at `maxGraphemes: 300`** (`maxLength: 3000` 30 + bytes is a secondary guard). There is **no sanctioned threaded longform** — whitewind / 31 + leaflet longform records don't thread either; they live off-thread behind a short 32 + companion post. So **unlimited-length is impossible if the reply is to appear on 33 + Bluesky**. 34 + 3. **Decision: replies are `app.bsky.feed.post`, 300-grapheme cap, threaded under the 35 + companion post** (option A, user-approved). The block editor is dropped for replies in 36 + favour of a simple text composer. This matches the whole ecosystem and the user's stated 37 + expectation ("my reply shows up on the Bluesky thread"). 38 + 39 + Record shapes (all `subject`/`root`/`parent`/`record` are `com.atproto.repo.strongRef` 40 + `{uri, cid}`; rkey = TID; required fields per lexicon): 41 + 42 + - **like** — `app.bsky.feed.like` `{ subject, createdAt }` 43 + - **repost** — `app.bsky.feed.repost` `{ subject, createdAt }` 44 + - **reply** — `app.bsky.feed.post` `{ text, createdAt, reply: { root, parent } }` 45 + - **quote** — `app.bsky.feed.post` `{ text, createdAt, embed: { $type: 46 + 'app.bsky.embed.record', record } }` 47 + 48 + OAuth: `transition:generic` (the existing `OAUTH_SCOPE`) already authorizes writing all 49 + `app.bsky.feed.*` records. **No scope change.** Granular `repo:app.bsky.feed.*` scopes are 50 + a later tightening (out of scope). 51 + 52 + ## Architecture 53 + 54 + ### The target post 55 + 56 + The reader page already fetches the `site.standard.document`. We extend its interface with 57 + `bskyPostRef?: StrongRef`. Every action targets that strongRef: 58 + 59 + - like / repost / quote → `subject` / `record` = `bskyPostRef`. 60 + - reply → the companion post is the thread origin, so `root === parent === bskyPostRef`. 61 + 62 + If `bskyPostRef` is absent (legacy documents published before Decision 0013, or a failed 63 + third write), there is no Bluesky thread to act on → **the action bar is not rendered**. 64 + 65 + ### Auth on the reader page 66 + 67 + A new **client-only island** `PostActions` (`client:only="react"`) wraps the existing 68 + `AuthProvider` and consumes `useAuth()` — the same OAuth machinery Studio uses. `redirect_uri` 69 + is the current page (already how `createOAuthClient` works), and the loopback `client_id` is 70 + already path-less, so the OAuth round-trip returns the reader to the article URL. Signed-out 71 + readers see a "Sign in to react" affordance that kicks off the same `signIn` flow as 72 + `LoginForm`. 73 + 74 + **Hard constraint upheld:** the reader page must never import `@wordpress/*` (Decision 0003). 75 + `PostActions` imports only React, `@atproto/api`, and the OAuth client — no editor stack. 76 + 77 + ### Counts + viewer state 78 + 79 + On mount (and after each successful action), the island calls 80 + `agent.app.bsky.feed.getPosts({ uris: [postUri] })`, which returns `likeCount`, 81 + `repostCount`, `replyCount` and `viewer.like` / `viewer.repost` (the rkeys of the viewer's 82 + existing like/repost records). This drives: 83 + 84 + - active/inactive button state, 85 + - the record URI to **delete** when toggling a like/repost off (atproto has no native 86 + "unlike"; you delete the like record). 87 + 88 + A "View thread on Bluesky" link points at 89 + `https://bsky.app/profile/<authorDid>/post/<postRkey>`. **Rendering the reply thread inline 90 + is out of scope for v1.** 91 + 92 + ### Composer 93 + 94 + A plain `<textarea>` (no block editor) used for both reply and quote, with a live grapheme 95 + counter via the browser-native **`Intl.Segmenter`** (no new dependency; matches Bluesky's 96 + 300-grapheme rule). Plain text only in v1 — no facet/link/mention detection, so URLs in a 97 + reply won't be clickable (acceptable for a simple composer; noted as a future addition). 98 + Submit is disabled when over 300 graphemes; a reply must also be non-empty, but a quote 99 + may be posted with no commentary (a bare quote-repost, as on Bluesky). 100 + 101 + A small persistent note states that actions are **public and happen on Bluesky** — the 102 + "don't surprise users" product guardrail (brief §10), the same principle the publish panel 103 + follows. 104 + 105 + ### Guardrails 106 + 107 + - All writes go **browser → the reader's own PDS** over OAuth/DPoP. There is no SkyPress 108 + server write endpoint, so there is **no CSRF surface to add**; rate-limiting / abuse is 109 + the PDS's concern. 110 + - No secrets in the client (OAuth public client, Decision 0004). 111 + - Reads via `getPosts` hit the AppView through the authenticated agent (signed-in only); 112 + signed-out readers see actions that prompt sign-in rather than live counts. 113 + 114 + ## Components / data flow 115 + 116 + ``` 117 + [rkey].astro (SSR, prerender=false) 118 + └─ reads site.standard.document ──► doc.bskyPostRef {uri, cid} 119 + └─ mounts <PostActions client:only="react" 120 + postUri postCid authorDid postRkey articleTitle /> (only if bskyPostRef) 121 + 122 + PostActions (island) 123 + └─ <AuthProvider> 124 + └─ signed-out → "Sign in to react" → useAuth().signIn() 125 + └─ signed-in → action bar (like / repost / quote / reply) + counts 126 + └─ composer (textarea + grapheme counter) for reply / quote 127 + └─ calls src/lib/social/interactions.ts (agent orchestration) 128 + ``` 129 + 130 + ## Modules (TDD — pure logic tested first) 131 + 132 + - **`src/lib/social/records.ts`** — pure, no `@atproto/*`. `buildLike(subject)`, 133 + `buildRepost(subject)`, `buildReply({ text, root, parent, createdAt })`, 134 + `buildQuote({ text, subject, createdAt })`, `graphemeLength(text)`, 135 + `validateReplyText(text)` (non-empty, ≤300 graphemes). Mirrors `publish/records.ts`. 136 + Fully unit-tested with fidelity assertions on the emitted record shapes. 137 + - **`src/lib/social/interactions.ts`** — thin orchestration over an `Agent`: 138 + `like` / `unlike`, `repost` / `unrepost`, `postReply`, `postQuote`, `fetchPostState` 139 + (wraps `getPosts`). Tested with a mocked agent. 140 + - **`src/components/PostActions.tsx`** — the island UI. 141 + - **`src/pages/[author]/[slug]/[rkey].astro`** — add `bskyPostRef` to `SkyDocument`; mount 142 + the island (guarded on `bskyPostRef` presence). 143 + 144 + ## Out of scope for v1 145 + 146 + - Inline rendering of the reply thread (link out to Bluesky instead). 147 + - Rich-text facets (clickable links / mentions) in replies. 148 + - Granular `repo:app.bsky.feed.*` OAuth scopes (keep `transition:generic`). 149 + - De-duplicating an accidental double-like server-side (we rely on `viewer.like` state to 150 + toggle; the PDS would otherwise allow duplicates, as it does for the official client). 151 + 152 + ## Testing 153 + 154 + - Unit: `records.test.ts` (record shapes, grapheme counting, validation), 155 + `interactions.test.ts` (correct collection / record / rkey passed to a mocked agent; 156 + toggle deletes the right URI). 157 + - Smoke: `npm run check` + `npm test`, then a browser pass on the reader page (sign in, 158 + like, reply, verify the record lands and the thread shows it on Bluesky).
+66
src/components/PostActions.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import { ActionsGate } from './PostActions'; 5 + import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider'; 6 + 7 + const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' }; 8 + 9 + /** Render ActionsGate under a hand-rolled auth context (bypasses real OAuth). */ 10 + function renderGate( auth: Partial< AuthContextValue > ): string { 11 + const value: AuthContextValue = { 12 + status: 'signed-out', 13 + agent: null, 14 + did: null, 15 + handle: null, 16 + displayName: null, 17 + avatar: null, 18 + pdsUrl: null, 19 + error: null, 20 + signIn: async () => {}, 21 + signOut: async () => {}, 22 + ...auth, 23 + }; 24 + return renderToStaticMarkup( 25 + createElement( AuthContext.Provider, { value }, createElement( ActionsGate, PROPS ) ) 26 + ); 27 + } 28 + 29 + describe( 'PostActions render branches', () => { 30 + it( 'shows a loading state while auth initialises', () => { 31 + expect( renderGate( { status: 'loading' } ) ).toContain( 'Loading actions…' ); 32 + } ); 33 + 34 + it( 'prompts sign-in (with the public-actions note) when signed out', () => { 35 + const markup = renderGate( { status: 'signed-out' } ); 36 + expect( markup ).toContain( 'Sign in to react' ); 37 + expect( markup.toLowerCase() ).toContain( 'public' ); 38 + // Even signed-out, the thread is linkable on Bluesky. 39 + expect( markup ).toContain( 'bsky.app/profile/did:plc:writer/post/3kpost' ); 40 + } ); 41 + 42 + it( 'renders the four actions when signed in', () => { 43 + const markup = renderGate( { 44 + status: 'signed-in', 45 + agent: {} as never, 46 + did: 'did:plc:reader', 47 + handle: 'reader.test', 48 + } ); 49 + expect( markup ).toContain( 'Like' ); 50 + expect( markup ).toContain( 'Repost' ); 51 + expect( markup ).toContain( 'Quote' ); 52 + expect( markup ).toContain( 'Reply' ); 53 + expect( markup.toLowerCase() ).toContain( 'public' ); 54 + } ); 55 + 56 + it( 'surfaces a sign-in error while still signed out (signIn sets error, not status)', () => { 57 + // AuthProvider.signIn reports invalid handles / sign-in failures via `error` 58 + // without flipping `status` to 'error', so the affordance must show it regardless. 59 + const markup = renderGate( { 60 + status: 'signed-out', 61 + error: 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.', 62 + } ); 63 + expect( markup ).toContain( 'Enter a handle' ); 64 + expect( markup ).toContain( 'role="alert"' ); 65 + } ); 66 + } );
+329
src/components/PostActions.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { AuthProvider } from '../lib/auth/AuthProvider'; 3 + import { useAuth } from '../lib/auth/useAuth'; 4 + import { 5 + like, 6 + unlike, 7 + repost, 8 + unrepost, 9 + postReply, 10 + postQuote, 11 + fetchPostState, 12 + type PostState, 13 + } from '../lib/social/interactions'; 14 + import { 15 + graphemeLength, 16 + validateReplyText, 17 + bskyPostWebUrl, 18 + MAX_REPLY_GRAPHEMES, 19 + type StrongRef, 20 + } from '../lib/social/records'; 21 + 22 + export interface PostActionsProps { 23 + /** The companion `app.bsky.feed.post` AT-URI (the article's Bluesky post). */ 24 + postUri: string; 25 + /** Its cid — together with `postUri` it forms the strongRef every action targets. */ 26 + postCid: string; 27 + } 28 + 29 + type Composer = 'reply' | 'quote' | null; 30 + 31 + /** A composed reply/quote in flight, surfaced as a success or error banner. */ 32 + interface Flash { 33 + kind: 'success' | 'error'; 34 + message: string; 35 + /** For a success, the bsky.app link to the freshly created post. */ 36 + href?: string; 37 + } 38 + 39 + /** 40 + * The signed-in / signed-out action bar. Renders inside <AuthProvider>; reads the 41 + * companion post's counts + the viewer's like/repost state, and writes like / repost / 42 + * reply / quote records to the reader's own PDS. 43 + */ 44 + export function ActionsGate( { postUri, postCid }: PostActionsProps ) { 45 + const { status, agent, did, signIn, error: authError } = useAuth(); 46 + const subject: StrongRef = { uri: postUri, cid: postCid }; 47 + 48 + const [ state, setState ] = useState< PostState | null >( null ); 49 + const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null ); 50 + const [ composer, setComposer ] = useState< Composer >( null ); 51 + const [ text, setText ] = useState( '' ); 52 + const [ submitting, setSubmitting ] = useState( false ); 53 + const [ flash, setFlash ] = useState< Flash | null >( null ); 54 + const [ handle, setHandle ] = useState( '' ); 55 + 56 + // Guards a late `refresh()` setState after unmount (refresh runs from event 57 + // handlers, so it can't rely on an effect cleanup like the mount load below). 58 + const mounted = useRef( true ); 59 + useEffect( () => () => { 60 + mounted.current = false; 61 + }, [] ); 62 + 63 + // Load counts + viewer state once signed in (and refresh after each write). 64 + useEffect( () => { 65 + if ( status !== 'signed-in' || ! agent ) { 66 + return; 67 + } 68 + 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 + } ); 74 + return () => { 75 + cancelled = true; 76 + }; 77 + }, [ status, agent, postUri ] ); 78 + 79 + const refresh = () => { 80 + if ( agent ) { 81 + fetchPostState( agent, postUri ) 82 + .then( ( next ) => mounted.current && next && setState( next ) ) 83 + .catch( () => {} ); 84 + } 85 + }; 86 + 87 + const onToggleLike = async () => { 88 + if ( ! agent || ! did || busy ) { 89 + return; 90 + } 91 + setBusy( 'like' ); 92 + setFlash( null ); 93 + try { 94 + if ( state?.viewerLikeUri ) { 95 + await unlike( agent, did, state.viewerLikeUri ); 96 + } else { 97 + await like( agent, did, subject ); 98 + } 99 + refresh(); 100 + } catch ( err ) { 101 + setFlash( { kind: 'error', message: errorText( err, 'Could not update your like.' ) } ); 102 + } finally { 103 + setBusy( null ); 104 + } 105 + }; 106 + 107 + const onToggleRepost = async () => { 108 + if ( ! agent || ! did || busy ) { 109 + return; 110 + } 111 + setBusy( 'repost' ); 112 + setFlash( null ); 113 + try { 114 + if ( state?.viewerRepostUri ) { 115 + await unrepost( agent, did, state.viewerRepostUri ); 116 + } else { 117 + await repost( agent, did, subject ); 118 + } 119 + refresh(); 120 + } catch ( err ) { 121 + setFlash( { kind: 'error', message: errorText( err, 'Could not update your repost.' ) } ); 122 + } finally { 123 + setBusy( null ); 124 + } 125 + }; 126 + 127 + const openComposer = ( mode: Composer ) => { 128 + setComposer( mode ); 129 + setText( '' ); 130 + setFlash( null ); 131 + }; 132 + 133 + const onSubmitComposer = async () => { 134 + if ( ! agent || ! did || ! composer || submitting ) { 135 + return; 136 + } 137 + if ( ! validateReplyText( text, { allowEmpty: composer === 'quote' } ).ok ) { 138 + return; 139 + } 140 + setSubmitting( true ); 141 + try { 142 + const action = composer === 'reply' ? postReply : postQuote; 143 + const uri = await action( agent, did, { text, subject } ); 144 + setComposer( null ); 145 + setText( '' ); 146 + setFlash( { 147 + kind: 'success', 148 + message: composer === 'reply' ? 'Reply posted to Bluesky.' : 'Quote posted to Bluesky.', 149 + href: bskyPostWebUrl( uri ), 150 + } ); 151 + refresh(); 152 + } catch ( err ) { 153 + setFlash( { kind: 'error', message: errorText( err, 'Could not post to Bluesky.' ) } ); 154 + } finally { 155 + setSubmitting( false ); 156 + } 157 + }; 158 + 159 + if ( status === 'loading' ) { 160 + return <div className="post-actions post-actions--loading">Loading actions…</div>; 161 + } 162 + 163 + const threadUrl = bskyPostWebUrl( postUri ); 164 + 165 + // Signed-out: prompt sign-in (same OAuth flow as the editor) before any action. 166 + if ( status !== 'signed-in' || ! agent || ! did ) { 167 + return ( 168 + <div className="post-actions"> 169 + <p className="post-actions__note"> 170 + Sign in with your AT Protocol account to like, repost, or reply. Actions are 171 + public and happen on Bluesky. 172 + </p> 173 + <form 174 + className="post-actions__signin" 175 + onSubmit={ ( event ) => { 176 + event.preventDefault(); 177 + void signIn( handle ); 178 + } } 179 + > 180 + <input 181 + className="post-actions__signin-input" 182 + aria-label="Your handle, DID, or PDS URL" 183 + autoComplete="username" 184 + autoCapitalize="none" 185 + autoCorrect="off" 186 + spellCheck={ false } 187 + placeholder="alice.bsky.social" 188 + value={ handle } 189 + onChange={ ( event ) => setHandle( event.target.value ) } 190 + /> 191 + <button className="post-actions__signin-btn" type="submit"> 192 + Sign in to react 193 + </button> 194 + </form> 195 + { authError && ( 196 + <p className="post-actions__error" role="alert"> 197 + { authError } 198 + </p> 199 + ) } 200 + <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer"> 201 + View this post on Bluesky 202 + </a> 203 + </div> 204 + ); 205 + } 206 + 207 + const liked = Boolean( state?.viewerLikeUri ); 208 + const reposted = Boolean( state?.viewerRepostUri ); 209 + // A quote can be posted with no commentary (bare quote-repost); a reply cannot. 210 + const validity = validateReplyText( text, { allowEmpty: composer === 'quote' } ); 211 + const graphemes = graphemeLength( text ); 212 + 213 + return ( 214 + <div className="post-actions"> 215 + <div className="post-actions__bar" role="group" aria-label="Post actions"> 216 + <button 217 + type="button" 218 + className={ `post-actions__btn${ liked ? ' is-active' : '' }` } 219 + aria-pressed={ liked } 220 + disabled={ busy === 'like' } 221 + onClick={ onToggleLike } 222 + > 223 + { liked ? '♥' : '♡' } Like{ countLabel( state?.likeCount ) } 224 + </button> 225 + <button 226 + type="button" 227 + className={ `post-actions__btn${ reposted ? ' is-active' : '' }` } 228 + aria-pressed={ reposted } 229 + disabled={ busy === 'repost' } 230 + onClick={ onToggleRepost } 231 + > 232 + ⇄ Repost{ countLabel( state?.repostCount ) } 233 + </button> 234 + <button type="button" className="post-actions__btn" onClick={ () => openComposer( 'quote' ) }> 235 + ❝ Quote 236 + </button> 237 + <button type="button" className="post-actions__btn" onClick={ () => openComposer( 'reply' ) }> 238 + ↩ Reply{ countLabel( state?.replyCount ) } 239 + </button> 240 + </div> 241 + 242 + { composer && ( 243 + <div className="post-actions__composer"> 244 + <label className="post-actions__composer-label" htmlFor="post-actions-text"> 245 + { composer === 'reply' ? 'Your reply' : 'Add a comment to your quote' } 246 + </label> 247 + <textarea 248 + id="post-actions-text" 249 + className="post-actions__textarea" 250 + rows={ 4 } 251 + placeholder={ composer === 'reply' ? 'Write a reply…' : 'Optional comment…' } 252 + value={ text } 253 + onChange={ ( event ) => setText( event.target.value ) } 254 + /> 255 + <div className="post-actions__composer-foot"> 256 + <span 257 + className={ `post-actions__count${ 258 + graphemes > MAX_REPLY_GRAPHEMES ? ' is-over' : '' 259 + }` } 260 + > 261 + { graphemes } / { MAX_REPLY_GRAPHEMES } 262 + </span> 263 + <div className="post-actions__composer-btns"> 264 + <button 265 + type="button" 266 + className="post-actions__btn" 267 + onClick={ () => setComposer( null ) } 268 + disabled={ submitting } 269 + > 270 + Cancel 271 + </button> 272 + <button 273 + type="button" 274 + className="post-actions__submit" 275 + onClick={ onSubmitComposer } 276 + disabled={ submitting || ! validity.ok } 277 + > 278 + { submitting 279 + ? 'Posting…' 280 + : composer === 'reply' 281 + ? 'Post reply' 282 + : 'Post quote' } 283 + </button> 284 + </div> 285 + </div> 286 + </div> 287 + ) } 288 + 289 + { flash && ( 290 + <p 291 + className={ `post-actions__flash post-actions__flash--${ flash.kind }` } 292 + role={ flash.kind === 'error' ? 'alert' : 'status' } 293 + > 294 + { flash.message }{ ' ' } 295 + { flash.href && ( 296 + <a href={ flash.href } target="_blank" rel="noopener noreferrer"> 297 + View on Bluesky 298 + </a> 299 + ) } 300 + </p> 301 + ) } 302 + 303 + <p className="post-actions__note"> 304 + Likes, reposts, quotes, and replies are public and happen on Bluesky. 305 + </p> 306 + <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer"> 307 + View the full thread on Bluesky 308 + </a> 309 + </div> 310 + ); 311 + } 312 + 313 + /** A `(n)` suffix for a button label, omitted when the count is zero/unknown. */ 314 + function countLabel( count: number | undefined ): string { 315 + return count && count > 0 ? ` (${ count })` : ''; 316 + } 317 + 318 + function errorText( err: unknown, fallback: string ): string { 319 + const detail = err instanceof Error ? err.message : ''; 320 + return detail ? `${ fallback } ${ detail }` : fallback; 321 + } 322 + 323 + export default function PostActions( props: PostActionsProps ) { 324 + return ( 325 + <AuthProvider> 326 + <ActionsGate { ...props } /> 327 + </AuthProvider> 328 + ); 329 + }
+4 -2
src/lib/landing/landing-content.test.ts
··· 58 58 expect( index ).toMatch( /[Ff]ree/ ); 59 59 } ); 60 60 61 - it( 'keeps the honest Bluesky cross-post note (product guardrail)', () => { 62 - expect( index ).toMatch( /posts to Bluesky/ ); 61 + it( 'no longer carries the Bluesky cross-post note on the home page', () => { 62 + // The "One thing worth knowing…" notice was removed from the landing page 63 + // (commit c1508f9); the cross-post disclosure now lives on the publish panel. 64 + expect( index ).not.toMatch( /posts to Bluesky/ ); 63 65 } ); 64 66 65 67 it( 'shows the three-up "see it in action" screenshot strip', () => {
+151
src/lib/social/interactions.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import { 3 + like, 4 + unlike, 5 + repost, 6 + unrepost, 7 + postReply, 8 + postQuote, 9 + fetchPostState, 10 + } from './interactions'; 11 + import type { StrongRef } from './records'; 12 + 13 + const DID = 'did:plc:reader'; 14 + const SUBJECT: StrongRef = { 15 + uri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', 16 + cid: 'bafypost', 17 + }; 18 + 19 + /** A minimal mock of the `@atproto/api` Agent surface these helpers touch. */ 20 + function mockAgent( overrides: Record< string, unknown > = {} ) { 21 + const createRecord = vi.fn( async () => ( { 22 + data: { uri: 'at://did:plc:reader/app.bsky.feed.like/3knew', cid: 'bafynew' }, 23 + } ) ); 24 + const deleteRecord = vi.fn( async () => ( {} ) ); 25 + const getPosts = vi.fn( async () => ( { 26 + data: { 27 + posts: [ 28 + { 29 + uri: SUBJECT.uri, 30 + cid: SUBJECT.cid, 31 + likeCount: 3, 32 + repostCount: 1, 33 + replyCount: 2, 34 + viewer: { 35 + like: 'at://did:plc:reader/app.bsky.feed.like/3kexisting', 36 + repost: undefined, 37 + }, 38 + }, 39 + ], 40 + }, 41 + } ) ); 42 + return { 43 + com: { atproto: { repo: { createRecord, deleteRecord } } }, 44 + app: { bsky: { feed: { getPosts } } }, 45 + ...overrides, 46 + // expose the spies for assertions 47 + _spies: { createRecord, deleteRecord, getPosts }, 48 + } as never; 49 + } 50 + 51 + describe( 'like', () => { 52 + it( 'creates an app.bsky.feed.like in the reader repo and returns its uri', async () => { 53 + const agent = mockAgent(); 54 + const uri = await like( agent, DID, SUBJECT ); 55 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 56 + expect( createRecord ).toHaveBeenCalledTimes( 1 ); 57 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 58 + expect( arg.repo ).toBe( DID ); 59 + expect( arg.collection ).toBe( 'app.bsky.feed.like' ); 60 + expect( arg.record.$type ).toBe( 'app.bsky.feed.like' ); 61 + expect( arg.record.subject ).toEqual( SUBJECT ); 62 + expect( typeof arg.record.createdAt ).toBe( 'string' ); 63 + expect( uri ).toBe( 'at://did:plc:reader/app.bsky.feed.like/3knew' ); 64 + } ); 65 + } ); 66 + 67 + describe( 'unlike', () => { 68 + it( 'deletes the like record by the rkey parsed from its uri', async () => { 69 + const agent = mockAgent(); 70 + await unlike( agent, DID, 'at://did:plc:reader/app.bsky.feed.like/3kexisting' ); 71 + const { deleteRecord } = ( agent as never as { _spies: { deleteRecord: ReturnType< typeof vi.fn > } } )._spies; 72 + expect( deleteRecord ).toHaveBeenCalledWith( { 73 + repo: DID, 74 + collection: 'app.bsky.feed.like', 75 + rkey: '3kexisting', 76 + } ); 77 + } ); 78 + } ); 79 + 80 + describe( 'repost / unrepost', () => { 81 + it( 'creates an app.bsky.feed.repost', async () => { 82 + const agent = mockAgent(); 83 + await repost( agent, DID, SUBJECT ); 84 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 85 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 86 + expect( arg.collection ).toBe( 'app.bsky.feed.repost' ); 87 + expect( arg.record.$type ).toBe( 'app.bsky.feed.repost' ); 88 + expect( arg.record.subject ).toEqual( SUBJECT ); 89 + } ); 90 + 91 + it( 'deletes the repost by rkey', async () => { 92 + const agent = mockAgent(); 93 + await unrepost( agent, DID, 'at://did:plc:reader/app.bsky.feed.repost/3krp' ); 94 + const { deleteRecord } = ( agent as never as { _spies: { deleteRecord: ReturnType< typeof vi.fn > } } )._spies; 95 + expect( deleteRecord ).toHaveBeenCalledWith( { 96 + repo: DID, 97 + collection: 'app.bsky.feed.repost', 98 + rkey: '3krp', 99 + } ); 100 + } ); 101 + } ); 102 + 103 + describe( 'postReply', () => { 104 + it( 'creates a post threaded under the companion post', async () => { 105 + const agent = mockAgent(); 106 + await postReply( agent, DID, { text: 'great read', subject: SUBJECT } ); 107 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 108 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 109 + expect( arg.collection ).toBe( 'app.bsky.feed.post' ); 110 + expect( arg.record.text ).toBe( 'great read' ); 111 + expect( arg.record.reply ).toEqual( { root: SUBJECT, parent: SUBJECT } ); 112 + } ); 113 + } ); 114 + 115 + describe( 'postQuote', () => { 116 + it( 'creates a standalone post embedding the companion post', async () => { 117 + const agent = mockAgent(); 118 + await postQuote( agent, DID, { text: 'worth a read', subject: SUBJECT } ); 119 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 120 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 121 + expect( arg.collection ).toBe( 'app.bsky.feed.post' ); 122 + expect( arg.record.embed ).toEqual( { 123 + $type: 'app.bsky.embed.record', 124 + record: SUBJECT, 125 + } ); 126 + expect( arg.record.reply ).toBeUndefined(); 127 + } ); 128 + } ); 129 + 130 + describe( 'fetchPostState', () => { 131 + it( 'returns counts and the viewer like/repost rkey uris', async () => { 132 + const agent = mockAgent(); 133 + const state = await fetchPostState( agent, SUBJECT.uri ); 134 + const { getPosts } = ( agent as never as { _spies: { getPosts: ReturnType< typeof vi.fn > } } )._spies; 135 + expect( getPosts ).toHaveBeenCalledWith( { uris: [ SUBJECT.uri ] } ); 136 + expect( state ).toEqual( { 137 + likeCount: 3, 138 + repostCount: 1, 139 + replyCount: 2, 140 + viewerLikeUri: 'at://did:plc:reader/app.bsky.feed.like/3kexisting', 141 + viewerRepostUri: null, 142 + } ); 143 + } ); 144 + 145 + it( 'returns null when the post is not found', async () => { 146 + const agent = mockAgent( { 147 + app: { bsky: { feed: { getPosts: vi.fn( async () => ( { data: { posts: [] } } ) ) } } }, 148 + } ); 149 + expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull(); 150 + } ); 151 + } );
+130
src/lib/social/interactions.ts
··· 1 + /** 2 + * Agent orchestration for reader social actions: create/delete the like, repost, reply, 3 + * and quote records on the reader's own PDS, and read the companion post's counts + 4 + * viewer state from the Bluesky AppView. 5 + * 6 + * All writes go browser → the reader's PDS over the authenticated OAuth agent (Decision 7 + * 0004). Record-shaping is delegated to the pure builders in `records.ts`. 8 + */ 9 + import type { Agent } from '@atproto/api'; 10 + import { 11 + buildLike, 12 + buildRepost, 13 + buildReply, 14 + buildQuote, 15 + type StrongRef, 16 + } from './records'; 17 + 18 + const LIKE_COLLECTION = 'app.bsky.feed.like'; 19 + const REPOST_COLLECTION = 'app.bsky.feed.repost'; 20 + const POST_COLLECTION = 'app.bsky.feed.post'; 21 + 22 + /** createRecord types `record` as an open index signature; our records are precise. */ 23 + function asRecord( value: object ): Record< string, unknown > { 24 + return value as Record< string, unknown >; 25 + } 26 + 27 + /** rkey is the last segment of an AT-URI. */ 28 + function rkeyFromUri( uri: string ): string { 29 + return uri.split( '/' ).pop() ?? ''; 30 + } 31 + 32 + /** Like the companion post; returns the created like record's AT-URI (for unliking). */ 33 + export async function like( agent: Agent, did: string, subject: StrongRef ): Promise< string > { 34 + const res = await agent.com.atproto.repo.createRecord( { 35 + repo: did, 36 + collection: LIKE_COLLECTION, 37 + record: asRecord( buildLike( subject, new Date().toISOString() ) ), 38 + } ); 39 + return res.data.uri; 40 + } 41 + 42 + /** Remove a like by deleting its record (atproto has no native "unlike"). */ 43 + export async function unlike( agent: Agent, did: string, likeUri: string ): Promise< void > { 44 + await agent.com.atproto.repo.deleteRecord( { 45 + repo: did, 46 + collection: LIKE_COLLECTION, 47 + rkey: rkeyFromUri( likeUri ), 48 + } ); 49 + } 50 + 51 + /** Repost the companion post; returns the created repost record's AT-URI. */ 52 + export async function repost( agent: Agent, did: string, subject: StrongRef ): Promise< string > { 53 + const res = await agent.com.atproto.repo.createRecord( { 54 + repo: did, 55 + collection: REPOST_COLLECTION, 56 + record: asRecord( buildRepost( subject, new Date().toISOString() ) ), 57 + } ); 58 + return res.data.uri; 59 + } 60 + 61 + /** Undo a repost by deleting its record. */ 62 + export async function unrepost( agent: Agent, did: string, repostUri: string ): Promise< void > { 63 + await agent.com.atproto.repo.deleteRecord( { 64 + repo: did, 65 + collection: REPOST_COLLECTION, 66 + rkey: rkeyFromUri( repostUri ), 67 + } ); 68 + } 69 + 70 + /** Reply to the companion post; returns the created post's AT-URI. */ 71 + export async function postReply( 72 + agent: Agent, 73 + did: string, 74 + input: { text: string; subject: StrongRef } 75 + ): Promise< string > { 76 + const res = await agent.com.atproto.repo.createRecord( { 77 + repo: did, 78 + collection: POST_COLLECTION, 79 + record: asRecord( 80 + buildReply( { text: input.text, subject: input.subject, createdAt: new Date().toISOString() } ) 81 + ), 82 + } ); 83 + return res.data.uri; 84 + } 85 + 86 + /** Quote-repost the companion post; returns the created post's AT-URI. */ 87 + export async function postQuote( 88 + agent: Agent, 89 + did: string, 90 + input: { text: string; subject: StrongRef } 91 + ): Promise< string > { 92 + const res = await agent.com.atproto.repo.createRecord( { 93 + repo: did, 94 + collection: POST_COLLECTION, 95 + record: asRecord( 96 + buildQuote( { text: input.text, subject: input.subject, createdAt: new Date().toISOString() } ) 97 + ), 98 + } ); 99 + return res.data.uri; 100 + } 101 + 102 + export interface PostState { 103 + likeCount: number; 104 + repostCount: number; 105 + replyCount: number; 106 + /** The viewer's own like record URI, or null if they haven't liked it. */ 107 + viewerLikeUri: string | null; 108 + /** The viewer's own repost record URI, or null if they haven't reposted it. */ 109 + viewerRepostUri: string | null; 110 + } 111 + 112 + /** 113 + * Read the companion post's like/repost/reply counts and the viewer's own like/repost 114 + * record URIs (so the UI can show active state and toggle by deleting the right record). 115 + * Returns null if the post can't be found (e.g. deleted, or not yet indexed). 116 + */ 117 + export async function fetchPostState( agent: Agent, postUri: string ): Promise< PostState | null > { 118 + const res = await agent.app.bsky.feed.getPosts( { uris: [ postUri ] } ); 119 + const post = res.data.posts[ 0 ]; 120 + if ( ! post ) { 121 + return null; 122 + } 123 + return { 124 + likeCount: post.likeCount ?? 0, 125 + repostCount: post.repostCount ?? 0, 126 + replyCount: post.replyCount ?? 0, 127 + viewerLikeUri: post.viewer?.like ?? null, 128 + viewerRepostUri: post.viewer?.repost ?? null, 129 + }; 130 + }
+137
src/lib/social/records.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + buildLike, 4 + buildRepost, 5 + buildReply, 6 + buildQuote, 7 + graphemeLength, 8 + validateReplyText, 9 + bskyPostWebUrl, 10 + MAX_REPLY_GRAPHEMES, 11 + type StrongRef, 12 + } from './records'; 13 + 14 + const SUBJECT: StrongRef = { 15 + uri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', 16 + cid: 'bafypost', 17 + }; 18 + const CREATED_AT = '2026-06-09T12:00:00.000Z'; 19 + 20 + describe( 'buildLike', () => { 21 + it( 'emits an app.bsky.feed.like targeting the subject', () => { 22 + expect( buildLike( SUBJECT, CREATED_AT ) ).toEqual( { 23 + $type: 'app.bsky.feed.like', 24 + subject: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 25 + createdAt: CREATED_AT, 26 + } ); 27 + } ); 28 + } ); 29 + 30 + describe( 'buildRepost', () => { 31 + it( 'emits an app.bsky.feed.repost targeting the subject', () => { 32 + expect( buildRepost( SUBJECT, CREATED_AT ) ).toEqual( { 33 + $type: 'app.bsky.feed.repost', 34 + subject: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 35 + createdAt: CREATED_AT, 36 + } ); 37 + } ); 38 + } ); 39 + 40 + describe( 'buildReply', () => { 41 + it( 'emits a post with reply.root and reply.parent both the companion post', () => { 42 + // The companion post is the thread origin, so root === parent === subject. 43 + expect( 44 + buildReply( { text: 'Nice piece!', subject: SUBJECT, createdAt: CREATED_AT } ) 45 + ).toEqual( { 46 + $type: 'app.bsky.feed.post', 47 + text: 'Nice piece!', 48 + createdAt: CREATED_AT, 49 + reply: { 50 + root: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 51 + parent: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 52 + }, 53 + } ); 54 + } ); 55 + 56 + it( 'does not carry an embed', () => { 57 + const record = buildReply( { text: 'hi', subject: SUBJECT, createdAt: CREATED_AT } ); 58 + expect( record ).not.toHaveProperty( 'embed' ); 59 + } ); 60 + } ); 61 + 62 + describe( 'buildQuote', () => { 63 + it( 'emits a standalone post embedding the subject via app.bsky.embed.record', () => { 64 + expect( 65 + buildQuote( { text: 'Worth a read:', subject: SUBJECT, createdAt: CREATED_AT } ) 66 + ).toEqual( { 67 + $type: 'app.bsky.feed.post', 68 + text: 'Worth a read:', 69 + createdAt: CREATED_AT, 70 + embed: { 71 + $type: 'app.bsky.embed.record', 72 + record: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 73 + }, 74 + } ); 75 + } ); 76 + 77 + it( 'is not a reply (no reply field) — a quote is a standalone post', () => { 78 + const record = buildQuote( { text: 'hi', subject: SUBJECT, createdAt: CREATED_AT } ); 79 + expect( record ).not.toHaveProperty( 'reply' ); 80 + } ); 81 + 82 + it( 'allows empty commentary text', () => { 83 + expect( buildQuote( { text: '', subject: SUBJECT, createdAt: CREATED_AT } ).text ).toBe( '' ); 84 + } ); 85 + } ); 86 + 87 + describe( 'graphemeLength', () => { 88 + it( 'counts plain ASCII by character', () => { 89 + expect( graphemeLength( 'hello' ) ).toBe( 5 ); 90 + expect( graphemeLength( '' ) ).toBe( 0 ); 91 + } ); 92 + 93 + it( 'counts a multi-codepoint emoji as one grapheme', () => { 94 + // Family emoji = several codepoints joined with ZWJ, but one user-perceived character. 95 + expect( graphemeLength( '👨‍👩‍👧‍👦' ) ).toBe( 1 ); 96 + // Flag = two regional-indicator codepoints, one grapheme. 97 + expect( graphemeLength( '🇫🇷' ) ).toBe( 1 ); 98 + } ); 99 + } ); 100 + 101 + describe( 'bskyPostWebUrl', () => { 102 + it( 'maps an at:// post uri to its bsky.app profile/post URL', () => { 103 + expect( bskyPostWebUrl( SUBJECT.uri ) ).toBe( 104 + 'https://bsky.app/profile/did:plc:writer/post/3kpost' 105 + ); 106 + } ); 107 + 108 + it( 'falls back to bsky.app for an unparseable uri', () => { 109 + expect( bskyPostWebUrl( 'not-an-at-uri' ) ).toBe( 'https://bsky.app' ); 110 + } ); 111 + } ); 112 + 113 + describe( 'validateReplyText', () => { 114 + it( 'rejects empty / whitespace-only text', () => { 115 + expect( validateReplyText( '' ).ok ).toBe( false ); 116 + expect( validateReplyText( ' \n ' ).ok ).toBe( false ); 117 + } ); 118 + 119 + it( 'accepts text up to the grapheme cap', () => { 120 + expect( validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES ) ).ok ).toBe( true ); 121 + } ); 122 + 123 + it( 'rejects text over the grapheme cap', () => { 124 + expect( validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES + 1 ) ).ok ).toBe( false ); 125 + } ); 126 + 127 + it( 'accepts empty text when allowEmpty is set (a bare quote-repost)', () => { 128 + expect( validateReplyText( '', { allowEmpty: true } ).ok ).toBe( true ); 129 + expect( validateReplyText( ' ', { allowEmpty: true } ).ok ).toBe( true ); 130 + } ); 131 + 132 + it( 'still rejects over-cap text even when allowEmpty is set', () => { 133 + expect( 134 + validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES + 1 ), { allowEmpty: true } ).ok 135 + ).toBe( false ); 136 + } ); 137 + } );
+138
src/lib/social/records.ts
··· 1 + /** 2 + * Pure builders for the records a reader's social action writes: like, repost, reply, 3 + * and quote-repost (design 2026-06-09-social-actions-on-posts). 4 + * 5 + * No `@atproto/*` or network here — just record-shaping, so it's fully unit-testable. 6 + * Orchestration (createRecord/deleteRecord via the Agent) lives in `interactions.ts`. 7 + * 8 + * All four actions target the article's companion `app.bsky.feed.post` (Decision 0013) by 9 + * its `com.atproto.repo.strongRef`. Only `app.bsky.*` records thread/federate on Bluesky, 10 + * which is why replies are posts (not a `site.standard.*` record) and are capped at 300 11 + * graphemes — there is no threaded longform on Bluesky. 12 + */ 13 + 14 + /** A `com.atproto.repo.strongRef` — a URI plus its content-hash fingerprint. */ 15 + export interface StrongRef { 16 + uri: string; 17 + cid: string; 18 + } 19 + 20 + /** Bluesky's user-facing post-text limit is 300 graphemes (not bytes/codepoints). */ 21 + export const MAX_REPLY_GRAPHEMES = 300; 22 + 23 + export interface LikeRecord { 24 + $type: 'app.bsky.feed.like'; 25 + subject: StrongRef; 26 + createdAt: string; 27 + } 28 + 29 + export interface RepostRecord { 30 + $type: 'app.bsky.feed.repost'; 31 + subject: StrongRef; 32 + createdAt: string; 33 + } 34 + 35 + export interface ReplyRecord { 36 + $type: 'app.bsky.feed.post'; 37 + text: string; 38 + createdAt: string; 39 + reply: { root: StrongRef; parent: StrongRef }; 40 + } 41 + 42 + export interface QuoteRecord { 43 + $type: 'app.bsky.feed.post'; 44 + text: string; 45 + createdAt: string; 46 + embed: { $type: 'app.bsky.embed.record'; record: StrongRef }; 47 + } 48 + 49 + /** Strip a strongRef down to the exact `{ uri, cid }` shape the lexicon expects. */ 50 + function ref( subject: StrongRef ): StrongRef { 51 + return { uri: subject.uri, cid: subject.cid }; 52 + } 53 + 54 + /** `app.bsky.feed.like` over the companion post. */ 55 + export function buildLike( subject: StrongRef, createdAt: string ): LikeRecord { 56 + return { $type: 'app.bsky.feed.like', subject: ref( subject ), createdAt }; 57 + } 58 + 59 + /** `app.bsky.feed.repost` over the companion post. */ 60 + export function buildRepost( subject: StrongRef, createdAt: string ): RepostRecord { 61 + return { $type: 'app.bsky.feed.repost', subject: ref( subject ), createdAt }; 62 + } 63 + 64 + /** 65 + * A reply `app.bsky.feed.post`. The companion post is the thread origin, so both `root` 66 + * and `parent` are that post — a reader replies directly to the article's post. 67 + */ 68 + export function buildReply( input: { 69 + text: string; 70 + subject: StrongRef; 71 + createdAt: string; 72 + } ): ReplyRecord { 73 + return { 74 + $type: 'app.bsky.feed.post', 75 + text: input.text, 76 + createdAt: input.createdAt, 77 + reply: { root: ref( input.subject ), parent: ref( input.subject ) }, 78 + }; 79 + } 80 + 81 + /** 82 + * A quote-repost: a standalone `app.bsky.feed.post` embedding the companion post via 83 + * `app.bsky.embed.record`. Not a reply — it does not thread under the post. 84 + */ 85 + export function buildQuote( input: { 86 + text: string; 87 + subject: StrongRef; 88 + createdAt: string; 89 + } ): QuoteRecord { 90 + return { 91 + $type: 'app.bsky.feed.post', 92 + text: input.text, 93 + createdAt: input.createdAt, 94 + embed: { $type: 'app.bsky.embed.record', record: ref( input.subject ) }, 95 + }; 96 + } 97 + 98 + /** 99 + * The bsky.app web URL for a post AT-URI, for "view thread on Bluesky" links. 100 + * `at://<did>/app.bsky.feed.post/<rkey>` → `https://bsky.app/profile/<did>/post/<rkey>`. 101 + * Falls back to the bsky.app home for an unparseable URI. 102 + */ 103 + export function bskyPostWebUrl( postUri: string ): string { 104 + const match = postUri.match( /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/ ); 105 + return match ? `https://bsky.app/profile/${ match[ 1 ] }/post/${ match[ 2 ] }` : 'https://bsky.app'; 106 + } 107 + 108 + /** 109 + * Count user-perceived characters (grapheme clusters), matching Bluesky's 300-grapheme 110 + * rule. `Intl.Segmenter` is browser-native and counts ZWJ emoji / flags as one each, so 111 + * a family emoji costs one toward the cap — not the number of underlying codepoints. 112 + */ 113 + export function graphemeLength( text: string ): number { 114 + const segmenter = new Intl.Segmenter( undefined, { granularity: 'grapheme' } ); 115 + let count = 0; 116 + for ( const _ of segmenter.segment( text ) ) { 117 + count++; 118 + } 119 + return count; 120 + } 121 + 122 + export interface ReplyValidation { 123 + ok: boolean; 124 + graphemes: number; 125 + } 126 + 127 + /** 128 + * A reply/quote body must be within the grapheme cap. A reply must also be non-empty; 129 + * a quote may have no commentary (`allowEmpty`), matching Bluesky's bare quote-repost. 130 + */ 131 + export function validateReplyText( 132 + text: string, 133 + options: { allowEmpty?: boolean } = {} 134 + ): ReplyValidation { 135 + const graphemes = graphemeLength( text ); 136 + const hasBody = options.allowEmpty || text.trim().length > 0; 137 + return { ok: hasBody && graphemes <= MAX_REPLY_GRAPHEMES, graphemes }; 138 + }
+27
src/pages/[author]/[slug]/[rkey].astro
··· 16 16 import ErrorScene from '../../../components/ErrorScene.astro'; 17 17 import { errorScene } from '../../../lib/reader/errors'; 18 18 import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 19 + import PostActions from '../../../components/PostActions.tsx'; 19 20 20 21 // Frontend block styles only — no editor chrome, no JS. 21 22 import '@wordpress/block-library/build-style/common.css'; 22 23 import '@wordpress/block-library/build-style/style.css'; 23 24 import '@wordpress/block-library/build-style/theme.css'; 25 + // Social action bar (a client:only island; its chrome is styled globally). 26 + import '../../../styles/post-actions.css'; 24 27 25 28 // Read-through renderer: resolve + fetch at request time (Decision 0007). 26 29 export const prerender = false; ··· 33 36 updatedAt?: string; 34 37 site?: string; 35 38 content?: { blocks?: BlockNode[] }; 39 + /** strongRef to the companion `app.bsky.feed.post` — the target of reader actions. */ 40 + bskyPostRef?: { uri: string; cid: string }; 36 41 } 37 42 38 43 const { author, slug, rkey } = Astro.params; ··· 60 65 let initial = ''; 61 66 let themeStyle = ''; 62 67 let ogImage = ''; 68 + let bskyPostRef: { uri: string; cid: string } | null = null; 63 69 64 70 if ( ! author || ! author.startsWith( '@' ) || ! slug || ! rkey ) { 65 71 error = errorScene( 'not-found' ); ··· 105 111 publishedLabel = doc.publishedAt ? formatLongDate( doc.publishedAt ) : null; 106 112 updatedLabel = doc.updatedAt ? formatLongDate( doc.updatedAt ) : null; 107 113 themeStyle = themeStyleBlock( publication.basicTheme ); 114 + // The companion Bluesky post is the target of every reader action; absent it 115 + // (legacy docs) there's no thread to act on, so the action bar is omitted. 116 + bskyPostRef = 117 + doc.bskyPostRef?.uri && doc.bskyPostRef?.cid ? doc.bskyPostRef : null; 108 118 109 119 const profile = await fetchActorProfile( pdsUrl, did ); 110 120 authorName = profile.displayName ?? `@${ handle }`; ··· 187 197 </p> 188 198 <h1 class="reader__title">{title}</h1> 189 199 <article class="reader__article" set:html={html} /> 200 + {bskyPostRef && ( 201 + <PostActions 202 + client:only="react" 203 + postUri={bskyPostRef.uri} 204 + postCid={bskyPostRef.cid} 205 + > 206 + <p slot="fallback" class="reader__actions-loading">Loading actions…</p> 207 + </PostActions> 208 + )} 190 209 <PublicationFooter name={publication!.name} pubUrl={pubUrl} feedHref={feedHref} /> 191 210 </main> 192 211 </Base> ··· 310 329 } 311 330 .reader__article :global(code) { 312 331 font-family: var(--font-mono); 332 + } 333 + .reader__actions-loading { 334 + max-width: 40rem; 335 + margin: 2.5rem auto 0; 336 + padding: 1.5rem 1.5rem 0; 337 + border-top: 1px solid var(--line); 338 + color: var(--muted); 339 + font-size: 0.95rem; 313 340 } 314 341 </style>
+143
src/styles/post-actions.css
··· 1 + /** 2 + * Reader social-action bar (`PostActions`). The component is a `client:only` React 3 + * island mounted on the article page, so Astro's component-scoped styles never reach 4 + * its DOM — these rules must be global (imported from `[rkey].astro`). 5 + */ 6 + .post-actions { 7 + max-width: 40rem; 8 + margin: 2.5rem auto 0; 9 + padding: 1.5rem 1.5rem 0; 10 + border-top: 1px solid var(--line); 11 + } 12 + .post-actions--loading { 13 + color: var(--muted); 14 + font-size: 0.95rem; 15 + } 16 + .post-actions__bar { 17 + display: flex; 18 + flex-wrap: wrap; 19 + gap: 0.5rem; 20 + } 21 + .post-actions__btn { 22 + display: inline-flex; 23 + align-items: center; 24 + gap: 0.35rem; 25 + padding: 0.5rem 0.85rem; 26 + border: 1px solid var(--line-strong, var(--line)); 27 + border-radius: 999px; 28 + background: transparent; 29 + color: var(--ink); 30 + font: inherit; 31 + font-size: 0.95rem; 32 + cursor: pointer; 33 + } 34 + .post-actions__btn:hover { 35 + border-color: var(--sun); 36 + } 37 + .post-actions__btn:disabled { 38 + opacity: 0.55; 39 + cursor: default; 40 + } 41 + .post-actions__btn.is-active { 42 + border-color: var(--sun); 43 + color: var(--sun); 44 + } 45 + .post-actions__composer { 46 + margin-top: 1rem; 47 + } 48 + .post-actions__composer-label { 49 + display: block; 50 + font-size: 0.85rem; 51 + font-weight: 600; 52 + margin-bottom: 0.4rem; 53 + } 54 + .post-actions__textarea { 55 + width: 100%; 56 + box-sizing: border-box; 57 + padding: 0.7rem 0.8rem; 58 + border: 1px solid var(--line-strong, var(--line)); 59 + border-radius: 10px; 60 + font: inherit; 61 + resize: vertical; 62 + } 63 + .post-actions__composer-foot { 64 + display: flex; 65 + align-items: center; 66 + justify-content: space-between; 67 + margin-top: 0.6rem; 68 + gap: 0.75rem; 69 + } 70 + .post-actions__count { 71 + font-size: 0.85rem; 72 + color: var(--muted); 73 + font-variant-numeric: tabular-nums; 74 + } 75 + .post-actions__count.is-over { 76 + color: var(--ember, #c0392b); 77 + font-weight: 600; 78 + } 79 + .post-actions__composer-btns { 80 + display: flex; 81 + gap: 0.5rem; 82 + } 83 + .post-actions__submit { 84 + padding: 0.5rem 1rem; 85 + border: none; 86 + border-radius: 999px; 87 + background: var(--sun); 88 + color: var(--paper, #fff); 89 + font: inherit; 90 + font-weight: 600; 91 + cursor: pointer; 92 + } 93 + .post-actions__submit:disabled { 94 + opacity: 0.5; 95 + cursor: default; 96 + } 97 + .post-actions__signin { 98 + display: flex; 99 + flex-wrap: wrap; 100 + gap: 0.5rem; 101 + margin: 0.5rem 0; 102 + } 103 + .post-actions__signin-input { 104 + flex: 1 1 14rem; 105 + box-sizing: border-box; 106 + padding: 0.55rem 0.7rem; 107 + border: 1px solid var(--line-strong, var(--line)); 108 + border-radius: 8px; 109 + font: inherit; 110 + } 111 + .post-actions__signin-btn { 112 + padding: 0.55rem 1.1rem; 113 + border: none; 114 + border-radius: 999px; 115 + background: var(--sun); 116 + color: var(--paper, #fff); 117 + font: inherit; 118 + font-weight: 600; 119 + cursor: pointer; 120 + } 121 + .post-actions__note { 122 + margin: 0.9rem 0 0; 123 + font-size: 0.82rem; 124 + color: var(--muted); 125 + } 126 + .post-actions__error, 127 + .post-actions__flash--error { 128 + color: var(--ember, #c0392b); 129 + font-size: 0.9rem; 130 + } 131 + .post-actions__flash { 132 + margin: 0.9rem 0 0; 133 + font-size: 0.9rem; 134 + } 135 + .post-actions__flash--success { 136 + color: var(--ink-soft, var(--ink)); 137 + } 138 + .post-actions__thread { 139 + display: inline-block; 140 + margin-top: 0.75rem; 141 + color: var(--sun); 142 + font-size: 0.9rem; 143 + }