See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add post-embeds feature design doc

Covers images, video (thumbnail + link-out), external link cards,
quote posts, and recordWithMedia. Quote posts are hydrated during
jetstream flush via AppView getPosts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+229
+229
docs/superpowers/specs/2026-04-11-post-embeds-design.md
··· 1 + # Post embeds (images, video, external, quote) — design 2 + 3 + **Status:** Draft 4 + **Date:** 2026-04-11 5 + 6 + ## Problem 7 + 8 + The profile page renders post text only. Posts that consist of an image, a video, a link card, or a quote post show up as empty or misleading tiles — e.g. `https://bsky.app/profile/did:plc:vc7f4oafdgxsihk4cry2xpze/post/3lc52lahzgc24` is a post with just an image and no text, which currently renders as a blank card. 9 + 10 + We need to display all five Bluesky embed types alongside post text, including image alt text. 11 + 12 + ## Scope 13 + 14 + In scope: 15 + 16 + - `app.bsky.embed.images` (1–4 images) 17 + - `app.bsky.embed.video` (single video, as thumbnail + link-out; no inline playback) 18 + - `app.bsky.embed.external` (link card) 19 + - `app.bsky.embed.record` (quote post) 20 + - `app.bsky.embed.recordWithMedia` (quote post + images/video) 21 + 22 + Out of scope: 23 + 24 + - Inline HLS video playback 25 + - Lazy re-hydration of quote posts that become top-25 days after the initial jetstream ingest 26 + - GIFs/Tenor treated as anything special — they arrive as `external` embeds and render as link cards 27 + - Nested quote-of-quote hydration (only the immediate quoted post is hydrated) 28 + 29 + ## Architecture 30 + 31 + ### Data shape 32 + 33 + New tagged-union type in `app/lib/atproto/types.ts`: 34 + 35 + ```ts 36 + export type PostEmbed = 37 + | ImagesEmbed 38 + | VideoEmbed 39 + | ExternalEmbed 40 + | RecordEmbed 41 + | RecordWithMediaEmbed 42 + 43 + export interface ImagesEmbed { 44 + type: 'images' 45 + items: Array<{ 46 + thumb: string 47 + fullsize: string 48 + alt: string 49 + aspectRatio?: { width: number; height: number } 50 + }> 51 + } 52 + 53 + export interface VideoEmbed { 54 + type: 'video' 55 + thumbnail: string 56 + alt: string 57 + aspectRatio?: { width: number; height: number } 58 + } 59 + 60 + export interface ExternalEmbed { 61 + type: 'external' 62 + uri: string 63 + title: string 64 + description: string 65 + thumb: string | null 66 + } 67 + 68 + export interface RecordEmbed { 69 + type: 'record' 70 + quoted: QuotedPost | null // null = hydration failed / not yet hydrated 71 + } 72 + 73 + export interface RecordWithMediaEmbed { 74 + type: 'recordWithMedia' 75 + quoted: QuotedPost | null 76 + media: ImagesEmbed | VideoEmbed 77 + } 78 + 79 + export interface QuotedPost { 80 + uri: string 81 + authorDid: string 82 + authorHandle: string 83 + authorDisplayName: string 84 + text: string 85 + createdAt: string // ISO 8601 86 + } 87 + ``` 88 + 89 + `PostSnapshot` gains `embed: PostEmbed | null`. 90 + 91 + ### ClickHouse schema 92 + 93 + New column on `post_snapshots`: 94 + 95 + ```sql 96 + ALTER TABLE post_snapshots ADD COLUMN embed_json String DEFAULT '' 97 + ``` 98 + 99 + Migration file: `database/clickhouse/NNN_add_embed_to_post_snapshots.sql` (next sequential number). User will drop and re-run migrations locally — no deployed data to migrate. 100 + 101 + Empty string (`''`) in the column is the canonical "no embed" value; the store maps it to `null` on read. Stored JSON is produced with `JSON.stringify(embed)` and parsed with `JSON.parse` on read. 102 + 103 + ### CDN URL construction (jetstream path) 104 + 105 + Jetstream records contain raw blob CIDs, not hydrated URLs. The jetstream parser constructs URLs from `(did, cid)` pairs using these formats (verified empirically against `public.api.bsky.app` responses on 2026-04-11): 106 + 107 + - **Image thumb:** `https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}` 108 + - **Image fullsize:** `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}` 109 + - **External thumb:** same as image thumb (pulled from `external.thumb` blob on the post author's DID) 110 + - **Video thumbnail:** `https://video.bsky.app/watch/{url-encoded-did}/{cid}/thumbnail.jpg` 111 + - The DID is URL-encoded (e.g. `did%3Aplc%3Az72i7hdynmk6r22z27h6tvur`), not raw 112 + 113 + The backfill path (getAuthorFeed) never constructs these — the AppView returns fully hydrated URLs in the `#view` union, and the parser reads them directly. 114 + 115 + ## Components 116 + 117 + ### 1. `app/lib/atproto/parsers/get_author_feed.ts` 118 + 119 + Extend the existing parser to read `post.embed` (the hydrated `#view` union) and return a `PostEmbed`. Map each `$type`: 120 + 121 + - `app.bsky.embed.images#view` → `ImagesEmbed` (uses `thumb`, `fullsize`, `alt`, `aspectRatio` straight from the response) 122 + - `app.bsky.embed.video#view` → `VideoEmbed` (uses `thumbnail`, `alt`, `aspectRatio`; `playlist` ignored) 123 + - `app.bsky.embed.external#view` → `ExternalEmbed` (reads `external.{uri,title,description,thumb}`; thumb is already a full URL) 124 + - `app.bsky.embed.record#view` → `RecordEmbed`. Reads `record.{uri,author,value}`. The nested post body lives at `record.value` (confirmed from live sample); text at `record.value.text`, createdAt at `record.value.createdAt`. 125 + - `app.bsky.embed.recordWithMedia#view` → `RecordWithMediaEmbed`. Note the shape asymmetry: `recordWithMedia.record` is `{ record: { uri, author, value, ... } }` — an extra level of nesting compared to plain `record` embed. Handle both shapes. 126 + 127 + Missing or malformed embed → the parser sets `embed: null` on that snapshot and logs (does not throw — other snapshots in the same batch must still be inserted). 128 + 129 + ### 2. `app/lib/atproto/parsers/jetstream.ts` 130 + 131 + New exported function: 132 + 133 + ```ts 134 + export function parsePostEmbed( 135 + record: unknown, 136 + authorDid: string 137 + ): { embed: PostEmbed | null; quotedUri: string | null } 138 + ``` 139 + 140 + Called by `jetstream_consumer.ts` **only** when `isTrackedAuthor === true`, so we don't waste CPU parsing embeds on the firehose for untracked posts. 141 + 142 + Returns the parsed embed plus (separately) the AT-URI of any quoted post that needs hydration. For `record` and `recordWithMedia`, the returned embed has `quoted: null`; the consumer fills it in during `flushBuffer` (see §3). 143 + 144 + URL construction: the helpers described above. DID URL-encoding uses `encodeURIComponent`. 145 + 146 + Malformed records → return `{ embed: null, quotedUri: null }` and log. Must not throw — dropping a firehose event is strictly worse than showing the post without an embed. 147 + 148 + ### 3. `app/services/jetstream_consumer.ts` 149 + 150 + In `handlePostEvent`, when `isTrackedAuthor`: 151 + 152 + 1. Call `parsePostEmbed(record, authorDid)`. 153 + 2. Attach the `embed` to the snapshot (may be null). 154 + 3. If `quotedUri` is non-null, store it on the snapshot object as a transient field (not persisted; read by flush). 155 + 156 + In `flushBuffer`, before the ClickHouse insert: 157 + 158 + 1. Collect all transient `quotedUri`s from the in-flight batch (local const — no pending-state buffer; this is purely per-flush). 159 + 2. If the collection is non-empty, call `atprotoClient.getPosts(uris)` — batched up to 25 URIs per call, matching the AppView limit. Chunk if >25. 160 + 3. Build a `Map<uri, QuotedPost>` from the response. For each snapshot with a `quotedUri`, set `embed.quoted` (both `RecordEmbed` and `RecordWithMediaEmbed` carry `quoted` at the top level) from the map. Missing URIs stay `null`. 161 + 4. On 429: retry via whatever backoff pattern `AtprotoClient` already uses for `getAuthorFeed`. On other errors: log and fall through — insert the snapshots with `quoted: null`. 162 + 5. Strip the transient `quotedUri` field before passing to `insertPostSnapshots`. 163 + 164 + ### 4. `AtprotoClient.getPosts` 165 + 166 + If not already present in `app/lib/atproto/`, add it — wraps `app.bsky.feed.getPosts` with the same fetch/retry pattern as `getAuthorFeed` and `getProfile`. Returns parsed `QuotedPost[]`. A new parser file `app/lib/atproto/parsers/get_posts.ts` covers the response shape. 167 + 168 + ### 5. `app/lib/clickhouse/store.ts` 169 + 170 + - `insertPostSnapshots`: add `embed_json: s.embed ? JSON.stringify(s.embed) : ''` to the value row. 171 + - `getTopPosts`: add `s.embed_json` to `SELECT_COLUMNS` and `GROUP BY`. Map to `embed: row.embed_json ? JSON.parse(row.embed_json) as PostEmbed : null` in the result. 172 + - `tombstonePost` / `tombstoneUserSnapshots`: `embed_json: ''` for tombstone rows; `tombstoneUserSnapshots` INSERT SELECT passes `''` in the positional column list. 173 + - `TopPostsResult` type in `app/lib/clickhouse/types.ts` gains `embed: PostEmbed | null`. 174 + 175 + ### 6. `app/controllers/profile_controller.ts` 176 + 177 + Pass `embed` through in the `postsWithUrl` mapping — just `embed: p.embed`. No HTML escaping needed; Edge escapes string interpolation by default, and URLs/text fields aren't dangerous once interpolated through `{{ }}`. 178 + 179 + ### 7. `resources/views/pages/profile/show.edge` 180 + 181 + Below the existing `<p>{{{ post.postTextSafe }}}</p>`, add a conditional block scoped to `post.embed`: 182 + 183 + ```edge 184 + @if(post.embed) 185 + @if(post.embed.type === 'images') 186 + {{-- Grid: 1 = single, 2 = two cols, 3 = 1 big + 2 small, 4 = 2x2 --}} 187 + @elseif(post.embed.type === 'video') 188 + {{-- Thumbnail + ▶ overlay, wrapped in <a> to post.bskyUrl --}} 189 + @elseif(post.embed.type === 'external') 190 + {{-- Bordered card: thumb + title + description, <a> to uri --}} 191 + @elseif(post.embed.type === 'record') 192 + {{-- Nested mini-card for quoted post; fallback "quoted post →" link when quoted is null --}} 193 + @elseif(post.embed.type === 'recordWithMedia') 194 + {{-- Quote card + media block stacked --}} 195 + @endif 196 + @endif 197 + ``` 198 + 199 + Image rendering: `<img src="{{ img.thumb }}" alt="{{ img.alt }}" loading="lazy">` wrapped in `<a href="{{ img.fullsize }}" target="_blank" rel="noopener">`. Aspect ratio applied via inline `style` when known. **Alt text is only in the `alt` attribute — no visible figcaption.** 200 + 201 + Video rendering: `<img src="{{ video.thumbnail }}" alt="{{ video.alt }}">` wrapped in `<a href="{{ post.bskyUrl }}">` with a `▶` CSS-positioned overlay badge. 202 + 203 + External rendering: Flex card with optional thumb on the left, title (bold) + description (muted) on the right, wrapped in `<a>`. 204 + 205 + Quote rendering (both `record` and the quote half of `recordWithMedia`): nested bordered mini-card showing `authorDisplayName`, `@authorHandle`, and truncated `text`. When `quoted` is null, render a plain "quoted post →" link to bsky.app derived from the `uri` via the same `atUriToBskyUrl` helper already used for `bskyUrl`. 206 + 207 + All five branches live inline in the template — no new Edge components. Styles follow the existing inline-style pattern on `show.edge`. 208 + 209 + ## Testing 210 + 211 + - **Parser unit tests** 212 + - `tests/unit/atproto/get_author_feed_parser.spec.ts`: extend with fixtures for all 5 `#view` embed types plus a post with no embed. Assert output shape and that malformed embed → `embed: null` without throwing. 213 + - New `tests/unit/atproto/jetstream_embed_parser.spec.ts`: all 5 raw record embed types, CID→URL construction (including DID URL-encoding for video), malformed fields, missing fields, `quotedUri` extraction. 214 + - New `tests/unit/atproto/get_posts_parser.spec.ts`: happy path + missing post + malformed response. 215 + - **ClickHouse round-trip** 216 + - Extend `tests/unit/clickhouse_store.spec.ts` with cases that insert a snapshot carrying each embed variant, read it back via `getTopPosts`, and assert structural equality. Cover the `embed: null` path. Cover the tombstone path (`embed_json = ''` survives the merge). 217 + - **Jetstream consumer** 218 + - Extend `tests/unit/jetstream_consumer.spec.ts`: tracked-author post with quote embed triggers `getPosts` during flush; resulting snapshot has `quoted` populated. 429 from `getPosts` propagates through the injected client's existing retry path. Non-429 error → snapshots still inserted with `quoted: null`. 219 + - **No template tests.** Manual browser check against `/profile/jcsalterego.bsky.social/likes` plus a handful of profiles chosen to exercise each embed type. 220 + 221 + ## Open questions 222 + 223 + None. 224 + 225 + ## Non-goals / deferred 226 + 227 + - Dev-ergonomic drop-and-rebackfill command — user handles this manually for this change. 228 + - Metrics for quote-hydration success rate. 229 + - Re-rendering embeds on snapshot refresh (snapshots are take-once at backfill/jetstream time; if an image is replaced on the author's PDS, we keep the old URL — acceptable).