experimental bluesky client
0
fork

Configure Feed

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

Add reply/thread UI

+470 -278
+50 -84
HANDOFF.md
··· 17 17 18 18 ### DID cookie (`src/routes/callback.tsx`) 19 19 20 - After `client.callback()` succeeds, sets a `did` cookie via a mutable `new Response(null, { headers: { 'Set-Cookie': ..., 'Location': ... } })`. **Do not use `Response.redirect()` here** — it creates a response with immutable headers, which crashes TanStack Start's `mergeEventResponseHeaders` when it tries to attach queued cookies. The DID is not sensitive on its own; actual tokens live in `sessionStore`. 20 + After `client.callback()` succeeds, sets a `did` cookie via a mutable `new Response(null, { headers: { 'Set-Cookie': ..., 'Location': ... } })`. **Do not use `Response.redirect()` here** — it creates a response with immutable headers, which crashes TanStack Start's `mergeEventResponseHeaders` when it tries to attach queued cookies. 21 21 22 - ### Feed loader (`src/routes/feed.tsx`) 22 + **Important:** the DID is set as-is (no `encodeURIComponent`) — colons are valid in cookie values, and encoding them causes `client.restore()` to fail because the session is stored under the plain DID (`did:plc:...`) but the cookie would contain `did%3Aplc%3A...`. 23 23 24 - Uses `createServerFn` (not a raw loader) for all server-only work — `getCookie`, `client.restore()`, DB access via the OAuth client, and `agent.getTimeline()`. This is required because TanStack Start loaders are isomorphic (run on both client and server); `better-sqlite3` is Node-only and will crash in the browser if imported directly in a loader. 24 + ### Auth-aware header + logout (`src/components/Header.tsx`, `src/routes/logout.tsx`, `src/routes/__root.tsx`) 25 25 26 - The server function returns a mapped subset of the feed data (not raw ATProto types) to avoid a type incompatibility with `createServerFn`'s serialization constraints (`{ [x: string]: unknown }` vs `{ [x: string]: {} }`). 26 + - `__root.tsx` uses a `shellComponent`/`component` split: `RootDocument` is the HTML-only shell, `RootLayout` is where `Route.useLoaderData()` works. The root loader calls a `getAuthState` server fn that reads the `did` cookie. 27 + - `Header` accepts `did: string | null` — shows Feed link + Sign out button only when logged in. 28 + - `logout.tsx` is a server POST handler that sets `did=; Max-Age=0` and redirects to `/login`. 27 29 28 - ### Feed UI polish (`src/routes/feed.tsx`) 30 + ### Feed UI — reply parents (`src/routes/feed.tsx`) 29 31 30 - - Cards now use `.island-shell` (frosted glass style) instead of bare `border rounded-lg` 31 - - Outer wrapper uses `bg-[--bg-base]` for the page background color token 32 + - Each feed item now includes a `reply` field (mapped from `item.reply?.parent`, cast to known shape, discriminated by `$type === 'app.bsky.feed.defs#postView'`). 33 + - Feed cards are wrapped in a `<Link to="/post/$uri">` for navigation to the thread view. 34 + - When `item.reply` is non-null, a parent snippet is shown above the main post: left-bordered block with the parent author + truncated text, followed by an `↩ replying to @handle` kicker label. 35 + - If `client.restore(did)` throws (expired/missing session), the `did` cookie is cleared and the user is redirected to `/login` cleanly. 36 + 37 + ### Thread route stub (`src/routes/post.$uri.tsx`) 38 + 39 + Created as a stub — renders "Thread view coming soon" with the decoded URI. Full implementation is the next task. 40 + 41 + ### Biome formatter (`biome.json`) 42 + 43 + Configured project-wide: tabs, single quotes, no semicolons, `organizeImports` assist. `src/routeTree.gen.ts` is excluded via `files.includes` negation pattern. All files are currently lint/format clean. 32 44 33 45 ## Current state of key files 34 46 35 47 ``` 36 - src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables 37 - src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore 38 - src/routes/callback.tsx ← sets DID cookie, redirects to /feed 39 - src/routes/feed.tsx ← createServerFn fetches timeline; island-shell card UI 40 - src/routes/login.tsx ← plain unstyled form; working, needs polish 41 - src/routes/__root.tsx ← shell with Header + Footer components, devtools, theme init script 42 - src/components/Header.tsx ← sticky nav with "Feed" link chip; uses design tokens 43 - src/components/Footer.tsx ← copyright + "Built with TanStack Start" line 44 - src/styles.css ← Tailwind v4 + custom design system (see below) 48 + src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables 49 + src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore 50 + src/routes/callback.tsx ← sets DID cookie (plain, not encoded), redirects to /feed 51 + src/routes/logout.tsx ← POST handler, clears did cookie, redirects to /login 52 + src/routes/feed.tsx ← createServerFn fetches timeline + reply parents; island-shell cards with Link wrapper 53 + src/routes/post.$uri.tsx ← stub thread view route (not yet implemented) 54 + src/routes/login.tsx ← styled island-shell card with Fraunces heading 55 + src/routes/__root.tsx ← shellComponent/component split; root loader for auth state 56 + src/components/Header.tsx ← auth-aware sticky nav (Feed link + Sign out when logged in) 57 + src/components/Footer.tsx ← copyright + "Built with TanStack Start" line 58 + src/styles.css ← Tailwind v4 + custom design system (see below) 59 + biome.json ← formatter/linter config 45 60 ``` 46 61 47 62 ## Design system ··· 71 86 72 87 **Fonts**: Manrope (sans body), Fraunces (serif display) 73 88 74 - ## Remaining UI polish (carry-over) 75 - 76 - These were not finished last session: 77 - 78 - **`src/routes/feed.tsx`:** 79 - - `text-gray-500` on the handle should be `text-[--sea-ink-soft]` (hardcoded color, won't respect dark mode) 80 - - No timestamp — add `createdAt: (item.post.record as { createdAt?: string }).createdAt ?? null` to the mapped return in `getTimeline`, then render it 81 - - No avatar fallback for posts where `author.avatar` is null 82 - - No `pendingComponent` or `errorComponent` on the route 83 - 84 - **`src/routes/login.tsx`:** 85 - - Form has zero styling — needs centered card layout, `.island-shell`, lagoon accent on the button 86 - 87 - **`src/routes/__root.tsx`:** 88 - - Page title is still "TanStack Start Starter" — should be "Dudesky" 89 - 90 - ## Before threading: two features to add 91 - 92 - ### 1. Images in posts 93 - 94 - Bluesky posts can have image embeds. The embed lives at `item.post.embed` — when it's an image embed, the type is `app.bsky.embed.images#view` and it has an `images` array where each entry has `thumb` (URL), `fullsize` (URL), and `alt` (string). 89 + ## Done: threaded reply views ✓ 95 90 96 - Add `images` to the mapped return in `getTimeline`: 91 + Both the feed reply-parent UI and the full thread view are implemented and Playwright-verified. 97 92 98 - ```ts 99 - images: (item.post.embed as { images?: { thumb: string; fullsize: string; alt: string }[] } | undefined)?.images ?? [] 100 - ``` 93 + ### Feed reply parent UI (`src/routes/feed.tsx`) ✓ 101 94 102 - Then render them below the post text — a simple responsive grid of `<img>` tags with `src={thumb}` and `alt={alt}` works for starters. 95 + Visually verified — reply posts show a left-bordered parent snippet above the main post card. 103 96 104 - ### 2. Link previews 105 - 106 - Bluesky posts can have an external embed (link preview card) at `item.post.embed`. Discriminate on `$type` — the value for a link preview is `'app.bsky.embed.external#view'`. The embed's `external` object has `uri`, `title`, `description`, and optionally `thumb` (image URL). 107 - 108 - Add `linkPreview` to the mapped return: 97 + ### Thread view (`src/routes/post.$uri.tsx`) ✓ 109 98 110 - ```ts 111 - const embed = item.post.embed as { $type?: string; external?: { uri: string; title: string; description: string; thumb?: string } } | undefined 112 - linkPreview: embed?.$type === 'app.bsky.embed.external#view' ? (embed.external ?? null) : null 113 - ``` 99 + - `getThread` server fn calls `agent.getPostThread()`, walks `parent` chain root-first into `ancestors[]`, collects direct `replies[]` 100 + - Ancestors stack above focal post (dimmed, each links to its own thread), connector lines between sections 101 + - Focal post shown prominently with larger avatar and full timestamp 102 + - Replies listed below with same card style as feed 103 + - Navigation between thread levels verified end-to-end 114 104 115 - Render as a card below the post text — thumbnail on the left, title + description + domain on the right, full card is a link to `uri`. Use `.island-shell` or a bordered inset style. 105 + ## Next: images + link previews 116 106 117 - Note: a post has at most one embed — it's images OR a link preview, not both. Use `$type` to discriminate: `'app.bsky.embed.images#view'` for images, `'app.bsky.embed.external#view'` for link previews. 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. 118 108 119 - ## Next: threaded reply views 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`. 120 110 121 - Goal: clicking a post opens a thread view showing the post, its ancestors (parent chain), and its replies. 122 - 123 - ### ATProto API 124 - 125 - `agent.getPostThread({ uri })` returns a `ThreadViewPost` which has: 126 - - `post` — the post itself 127 - - `parent` — `ThreadViewPost | NotFoundPost | BlockedPost | undefined` (walk up for ancestors) 128 - - `replies` — `ThreadViewPost[] | ...` (direct replies) 129 - 130 - The `uri` for each post is already in the mapped feed data as `item.uri`. 131 - 132 - ### Suggested route 133 - 134 - `src/routes/post.$uri.tsx` — file-based dynamic route. The `uri` param will be the AT URI, but since it contains `/` characters (`at://did:plc:.../app.bsky.feed.post/...`) it needs to be encoded when used as a URL segment (use `encodeURIComponent` / `decodeURIComponent`). 135 - 136 - ### Data shape to map in the server fn 137 - 138 - Same `createServerFn` pattern as `feed.tsx`. Map to a plain serializable object — don't pass raw ATProto types through. Suggested shape: 139 - 140 - ```ts 141 - { 142 - post: { uri, text, author, createdAt }, 143 - ancestors: Array<{ uri, text, author, createdAt }>, // ordered root-first 144 - replies: Array<{ uri, text, author, createdAt }>, 145 - } 146 - ``` 147 - 148 - Walk `parent` recursively to build `ancestors`, collect `replies` array directly. 111 + A post has at most one embed — images OR link preview, not both. Discriminate by `$type`. 149 112 150 113 ## Conventions 151 114 152 115 - Path alias `#/` maps to `src/` 153 - - No semicolons in `.ts`/`.tsx` files 116 + - No semicolons in `.ts`/`.tsx` files (Biome enforced) 117 + - Tabs for indentation, single quotes (Biome enforced) 154 118 - Server route handlers return raw `Response` objects — use `new Response(null, { status: 302, headers: {...} })` for redirects, NOT `Response.redirect()` (immutable headers) and NOT TanStack Router's `redirect()` (doesn't work in server handlers) 155 119 - `createServerFn` for any server-only code called from loaders (DB, cookies, secrets) 120 + - ATProto type narrowing: cast `item.reply?.parent` to a local interface, then discriminate by `$type` — `in` checks don't narrow the union reliably 156 121 - Env vars: `VITE_APP_URL`, `PRIVATE_KEY_0/1/2`, optionally `DB_PATH` 157 122 - Read AGENTS.md and load the relevant SKILL.md files before working on TanStack-related tasks 158 123 - Playwright is configured for WSL2 with `--no-sandbox` in `~/.claude/plugins/cache/claude-plugins-official/playwright/unknown/.mcp.json` 159 - - To inject auth into the headless browser: set cookie `did=<url-encoded-did>` on `localhost:3001` via `browser_evaluate`, then navigate to `/feed` 124 + - 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
+188 -168
src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 - import { Route as AboutRouteImport } from './routes/about' 13 - import { Route as CallbackRouteImport } from './routes/callback' 14 - import { Route as ClientMetadataRouteImport } from './routes/client-metadata' 12 + import { Route as LogoutRouteImport } from './routes/logout' 13 + import { Route as LoginRouteImport } from './routes/login' 14 + import { Route as JwksRouteImport } from './routes/jwks' 15 15 import { Route as FeedRouteImport } from './routes/feed' 16 + import { Route as ClientMetadataRouteImport } from './routes/client-metadata' 17 + import { Route as CallbackRouteImport } from './routes/callback' 18 + import { Route as AboutRouteImport } from './routes/about' 16 19 import { Route as IndexRouteImport } from './routes/index' 17 - import { Route as JwksRouteImport } from './routes/jwks' 18 - import { Route as LoginRouteImport } from './routes/login' 19 - import { Route as LogoutRouteImport } from './routes/logout' 20 + import { Route as PostUriRouteImport } from './routes/post.$uri' 20 21 21 22 const LogoutRoute = LogoutRouteImport.update({ 22 - id: '/logout', 23 - path: '/logout', 24 - getParentRoute: () => rootRouteImport, 23 + id: '/logout', 24 + path: '/logout', 25 + getParentRoute: () => rootRouteImport, 25 26 } as any) 26 27 const LoginRoute = LoginRouteImport.update({ 27 - id: '/login', 28 - path: '/login', 29 - getParentRoute: () => rootRouteImport, 28 + id: '/login', 29 + path: '/login', 30 + getParentRoute: () => rootRouteImport, 30 31 } as any) 31 32 const JwksRoute = JwksRouteImport.update({ 32 - id: '/jwks', 33 - path: '/jwks', 34 - getParentRoute: () => rootRouteImport, 33 + id: '/jwks', 34 + path: '/jwks', 35 + getParentRoute: () => rootRouteImport, 35 36 } as any) 36 37 const FeedRoute = FeedRouteImport.update({ 37 - id: '/feed', 38 - path: '/feed', 39 - getParentRoute: () => rootRouteImport, 38 + id: '/feed', 39 + path: '/feed', 40 + getParentRoute: () => rootRouteImport, 40 41 } as any) 41 42 const ClientMetadataRoute = ClientMetadataRouteImport.update({ 42 - id: '/client-metadata', 43 - path: '/client-metadata', 44 - getParentRoute: () => rootRouteImport, 43 + id: '/client-metadata', 44 + path: '/client-metadata', 45 + getParentRoute: () => rootRouteImport, 45 46 } as any) 46 47 const CallbackRoute = CallbackRouteImport.update({ 47 - id: '/callback', 48 - path: '/callback', 49 - getParentRoute: () => rootRouteImport, 48 + id: '/callback', 49 + path: '/callback', 50 + getParentRoute: () => rootRouteImport, 50 51 } as any) 51 52 const AboutRoute = AboutRouteImport.update({ 52 - id: '/about', 53 - path: '/about', 54 - getParentRoute: () => rootRouteImport, 53 + id: '/about', 54 + path: '/about', 55 + getParentRoute: () => rootRouteImport, 55 56 } as any) 56 57 const IndexRoute = IndexRouteImport.update({ 57 - id: '/', 58 - path: '/', 59 - getParentRoute: () => rootRouteImport, 58 + id: '/', 59 + path: '/', 60 + getParentRoute: () => rootRouteImport, 61 + } as any) 62 + const PostUriRoute = PostUriRouteImport.update({ 63 + id: '/post/$uri', 64 + path: '/post/$uri', 65 + getParentRoute: () => rootRouteImport, 60 66 } as any) 61 67 62 68 export interface FileRoutesByFullPath { 63 - '/': typeof IndexRoute 64 - '/about': typeof AboutRoute 65 - '/callback': typeof CallbackRoute 66 - '/client-metadata': typeof ClientMetadataRoute 67 - '/feed': typeof FeedRoute 68 - '/jwks': typeof JwksRoute 69 - '/login': typeof LoginRoute 70 - '/logout': typeof LogoutRoute 69 + '/': typeof IndexRoute 70 + '/about': typeof AboutRoute 71 + '/callback': typeof CallbackRoute 72 + '/client-metadata': typeof ClientMetadataRoute 73 + '/feed': typeof FeedRoute 74 + '/jwks': typeof JwksRoute 75 + '/login': typeof LoginRoute 76 + '/logout': typeof LogoutRoute 77 + '/post/$uri': typeof PostUriRoute 71 78 } 72 79 export interface FileRoutesByTo { 73 - '/': typeof IndexRoute 74 - '/about': typeof AboutRoute 75 - '/callback': typeof CallbackRoute 76 - '/client-metadata': typeof ClientMetadataRoute 77 - '/feed': typeof FeedRoute 78 - '/jwks': typeof JwksRoute 79 - '/login': typeof LoginRoute 80 - '/logout': typeof LogoutRoute 80 + '/': typeof IndexRoute 81 + '/about': typeof AboutRoute 82 + '/callback': typeof CallbackRoute 83 + '/client-metadata': typeof ClientMetadataRoute 84 + '/feed': typeof FeedRoute 85 + '/jwks': typeof JwksRoute 86 + '/login': typeof LoginRoute 87 + '/logout': typeof LogoutRoute 88 + '/post/$uri': typeof PostUriRoute 81 89 } 82 90 export interface FileRoutesById { 83 - __root__: typeof rootRouteImport 84 - '/': typeof IndexRoute 85 - '/about': typeof AboutRoute 86 - '/callback': typeof CallbackRoute 87 - '/client-metadata': typeof ClientMetadataRoute 88 - '/feed': typeof FeedRoute 89 - '/jwks': typeof JwksRoute 90 - '/login': typeof LoginRoute 91 - '/logout': typeof LogoutRoute 91 + __root__: typeof rootRouteImport 92 + '/': typeof IndexRoute 93 + '/about': typeof AboutRoute 94 + '/callback': typeof CallbackRoute 95 + '/client-metadata': typeof ClientMetadataRoute 96 + '/feed': typeof FeedRoute 97 + '/jwks': typeof JwksRoute 98 + '/login': typeof LoginRoute 99 + '/logout': typeof LogoutRoute 100 + '/post/$uri': typeof PostUriRoute 92 101 } 93 102 export interface FileRouteTypes { 94 - fileRoutesByFullPath: FileRoutesByFullPath 95 - fullPaths: 96 - | '/' 97 - | '/about' 98 - | '/callback' 99 - | '/client-metadata' 100 - | '/feed' 101 - | '/jwks' 102 - | '/login' 103 - | '/logout' 104 - fileRoutesByTo: FileRoutesByTo 105 - to: 106 - | '/' 107 - | '/about' 108 - | '/callback' 109 - | '/client-metadata' 110 - | '/feed' 111 - | '/jwks' 112 - | '/login' 113 - | '/logout' 114 - id: 115 - | '__root__' 116 - | '/' 117 - | '/about' 118 - | '/callback' 119 - | '/client-metadata' 120 - | '/feed' 121 - | '/jwks' 122 - | '/login' 123 - | '/logout' 124 - fileRoutesById: FileRoutesById 103 + fileRoutesByFullPath: FileRoutesByFullPath 104 + fullPaths: 105 + | '/' 106 + | '/about' 107 + | '/callback' 108 + | '/client-metadata' 109 + | '/feed' 110 + | '/jwks' 111 + | '/login' 112 + | '/logout' 113 + | '/post/$uri' 114 + fileRoutesByTo: FileRoutesByTo 115 + to: 116 + | '/' 117 + | '/about' 118 + | '/callback' 119 + | '/client-metadata' 120 + | '/feed' 121 + | '/jwks' 122 + | '/login' 123 + | '/logout' 124 + | '/post/$uri' 125 + id: 126 + | '__root__' 127 + | '/' 128 + | '/about' 129 + | '/callback' 130 + | '/client-metadata' 131 + | '/feed' 132 + | '/jwks' 133 + | '/login' 134 + | '/logout' 135 + | '/post/$uri' 136 + fileRoutesById: FileRoutesById 125 137 } 126 138 export interface RootRouteChildren { 127 - IndexRoute: typeof IndexRoute 128 - AboutRoute: typeof AboutRoute 129 - CallbackRoute: typeof CallbackRoute 130 - ClientMetadataRoute: typeof ClientMetadataRoute 131 - FeedRoute: typeof FeedRoute 132 - JwksRoute: typeof JwksRoute 133 - LoginRoute: typeof LoginRoute 134 - LogoutRoute: typeof LogoutRoute 139 + IndexRoute: typeof IndexRoute 140 + AboutRoute: typeof AboutRoute 141 + CallbackRoute: typeof CallbackRoute 142 + ClientMetadataRoute: typeof ClientMetadataRoute 143 + FeedRoute: typeof FeedRoute 144 + JwksRoute: typeof JwksRoute 145 + LoginRoute: typeof LoginRoute 146 + LogoutRoute: typeof LogoutRoute 147 + PostUriRoute: typeof PostUriRoute 135 148 } 136 149 137 150 declare module '@tanstack/react-router' { 138 - interface FileRoutesByPath { 139 - '/logout': { 140 - id: '/logout' 141 - path: '/logout' 142 - fullPath: '/logout' 143 - preLoaderRoute: typeof LogoutRouteImport 144 - parentRoute: typeof rootRouteImport 145 - } 146 - '/login': { 147 - id: '/login' 148 - path: '/login' 149 - fullPath: '/login' 150 - preLoaderRoute: typeof LoginRouteImport 151 - parentRoute: typeof rootRouteImport 152 - } 153 - '/jwks': { 154 - id: '/jwks' 155 - path: '/jwks' 156 - fullPath: '/jwks' 157 - preLoaderRoute: typeof JwksRouteImport 158 - parentRoute: typeof rootRouteImport 159 - } 160 - '/feed': { 161 - id: '/feed' 162 - path: '/feed' 163 - fullPath: '/feed' 164 - preLoaderRoute: typeof FeedRouteImport 165 - parentRoute: typeof rootRouteImport 166 - } 167 - '/client-metadata': { 168 - id: '/client-metadata' 169 - path: '/client-metadata' 170 - fullPath: '/client-metadata' 171 - preLoaderRoute: typeof ClientMetadataRouteImport 172 - parentRoute: typeof rootRouteImport 173 - } 174 - '/callback': { 175 - id: '/callback' 176 - path: '/callback' 177 - fullPath: '/callback' 178 - preLoaderRoute: typeof CallbackRouteImport 179 - parentRoute: typeof rootRouteImport 180 - } 181 - '/about': { 182 - id: '/about' 183 - path: '/about' 184 - fullPath: '/about' 185 - preLoaderRoute: typeof AboutRouteImport 186 - parentRoute: typeof rootRouteImport 187 - } 188 - '/': { 189 - id: '/' 190 - path: '/' 191 - fullPath: '/' 192 - preLoaderRoute: typeof IndexRouteImport 193 - parentRoute: typeof rootRouteImport 194 - } 195 - } 151 + interface FileRoutesByPath { 152 + '/logout': { 153 + id: '/logout' 154 + path: '/logout' 155 + fullPath: '/logout' 156 + preLoaderRoute: typeof LogoutRouteImport 157 + parentRoute: typeof rootRouteImport 158 + } 159 + '/login': { 160 + id: '/login' 161 + path: '/login' 162 + fullPath: '/login' 163 + preLoaderRoute: typeof LoginRouteImport 164 + parentRoute: typeof rootRouteImport 165 + } 166 + '/jwks': { 167 + id: '/jwks' 168 + path: '/jwks' 169 + fullPath: '/jwks' 170 + preLoaderRoute: typeof JwksRouteImport 171 + parentRoute: typeof rootRouteImport 172 + } 173 + '/feed': { 174 + id: '/feed' 175 + path: '/feed' 176 + fullPath: '/feed' 177 + preLoaderRoute: typeof FeedRouteImport 178 + parentRoute: typeof rootRouteImport 179 + } 180 + '/client-metadata': { 181 + id: '/client-metadata' 182 + path: '/client-metadata' 183 + fullPath: '/client-metadata' 184 + preLoaderRoute: typeof ClientMetadataRouteImport 185 + parentRoute: typeof rootRouteImport 186 + } 187 + '/callback': { 188 + id: '/callback' 189 + path: '/callback' 190 + fullPath: '/callback' 191 + preLoaderRoute: typeof CallbackRouteImport 192 + parentRoute: typeof rootRouteImport 193 + } 194 + '/about': { 195 + id: '/about' 196 + path: '/about' 197 + fullPath: '/about' 198 + preLoaderRoute: typeof AboutRouteImport 199 + parentRoute: typeof rootRouteImport 200 + } 201 + '/': { 202 + id: '/' 203 + path: '/' 204 + fullPath: '/' 205 + preLoaderRoute: typeof IndexRouteImport 206 + parentRoute: typeof rootRouteImport 207 + } 208 + '/post/$uri': { 209 + id: '/post/$uri' 210 + path: '/post/$uri' 211 + fullPath: '/post/$uri' 212 + preLoaderRoute: typeof PostUriRouteImport 213 + parentRoute: typeof rootRouteImport 214 + } 215 + } 196 216 } 197 217 198 218 const rootRouteChildren: RootRouteChildren = { 199 - IndexRoute: IndexRoute, 200 - AboutRoute: AboutRoute, 201 - CallbackRoute: CallbackRoute, 202 - ClientMetadataRoute: ClientMetadataRoute, 203 - FeedRoute: FeedRoute, 204 - JwksRoute: JwksRoute, 205 - LoginRoute: LoginRoute, 206 - LogoutRoute: LogoutRoute, 219 + IndexRoute: IndexRoute, 220 + AboutRoute: AboutRoute, 221 + CallbackRoute: CallbackRoute, 222 + ClientMetadataRoute: ClientMetadataRoute, 223 + FeedRoute: FeedRoute, 224 + JwksRoute: JwksRoute, 225 + LoginRoute: LoginRoute, 226 + LogoutRoute: LogoutRoute, 227 + PostUriRoute: PostUriRoute, 207 228 } 208 229 export const routeTree = rootRouteImport 209 - ._addFileChildren(rootRouteChildren) 210 - ._addFileTypes<FileRouteTypes>() 230 + ._addFileChildren(rootRouteChildren) 231 + ._addFileTypes<FileRouteTypes>() 211 232 212 233 import type { getRouter } from './router.tsx' 213 234 import type { startInstance } from './start.ts' 214 - 215 235 declare module '@tanstack/react-start' { 216 - interface Register { 217 - ssr: true 218 - router: Awaited<ReturnType<typeof getRouter>> 219 - config: Awaited<ReturnType<typeof startInstance.getOptions>> 220 - } 236 + interface Register { 237 + ssr: true 238 + router: Awaited<ReturnType<typeof getRouter>> 239 + config: Awaited<ReturnType<typeof startInstance.getOptions>> 240 + } 221 241 }
+1 -1
src/routes/callback.tsx
··· 14 14 status: 302, 15 15 headers: { 16 16 Location: new URL('/feed', request.url).toString(), 17 - 'Set-Cookie': `did=${encodeURIComponent(session.sub)}; Path=/; HttpOnly; SameSite=Lax`, 17 + 'Set-Cookie': `did=${session.sub}; Path=/; HttpOnly; SameSite=Lax`, 18 18 }, 19 19 }) 20 20 } catch (e) {
+51 -25
src/routes/feed.tsx
··· 1 1 import { Agent } from '@atproto/api' 2 - import { createFileRoute, redirect } from '@tanstack/react-router' 2 + import { createFileRoute, Link, redirect } from '@tanstack/react-router' 3 3 import { createServerFn } from '@tanstack/react-start' 4 - import { getCookie } from '@tanstack/react-start/server' 4 + import { getCookie, setCookie } from '@tanstack/react-start/server' 5 5 import { client } from '#/lib/oauth-client' 6 6 7 7 const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { ··· 11 11 throw redirect({ to: '/login' }) 12 12 } 13 13 14 - const session = await client.restore(did) 14 + let session: Awaited<ReturnType<typeof client.restore>> 15 + try { 16 + session = await client.restore(did) 17 + } catch { 18 + setCookie('did', '', { maxAge: 0, path: '/' }) 19 + throw redirect({ to: '/login' }) 20 + } 15 21 const agent = new Agent(session) 16 22 const { data } = await agent.getTimeline({ limit: 50 }) 17 23 return { ··· 64 70 return ( 65 71 <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-4"> 66 72 {feed.map((item) => ( 67 - <article key={item.uri} className="island-shell p-4 space-y-2"> 68 - <div className="flex items-center gap-2"> 69 - {item.author.avatar && ( 70 - <img 71 - src={item.author.avatar} 72 - alt="" 73 - className="w-8 h-8 rounded-full" 74 - /> 73 + <Link 74 + key={item.uri} 75 + to="/post/$uri" 76 + params={{ uri: encodeURIComponent(item.uri) }} 77 + className="block no-underline" 78 + > 79 + <article className="island-shell p-4 space-y-2 hover:border-[--lagoon-deep]/40"> 80 + {item.reply && ( 81 + <div className="border-l-2 border-[--line] pl-3 text-sm text-[--sea-ink-soft] space-y-0.5"> 82 + <span className="font-medium"> 83 + {item.reply.author.displayName ?? item.reply.author.handle} 84 + </span>{' '} 85 + <span>@{item.reply.author.handle}</span> 86 + <p className="line-clamp-2 m-0">{item.reply.text}</p> 87 + </div> 75 88 )} 76 - <div> 77 - <span className="font-semibold"> 78 - {item.author.displayName ?? item.author.handle} 79 - </span> 80 - <span className="text-sm text-[--sea-ink-soft] mx-1"> 81 - @{item.author.handle} 82 - </span> 83 - || 84 - <span className="text-sm text-[--sea-ink-soft] ml-1"> 85 - {item.createdAt} 86 - </span> 89 + {item.reply && ( 90 + <p className="island-kicker text-xs m-0"> 91 + ↩ replying to @{item.reply.author.handle} 92 + </p> 93 + )} 94 + <div className="flex items-center gap-2"> 95 + {item.author.avatar && ( 96 + <img 97 + src={item.author.avatar} 98 + alt="" 99 + className="w-8 h-8 rounded-full" 100 + /> 101 + )} 102 + <div> 103 + <span className="font-semibold"> 104 + {item.author.displayName ?? item.author.handle} 105 + </span> 106 + <span className="text-sm text-[--sea-ink-soft] mx-1"> 107 + @{item.author.handle} 108 + </span> 109 + <span className="text-sm text-[--sea-ink-soft] ml-1"> 110 + {item.createdAt} 111 + </span> 112 + </div> 87 113 </div> 88 - </div> 89 - <p className="whitespace-pre-wrap">{item.text}</p> 90 - </article> 114 + <p className="whitespace-pre-wrap m-0">{item.text}</p> 115 + </article> 116 + </Link> 91 117 ))} 92 118 </div> 93 119 )
+180
src/routes/post.$uri.tsx
··· 1 + import { Agent } from '@atproto/api' 2 + import { createFileRoute, Link, redirect } from '@tanstack/react-router' 3 + import { createServerFn } from '@tanstack/react-start' 4 + import { getCookie, setCookie } from '@tanstack/react-start/server' 5 + 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 + } 13 + 14 + type ThreadViewPost = { 15 + $type: string 16 + post: { 17 + uri: string 18 + record: unknown 19 + author: { 20 + handle: string 21 + displayName?: string | null 22 + avatar?: string | null 23 + } 24 + } 25 + parent?: unknown 26 + replies?: unknown[] 27 + } 28 + 29 + const isThreadViewPost = (node: unknown): node is ThreadViewPost => 30 + (node as { $type?: string })?.$type === 'app.bsky.feed.defs#threadViewPost' 31 + 32 + const toPost = (node: ThreadViewPost): PostData => ({ 33 + uri: node.post.uri, 34 + text: (node.post.record as { text?: string }).text ?? '', 35 + author: { 36 + handle: node.post.author.handle, 37 + displayName: node.post.author.displayName ?? null, 38 + avatar: node.post.author.avatar ?? null, 39 + }, 40 + createdAt: (node.post.record as { createdAt?: string }).createdAt, 41 + }) 42 + 43 + const getThread = createServerFn({ method: 'GET' }) 44 + .inputValidator((data: { uri: string }) => data) 45 + .handler(async ({ data }) => { 46 + const did = getCookie('did') 47 + if (!did) throw redirect({ to: '/login' }) 48 + 49 + let session: Awaited<ReturnType<typeof client.restore>> 50 + try { 51 + session = await client.restore(did) 52 + } catch { 53 + setCookie('did', '', { maxAge: 0, path: '/' }) 54 + throw redirect({ to: '/login' }) 55 + } 56 + 57 + const agent = new Agent(session) 58 + const { data: threadData } = await agent.getPostThread({ 59 + uri: decodeURIComponent(data.uri), 60 + }) 61 + 62 + if (!isThreadViewPost(threadData.thread)) { 63 + throw new Error('Thread not available') 64 + } 65 + 66 + const focal = threadData.thread 67 + 68 + const ancestors: PostData[] = [] 69 + let current: unknown = focal.parent 70 + while (isThreadViewPost(current)) { 71 + ancestors.unshift(toPost(current)) 72 + current = current.parent 73 + } 74 + 75 + const replies: PostData[] = (focal.replies ?? []) 76 + .filter(isThreadViewPost) 77 + .map(toPost) 78 + 79 + return { post: toPost(focal), ancestors, replies } 80 + }) 81 + 82 + export const Route = createFileRoute('/post/$uri')({ 83 + loader: ({ params }) => getThread({ data: { uri: params.uri } }), 84 + component: PostPage, 85 + }) 86 + 87 + function PostHeader({ 88 + post, 89 + large = false, 90 + }: { 91 + post: PostData 92 + large?: boolean 93 + }) { 94 + return ( 95 + <div className="flex items-center gap-2"> 96 + {post.author.avatar && ( 97 + <img 98 + src={post.author.avatar} 99 + alt="" 100 + className={large ? 'w-10 h-10 rounded-full' : 'w-8 h-8 rounded-full'} 101 + /> 102 + )} 103 + <div> 104 + <span 105 + className={ 106 + large ? 'font-semibold text-base' : 'font-semibold text-sm' 107 + } 108 + > 109 + {post.author.displayName ?? post.author.handle} 110 + </span> 111 + <span className="text-sm text-[--sea-ink-soft] mx-1"> 112 + @{post.author.handle} 113 + </span> 114 + </div> 115 + </div> 116 + ) 117 + } 118 + 119 + function PostPage() { 120 + const { post, ancestors, replies } = Route.useLoaderData() 121 + 122 + return ( 123 + <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-1"> 124 + {ancestors.map((ancestor) => ( 125 + <Link 126 + key={ancestor.uri} 127 + to="/post/$uri" 128 + params={{ uri: encodeURIComponent(ancestor.uri) }} 129 + className="block no-underline" 130 + > 131 + <article className="island-shell p-4 space-y-2 hover:border-[--lagoon-deep]/40 opacity-80"> 132 + <PostHeader post={ancestor} /> 133 + <p className="whitespace-pre-wrap m-0 text-sm">{ancestor.text}</p> 134 + </article> 135 + </Link> 136 + ))} 137 + 138 + {ancestors.length > 0 && ( 139 + <div className="flex justify-center py-1"> 140 + <div className="w-0.5 h-4 bg-[--line]" /> 141 + </div> 142 + )} 143 + 144 + <article className="island-shell p-5 space-y-3 border-[--lagoon]/40"> 145 + <PostHeader post={post} large /> 146 + <p className="whitespace-pre-wrap m-0">{post.text}</p> 147 + {post.createdAt && ( 148 + <p className="text-xs text-[--sea-ink-soft] m-0"> 149 + {new Date(post.createdAt).toLocaleString()} 150 + </p> 151 + )} 152 + </article> 153 + 154 + {replies.length > 0 && ( 155 + <> 156 + <div className="flex justify-center py-1"> 157 + <div className="w-0.5 h-4 bg-[--line]" /> 158 + </div> 159 + <div className="space-y-2"> 160 + {replies.map((reply) => ( 161 + <Link 162 + key={reply.uri} 163 + to="/post/$uri" 164 + params={{ uri: encodeURIComponent(reply.uri) }} 165 + className="block no-underline" 166 + > 167 + <article className="island-shell p-4 space-y-2 hover:border-[--lagoon-deep]/40"> 168 + <PostHeader post={reply} /> 169 + <p className="whitespace-pre-wrap m-0 text-sm"> 170 + {reply.text} 171 + </p> 172 + </article> 173 + </Link> 174 + ))} 175 + </div> 176 + </> 177 + )} 178 + </div> 179 + ) 180 + }