experimental bluesky client
0
fork

Configure Feed

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

Add embeds, images

+151 -12
+7 -5
HANDOFF.md
··· 102 102 - Replies listed below with same card style as feed 103 103 - Navigation between thread levels verified end-to-end 104 104 105 - ## Next: images + link previews 105 + ## Done: images + link previews ✓ 106 106 107 - **Images:** `item.post.embed` with `$type === 'app.bsky.embed.images#view'` has an `images` array (`{ thumb, fullsize, alt }`). Map as `images` in the server fn, render below post text as a responsive grid. 107 + `src/lib/types.ts` holds shared `Author`, `PostData`, and `EmbedData` types plus the `extractEmbed` helper. `src/components/EmbedBlock.tsx` renders both embed types. Both `feed.tsx` and `post.$uri.tsx` extract and render embeds. 108 108 109 - **Link previews:** `$type === 'app.bsky.embed.external#view'` has `external: { uri, title, description, thumb? }`. Render as a bordered card below post text; full card is a link to `uri`. 109 + - **Images** render as a responsive grid (1-up full-width, 2-up two columns, 3-up first image spans both columns). Clicking an image opens fullsize in a new tab without triggering card navigation. 110 + - **Link previews** render as a bordered card with thumb, hostname kicker, title, and description. Clicking opens the URL in a new tab. 111 + - Uses `<button type="button">` + `window.open` instead of `<a>` to avoid nested anchor invalid HTML (feed cards are themselves `<Link>` → `<a>` wrappers). 110 112 111 - A post has at most one embed — images OR link preview, not both. Discriminate by `$type`. 113 + ## Next: ??? 112 114 113 115 ## Conventions 114 116 ··· 122 124 - Read AGENTS.md and load the relevant SKILL.md files before working on TanStack-related tasks 123 125 - Playwright is configured for WSL2 with `--no-sandbox` in `~/.claude/plugins/cache/claude-plugins-official/playwright/unknown/.mcp.json` 124 126 - Dev server runs on port 3001 (3000 is taken by something else) 125 - - **Playwright auth workaround:** after user logs in manually and lands on `/feed`, take a screenshot immediately — do NOT navigate away first, as subsequent navigations lose the session 127 + - **Playwright auth:** the session cookie persists correctly through both full-page navigations and client-side TanStack Router transitions. Log in manually once and navigate freely.
+73
src/components/EmbedBlock.tsx
··· 1 + import type { EmbedData } from '#/lib/types' 2 + 3 + export function EmbedBlock({ embed }: { embed: EmbedData }) { 4 + if (!embed) return null 5 + 6 + if (embed.type === 'images') { 7 + const { images } = embed 8 + const count = images.length 9 + return ( 10 + <div 11 + className={`grid gap-0.5 mt-2 rounded-lg overflow-hidden ${count >= 2 ? 'grid-cols-2' : 'grid-cols-1'}`} 12 + > 13 + {images.map((img, i) => ( 14 + <button 15 + key={img.fullsize} 16 + type="button" 17 + className={`block overflow-hidden cursor-pointer p-0 border-0 bg-transparent ${count === 3 && i === 0 ? 'col-span-2' : ''}`} 18 + onClick={(e) => { 19 + e.stopPropagation() 20 + e.preventDefault() 21 + window.open(img.fullsize, '_blank', 'noreferrer') 22 + }} 23 + > 24 + <img 25 + src={img.thumb} 26 + alt={img.alt} 27 + className="w-full h-48 object-cover" 28 + /> 29 + </button> 30 + ))} 31 + </div> 32 + ) 33 + } 34 + 35 + if (embed.type === 'external') { 36 + let hostname = embed.uri 37 + try { 38 + hostname = new URL(embed.uri).hostname 39 + } catch {} 40 + return ( 41 + <button 42 + type="button" 43 + className="w-full mt-2 border border-[--line] rounded-lg overflow-hidden cursor-pointer text-left hover:border-[--lagoon-deep]/40 transition-colors p-0 bg-transparent" 44 + onClick={(e) => { 45 + e.stopPropagation() 46 + e.preventDefault() 47 + window.open(embed.uri, '_blank', 'noreferrer') 48 + }} 49 + > 50 + {embed.thumb && ( 51 + <img 52 + src={embed.thumb} 53 + alt="" 54 + className="w-full max-h-40 object-cover" 55 + /> 56 + )} 57 + <div className="p-3 space-y-0.5 bg-[--surface]"> 58 + <p className="island-kicker m-0 text-[10px]">{hostname}</p> 59 + <p className="font-semibold text-sm text-[--sea-ink] m-0 line-clamp-1"> 60 + {embed.title} 61 + </p> 62 + {embed.description && ( 63 + <p className="text-xs text-[--sea-ink-soft] m-0 line-clamp-2"> 64 + {embed.description} 65 + </p> 66 + )} 67 + </div> 68 + </button> 69 + ) 70 + } 71 + 72 + return null 73 + }
+60
src/lib/types.ts
··· 1 + export interface Author { 2 + handle: string 3 + displayName: string | null 4 + avatar: string | null 5 + } 6 + 7 + export type ImageEmbed = { 8 + type: 'images' 9 + images: { thumb: string; fullsize: string; alt: string }[] 10 + } 11 + 12 + export type ExternalEmbed = { 13 + type: 'external' 14 + uri: string 15 + title: string 16 + description: string 17 + thumb: string | null 18 + } 19 + 20 + export type EmbedData = ImageEmbed | ExternalEmbed | null 21 + 22 + export interface PostData { 23 + uri: string 24 + text: string 25 + author: Author 26 + createdAt: string | undefined 27 + embed: EmbedData 28 + } 29 + 30 + export function extractEmbed(rawEmbed: unknown): EmbedData { 31 + const embed = rawEmbed as { $type: string } | undefined 32 + if (!embed) return null 33 + 34 + if (embed.$type === 'app.bsky.embed.images#view') { 35 + const { images } = embed as unknown as { 36 + images: { thumb: string; fullsize: string; alt: string }[] 37 + } 38 + return { type: 'images', images } 39 + } 40 + 41 + if (embed.$type === 'app.bsky.embed.external#view') { 42 + const { external } = embed as unknown as { 43 + external: { 44 + uri: string 45 + title: string 46 + description: string 47 + thumb?: string 48 + } 49 + } 50 + return { 51 + type: 'external', 52 + uri: external.uri, 53 + title: external.title, 54 + description: external.description, 55 + thumb: external.thumb ?? null, 56 + } 57 + } 58 + 59 + return null 60 + }
+4
src/routes/feed.tsx
··· 2 2 import { createFileRoute, Link, redirect } from '@tanstack/react-router' 3 3 import { createServerFn } from '@tanstack/react-start' 4 4 import { getCookie, setCookie } from '@tanstack/react-start/server' 5 + import { EmbedBlock } from '#/components/EmbedBlock' 5 6 import { client } from '#/lib/oauth-client' 7 + import { extractEmbed } from '#/lib/types' 6 8 7 9 const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { 8 10 const did = getCookie('did') ··· 30 32 avatar: item.post.author.avatar ?? null, 31 33 }, 32 34 createdAt: (item.post.record as { createdAt?: string }).createdAt, 35 + embed: extractEmbed(item.post.embed), 33 36 reply: (() => { 34 37 const parent = item.reply?.parent as 35 38 | { ··· 112 115 </div> 113 116 </div> 114 117 <p className="whitespace-pre-wrap m-0">{item.text}</p> 118 + <EmbedBlock embed={item.embed} /> 115 119 </article> 116 120 </Link> 117 121 ))}
+7 -7
src/routes/post.$uri.tsx
··· 2 2 import { createFileRoute, Link, redirect } from '@tanstack/react-router' 3 3 import { createServerFn } from '@tanstack/react-start' 4 4 import { getCookie, setCookie } from '@tanstack/react-start/server' 5 + import { EmbedBlock } from '#/components/EmbedBlock' 5 6 import { client } from '#/lib/oauth-client' 6 - 7 - interface PostData { 8 - uri: string 9 - text: string 10 - author: { handle: string; displayName: string | null; avatar: string | null } 11 - createdAt: string | undefined 12 - } 7 + import { type PostData, extractEmbed } from '#/lib/types' 13 8 14 9 type ThreadViewPost = { 15 10 $type: string 16 11 post: { 17 12 uri: string 18 13 record: unknown 14 + embed?: unknown 19 15 author: { 20 16 handle: string 21 17 displayName?: string | null ··· 38 34 avatar: node.post.author.avatar ?? null, 39 35 }, 40 36 createdAt: (node.post.record as { createdAt?: string }).createdAt, 37 + embed: extractEmbed(node.post.embed), 41 38 }) 42 39 43 40 const getThread = createServerFn({ method: 'GET' }) ··· 131 128 <article className="island-shell p-4 space-y-2 hover:border-[--lagoon-deep]/40 opacity-80"> 132 129 <PostHeader post={ancestor} /> 133 130 <p className="whitespace-pre-wrap m-0 text-sm">{ancestor.text}</p> 131 + <EmbedBlock embed={ancestor.embed} /> 134 132 </article> 135 133 </Link> 136 134 ))} ··· 144 142 <article className="island-shell p-5 space-y-3 border-[--lagoon]/40"> 145 143 <PostHeader post={post} large /> 146 144 <p className="whitespace-pre-wrap m-0">{post.text}</p> 145 + <EmbedBlock embed={post.embed} /> 147 146 {post.createdAt && ( 148 147 <p className="text-xs text-[--sea-ink-soft] m-0"> 149 148 {new Date(post.createdAt).toLocaleString()} ··· 169 168 <p className="whitespace-pre-wrap m-0 text-sm"> 170 169 {reply.text} 171 170 </p> 171 + <EmbedBlock embed={reply.embed} /> 172 172 </article> 173 173 </Link> 174 174 ))}