diff --git a/HANDOFF.md b/HANDOFF.md index 2bda83a..e73c3ad 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -17,31 +17,46 @@ Both stores serialize ATProto state objects as JSON text. ### DID cookie (`src/routes/callback.tsx`) -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`. +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. -### Feed loader (`src/routes/feed.tsx`) +**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...`. -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. +### Auth-aware header + logout (`src/components/Header.tsx`, `src/routes/logout.tsx`, `src/routes/__root.tsx`) -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]: {} }`). +- `__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. +- `Header` accepts `did: string | null` — shows Feed link + Sign out button only when logged in. +- `logout.tsx` is a server POST handler that sets `did=; Max-Age=0` and redirects to `/login`. -### Feed UI polish (`src/routes/feed.tsx`) +### Feed UI — reply parents (`src/routes/feed.tsx`) -- Cards now use `.island-shell` (frosted glass style) instead of bare `border rounded-lg` -- Outer wrapper uses `bg-[--bg-base]` for the page background color token +- 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'`). +- Feed cards are wrapped in a `` for navigation to the thread view. +- 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. +- If `client.restore(did)` throws (expired/missing session), the `did` cookie is cleared and the user is redirected to `/login` cleanly. + +### Thread route stub (`src/routes/post.$uri.tsx`) + +Created as a stub — renders "Thread view coming soon" with the decoded URI. Full implementation is the next task. + +### Biome formatter (`biome.json`) + +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. ## Current state of key files ``` -src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables -src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore -src/routes/callback.tsx ← sets DID cookie, redirects to /feed -src/routes/feed.tsx ← createServerFn fetches timeline; island-shell card UI -src/routes/login.tsx ← plain unstyled form; working, needs polish -src/routes/__root.tsx ← shell with Header + Footer components, devtools, theme init script -src/components/Header.tsx ← sticky nav with "Feed" link chip; uses design tokens -src/components/Footer.tsx ← copyright + "Built with TanStack Start" line -src/styles.css ← Tailwind v4 + custom design system (see below) +src/lib/db.ts ← SQLite setup, creates oauth_state + oauth_session tables +src/lib/oauth-client.ts ← NodeOAuthClient with DB-backed stateStore + sessionStore +src/routes/callback.tsx ← sets DID cookie (plain, not encoded), redirects to /feed +src/routes/logout.tsx ← POST handler, clears did cookie, redirects to /login +src/routes/feed.tsx ← createServerFn fetches timeline + reply parents; island-shell cards with Link wrapper +src/routes/post.$uri.tsx ← stub thread view route (not yet implemented) +src/routes/login.tsx ← styled island-shell card with Fraunces heading +src/routes/__root.tsx ← shellComponent/component split; root loader for auth state +src/components/Header.tsx ← auth-aware sticky nav (Feed link + Sign out when logged in) +src/components/Footer.tsx ← copyright + "Built with TanStack Start" line +src/styles.css ← Tailwind v4 + custom design system (see below) +biome.json ← formatter/linter config ``` ## Design system @@ -71,89 +86,40 @@ To use design tokens as Tailwind values: `text-[--sea-ink-soft]`, `bg-[--surface **Fonts**: Manrope (sans body), Fraunces (serif display) -## Remaining UI polish (carry-over) +## Done: threaded reply views ✓ -These were not finished last session: +Both the feed reply-parent UI and the full thread view are implemented and Playwright-verified. -**`src/routes/feed.tsx`:** -- `text-gray-500` on the handle should be `text-[--sea-ink-soft]` (hardcoded color, won't respect dark mode) -- No timestamp — add `createdAt: (item.post.record as { createdAt?: string }).createdAt ?? null` to the mapped return in `getTimeline`, then render it -- No avatar fallback for posts where `author.avatar` is null -- No `pendingComponent` or `errorComponent` on the route +### Feed reply parent UI (`src/routes/feed.tsx`) ✓ -**`src/routes/login.tsx`:** -- Form has zero styling — needs centered card layout, `.island-shell`, lagoon accent on the button +Visually verified — reply posts show a left-bordered parent snippet above the main post card. -**`src/routes/__root.tsx`:** -- Page title is still "TanStack Start Starter" — should be "Dudesky" +### Thread view (`src/routes/post.$uri.tsx`) ✓ -## Before threading: two features to add +- `getThread` server fn calls `agent.getPostThread()`, walks `parent` chain root-first into `ancestors[]`, collects direct `replies[]` +- Ancestors stack above focal post (dimmed, each links to its own thread), connector lines between sections +- Focal post shown prominently with larger avatar and full timestamp +- Replies listed below with same card style as feed +- Navigation between thread levels verified end-to-end -### 1. Images in posts +## Next: images + link previews -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). - -Add `images` to the mapped return in `getTimeline`: - -```ts -images: (item.post.embed as { images?: { thumb: string; fullsize: string; alt: string }[] } | undefined)?.images ?? [] -``` - -Then render them below the post text — a simple responsive grid of `` tags with `src={thumb}` and `alt={alt}` works for starters. - -### 2. Link previews - -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). - -Add `linkPreview` to the mapped return: - -```ts -const embed = item.post.embed as { $type?: string; external?: { uri: string; title: string; description: string; thumb?: string } } | undefined -linkPreview: embed?.$type === 'app.bsky.embed.external#view' ? (embed.external ?? null) : null -``` +**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. -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. - -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. - -## Next: threaded reply views - -Goal: clicking a post opens a thread view showing the post, its ancestors (parent chain), and its replies. - -### ATProto API - -`agent.getPostThread({ uri })` returns a `ThreadViewPost` which has: -- `post` — the post itself -- `parent` — `ThreadViewPost | NotFoundPost | BlockedPost | undefined` (walk up for ancestors) -- `replies` — `ThreadViewPost[] | ...` (direct replies) - -The `uri` for each post is already in the mapped feed data as `item.uri`. - -### Suggested route - -`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`). - -### Data shape to map in the server fn - -Same `createServerFn` pattern as `feed.tsx`. Map to a plain serializable object — don't pass raw ATProto types through. Suggested shape: - -```ts -{ - post: { uri, text, author, createdAt }, - ancestors: Array<{ uri, text, author, createdAt }>, // ordered root-first - replies: Array<{ uri, text, author, createdAt }>, -} -``` +**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`. -Walk `parent` recursively to build `ancestors`, collect `replies` array directly. +A post has at most one embed — images OR link preview, not both. Discriminate by `$type`. ## Conventions - Path alias `#/` maps to `src/` -- No semicolons in `.ts`/`.tsx` files +- No semicolons in `.ts`/`.tsx` files (Biome enforced) +- Tabs for indentation, single quotes (Biome enforced) - 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) - `createServerFn` for any server-only code called from loaders (DB, cookies, secrets) +- ATProto type narrowing: cast `item.reply?.parent` to a local interface, then discriminate by `$type` — `in` checks don't narrow the union reliably - Env vars: `VITE_APP_URL`, `PRIVATE_KEY_0/1/2`, optionally `DB_PATH` - Read AGENTS.md and load the relevant SKILL.md files before working on TanStack-related tasks - Playwright is configured for WSL2 with `--no-sandbox` in `~/.claude/plugins/cache/claude-plugins-official/playwright/unknown/.mcp.json` -- To inject auth into the headless browser: set cookie `did=` on `localhost:3001` via `browser_evaluate`, then navigate to `/feed` +- Dev server runs on port 3001 (3000 is taken by something else) +- **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 diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 9f7d5e1..205a076 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,213 +9,233 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as AboutRouteImport } from './routes/about' -import { Route as CallbackRouteImport } from './routes/callback' -import { Route as ClientMetadataRouteImport } from './routes/client-metadata' +import { Route as LogoutRouteImport } from './routes/logout' +import { Route as LoginRouteImport } from './routes/login' +import { Route as JwksRouteImport } from './routes/jwks' import { Route as FeedRouteImport } from './routes/feed' +import { Route as ClientMetadataRouteImport } from './routes/client-metadata' +import { Route as CallbackRouteImport } from './routes/callback' +import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' -import { Route as JwksRouteImport } from './routes/jwks' -import { Route as LoginRouteImport } from './routes/login' -import { Route as LogoutRouteImport } from './routes/logout' +import { Route as PostUriRouteImport } from './routes/post.$uri' const LogoutRoute = LogoutRouteImport.update({ - id: '/logout', - path: '/logout', - getParentRoute: () => rootRouteImport, + id: '/logout', + path: '/logout', + getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => rootRouteImport, + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, } as any) const JwksRoute = JwksRouteImport.update({ - id: '/jwks', - path: '/jwks', - getParentRoute: () => rootRouteImport, + id: '/jwks', + path: '/jwks', + getParentRoute: () => rootRouteImport, } as any) const FeedRoute = FeedRouteImport.update({ - id: '/feed', - path: '/feed', - getParentRoute: () => rootRouteImport, + id: '/feed', + path: '/feed', + getParentRoute: () => rootRouteImport, } as any) const ClientMetadataRoute = ClientMetadataRouteImport.update({ - id: '/client-metadata', - path: '/client-metadata', - getParentRoute: () => rootRouteImport, + id: '/client-metadata', + path: '/client-metadata', + getParentRoute: () => rootRouteImport, } as any) const CallbackRoute = CallbackRouteImport.update({ - id: '/callback', - path: '/callback', - getParentRoute: () => rootRouteImport, + id: '/callback', + path: '/callback', + getParentRoute: () => rootRouteImport, } as any) const AboutRoute = AboutRouteImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => rootRouteImport, + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, } as any) const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const PostUriRoute = PostUriRouteImport.update({ + id: '/post/$uri', + path: '/post/$uri', + getParentRoute: () => rootRouteImport, } as any) export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/callback': typeof CallbackRoute - '/client-metadata': typeof ClientMetadataRoute - '/feed': typeof FeedRoute - '/jwks': typeof JwksRoute - '/login': typeof LoginRoute - '/logout': typeof LogoutRoute + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/callback': typeof CallbackRoute + '/client-metadata': typeof ClientMetadataRoute + '/feed': typeof FeedRoute + '/jwks': typeof JwksRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/post/$uri': typeof PostUriRoute } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/callback': typeof CallbackRoute - '/client-metadata': typeof ClientMetadataRoute - '/feed': typeof FeedRoute - '/jwks': typeof JwksRoute - '/login': typeof LoginRoute - '/logout': typeof LogoutRoute + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/callback': typeof CallbackRoute + '/client-metadata': typeof ClientMetadataRoute + '/feed': typeof FeedRoute + '/jwks': typeof JwksRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/post/$uri': typeof PostUriRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/callback': typeof CallbackRoute - '/client-metadata': typeof ClientMetadataRoute - '/feed': typeof FeedRoute - '/jwks': typeof JwksRoute - '/login': typeof LoginRoute - '/logout': typeof LogoutRoute + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/callback': typeof CallbackRoute + '/client-metadata': typeof ClientMetadataRoute + '/feed': typeof FeedRoute + '/jwks': typeof JwksRoute + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/post/$uri': typeof PostUriRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/about' - | '/callback' - | '/client-metadata' - | '/feed' - | '/jwks' - | '/login' - | '/logout' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/about' - | '/callback' - | '/client-metadata' - | '/feed' - | '/jwks' - | '/login' - | '/logout' - id: - | '__root__' - | '/' - | '/about' - | '/callback' - | '/client-metadata' - | '/feed' - | '/jwks' - | '/login' - | '/logout' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/about' + | '/callback' + | '/client-metadata' + | '/feed' + | '/jwks' + | '/login' + | '/logout' + | '/post/$uri' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/callback' + | '/client-metadata' + | '/feed' + | '/jwks' + | '/login' + | '/logout' + | '/post/$uri' + id: + | '__root__' + | '/' + | '/about' + | '/callback' + | '/client-metadata' + | '/feed' + | '/jwks' + | '/login' + | '/logout' + | '/post/$uri' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - AboutRoute: typeof AboutRoute - CallbackRoute: typeof CallbackRoute - ClientMetadataRoute: typeof ClientMetadataRoute - FeedRoute: typeof FeedRoute - JwksRoute: typeof JwksRoute - LoginRoute: typeof LoginRoute - LogoutRoute: typeof LogoutRoute + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute + CallbackRoute: typeof CallbackRoute + ClientMetadataRoute: typeof ClientMetadataRoute + FeedRoute: typeof FeedRoute + JwksRoute: typeof JwksRoute + LoginRoute: typeof LoginRoute + LogoutRoute: typeof LogoutRoute + PostUriRoute: typeof PostUriRoute } declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/logout': { - id: '/logout' - path: '/logout' - fullPath: '/logout' - preLoaderRoute: typeof LogoutRouteImport - parentRoute: typeof rootRouteImport - } - '/login': { - id: '/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LoginRouteImport - parentRoute: typeof rootRouteImport - } - '/jwks': { - id: '/jwks' - path: '/jwks' - fullPath: '/jwks' - preLoaderRoute: typeof JwksRouteImport - parentRoute: typeof rootRouteImport - } - '/feed': { - id: '/feed' - path: '/feed' - fullPath: '/feed' - preLoaderRoute: typeof FeedRouteImport - parentRoute: typeof rootRouteImport - } - '/client-metadata': { - id: '/client-metadata' - path: '/client-metadata' - fullPath: '/client-metadata' - preLoaderRoute: typeof ClientMetadataRouteImport - parentRoute: typeof rootRouteImport - } - '/callback': { - id: '/callback' - path: '/callback' - fullPath: '/callback' - preLoaderRoute: typeof CallbackRouteImport - parentRoute: typeof rootRouteImport - } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutRouteImport - parentRoute: typeof rootRouteImport - } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - } + interface FileRoutesByPath { + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/jwks': { + id: '/jwks' + path: '/jwks' + fullPath: '/jwks' + preLoaderRoute: typeof JwksRouteImport + parentRoute: typeof rootRouteImport + } + '/feed': { + id: '/feed' + path: '/feed' + fullPath: '/feed' + preLoaderRoute: typeof FeedRouteImport + parentRoute: typeof rootRouteImport + } + '/client-metadata': { + id: '/client-metadata' + path: '/client-metadata' + fullPath: '/client-metadata' + preLoaderRoute: typeof ClientMetadataRouteImport + parentRoute: typeof rootRouteImport + } + '/callback': { + id: '/callback' + path: '/callback' + fullPath: '/callback' + preLoaderRoute: typeof CallbackRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/post/$uri': { + id: '/post/$uri' + path: '/post/$uri' + fullPath: '/post/$uri' + preLoaderRoute: typeof PostUriRouteImport + parentRoute: typeof rootRouteImport + } + } } const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - AboutRoute: AboutRoute, - CallbackRoute: CallbackRoute, - ClientMetadataRoute: ClientMetadataRoute, - FeedRoute: FeedRoute, - JwksRoute: JwksRoute, - LoginRoute: LoginRoute, - LogoutRoute: LogoutRoute, + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, + CallbackRoute: CallbackRoute, + ClientMetadataRoute: ClientMetadataRoute, + FeedRoute: FeedRoute, + JwksRoute: JwksRoute, + LoginRoute: LoginRoute, + LogoutRoute: LogoutRoute, + PostUriRoute: PostUriRoute, } export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileChildren(rootRouteChildren) + ._addFileTypes() import type { getRouter } from './router.tsx' import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - config: Awaited> - } + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } } diff --git a/src/routes/callback.tsx b/src/routes/callback.tsx index 5fb4aa8..af7e6fa 100644 --- a/src/routes/callback.tsx +++ b/src/routes/callback.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute('/callback')({ status: 302, headers: { Location: new URL('/feed', request.url).toString(), - 'Set-Cookie': `did=${encodeURIComponent(session.sub)}; Path=/; HttpOnly; SameSite=Lax`, + 'Set-Cookie': `did=${session.sub}; Path=/; HttpOnly; SameSite=Lax`, }, }) } catch (e) { diff --git a/src/routes/feed.tsx b/src/routes/feed.tsx index 7e9eb2e..56f4285 100644 --- a/src/routes/feed.tsx +++ b/src/routes/feed.tsx @@ -1,7 +1,7 @@ import { Agent } from '@atproto/api' -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, Link, redirect } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' -import { getCookie } from '@tanstack/react-start/server' +import { getCookie, setCookie } from '@tanstack/react-start/server' import { client } from '#/lib/oauth-client' const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { @@ -11,7 +11,13 @@ const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { throw redirect({ to: '/login' }) } - const session = await client.restore(did) + let session: Awaited> + try { + session = await client.restore(did) + } catch { + setCookie('did', '', { maxAge: 0, path: '/' }) + throw redirect({ to: '/login' }) + } const agent = new Agent(session) const { data } = await agent.getTimeline({ limit: 50 }) return { @@ -64,30 +70,50 @@ function FeedPage() { return (
{feed.map((item) => ( -
-
- {item.author.avatar && ( - + +
+ {item.reply && ( +
+ + {item.reply.author.displayName ?? item.reply.author.handle} + {' '} + @{item.reply.author.handle} +

{item.reply.text}

+
+ )} + {item.reply && ( +

+ ↩ replying to @{item.reply.author.handle} +

)} -
- - {item.author.displayName ?? item.author.handle} - - - @{item.author.handle} - - || - - {item.createdAt} - +
+ {item.author.avatar && ( + + )} +
+ + {item.author.displayName ?? item.author.handle} + + + @{item.author.handle} + + + {item.createdAt} + +
-
-

{item.text}

-
+

{item.text}

+
+ ))}
) diff --git a/src/routes/post.$uri.tsx b/src/routes/post.$uri.tsx new file mode 100644 index 0000000..9ad6f3a --- /dev/null +++ b/src/routes/post.$uri.tsx @@ -0,0 +1,180 @@ +import { Agent } from '@atproto/api' +import { createFileRoute, Link, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { getCookie, setCookie } from '@tanstack/react-start/server' +import { client } from '#/lib/oauth-client' + +interface PostData { + uri: string + text: string + author: { handle: string; displayName: string | null; avatar: string | null } + createdAt: string | undefined +} + +type ThreadViewPost = { + $type: string + post: { + uri: string + record: unknown + author: { + handle: string + displayName?: string | null + avatar?: string | null + } + } + parent?: unknown + replies?: unknown[] +} + +const isThreadViewPost = (node: unknown): node is ThreadViewPost => + (node as { $type?: string })?.$type === 'app.bsky.feed.defs#threadViewPost' + +const toPost = (node: ThreadViewPost): PostData => ({ + uri: node.post.uri, + text: (node.post.record as { text?: string }).text ?? '', + author: { + handle: node.post.author.handle, + displayName: node.post.author.displayName ?? null, + avatar: node.post.author.avatar ?? null, + }, + createdAt: (node.post.record as { createdAt?: string }).createdAt, +}) + +const getThread = createServerFn({ method: 'GET' }) + .inputValidator((data: { uri: string }) => data) + .handler(async ({ data }) => { + const did = getCookie('did') + if (!did) throw redirect({ to: '/login' }) + + let session: Awaited> + try { + session = await client.restore(did) + } catch { + setCookie('did', '', { maxAge: 0, path: '/' }) + throw redirect({ to: '/login' }) + } + + const agent = new Agent(session) + const { data: threadData } = await agent.getPostThread({ + uri: decodeURIComponent(data.uri), + }) + + if (!isThreadViewPost(threadData.thread)) { + throw new Error('Thread not available') + } + + const focal = threadData.thread + + const ancestors: PostData[] = [] + let current: unknown = focal.parent + while (isThreadViewPost(current)) { + ancestors.unshift(toPost(current)) + current = current.parent + } + + const replies: PostData[] = (focal.replies ?? []) + .filter(isThreadViewPost) + .map(toPost) + + return { post: toPost(focal), ancestors, replies } + }) + +export const Route = createFileRoute('/post/$uri')({ + loader: ({ params }) => getThread({ data: { uri: params.uri } }), + component: PostPage, +}) + +function PostHeader({ + post, + large = false, +}: { + post: PostData + large?: boolean +}) { + return ( +
+ {post.author.avatar && ( + + )} +
+ + {post.author.displayName ?? post.author.handle} + + + @{post.author.handle} + +
+
+ ) +} + +function PostPage() { + const { post, ancestors, replies } = Route.useLoaderData() + + return ( +
+ {ancestors.map((ancestor) => ( + +
+ +

{ancestor.text}

+
+ + ))} + + {ancestors.length > 0 && ( +
+
+
+ )} + +
+ +

{post.text}

+ {post.createdAt && ( +

+ {new Date(post.createdAt).toLocaleString()} +

+ )} +
+ + {replies.length > 0 && ( + <> +
+
+
+
+ {replies.map((reply) => ( + +
+ +

+ {reply.text} +

+
+ + ))} +
+ + )} +
+ ) +}