···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 empty or over 300 graphemes.
9999+100100+A small persistent note states that actions are **public and happen on Bluesky** — the
101101+"don't surprise users" product guardrail (brief §10), the same principle the publish panel
102102+follows.
103103+104104+### Guardrails
105105+106106+- All writes go **browser → the reader's own PDS** over OAuth/DPoP. There is no SkyPress
107107+ server write endpoint, so there is **no CSRF surface to add**; rate-limiting / abuse is
108108+ the PDS's concern.
109109+- No secrets in the client (OAuth public client, Decision 0004).
110110+- Reads via `getPosts` hit the AppView through the authenticated agent (signed-in only);
111111+ signed-out readers see actions that prompt sign-in rather than live counts.
112112+113113+## Components / data flow
114114+115115+```
116116+[rkey].astro (SSR, prerender=false)
117117+ └─ reads site.standard.document ──► doc.bskyPostRef {uri, cid}
118118+ └─ mounts <PostActions client:only="react"
119119+ postUri postCid authorDid postRkey articleTitle /> (only if bskyPostRef)
120120+121121+PostActions (island)
122122+ └─ <AuthProvider>
123123+ └─ signed-out → "Sign in to react" → useAuth().signIn()
124124+ └─ signed-in → action bar (like / repost / quote / reply) + counts
125125+ └─ composer (textarea + grapheme counter) for reply / quote
126126+ └─ calls src/lib/social/interactions.ts (agent orchestration)
127127+```
128128+129129+## Modules (TDD — pure logic tested first)
130130+131131+- **`src/lib/social/records.ts`** — pure, no `@atproto/*`. `buildLike(subject)`,
132132+ `buildRepost(subject)`, `buildReply({ text, root, parent, createdAt })`,
133133+ `buildQuote({ text, subject, createdAt })`, `graphemeLength(text)`,
134134+ `validateReplyText(text)` (non-empty, ≤300 graphemes). Mirrors `publish/records.ts`.
135135+ Fully unit-tested with fidelity assertions on the emitted record shapes.
136136+- **`src/lib/social/interactions.ts`** — thin orchestration over an `Agent`:
137137+ `like` / `unlike`, `repost` / `unrepost`, `postReply`, `postQuote`, `fetchPostState`
138138+ (wraps `getPosts`). Tested with a mocked agent.
139139+- **`src/components/PostActions.tsx`** — the island UI.
140140+- **`src/pages/[author]/[slug]/[rkey].astro`** — add `bskyPostRef` to `SkyDocument`; mount
141141+ the island (guarded on `bskyPostRef` presence).
142142+143143+## Out of scope for v1
144144+145145+- Inline rendering of the reply thread (link out to Bluesky instead).
146146+- Rich-text facets (clickable links / mentions) in replies.
147147+- Granular `repo:app.bsky.feed.*` OAuth scopes (keep `transition:generic`).
148148+- De-duplicating an accidental double-like server-side (we rely on `viewer.like` state to
149149+ toggle; the PDS would otherwise allow duplicates, as it does for the official client).
150150+151151+## Testing
152152+153153+- Unit: `records.test.ts` (record shapes, grapheme counting, validation),
154154+ `interactions.test.ts` (correct collection / record / rkey passed to a mocked agent;
155155+ toggle deletes the right URI).
156156+- Smoke: `npm run check` + `npm test`, then a browser pass on the reader page (sign in,
157157+ like, reply, verify the record lands and the thread shows it on Bluesky).