···11+# 0015 — Reader social actions write `app.bsky.feed.*` (not `site.standard.*`)
22+33+- **Status:** Accepted
44+- **Date:** 2026-06-09
55+- **Scope:** The first write-path feature on the *reading* side — like / repost / quote /
66+ reply on a published article (design
77+ `docs/superpowers/specs/2026-06-09-social-actions-on-posts-design.md`).
88+99+## Context
1010+1111+The brief asked whether reader replies could be **unlimited-length, block-editor-authored**,
1212+and written as a `site.standard.*` record (the lexicon family used for publications +
1313+documents), while *also* appearing as a reply under the companion Bluesky post that publish
1414+already creates (Decision 0005 / 0013).
1515+1616+Research against the canonical lexicons (2026-06-09) showed these goals are mutually
1717+exclusive:
1818+1919+1. **Only `app.bsky.*` records thread / federate on Bluesky.** The Bluesky AppView is a
2020+ Lexicon-specific indexer; it materialises only `app.bsky.*` collections into threads,
2121+ like/repost counts, and notifications. A `site.standard.*` (or any non-`app.bsky`) record
2222+ is never indexed into a bsky thread — which is exactly why `site.standard` has **no**
2323+ comment/reply type and instead carries `bskyPostRef` to delegate comments to a companion
2424+ `app.bsky.feed.post`.
2525+2. **`app.bsky.feed.post.text` is hard-capped at `maxGraphemes: 300`.** There is no
2626+ sanctioned threaded longform — whitewind / leaflet longform records don't thread either;
2727+ they live off-thread behind a short companion post.
2828+2929+## Decision
3030+3131+Reader actions are **native `app.bsky.feed.*` records targeting the article's companion
3232+post** by its strongRef (`bskyPostRef`, Decision 0013). A reply that should show on Bluesky
3333+**must** be an `app.bsky.feed.post`; it is therefore capped at 300 graphemes, and the block
3434+editor is **not** reused for replies — a plain composer with an `Intl.Segmenter` grapheme
3535+counter is used instead.
3636+3737+- **like** → `app.bsky.feed.like` `{ subject, createdAt }`
3838+- **repost** → `app.bsky.feed.repost` `{ subject, createdAt }`
3939+- **reply** → `app.bsky.feed.post` `{ text, createdAt, reply: { root, parent } }` — the
4040+ companion post is the thread origin, so `root === parent === bskyPostRef`.
4141+- **quote** → `app.bsky.feed.post` `{ text, createdAt, embed: { $type:
4242+ 'app.bsky.embed.record', record: bskyPostRef } }` — a standalone post, not threaded.
4343+4444+`unlike` / `unrepost` delete the viewer's own like/repost record (atproto has no native
4545+"unlike"); the record URI to delete comes from `app.bsky.feed.getPosts`' `viewer.like` /
4646+`viewer.repost`, which also drives counts + active button state.
4747+4848+No SkyPress lexicon change — these are Bluesky-native records, not part of `site.standard.*`
4949+or `blog.skypress.*`.
5050+5151+## Consequences
5252+5353+- **No new OAuth scope:** the existing `transition:generic` (`OAUTH_SCOPE`) already
5454+ authorizes writing `app.bsky.feed.*`. Granular `repo:app.bsky.feed.*` scopes are a later
5555+ tightening (Decision 0005's scope-narrowing note).
5656+- **Auth on the read path:** the article page mounts a `client:only` `PostActions` island
5757+ wrapping the existing `AuthProvider`; the OAuth redirect returns to the article URL. The
5858+ read path still imports **no** `@wordpress/*` (Decision 0003) — the island uses only
5959+ `@atproto/api` + the OAuth client.
6060+- **No CSRF surface added:** all writes go browser → the reader's own PDS over OAuth/DPoP;
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+- **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in
6565+ states, that actions are public and happen on Bluesky.
6666+6767+## Out of scope (v1)
6868+6969+Inline rendering of the reply thread (we link out to Bluesky), rich-text facets (clickable
7070+links/mentions) in replies, and the granular `repo:app.bsky.feed.*` scopes.
···11+# Social actions on published articles (like / repost / quote / reply) — design
22+33+- **Date:** 2026-06-09
44+- **Scope:** The public reader article page (`src/pages/[author]/[slug]/[rkey].astro`).
55+ Adds the first **write-path / interactive** feature on the reading side; reading pages
66+ were anonymous and read-only until now.
77+- **Decision doc:** `docs/decisions/0015-social-actions-on-posts.md` (to be written with
88+ the code) — records the record-type / unlimited-length resolution below.
99+1010+## Problem
1111+1212+Readers can read a SkyPress article but cannot react to it. We want like, repost,
1313+quote-repost, and reply surfaced at the bottom of every post, and a reader's reply should
1414+appear on Bluesky as a reply under the **companion `app.bsky.feed.post`** that publishing
1515+already creates alongside each document (Decision 0005 / 0013).
1616+1717+## Research that settled the open questions (2026-06-09, verified against canonical lexicons)
1818+1919+The brief asked whether replies could be unlimited-length, block-editor-authored, and
2020+written as a `site.standard.*` record. Research closed all three:
2121+2222+1. **Only `app.bsky.*` records thread/federate on Bluesky.** The Bluesky AppView is a
2323+ Lexicon-specific indexer; it materialises only `app.bsky.*` collections into threads,
2424+ like/repost counts, and notifications. A `site.standard.*` (or any non-`app.bsky`)
2525+ record is never indexed into a bsky thread — which is exactly why `site.standard` has
2626+ **no** comment/reply type and instead carries `bskyPostRef` to delegate comments to a
2727+ companion `app.bsky.feed.post`. So a reply that should appear on Bluesky **must** be an
2828+ `app.bsky.feed.post`.
2929+2. **`app.bsky.feed.post.text` is hard-capped at `maxGraphemes: 300`** (`maxLength: 3000`
3030+ bytes is a secondary guard). There is **no sanctioned threaded longform** — whitewind /
3131+ leaflet longform records don't thread either; they live off-thread behind a short
3232+ companion post. So **unlimited-length is impossible if the reply is to appear on
3333+ Bluesky**.
3434+3. **Decision: replies are `app.bsky.feed.post`, 300-grapheme cap, threaded under the
3535+ companion post** (option A, user-approved). The block editor is dropped for replies in
3636+ favour of a simple text composer. This matches the whole ecosystem and the user's stated
3737+ expectation ("my reply shows up on the Bluesky thread").
3838+3939+Record shapes (all `subject`/`root`/`parent`/`record` are `com.atproto.repo.strongRef`
4040+`{uri, cid}`; rkey = TID; required fields per lexicon):
4141+4242+- **like** — `app.bsky.feed.like` `{ subject, createdAt }`
4343+- **repost** — `app.bsky.feed.repost` `{ subject, createdAt }`
4444+- **reply** — `app.bsky.feed.post` `{ text, createdAt, reply: { root, parent } }`
4545+- **quote** — `app.bsky.feed.post` `{ text, createdAt, embed: { $type:
4646+ 'app.bsky.embed.record', record } }`
4747+4848+OAuth: `transition:generic` (the existing `OAUTH_SCOPE`) already authorizes writing all
4949+`app.bsky.feed.*` records. **No scope change.** Granular `repo:app.bsky.feed.*` scopes are
5050+a later tightening (out of scope).
5151+5252+## Architecture
5353+5454+### The target post
5555+5656+The reader page already fetches the `site.standard.document`. We extend its interface with
5757+`bskyPostRef?: StrongRef`. Every action targets that strongRef:
5858+5959+- like / repost / quote → `subject` / `record` = `bskyPostRef`.
6060+- reply → the companion post is the thread origin, so `root === parent === bskyPostRef`.
6161+6262+If `bskyPostRef` is absent (legacy documents published before Decision 0013, or a failed
6363+third write), there is no Bluesky thread to act on → **the action bar is not rendered**.
6464+6565+### Auth on the reader page
6666+6767+A new **client-only island** `PostActions` (`client:only="react"`) wraps the existing
6868+`AuthProvider` and consumes `useAuth()` — the same OAuth machinery Studio uses. `redirect_uri`
6969+is the current page (already how `createOAuthClient` works), and the loopback `client_id` is
7070+already path-less, so the OAuth round-trip returns the reader to the article URL. Signed-out
7171+readers see a "Sign in to react" affordance that kicks off the same `signIn` flow as
7272+`LoginForm`.
7373+7474+**Hard constraint upheld:** the reader page must never import `@wordpress/*` (Decision 0003).
7575+`PostActions` imports only React, `@atproto/api`, and the OAuth client — no editor stack.
7676+7777+### Counts + viewer state
7878+7979+On mount (and after each successful action), the island calls
8080+`agent.app.bsky.feed.getPosts({ uris: [postUri] })`, which returns `likeCount`,
8181+`repostCount`, `replyCount` and `viewer.like` / `viewer.repost` (the rkeys of the viewer's
8282+existing like/repost records). This drives:
8383+8484+- active/inactive button state,
8585+- the record URI to **delete** when toggling a like/repost off (atproto has no native
8686+ "unlike"; you delete the like record).
8787+8888+A "View thread on Bluesky" link points at
8989+`https://bsky.app/profile/<authorDid>/post/<postRkey>`. **Rendering the reply thread inline
9090+is out of scope for v1.**
9191+9292+### Composer
9393+9494+A plain `<textarea>` (no block editor) used for both reply and quote, with a live grapheme
9595+counter via the browser-native **`Intl.Segmenter`** (no new dependency; matches Bluesky's
9696+300-grapheme rule). Plain text only in v1 — no facet/link/mention detection, so URLs in a
9797+reply won't be clickable (acceptable for a simple composer; noted as a future addition).
9898+Submit is disabled when over 300 graphemes; a reply must also be non-empty, but a quote
9999+may be posted with no commentary (a bare quote-repost, as on Bluesky).
100100+101101+A small persistent note states that actions are **public and happen on Bluesky** — the
102102+"don't surprise users" product guardrail (brief §10), the same principle the publish panel
103103+follows.
104104+105105+### Guardrails
106106+107107+- All writes go **browser → the reader's own PDS** over OAuth/DPoP. There is no SkyPress
108108+ server write endpoint, so there is **no CSRF surface to add**; rate-limiting / abuse is
109109+ the PDS's concern.
110110+- No secrets in the client (OAuth public client, Decision 0004).
111111+- Reads via `getPosts` hit the AppView through the authenticated agent (signed-in only);
112112+ signed-out readers see actions that prompt sign-in rather than live counts.
113113+114114+## Components / data flow
115115+116116+```
117117+[rkey].astro (SSR, prerender=false)
118118+ └─ reads site.standard.document ──► doc.bskyPostRef {uri, cid}
119119+ └─ mounts <PostActions client:only="react"
120120+ postUri postCid authorDid postRkey articleTitle /> (only if bskyPostRef)
121121+122122+PostActions (island)
123123+ └─ <AuthProvider>
124124+ └─ signed-out → "Sign in to react" → useAuth().signIn()
125125+ └─ signed-in → action bar (like / repost / quote / reply) + counts
126126+ └─ composer (textarea + grapheme counter) for reply / quote
127127+ └─ calls src/lib/social/interactions.ts (agent orchestration)
128128+```
129129+130130+## Modules (TDD — pure logic tested first)
131131+132132+- **`src/lib/social/records.ts`** — pure, no `@atproto/*`. `buildLike(subject)`,
133133+ `buildRepost(subject)`, `buildReply({ text, root, parent, createdAt })`,
134134+ `buildQuote({ text, subject, createdAt })`, `graphemeLength(text)`,
135135+ `validateReplyText(text)` (non-empty, ≤300 graphemes). Mirrors `publish/records.ts`.
136136+ Fully unit-tested with fidelity assertions on the emitted record shapes.
137137+- **`src/lib/social/interactions.ts`** — thin orchestration over an `Agent`:
138138+ `like` / `unlike`, `repost` / `unrepost`, `postReply`, `postQuote`, `fetchPostState`
139139+ (wraps `getPosts`). Tested with a mocked agent.
140140+- **`src/components/PostActions.tsx`** — the island UI.
141141+- **`src/pages/[author]/[slug]/[rkey].astro`** — add `bskyPostRef` to `SkyDocument`; mount
142142+ the island (guarded on `bskyPostRef` presence).
143143+144144+## Out of scope for v1
145145+146146+- Inline rendering of the reply thread (link out to Bluesky instead).
147147+- Rich-text facets (clickable links / mentions) in replies.
148148+- Granular `repo:app.bsky.feed.*` OAuth scopes (keep `transition:generic`).
149149+- De-duplicating an accidental double-like server-side (we rely on `viewer.like` state to
150150+ toggle; the PDS would otherwise allow duplicates, as it does for the official client).
151151+152152+## Testing
153153+154154+- Unit: `records.test.ts` (record shapes, grapheme counting, validation),
155155+ `interactions.test.ts` (correct collection / record / rkey passed to a mocked agent;
156156+ toggle deletes the right URI).
157157+- Smoke: `npm run check` + `npm test`, then a browser pass on the reader page (sign in,
158158+ like, reply, verify the record lands and the thread shows it on Bluesky).
+66
src/components/PostActions.test.tsx
···11+import { describe, it, expect } from 'vitest';
22+import { createElement } from 'react';
33+import { renderToStaticMarkup } from 'react-dom/server';
44+import { ActionsGate } from './PostActions';
55+import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider';
66+77+const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' };
88+99+/** Render ActionsGate under a hand-rolled auth context (bypasses real OAuth). */
1010+function renderGate( auth: Partial< AuthContextValue > ): string {
1111+ const value: AuthContextValue = {
1212+ status: 'signed-out',
1313+ agent: null,
1414+ did: null,
1515+ handle: null,
1616+ displayName: null,
1717+ avatar: null,
1818+ pdsUrl: null,
1919+ error: null,
2020+ signIn: async () => {},
2121+ signOut: async () => {},
2222+ ...auth,
2323+ };
2424+ return renderToStaticMarkup(
2525+ createElement( AuthContext.Provider, { value }, createElement( ActionsGate, PROPS ) )
2626+ );
2727+}
2828+2929+describe( 'PostActions render branches', () => {
3030+ it( 'shows a loading state while auth initialises', () => {
3131+ expect( renderGate( { status: 'loading' } ) ).toContain( 'Loading actions…' );
3232+ } );
3333+3434+ it( 'prompts sign-in (with the public-actions note) when signed out', () => {
3535+ const markup = renderGate( { status: 'signed-out' } );
3636+ expect( markup ).toContain( 'Sign in to react' );
3737+ expect( markup.toLowerCase() ).toContain( 'public' );
3838+ // Even signed-out, the thread is linkable on Bluesky.
3939+ expect( markup ).toContain( 'bsky.app/profile/did:plc:writer/post/3kpost' );
4040+ } );
4141+4242+ it( 'renders the four actions when signed in', () => {
4343+ const markup = renderGate( {
4444+ status: 'signed-in',
4545+ agent: {} as never,
4646+ did: 'did:plc:reader',
4747+ handle: 'reader.test',
4848+ } );
4949+ expect( markup ).toContain( 'Like' );
5050+ expect( markup ).toContain( 'Repost' );
5151+ expect( markup ).toContain( 'Quote' );
5252+ expect( markup ).toContain( 'Reply' );
5353+ expect( markup.toLowerCase() ).toContain( 'public' );
5454+ } );
5555+5656+ it( 'surfaces a sign-in error while still signed out (signIn sets error, not status)', () => {
5757+ // AuthProvider.signIn reports invalid handles / sign-in failures via `error`
5858+ // without flipping `status` to 'error', so the affordance must show it regardless.
5959+ const markup = renderGate( {
6060+ status: 'signed-out',
6161+ error: 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.',
6262+ } );
6363+ expect( markup ).toContain( 'Enter a handle' );
6464+ expect( markup ).toContain( 'role="alert"' );
6565+ } );
6666+} );
···5858 expect( index ).toMatch( /[Ff]ree/ );
5959 } );
60606161- it( 'keeps the honest Bluesky cross-post note (product guardrail)', () => {
6262- expect( index ).toMatch( /posts to Bluesky/ );
6161+ it( 'no longer carries the Bluesky cross-post note on the home page', () => {
6262+ // The "One thing worth knowing…" notice was removed from the landing page
6363+ // (commit c1508f9); the cross-post disclosure now lives on the publish panel.
6464+ expect( index ).not.toMatch( /posts to Bluesky/ );
6365 } );
64666567 it( 'shows the three-up "see it in action" screenshot strip', () => {