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.

Add design spec for social actions on posts

+157
+157
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 empty or over 300 graphemes. 99 + 100 + A small persistent note states that actions are **public and happen on Bluesky** — the 101 + "don't surprise users" product guardrail (brief §10), the same principle the publish panel 102 + follows. 103 + 104 + ### Guardrails 105 + 106 + - All writes go **browser → the reader's own PDS** over OAuth/DPoP. There is no SkyPress 107 + server write endpoint, so there is **no CSRF surface to add**; rate-limiting / abuse is 108 + the PDS's concern. 109 + - No secrets in the client (OAuth public client, Decision 0004). 110 + - Reads via `getPosts` hit the AppView through the authenticated agent (signed-in only); 111 + signed-out readers see actions that prompt sign-in rather than live counts. 112 + 113 + ## Components / data flow 114 + 115 + ``` 116 + [rkey].astro (SSR, prerender=false) 117 + └─ reads site.standard.document ──► doc.bskyPostRef {uri, cid} 118 + └─ mounts <PostActions client:only="react" 119 + postUri postCid authorDid postRkey articleTitle /> (only if bskyPostRef) 120 + 121 + PostActions (island) 122 + └─ <AuthProvider> 123 + └─ signed-out → "Sign in to react" → useAuth().signIn() 124 + └─ signed-in → action bar (like / repost / quote / reply) + counts 125 + └─ composer (textarea + grapheme counter) for reply / quote 126 + └─ calls src/lib/social/interactions.ts (agent orchestration) 127 + ``` 128 + 129 + ## Modules (TDD — pure logic tested first) 130 + 131 + - **`src/lib/social/records.ts`** — pure, no `@atproto/*`. `buildLike(subject)`, 132 + `buildRepost(subject)`, `buildReply({ text, root, parent, createdAt })`, 133 + `buildQuote({ text, subject, createdAt })`, `graphemeLength(text)`, 134 + `validateReplyText(text)` (non-empty, ≤300 graphemes). Mirrors `publish/records.ts`. 135 + Fully unit-tested with fidelity assertions on the emitted record shapes. 136 + - **`src/lib/social/interactions.ts`** — thin orchestration over an `Agent`: 137 + `like` / `unlike`, `repost` / `unrepost`, `postReply`, `postQuote`, `fetchPostState` 138 + (wraps `getPosts`). Tested with a mocked agent. 139 + - **`src/components/PostActions.tsx`** — the island UI. 140 + - **`src/pages/[author]/[slug]/[rkey].astro`** — add `bskyPostRef` to `SkyDocument`; mount 141 + the island (guarded on `bskyPostRef` presence). 142 + 143 + ## Out of scope for v1 144 + 145 + - Inline rendering of the reply thread (link out to Bluesky instead). 146 + - Rich-text facets (clickable links / mentions) in replies. 147 + - Granular `repo:app.bsky.feed.*` OAuth scopes (keep `transition:generic`). 148 + - De-duplicating an accidental double-like server-side (we rely on `viewer.like` state to 149 + toggle; the PDS would otherwise allow duplicates, as it does for the official client). 150 + 151 + ## Testing 152 + 153 + - Unit: `records.test.ts` (record shapes, grapheme counting, validation), 154 + `interactions.test.ts` (correct collection / record / rkey passed to a mocked agent; 155 + toggle deletes the right URI). 156 + - Smoke: `npm run check` + `npm test`, then a browser pass on the reader page (sign in, 157 + like, reply, verify the record lands and the thread shows it on Bluesky).