# Comments Feature Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add gallery detail page with comment bottom sheet — view, create, reply, delete comments. **Architecture:** New XRPC endpoint `getGalleryThread` fetches comments server-side. Gallery detail page at `/profile/[did]/gallery/[rkey]` reuses `GalleryCard` and opens a `CommentSheet` bottom sheet. Comments created/deleted via `callXrpc` with `dev.hatk.createRecord`/`dev.hatk.deleteRecord`. **Tech Stack:** SvelteKit 5, Svelte 5 runes, hatk XRPC (`defineQuery`, `callXrpc`), TanStack Svelte Query. --- ### Task 1: Lexicon for getGalleryThread **Files:** - Create: `lexicons/social/grain/unspecced/getGalleryThread.json` **Step 1: Create the lexicon definition** Follow the same pattern as `lexicons/social/grain/unspecced/getActorProfile.json`. ```json { "lexicon": 1, "id": "social.grain.unspecced.getGalleryThread", "defs": { "main": { "type": "query", "description": "Get comments for a gallery, sorted oldest-first with author profiles.", "parameters": { "type": "params", "required": ["gallery"], "properties": { "gallery": { "type": "string", "format": "at-uri", "description": "The gallery URI to fetch comments for." }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, "cursor": { "type": "string" } } }, "output": { "encoding": "application/json", "schema": { "type": "object", "required": ["comments"], "properties": { "comments": { "type": "array", "items": { "type": "ref", "ref": "social.grain.comment.defs#commentView" } }, "cursor": { "type": "string" }, "totalCount": { "type": "integer" } } } } } } } ``` **Step 2: Regenerate hatk types** Run: `npx hatk generate` (or whatever the project uses to regenerate `hatk.generated.ts` and `hatk.generated.client.ts` from lexicons). Check that `social.grain.unspecced.getGalleryThread` appears in the `XrpcSchema` type in `hatk.generated.ts`. --- ### Task 2: Server-side getGalleryThread Endpoint **Files:** - Create: `server/xrpc/getGalleryThread.ts` **Step 1: Create the XRPC handler** Follow the pattern from `server/xrpc/getActorProfile.ts` using `defineQuery`. Use `ctx.db.query` for SQL, `ctx.lookup` for profiles, `ctx.blobUrl` for avatars, and the `views.commentView()` validator. ```typescript import { defineQuery } from "$hatk"; import type { GrainActorProfile, Comment, Photo } from "$hatk"; import { views } from "$hatk"; export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx) => { const { ok, params, db, lookup, blobUrl, getRecords } = ctx; const { gallery, limit = 20, cursor } = params; // Count total comments for this gallery const countRows = (await db.query( `SELECT count(*) as cnt FROM "social.grain.comment" WHERE subject = $1`, [gallery], )) as { cnt: number }[]; const totalCount = countRows[0]?.cnt ?? 0; // Fetch comments with cursor-based pagination (oldest first) let query = `SELECT uri, did, cid, value, created_at FROM "social.grain.comment" WHERE subject = $1`; const queryParams: any[] = [gallery]; if (cursor) { query += ` AND created_at > $2`; queryParams.push(cursor); } query += ` ORDER BY created_at ASC LIMIT $${queryParams.length + 1}`; queryParams.push(limit + 1); // fetch one extra for cursor const rows = (await db.query(query, queryParams)) as Array<{ uri: string; did: string; cid: string; value: any; created_at: string; }>; const hasMore = rows.length > limit; const items = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? items[items.length - 1]?.created_at : undefined; // Hydrate author profiles const dids = [...new Set(items.map((r) => r.did))]; const profiles = await lookup("social.grain.actor.profile", "did", dids); // Hydrate focus photos const focusUris = items.map((r) => r.value.focus).filter(Boolean) as string[]; const focusPhotos = focusUris.length > 0 ? await getRecords("social.grain.photo", focusUris) : new Map(); const comments = items.map((row) => { const author = profiles.get(row.did); const focusPhoto = row.value.focus ? focusPhotos.get(row.value.focus) : null; return views.commentView({ uri: row.uri, cid: row.cid, text: row.value.text, facets: row.value.facets, replyTo: row.value.replyTo, createdAt: row.value.createdAt ?? row.created_at, author: author ? views.grainActorDefsProfileView({ cid: author.cid, did: author.did, handle: author.handle ?? author.did, displayName: author.value.displayName, avatar: blobUrl(author.did, author.value.avatar) ?? undefined, }) : views.grainActorDefsProfileView({ cid: row.cid, did: row.did, handle: row.did, }), ...(focusPhoto ? { focus: views.photoView({ uri: focusPhoto.uri, cid: focusPhoto.cid, thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", alt: focusPhoto.value.alt, aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, }), } : {}), }); }); return ok({ comments, ...(nextCursor ? { cursor: nextCursor } : {}), totalCount }); }); ``` **Step 2: Verify it compiles** Run: `npm run dev` — hatk should auto-register the endpoint. Test with curl: ``` curl "http://127.0.0.1:3000/xrpc/social.grain.unspecced.getGalleryThread?gallery=at://did:plc:xxx/social.grain.gallery/xxx" ``` --- ### Task 3: Query Helper and GalleryCard Modification **Files:** - Modify: `app/lib/queries.ts` - Modify: `app/lib/components/molecules/GalleryCard.svelte` **Step 1: Add query helper to `app/lib/queries.ts`** Add after the existing queries: ```typescript // ─── Gallery Thread (Comments) ────────────────────────────────────── export const galleryThreadQuery = (galleryUri: string, f?: Fetch) => queryOptions({ queryKey: ["getGalleryThread", galleryUri], queryFn: () => callXrpc("social.grain.unspecced.getGalleryThread", { gallery: galleryUri }, f), staleTime: 30_000, }); ``` **Step 2: Make GalleryCard comment button accept an onclick prop** In `app/lib/components/molecules/GalleryCard.svelte`, change the props to accept an optional `onCommentClick` callback: ```typescript let { gallery, onCommentClick }: { gallery: GalleryView; onCommentClick?: () => void } = $props(); ``` Update the comment button (around line 140): ```svelte ``` --- ### Task 4: Comment Component **Files:** - Create: `app/lib/components/molecules/Comment.svelte` **Step 1: Create the comment display component** ```svelte
{timeStr} {#if onReply} {/if} {#if isOwner && onDelete} {/if}
{#if comment.focus?.thumb} {comment.focus?.alt {/if}
``` --- ### Task 5: Comment Sheet Component **Files:** - Create: `app/lib/components/organisms/CommentSheet.svelte` **Step 1: Create the bottom sheet** This is the main comment interaction component. It handles: - Fetching and displaying comments (organized: roots first, then replies) - Posting new comments with rich text facet parsing - Replying to comments - Deleting own comments - Pagination ("Load earlier comments") - Focus photo indicator in input ```svelte {#if open}
Comments
{#if cursor} {/if} {#if loading}
{:else if organized.length === 0}
No comments yet. Be the first!
{:else} {#each organized as comment (comment.uri)} {/each} {/if}
{#if error}

{error}

{/if} {#if replyToHandle}
Replying to @{replyToHandle}
{/if}
{#if $viewer} {/if} {#if localFocusThumb}
Focused photo
{/if}
e.key === 'Enter' && handlePost()} />
{/if} ``` --- ### Task 6: Gallery Detail Page **Files:** - Create: `app/routes/profile/[did]/gallery/[rkey]/+page.server.ts` - Create: `app/routes/profile/[did]/gallery/[rkey]/+page.svelte` **Step 1: Create the server-side auth guard and data loader** `app/routes/profile/[did]/gallery/[rkey]/+page.server.ts`: ```typescript import { redirect } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async ({ parent }) => { const { viewer } = await parent(); if (!viewer) throw redirect(302, "/"); }; ``` **Step 2: Create the detail page** `app/routes/profile/[did]/gallery/[rkey]/+page.svelte`: This page fetches the actor's galleries via the existing `actorFeedQuery`, finds the specific gallery by rkey, displays it with `GalleryCard`, and manages the `CommentSheet`. ```svelte
{#if feed.isLoading}

Loading...

{:else if !gallery}

Gallery not found

{:else} { commentSheetOpen = false }} /> {/if}
``` --- ### Task 7: Link GalleryCard to Detail Page **Files:** - Modify: `app/lib/components/molecules/GalleryCard.svelte` The gallery card title or the card itself should link to the detail page. Add a link on the title: In the `card-content` section of GalleryCard.svelte, wrap the title in an anchor: ```svelte

{gallery.title}

{#if gallery.description}

{gallery.description}

{/if}
``` Add style: ```css .title-link { text-decoration: none; color: inherit; } .title-link:hover .title { text-decoration: underline; } ``` --- ### Task 8: End-to-End Verification **Step 1: Full flow test** 1. Log in 2. Navigate to a gallery detail page via the title link on a GalleryCard 3. Click the comment icon → sheet opens 4. Post a comment → appears optimistically 5. Reply to a comment → input prefills with @handle 6. Delete your own comment → disappears 7. Close sheet, verify comment count updated **Step 2: Edge cases** 1. Gallery with no comments → "No comments yet" empty state 2. Post with special characters / @mentions / URLs → facets parsed 3. Unauthenticated → redirects to home 4. Load more pagination (need >20 comments)