# Notifications Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a notifications system with 6 notification types (favorite, comment, mention, reply, follow) and read/unread tracking via server-side timestamp. **Architecture:** Notifications are computed by querying existing records (favorites, comments, follows) where the target is the viewer. No new database tables. Read/unread tracked via `lastSeenNotifications` timestamp in `_preferences`. New XRPC query endpoint serves paginated notifications. Bell icon with badge in sidebar + mobile bottom bar. **Tech Stack:** SvelteKit 5, TanStack Query, hatk XRPC framework, SQLite, lucide-svelte icons --- ### Task 1: Create the Notification Lexicon **Files:** - Create: `lexicons/social/grain/unspecced/getNotifications.json` **Step 1: Create the lexicon file** ```json { "lexicon": 1, "id": "social.grain.unspecced.getNotifications", "defs": { "main": { "type": "query", "description": "Get notifications for the authenticated user.", "parameters": { "type": "params", "required": ["viewer"], "properties": { "viewer": { "type": "string", "format": "did", "description": "The DID of the viewer to fetch notifications for." }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, "cursor": { "type": "string" } } }, "output": { "encoding": "application/json", "schema": { "type": "object", "required": ["notifications"], "properties": { "notifications": { "type": "array", "items": { "type": "ref", "ref": "#notificationItem" } }, "cursor": { "type": "string" }, "unseenCount": { "type": "integer" } } } } }, "notificationItem": { "type": "object", "required": ["uri", "reason", "createdAt", "author"], "properties": { "uri": { "type": "string", "format": "at-uri" }, "reason": { "type": "string", "knownValues": [ "gallery-favorite", "gallery-comment", "gallery-comment-mention", "gallery-mention", "reply", "follow" ] }, "createdAt": { "type": "string", "format": "datetime" }, "author": { "type": "ref", "ref": "social.grain.actor.defs#profileView" }, "galleryUri": { "type": "string", "format": "at-uri" }, "galleryTitle": { "type": "string" }, "galleryThumb": { "type": "string" }, "commentText": { "type": "string" }, "replyToText": { "type": "string" } } } } } ``` **Step 2: Regenerate types** Run: `npx hatk generate` Expected: `hatk.generated.ts` updated with new `GetNotifications` type and `NotificationItem` view type. --- ### Task 2: Create the XRPC Query Handler **Files:** - Create: `server/xrpc/getNotifications.ts` **Step 1: Write the handler** This handler computes notifications by querying favorites, comments, follows, and galleries where the viewer is the target. It unions all results, sorts by `created_at` desc, and paginates. ```typescript import { defineQuery } from "$hatk"; import type { GrainActorProfile, Photo, Gallery } from "$hatk"; import { views } from "$hatk"; export default defineQuery("social.grain.unspecced.getNotifications", async (ctx) => { const { ok, params, db, lookup, blobUrl, getRecords } = ctx; const { viewer, limit = 20, cursor } = params; // Get lastSeenNotifications from preferences const prefRows = (await db.query( `SELECT value FROM _preferences WHERE did = $1 AND key = 'lastSeenNotifications'`, [viewer], )) as { value: string }[]; const lastSeen = prefRows[0]?.value ?? null; // Build union query for all notification sources const cursorFilter = cursor ? `AND created_at < '${cursor}'` : ""; // 1. Favorites on viewer's galleries // 2. Comments on viewer's galleries (not by viewer) // 3. Replies to viewer's comments (not by viewer) // 4. Follows of viewer // 5. Gallery mentions of viewer (checked in app code via facets) // 6. Comment mentions of viewer (checked in app code via facets) const unionQuery = ` SELECT uri, did, created_at, 'favorite' as source, subject as gallery_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus FROM "social.grain.favorite" WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) AND did != $1 ${cursorFilter} UNION ALL SELECT uri, did, created_at, 'comment' as source, subject as gallery_uri, text, facets, reply_to, focus FROM "social.grain.comment" WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) AND did != $1 AND reply_to IS NULL ${cursorFilter} UNION ALL SELECT c.uri, c.did, c.created_at, 'reply' as source, c.subject as gallery_uri, c.text, c.facets, c.reply_to, c.focus FROM "social.grain.comment" c WHERE c.reply_to IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) AND c.did != $1 ${cursorFilter} UNION ALL SELECT uri, did, created_at, 'follow' as source, NULL as gallery_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus FROM "social.grain.graph.follow" WHERE subject = $1 AND did != $1 ${cursorFilter} UNION ALL SELECT uri, did, created_at, 'comment-mention' as source, subject as gallery_uri, text, facets, NULL as reply_to, focus FROM "social.grain.comment" WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${cursorFilter} UNION ALL SELECT uri, did, created_at, 'gallery-mention' as source, uri as gallery_uri, description as text, facets, NULL as reply_to, NULL as focus FROM "social.grain.gallery" WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${cursorFilter} ORDER BY created_at DESC LIMIT $2 `; const rows = (await db.query(unionQuery, [viewer, limit + 1])) as Array<{ uri: string; did: string; created_at: string; source: string; gallery_uri: string | null; text: string | null; facets: string | null; reply_to: string | null; focus: string | null; }>; const hasMore = rows.length > limit; const items = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? items[items.length - 1]?.created_at : undefined; // Determine notification reason function getReason(row: (typeof items)[0]): string { if (row.source === "favorite") return "gallery-favorite"; if (row.source === "follow") return "follow"; if (row.source === "comment-mention") return "gallery-comment-mention"; if (row.source === "gallery-mention") return "gallery-mention"; if (row.source === "reply") return "reply"; // Regular comment — check for mention facets if (row.facets) { try { const facets = JSON.parse(row.facets); const hasMention = Array.isArray(facets) && facets.some((f: any) => f.features?.some( (feat: any) => feat.$type === "app.bsky.richtext.facet#mention" && feat.did === viewer, ), ); if (hasMention) return "gallery-comment-mention"; } catch {} } return "gallery-comment"; } // Hydrate author profiles const dids = [...new Set(items.map((r) => r.did))]; const profiles = await lookup("social.grain.actor.profile", "did", dids); // Hydrate galleries for thumbnails const galleryUris = [...new Set(items.map((r) => r.gallery_uri).filter(Boolean))] as string[]; const galleries = galleryUris.length > 0 ? await getRecords("social.grain.gallery", galleryUris) : new Map(); // Get first photo thumbnail for each gallery const galleryItemRows = galleryUris.length > 0 ? ((await db.query( `SELECT gallery, item FROM "social.grain.gallery.item" WHERE gallery IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")}) ORDER BY position ASC`, galleryUris, )) as Array<{ gallery: string; item: string }>) : []; const firstPhotoByGallery = new Map(); for (const row of galleryItemRows) { if (!firstPhotoByGallery.has(row.gallery)) { firstPhotoByGallery.set(row.gallery, row.item); } } const allPhotoUris = [...new Set(firstPhotoByGallery.values())]; const photos = allPhotoUris.length > 0 ? await getRecords("social.grain.photo", allPhotoUris) : new Map(); // Hydrate reply-to texts const replyToUris = items.map((r) => r.reply_to).filter(Boolean) as string[]; const replyToComments = replyToUris.length > 0 ? ((await db.query( `SELECT uri, text FROM "social.grain.comment" WHERE uri IN (${replyToUris.map((_, i) => `$${i + 1}`).join(",")})`, replyToUris, )) as Array<{ uri: string; text: string }>) : []; const replyToTextMap = new Map(); for (const row of replyToComments) replyToTextMap.set(row.uri, row.text); // Count unseen let unseenCount = 0; if (lastSeen) { const countRows = (await db.query( `SELECT count(*) as cnt FROM ( SELECT created_at FROM "social.grain.favorite" WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) AND did != $1 AND created_at > $2 UNION ALL SELECT created_at FROM "social.grain.comment" WHERE (subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) OR reply_to IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) OR facets LIKE '%' || $1 || '%') AND did != $1 AND created_at > $2 UNION ALL SELECT created_at FROM "social.grain.graph.follow" WHERE subject = $1 AND did != $1 AND created_at > $2 UNION ALL SELECT created_at FROM "social.grain.gallery" WHERE facets LIKE '%' || $1 || '%' AND did != $1 AND created_at > $2 )`, [viewer, lastSeen], )) as { cnt: number }[]; unseenCount = countRows[0]?.cnt ?? 0; } const notifications = items.map((row) => { const author = profiles.get(row.did); const gallery = row.gallery_uri ? galleries.get(row.gallery_uri) : null; const photoUri = row.gallery_uri ? firstPhotoByGallery.get(row.gallery_uri) : null; const photo = photoUri ? photos.get(photoUri) : null; const thumb = photo ? (blobUrl(photo.did, photo.value.photo, "feed_thumbnail") ?? undefined) : undefined; return { uri: row.uri, reason: getReason(row), 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.uri, did: row.did, handle: row.did, }), ...(gallery ? { galleryUri: row.gallery_uri!, galleryTitle: gallery.value.title } : {}), ...(thumb ? { galleryThumb: thumb } : {}), ...(row.text ? { commentText: row.text } : {}), ...(row.reply_to && replyToTextMap.has(row.reply_to) ? { replyToText: replyToTextMap.get(row.reply_to) } : {}), }; }); return ok({ notifications, ...(nextCursor ? { cursor: nextCursor } : {}), unseenCount }); }); ``` **Step 2: Verify the server starts** Run: `npm run dev` (check for TypeScript errors) Expected: Server starts without errors. The new endpoint is registered automatically by hatk when it finds the file. --- ### Task 3: Add Query Helpers and Unseen Count Store **Files:** - Modify: `app/lib/queries.ts` - Modify: `app/lib/preferences.ts` **Step 1: Add notification queries to `queries.ts`** Add at the end of the file, before the closing content: ```typescript // ─── Notifications ───────────────────────────────────────────────── export const notificationsQuery = (viewer: string, f?: Fetch) => queryOptions({ queryKey: ["notifications", viewer], queryFn: () => callXrpc("social.grain.unspecced.getNotifications", { viewer, limit: 20 }, f), staleTime: 60_000, }); export const unseenNotificationCountQuery = (viewer: string, f?: Fetch) => queryOptions({ queryKey: ["unseenNotificationCount", viewer], queryFn: () => callXrpc("social.grain.unspecced.getNotifications", { viewer, limit: 1 }, f).then( (r: any) => (r?.unseenCount ?? 0) as number, ), staleTime: 60_000, }); ``` **Step 2: Add mark-as-seen helper to `preferences.ts`** Add at the end of the file: ```typescript export async function markNotificationsSeen(): Promise { await callXrpc("dev.hatk.putPreference", { key: "lastSeenNotifications", value: new Date().toISOString(), }); } ``` --- ### Task 4: Add Bell Icon to Desktop Sidebar **Files:** - Modify: `app/lib/components/organisms/Sidebar.svelte` **Step 1: Update the sidebar** Add the Bell import and unseen count query, then add the bell icon between Home and Settings: The ` ``` In the `.nav-items` div, add the bell icon after the Home link and before the `{#if $isAuthenticated}` block. The full `.nav-items` section becomes: ```svelte ``` Add styles for the badge inside the ` ``` --- ### Task 7: Create the NotificationItem Component **Files:** - Create: `app/lib/components/atoms/NotificationItem.svelte` **Step 1: Create the component** ```svelte
{authorName} {action} {timeStr}
{#if notif.reason === 'reply' && notif.replyToText}
{notif.replyToText}
{/if} {#if notif.commentText}
{notif.commentText}
{/if} {#if notif.galleryTitle && notif.reason !== 'follow'} {/if}
{#if notif.galleryThumb} {/if}
``` --- ### Task 8: Verify End-to-End **Step 1: Regenerate types** Run: `npx hatk generate` **Step 2: Start the dev server** Run: `npm run dev` **Step 3: Test manually** 1. Log in as a user who has received favorites/comments/follows 2. Check that the bell icon appears in the sidebar and mobile bottom bar 3. Navigate to `/notifications` and verify notifications render 4. Check that the badge shows the unseen count 5. After visiting the page, verify the badge clears 6. Scroll to trigger infinite scroll pagination