See the best posts from any Bluesky account
0
fork

Configure Feed

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

Drop quote-post support from embeds spec

Quoted posts can be deleted after caching and Bluesky expects
clients to respect deletion. Propagating deletes is too expensive,
so filter record/recordWithMedia out at ingest entirely. Simplifies
the union, the parsers, the consumer flow, and removes the need for
jetstream getPosts hydration.

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

+57 -87
+57 -87
docs/superpowers/specs/2026-04-11-post-embeds-design.md
··· 1 - # Post embeds (images, video, external, quote) — design 1 + # Post embeds (images, video, external link) — design 2 2 3 3 **Status:** Draft 4 4 **Date:** 2026-04-11 5 5 6 6 ## Problem 7 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. 8 + The profile page renders post text only. Posts that consist of an image, a video, or a link card show up as empty or misleading tiles — e.g. `https://bsky.app/profile/did:plc:vc7f4oafdgxsihk4cry2xpze/post/3lc52lahzgc24` is an image post with no text, which currently renders as a blank card. 9 9 10 - We need to display all five Bluesky embed types alongside post text, including image alt text. 10 + We need to display images (with alt text), video thumbnails, and external link cards alongside post text. 11 11 12 12 ## Scope 13 13 14 - In scope: 14 + In scope (rendered): 15 15 16 16 - `app.bsky.embed.images` (1–4 images) 17 17 - `app.bsky.embed.video` (single video, as thumbnail + link-out; no inline playback) 18 18 - `app.bsky.embed.external` (link card) 19 - - `app.bsky.embed.record` (quote post) 20 - - `app.bsky.embed.recordWithMedia` (quote post + images/video) 19 + 20 + **Excluded at ingest time** (post is not stored in `post_snapshots` at all): 21 + 22 + - `app.bsky.embed.record` (pure quote post) 23 + - `app.bsky.embed.recordWithMedia` (quote + media) 21 24 22 - Out of scope: 25 + Rationale: quoted posts can be deleted after we cache them, and Bluesky expects clients to respect deletion. Propagating deletes (either by scanning `embed_json` on every `post-delete` or by hydrating at render time) is too expensive for the benefit, so we simply don't track posts whose primary purpose is to quote another post. Quote posts with media get dropped along with non-media quote posts — the quote context is lost anyway, so rendering the media half alone is misleading. 26 + 27 + Also out of scope: 23 28 24 29 - Inline HLS video playback 25 - - GIFs/Tenor treated as anything special — they arrive as `external` embeds and render as link cards 26 - - Nested quote-of-quote hydration (only the immediate quoted post is hydrated) 30 + - GIFs/Tenor treated as anything special — they arrive as `external` embeds and render as link cards like any other 27 31 28 32 ## Architecture 29 33 30 34 ### Data shape 31 35 32 - New tagged-union type in `app/lib/atproto/types.ts`: 36 + New tagged union in `app/lib/atproto/types.ts`: 33 37 34 38 ```ts 35 - export type PostEmbed = 36 - | ImagesEmbed 37 - | VideoEmbed 38 - | ExternalEmbed 39 - | RecordEmbed 40 - | RecordWithMediaEmbed 39 + export type PostEmbed = ImagesEmbed | VideoEmbed | ExternalEmbed 41 40 42 41 export interface ImagesEmbed { 43 42 type: 'images' ··· 63 62 description: string 64 63 thumb: string | null 65 64 } 66 - 67 - export interface RecordEmbed { 68 - type: 'record' 69 - quoted: QuotedPost | null // null = hydration failed / not yet hydrated 70 - } 71 - 72 - export interface RecordWithMediaEmbed { 73 - type: 'recordWithMedia' 74 - quoted: QuotedPost | null 75 - media: ImagesEmbed | VideoEmbed 76 - } 77 - 78 - export interface QuotedPost { 79 - uri: string 80 - authorDid: string 81 - authorHandle: string 82 - authorDisplayName: string 83 - text: string 84 - createdAt: string // ISO 8601 85 - } 86 65 ``` 87 66 88 67 `PostSnapshot` gains `embed: PostEmbed | null`. ··· 97 76 98 77 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. 99 78 100 - 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. 79 + Empty string (`''`) 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. 101 80 102 81 ### CDN URL construction (jetstream path) 103 82 ··· 107 86 - **Image fullsize:** `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}` 108 87 - **External thumb:** same as image thumb (pulled from `external.thumb` blob on the post author's DID) 109 88 - **Video thumbnail:** `https://video.bsky.app/watch/{url-encoded-did}/{cid}/thumbnail.jpg` 110 - - The DID is URL-encoded (e.g. `did%3Aplc%3Az72i7hdynmk6r22z27h6tvur`), not raw 89 + - The DID is URL-encoded (e.g. `did%3Aplc%3Az72i7hdynmk6r22z27h6tvur`) via `encodeURIComponent`, not raw 111 90 112 91 The backfill path (getAuthorFeed) never constructs these — the AppView returns fully hydrated URLs in the `#view` union, and the parser reads them directly. 92 + 93 + ### Quote-post filtering 94 + 95 + Both ingest paths detect `app.bsky.embed.record` and `app.bsky.embed.recordWithMedia` and signal "skip this post entirely". The snapshot is never created. Backfill drops it from the returned array; jetstream consumer doesn't buffer it. Deleted-quote propagation simply doesn't exist as a problem because we never store anything referencing the quoted post. 113 96 114 97 ## Components 115 98 116 99 ### 1. `app/lib/atproto/parsers/get_author_feed.ts` 117 100 118 - Extend the existing parser to read `post.embed` (the hydrated `#view` union) and return a `PostEmbed`. Map each `$type`: 119 - 120 - - `app.bsky.embed.images#view` → `ImagesEmbed` (uses `thumb`, `fullsize`, `alt`, `aspectRatio` straight from the response) 121 - - `app.bsky.embed.video#view` → `VideoEmbed` (uses `thumbnail`, `alt`, `aspectRatio`; `playlist` ignored) 122 - - `app.bsky.embed.external#view` → `ExternalEmbed` (reads `external.{uri,title,description,thumb}`; thumb is already a full URL) 123 - - `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`. 124 - - `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. 101 + Extend the existing parser. Per feed item: 125 102 126 - 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). 103 + 1. If `post.embed.$type` is `app.bsky.embed.record#view` or `app.bsky.embed.recordWithMedia#view` → **skip this feed item** (don't push a snapshot). 104 + 2. Otherwise, parse `post.embed` (the hydrated `#view` union) into a `PostEmbed`: 105 + - `app.bsky.embed.images#view` → `ImagesEmbed` (uses `thumb`, `fullsize`, `alt`, `aspectRatio` straight from the response) 106 + - `app.bsky.embed.video#view` → `VideoEmbed` (uses `thumbnail`, `alt`, `aspectRatio`; `playlist` ignored) 107 + - `app.bsky.embed.external#view` → `ExternalEmbed` (reads `external.{uri,title,description,thumb}`; thumb is already a full URL) 108 + - No embed → `embed: null` 109 + 3. Malformed but recognized embed → `embed: null` and log. Do not throw — other snapshots in the same batch must still be inserted. 127 110 128 111 ### 2. `app/lib/atproto/parsers/jetstream.ts` 129 112 ··· 133 116 export function parsePostEmbed( 134 117 record: unknown, 135 118 authorDid: string 136 - ): { embed: PostEmbed | null; quotedUri: string | null } 119 + ): { skip: true } | { skip: false; embed: PostEmbed | null } 137 120 ``` 138 121 139 122 Called by `jetstream_consumer.ts` **only** when `isTrackedAuthor === true`, so we don't waste CPU parsing embeds on the firehose for untracked posts. 140 123 141 - 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). 142 - 143 - URL construction: the helpers described above. DID URL-encoding uses `encodeURIComponent`. 144 - 145 - 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. 124 + - `record.embed.$type === 'app.bsky.embed.record'` or `'app.bsky.embed.recordWithMedia'` → `{ skip: true }` 125 + - `app.bsky.embed.images` → `ImagesEmbed` built from blob CIDs + the author's DID 126 + - `app.bsky.embed.video` → `VideoEmbed` built from the blob CID + URL-encoded DID 127 + - `app.bsky.embed.external` → `ExternalEmbed` built from the blob CID (if any) + URL-encoded DID for the thumb 128 + - No embed → `{ skip: false, embed: null }` 129 + - Malformed → `{ skip: false, embed: null }` and log. Do not throw. 146 130 147 131 ### 3. `app/services/jetstream_consumer.ts` 148 132 149 - In `handlePostEvent`, when `isTrackedAuthor`: 133 + In `handlePostEvent`, modify Part A (the snapshot-insert branch gated on `isTrackedAuthor`): 150 134 151 135 1. Call `parsePostEmbed(record, authorDid)`. 152 - 2. Attach the `embed` to the snapshot (may be null). 153 - 3. If `quotedUri` is non-null, store it on the snapshot object as a transient field (not persisted; read by flush). 154 - 155 - In `flushBuffer`, before the ClickHouse insert: 156 - 157 - 1. Collect all transient `quotedUri`s from the in-flight batch (local const — no pending-state buffer; this is purely per-flush). 158 - 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. 159 - 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`. 160 - 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`. 161 - 5. Strip the transient `quotedUri` field before passing to `insertPostSnapshots`. 136 + 2. If the result is `{ skip: true }` → do NOT push a snapshot and do NOT advance the cursor. This matches the existing pattern where `advancePendingCursor` is only called when something actually gets buffered; fully untracked events follow the same pattern today. Fall through to Part B — if the skipped post quotes a tracked user, Part B will still fire and advance the cursor on its own. 137 + 3. Otherwise, push a snapshot with `embed` attached and advance the cursor, exactly as today. 162 138 163 - ### 4. `AtprotoClient.getPosts` 139 + **Part B (quote engagement detection) is untouched.** It already applies to ALL post events regardless of whether the post's author is tracked, and it writes to `engagement_events`, not `post_snapshots`. A post that we skip in Part A (because the tracked author is quoting someone) can still trigger a quote event in Part B if the quoted author is *also* tracked. These are orthogonal. 164 140 165 - 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. 141 + No changes to `flushBuffer`. No `getPosts` hydration call. 166 142 167 - ### 5. `app/lib/clickhouse/store.ts` 143 + ### 4. `app/lib/clickhouse/store.ts` 168 144 169 145 - `insertPostSnapshots`: add `embed_json: s.embed ? JSON.stringify(s.embed) : ''` to the value row. 170 146 - `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. 171 147 - `tombstonePost` / `tombstoneUserSnapshots`: `embed_json: ''` for tombstone rows; `tombstoneUserSnapshots` INSERT SELECT passes `''` in the positional column list. 172 148 - `TopPostsResult` type in `app/lib/clickhouse/types.ts` gains `embed: PostEmbed | null`. 173 149 174 - ### 6. `app/controllers/profile_controller.ts` 150 + ### 5. `app/controllers/profile_controller.ts` 175 151 176 - 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 `{{ }}`. 152 + 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 are safely interpolated through `{{ }}`. 177 153 178 - ### 7. `resources/views/pages/profile/show.edge` 154 + ### 6. `resources/views/pages/profile/show.edge` 179 155 180 156 Below the existing `<p>{{{ post.postTextSafe }}}</p>`, add a conditional block scoped to `post.embed`: 181 157 ··· 187 163 {{-- Thumbnail + ▶ overlay, wrapped in <a> to post.bskyUrl --}} 188 164 @elseif(post.embed.type === 'external') 189 165 {{-- Bordered card: thumb + title + description, <a> to uri --}} 190 - @elseif(post.embed.type === 'record') 191 - {{-- Nested mini-card for quoted post; fallback "quoted post →" link when quoted is null --}} 192 - @elseif(post.embed.type === 'recordWithMedia') 193 - {{-- Quote card + media block stacked --}} 194 166 @endif 195 167 @endif 196 168 ``` 197 169 198 - 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.** 199 - 200 - Video rendering: `<img src="{{ video.thumbnail }}" alt="{{ video.alt }}">` wrapped in `<a href="{{ post.bskyUrl }}">` with a `▶` CSS-positioned overlay badge. 201 - 202 - External rendering: Flex card with optional thumb on the left, title (bold) + description (muted) on the right, wrapped in `<a>`. 203 - 204 - 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`. 170 + - **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.** 171 + - **Video rendering:** `<img src="{{ video.thumbnail }}" alt="{{ video.alt }}">` wrapped in `<a href="{{ post.bskyUrl }}">` with a `▶` CSS-positioned overlay badge. 172 + - **External rendering:** Flex card with optional thumb on the left, title (bold) + description (muted) on the right, wrapped in `<a href="{{ post.embed.uri }}" target="_blank" rel="noopener">`. 205 173 206 - All five branches live inline in the template — no new Edge components. Styles follow the existing inline-style pattern on `show.edge`. 174 + All three branches live inline in the template — no new Edge components. Styles follow the existing inline-style pattern on `show.edge`. 207 175 208 176 ## Testing 209 177 210 178 - **Parser unit tests** 211 - - `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. 212 - - 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. 213 - - New `tests/unit/atproto/get_posts_parser.spec.ts`: happy path + missing post + malformed response. 179 + - `tests/unit/atproto/get_author_feed_parser.spec.ts`: extend with fixtures for `images`, `video`, `external`, `record` (must be skipped), `recordWithMedia` (must be skipped), and no-embed. Assert output shape and that malformed recognized embeds produce `embed: null` without throwing. 180 + - New `tests/unit/atproto/jetstream_embed_parser.spec.ts`: raw-record `images`, `video`, `external`, `record` (skip), `recordWithMedia` (skip), no-embed, malformed. Assert CID→URL construction including DID URL-encoding for the video path. 214 181 - **ClickHouse round-trip** 215 - - 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). 182 + - Extend `tests/unit/clickhouse_store.spec.ts` with cases that insert a snapshot carrying each of `images`/`video`/`external` + null, read back via `getTopPosts`, assert structural equality. Cover the tombstone path (`embed_json = ''` survives the merge). 216 183 - **Jetstream consumer** 217 - - 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`. 184 + - Extend `tests/unit/jetstream_consumer.spec.ts`: 185 + - Tracked-author post with an `images` embed → snapshot has `embed` populated. 186 + - Tracked-author post with a `record` embed → **no snapshot buffered**; cursor still advances. 187 + - Tracked-author post with a `recordWithMedia` embed → **no snapshot buffered**; cursor still advances. 218 188 - **No template tests.** Manual browser check against `/profile/jcsalterego.bsky.social/likes` plus a handful of profiles chosen to exercise each embed type. 219 189 220 190 ## Open questions ··· 224 194 ## Non-goals / deferred 225 195 226 196 - Dev-ergonomic drop-and-rebackfill command — user handles this manually for this change. 227 - - Metrics for quote-hydration success rate. 228 197 - 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). 198 + - Any future support for quote posts (would require a deletion-propagation strategy we're explicitly not building).