~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
854 lines · 24.9 KB Raw History
# 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<GrainActorProfile>("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<Gallery>("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<string, string>();
  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<Photo>("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<string, string>();
  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<void> {
  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 `<script>` block becomes:

```svelte
<script lang="ts">
  import { Home, Plus, Settings, Bell } from 'lucide-svelte'
  import AuthBar from './AuthBar.svelte'
  import Avatar from '../atoms/Avatar.svelte'
  import { isAuthenticated, viewer } from '$lib/stores'
  import { page } from '$app/state'
  import { createQuery } from '@tanstack/svelte-query'
  import { unseenNotificationCountQuery } from '$lib/queries'

  const unseenCount = createQuery(() => ({
    ...unseenNotificationCountQuery($viewer?.did ?? ''),
    enabled: !!$viewer?.did,
  }))
</script>
```

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
  <div class="nav-items">
    <a href="/" class="nav-item" class:active={page.url.pathname === '/'} title="Home">
      <Home size={22} />
    </a>
    {#if $isAuthenticated}
      <a href="/notifications" class="nav-item" class:active={page.url.pathname === '/notifications'} title="Notifications">
        <span class="bell-wrap">
          <Bell size={22} />
          {#if (unseenCount.data ?? 0) > 0}
            <span class="badge">{unseenCount.data! > 99 ? '99+' : unseenCount.data}</span>
          {/if}
        </span>
      </a>
      <a href="/settings/profile" class="nav-item" class:active={page.url.pathname.startsWith('/settings')} title="Settings">
        <Settings size={22} />
      </a>
      <a href="/create" class="nav-item" class:active={page.url.pathname === '/create'} title="Create">
        <Plus size={22} />
      </a>
    {/if}
  </div>
```

Add styles for the badge inside the `<style>` block:

```css
.bell-wrap {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}
.badge {
  position: absolute;
  top: -6px;
  right: -8px;
  background: var(--grain);
  color: #000;
  font-size: 10px;
  font-weight: 700;
  min-width: 16px;
  height: 16px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 4px;
  font-family: var(--font-body);
}
```

---

### Task 5: Add Bell Icon to Mobile Bottom Bar

**Files:**

- Modify: `app/lib/components/molecules/MobileBottomBar.svelte`

**Step 1: Update the mobile bottom bar**

Add the Bell import, unseen count query, and bell button between Create and Search:

The `<script>` block becomes:

```svelte
<script lang="ts">
  import { Image, Search, Plus, Bell } from 'lucide-svelte'
  import { goto } from '$app/navigation'
  import { page } from '$app/state'
  import { isAuthenticated, viewer } from '$lib/stores'
  import Avatar from '../atoms/Avatar.svelte'
  import { createQuery } from '@tanstack/svelte-query'
  import { unseenNotificationCountQuery } from '$lib/queries'

  let { onSearch }: { onSearch: () => void } = $props()

  const unseenCount = createQuery(() => ({
    ...unseenNotificationCountQuery($viewer?.did ?? ''),
    enabled: !!$viewer?.did,
  }))
</script>
```

In the template, add the bell button after the Create button and before the Search button. The full template becomes:

```svelte
<div class="mobile-bottom">
  <button
    class="mobile-tab"
    class:active={page.url.pathname === '/'}
    onclick={() => goto('/')}
  >
    <Image size={22} />
  </button>
  {#if $isAuthenticated}
    <button
      class="mobile-tab"
      class:active={page.url.pathname === '/create'}
      onclick={() => goto('/create')}
    >
      <Plus size={22} />
    </button>
    <button
      class="mobile-tab"
      class:active={page.url.pathname === '/notifications'}
      onclick={() => goto('/notifications')}
    >
      <span class="bell-wrap">
        <Bell size={22} />
        {#if (unseenCount.data ?? 0) > 0}
          <span class="badge">{unseenCount.data! > 99 ? '99+' : unseenCount.data}</span>
        {/if}
      </span>
    </button>
  {/if}
  <button class="mobile-tab" onclick={onSearch}>
    <Search size={22} />
  </button>
  {#if $isAuthenticated && $viewer}
    <button
      class="mobile-tab"
      class:active={page.url.pathname.startsWith('/profile/')}
      onclick={() => goto(`/profile/${encodeURIComponent($viewer!.did)}`)}
    >
      <Avatar did={$viewer.did} src={$viewer.avatar} name={$viewer.displayName || $viewer.handle} size={24} />
    </button>
  {/if}
</div>
```

Add badge styles to the `<style>` block:

```css
.bell-wrap {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}
.badge {
  position: absolute;
  top: -4px;
  right: -6px;
  background: var(--grain);
  color: #000;
  font-size: 9px;
  font-weight: 700;
  min-width: 14px;
  height: 14px;
  border-radius: 7px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 3px;
  font-family: var(--font-body);
}
```

---

### Task 6: Create the Notifications Page

**Files:**

- Create: `app/routes/notifications/+page.svelte`

**Step 1: Create the page component**

```svelte
<script lang="ts">
  import { onMount } from 'svelte'
  import { createQuery, useQueryClient } from '@tanstack/svelte-query'
  import { notificationsQuery } from '$lib/queries'
  import { markNotificationsSeen } from '$lib/preferences'
  import { viewer as viewerStore } from '$lib/stores'
  import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
  import NotificationItem from '$lib/components/atoms/NotificationItem.svelte'
  import Spinner from '$lib/components/atoms/Spinner.svelte'
  import OGMeta from '$lib/components/atoms/OGMeta.svelte'
  import { callXrpc } from '$hatk/client'

  const viewerDid = $derived($viewerStore?.did)
  const queryClient = useQueryClient()

  const notifications = createQuery(() => ({
    ...notificationsQuery(viewerDid ?? ''),
    enabled: !!viewerDid,
  }))

  let loadingMore = $state(false)
  let allItems: any[] = $state([])
  let currentCursor: string | undefined = $state(undefined)
  let hasMore = $state(true)
  let sentinel: HTMLDivElement | undefined = $state(undefined)

  $effect(() => {
    if (notifications.data) {
      allItems = notifications.data.notifications ?? []
      currentCursor = notifications.data.cursor
      hasMore = !!notifications.data.cursor
    }
  })

  onMount(async () => {
    if (viewerDid) {
      await markNotificationsSeen()
      queryClient.setQueryData(['unseenNotificationCount', viewerDid], 0)
    }
  })

  async function loadMore() {
    if (loadingMore || !hasMore || !viewerDid || !currentCursor) return
    loadingMore = true
    try {
      const result = await callXrpc('social.grain.unspecced.getNotifications', {
        viewer: viewerDid,
        limit: 20,
        cursor: currentCursor,
      }) as any
      const newItems = result?.notifications ?? []
      allItems = [...allItems, ...newItems]
      currentCursor = result?.cursor
      hasMore = !!result?.cursor
    } finally {
      loadingMore = false
    }
  }

  $effect(() => {
    if (!sentinel) return
    const observer = new IntersectionObserver(
      (entries) => { if (entries[0]?.isIntersecting) loadMore() },
      { rootMargin: '200px' },
    )
    observer.observe(sentinel)
    return () => observer.disconnect()
  })
</script>

<OGMeta title="Notifications - grain" />
<DetailHeader label="Notifications" />

{#if notifications.isLoading}
  <div class="center"><Spinner /></div>
{:else if allItems.length === 0}
  <div class="empty">No notifications yet</div>
{:else}
  <div class="notification-list">
    {#each allItems as notif (notif.uri)}
      <NotificationItem {notif} />
    {/each}
    {#if hasMore}
      <div bind:this={sentinel} class="sentinel">
        {#if loadingMore}<Spinner />{/if}
      </div>
    {/if}
  </div>
{/if}

<style>
  .center {
    display: flex;
    justify-content: center;
    padding: 40px;
  }
  .empty {
    text-align: center;
    padding: 60px 20px;
    color: var(--text-muted);
    font-size: 14px;
  }
  .notification-list {
    display: flex;
    flex-direction: column;
  }
  .sentinel {
    display: flex;
    justify-content: center;
    padding: 20px;
  }
</style>
```

---

### Task 7: Create the NotificationItem Component

**Files:**

- Create: `app/lib/components/atoms/NotificationItem.svelte`

**Step 1: Create the component**

```svelte
<script lang="ts">
  import Avatar from './Avatar.svelte'
  import { relativeTime } from '$lib/utils'

  let { notif }: { notif: any } = $props()

  const reasonText: Record<string, string> = {
    'gallery-favorite': 'favorited your gallery',
    'gallery-comment': 'commented on your gallery',
    'gallery-comment-mention': 'mentioned you in a comment',
    'gallery-mention': 'mentioned you in a gallery',
    'reply': 'replied to your comment',
    'follow': 'followed you',
  }

  const action = $derived(reasonText[notif.reason] ?? '')
  const timeStr = $derived(relativeTime(notif.createdAt || ''))
  const authorDid = $derived(notif.author?.did ?? '')
  const authorName = $derived(notif.author?.displayName || notif.author?.handle || authorDid.slice(0, 18))
  const authorAvatar = $derived(notif.author?.avatar ?? null)
</script>

<a class="notif" href={notif.galleryUri ? `/profile/${notif.galleryUri.split('/')[2]}/gallery/${notif.galleryUri.split('/').pop()}` : `/profile/${authorDid}`}>
  <div class="notif-avatar">
    <Avatar did={authorDid} src={authorAvatar} name={authorName} size={38} />
  </div>
  <div class="notif-body">
    <div class="notif-header">
      <span class="notif-author">{authorName}</span>
      <span class="notif-action">{action}</span>
      <span class="notif-time">{timeStr}</span>
    </div>
    {#if notif.reason === 'reply' && notif.replyToText}
      <div class="notif-quote">{notif.replyToText}</div>
    {/if}
    {#if notif.commentText}
      <div class="notif-comment">{notif.commentText}</div>
    {/if}
    {#if notif.galleryTitle && notif.reason !== 'follow'}
      <div class="notif-gallery-title">{notif.galleryTitle}</div>
    {/if}
  </div>
  {#if notif.galleryThumb}
    <img src={notif.galleryThumb} alt="" class="notif-thumb" loading="lazy" />
  {/if}
</a>

<style>
  .notif {
    display: flex;
    gap: 12px;
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
    text-decoration: none;
    color: inherit;
    transition: background 0.12s;
    align-items: flex-start;
  }
  .notif:hover {
    background: var(--bg-hover);
  }
  .notif-avatar {
    flex-shrink: 0;
  }
  .notif-body {
    flex: 1;
    min-width: 0;
  }
  .notif-header {
    font-size: 13px;
    line-height: 1.4;
  }
  .notif-author {
    font-weight: 600;
    color: var(--text-primary);
  }
  .notif-action {
    color: var(--text-secondary);
    margin-left: 4px;
  }
  .notif-time {
    color: var(--text-muted);
    margin-left: 4px;
    font-size: 12px;
  }
  .notif-quote {
    font-size: 12px;
    color: var(--text-muted);
    margin-top: 4px;
    padding: 4px 8px;
    border-left: 2px solid var(--border);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .notif-comment {
    font-size: 13px;
    color: var(--text-secondary);
    margin-top: 4px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .notif-gallery-title {
    font-size: 12px;
    color: var(--text-muted);
    margin-top: 2px;
  }
  .notif-thumb {
    width: 48px;
    height: 48px;
    border-radius: 6px;
    object-fit: cover;
    flex-shrink: 0;
  }
</style>
```

---

### 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