WIP PWA for Grain
0
fork

Configure Feed

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

Notifications Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a notifications page showing favorites, comments, mentions, replies, and follows with full data hydration.

Architecture: New notifications page component queries GraphQL with viewer DID, transforms union types to determine notification reason, renders each type with appropriate context (thumbnails, quoted text). Bell icon added to bottom nav between create and profile.

Tech Stack: Lit, GraphQL (quickslice), existing grainApi service pattern


Task 1: Add Bell Icons#

Files:

  • Modify: src/components/atoms/grain-icon.js:4-22

Step 1: Add bell icons to ICONS object

In src/components/atoms/grain-icon.js, add bell icons after line 17 (searchLine):

const ICONS = {
  heart: 'fa-regular fa-heart',
  heartFilled: 'fa-solid fa-heart',
  comment: 'fa-regular fa-comment',
  share: 'fa-solid fa-paper-plane',
  back: 'fa-solid fa-arrow-left',
  home: 'fa-solid fa-house',
  homeLine: 'fa-regular fa-house',
  user: 'fa-regular fa-user',
  userFilled: 'fa-solid fa-user',
  logout: 'fa-solid fa-right-from-bracket',
  plus: 'fa-solid fa-plus',
  search: 'fa-solid fa-magnifying-glass',
  searchLine: 'fa-solid fa-magnifying-glass',
  bell: 'fa-regular fa-bell',
  bellFilled: 'fa-solid fa-bell',
  ellipsis: 'fa-solid fa-ellipsis',
  ellipsisVertical: 'fa-solid fa-ellipsis-vertical',
  download: 'fa-solid fa-download',
  share: 'fa-solid fa-arrow-up-from-bracket'
};

Step 2: Verify manually

Open the app in browser, inspect a <grain-icon name="bell"> in devtools console to confirm it renders.

Step 3: Commit

git add src/components/atoms/grain-icon.js
git commit -m "feat: add bell icons to grain-icon"

Task 2: Add getNotifications to grainApi#

Files:

  • Modify: src/services/grain-api.js

Step 1: Add the getNotifications method

Add this method to GrainApiService class (before the closing brace of the class):

  async getNotifications(viewerDid, { first = 50 } = {}) {
    const query = `
      query Notifications($viewerDid: String!, $first: Int) {
        notifications(viewerDid: $viewerDid, first: $first) {
          edges {
            node {
              __typename
              ... on SocialGrainFavorite {
                uri
                did
                createdAt
                subject
                subjectResolved {
                  ... on SocialGrainGallery {
                    uri
                    title
                    actorHandle
                    socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
                      edges {
                        node {
                          itemResolved {
                            ... on SocialGrainPhoto {
                              photo { url(preset: "feed_thumbnail") }
                            }
                          }
                        }
                      }
                    }
                  }
                }
                socialGrainActorProfileByDid {
                  displayName
                  actorHandle
                  avatar { url(preset: "avatar") }
                }
              }
              ... on SocialGrainGraphFollow {
                uri
                did
                createdAt
                socialGrainActorProfileByDid {
                  displayName
                  actorHandle
                  avatar { url(preset: "avatar") }
                }
              }
              ... on SocialGrainComment {
                uri
                did
                createdAt
                text
                subject
                focus
                replyTo
                facets
                subjectResolved {
                  ... on SocialGrainGallery {
                    uri
                    title
                    actorHandle
                    socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
                      edges {
                        node {
                          itemResolved {
                            ... on SocialGrainPhoto {
                              photo { url(preset: "feed_thumbnail") }
                            }
                          }
                        }
                      }
                    }
                  }
                }
                focusResolved {
                  ... on SocialGrainPhoto {
                    uri
                    alt
                    photo { url(preset: "feed_thumbnail") }
                  }
                }
                replyToResolved {
                  ... on SocialGrainComment {
                    uri
                    text
                    actorHandle
                  }
                }
                socialGrainActorProfileByDid {
                  displayName
                  actorHandle
                  avatar { url(preset: "avatar") }
                }
              }
              ... on SocialGrainGallery {
                uri
                did
                createdAt
                title
                description
                facets
                actorHandle
                socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
                  edges {
                    node {
                      itemResolved {
                        ... on SocialGrainPhoto {
                          photo { url(preset: "feed_thumbnail") }
                        }
                      }
                    }
                  }
                }
                socialGrainActorProfileByDid {
                  displayName
                  actorHandle
                  avatar { url(preset: "avatar") }
                }
              }
            }
          }
        }
      }
    `;

    const response = await this.#execute(query, { viewerDid, first });
    return this.#transformNotificationsResponse(response, viewerDid);
  }

  #transformNotificationsResponse(response, viewerDid) {
    const connection = response.data?.notifications;
    if (!connection) return { notifications: [] };

    const notifications = connection.edges
      .map(edge => {
        const node = edge.node;
        const reason = this.#getNotificationReason(node, viewerDid);
        if (!reason) return null;

        const profile = node.socialGrainActorProfileByDid;
        const author = {
          handle: profile?.actorHandle || '',
          displayName: profile?.displayName || '',
          avatarUrl: profile?.avatar?.url || ''
        };

        const base = {
          uri: node.uri,
          createdAt: node.createdAt,
          reason,
          author
        };

        switch (node.__typename) {
          case 'SocialGrainFavorite': {
            const gallery = node.subjectResolved;
            const thumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
            return {
              ...base,
              gallery: gallery ? {
                uri: gallery.uri,
                title: gallery.title,
                handle: gallery.actorHandle,
                thumbnailUrl: thumb || ''
              } : null
            };
          }
          case 'SocialGrainGraphFollow':
            return base;
          case 'SocialGrainComment': {
            const gallery = node.subjectResolved;
            const galleryThumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
            const focusPhoto = node.focusResolved;
            const replyTo = node.replyToResolved;
            return {
              ...base,
              text: node.text,
              gallery: gallery ? {
                uri: gallery.uri,
                title: gallery.title,
                handle: gallery.actorHandle,
                thumbnailUrl: galleryThumb || ''
              } : null,
              focusPhoto: focusPhoto ? {
                uri: focusPhoto.uri,
                alt: focusPhoto.alt,
                thumbnailUrl: focusPhoto.photo?.url || ''
              } : null,
              replyTo: replyTo ? {
                uri: replyTo.uri,
                text: replyTo.text,
                handle: replyTo.actorHandle
              } : null
            };
          }
          case 'SocialGrainGallery': {
            const thumb = node.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
            return {
              ...base,
              gallery: {
                uri: node.uri,
                title: node.title,
                description: node.description,
                handle: node.actorHandle,
                thumbnailUrl: thumb || ''
              }
            };
          }
          default:
            return null;
        }
      })
      .filter(Boolean);

    return { notifications };
  }

  #getNotificationReason(node, viewerDid) {
    switch (node.__typename) {
      case 'SocialGrainFavorite':
        return 'gallery-favorite';
      case 'SocialGrainGraphFollow':
        return 'follow';
      case 'SocialGrainComment':
        if (this.#hasMentionFacet(node.facets, viewerDid)) {
          return 'gallery-comment-mention';
        }
        if (node.replyTo) {
          return 'reply';
        }
        return 'gallery-comment';
      case 'SocialGrainGallery':
        if (this.#hasMentionFacet(node.facets, viewerDid)) {
          return 'gallery-mention';
        }
        return null;
      default:
        return null;
    }
  }

  #hasMentionFacet(facets, viewerDid) {
    if (!Array.isArray(facets)) return false;
    return facets.some(facet => {
      const features = facet.features;
      if (!Array.isArray(features)) return false;
      return features.some(f =>
        f.$type === 'app.bsky.richtext.facet#mention' && f.did === viewerDid
      );
    });
  }

Step 2: Verify syntax

Run: npm run build (or equivalent) to check for syntax errors.

Step 3: Commit

git add src/services/grain-api.js
git commit -m "feat: add getNotifications to grainApi"

Task 3: Create Notifications Page Component#

Files:

  • Create: src/components/pages/grain-notifications.js

Step 1: Create the notifications page

Create src/components/pages/grain-notifications.js:

import { LitElement, html, css } from 'lit';
import { auth } from '../../services/auth.js';
import { grainApi } from '../../services/grain-api.js';
import { router } from '../../router.js';
import '../templates/grain-feed-layout.js';
import '../atoms/grain-spinner.js';

export class GrainNotifications extends LitElement {
  static properties = {
    _notifications: { state: true },
    _loading: { state: true },
    _error: { state: true },
    _user: { state: true }
  };

  static styles = css`
    :host {
      display: block;
    }
    .header {
      font-size: 1.25rem;
      font-weight: 600;
      padding: var(--space-md) var(--space-lg);
      border-bottom: 1px solid var(--color-border);
    }
    .error {
      padding: var(--space-lg);
      text-align: center;
      color: var(--color-error);
    }
    .empty {
      padding: var(--space-xl);
      text-align: center;
      color: var(--color-text-secondary);
    }
    .auth-prompt {
      padding: var(--space-xl);
      text-align: center;
      color: var(--color-text-secondary);
    }
    .notification-list {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    .notification-item {
      display: flex;
      flex-direction: column;
      gap: var(--space-sm);
      padding: var(--space-md) var(--space-lg);
      border-bottom: 1px solid var(--color-border);
    }
    .notification-header {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: var(--space-xs);
    }
    .author-link {
      display: flex;
      align-items: center;
      gap: var(--space-sm);
      text-decoration: none;
      color: inherit;
    }
    .author-link:hover {
      text-decoration: underline;
    }
    .avatar {
      width: 32px;
      height: 32px;
      border-radius: 50%;
      object-fit: cover;
      background: var(--color-bg-secondary);
    }
    .author-name {
      font-weight: 600;
    }
    .action-text {
      color: var(--color-text-secondary);
    }
    .time {
      color: var(--color-text-tertiary);
    }
    .context {
      margin-top: var(--space-xs);
    }
    .comment-text {
      margin-bottom: var(--space-sm);
    }
    .reply-quote {
      padding-left: var(--space-sm);
      border-left: 2px solid var(--color-border);
      color: var(--color-text-secondary);
      font-size: 0.875rem;
      margin-bottom: var(--space-sm);
    }
    .thumbnail {
      width: 100px;
      height: 100px;
      border-radius: var(--radius-sm);
      object-fit: cover;
    }
    .thumbnail-link {
      display: block;
      width: fit-content;
    }
  `;

  constructor() {
    super();
    this._notifications = [];
    this._loading = true;
    this._error = null;
    this._user = auth.user;
  }

  connectedCallback() {
    super.connectedCallback();
    this._unsubscribe = auth.subscribe(user => {
      this._user = user;
      if (user) {
        this.#loadNotifications();
      }
    });
    if (this._user) {
      this.#loadNotifications();
    } else {
      this._loading = false;
    }
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    this._unsubscribe?.();
  }

  async #loadNotifications() {
    if (!this._user?.did) {
      this._loading = false;
      return;
    }

    try {
      this._loading = true;
      this._error = null;
      const result = await grainApi.getNotifications(this._user.did);
      this._notifications = result.notifications;
    } catch (err) {
      console.error('Failed to load notifications:', err);
      this._error = err.message;
    } finally {
      this._loading = false;
    }
  }

  #formatRelativeTime(dateStr) {
    const date = new Date(dateStr);
    const now = new Date();
    const diffMs = now - date;
    const diffSec = Math.floor(diffMs / 1000);
    const diffMin = Math.floor(diffSec / 60);
    const diffHour = Math.floor(diffMin / 60);
    const diffDay = Math.floor(diffHour / 24);

    if (diffSec < 60) return 'now';
    if (diffMin < 60) return `${diffMin}m`;
    if (diffHour < 24) return `${diffHour}h`;
    if (diffDay < 7) return `${diffDay}d`;
    return date.toLocaleDateString();
  }

  #getActionText(reason) {
    switch (reason) {
      case 'gallery-favorite': return 'favorited your gallery';
      case 'gallery-comment': return 'commented on your gallery';
      case 'gallery-comment-mention': return 'mentioned you in a comment';
      case 'gallery-mention': return 'mentioned you in a gallery';
      case 'reply': return 'replied to your comment';
      case 'follow': return 'followed you';
      default: return '';
    }
  }

  #extractRkey(uri) {
    if (!uri) return '';
    const parts = uri.split('/');
    return parts[parts.length - 1];
  }

  #renderNotification(n) {
    const actionText = this.#getActionText(n.reason);
    const time = this.#formatRelativeTime(n.createdAt);

    return html`
      <li class="notification-item">
        <div class="notification-header">
          <a href="/profile/${n.author.handle}" class="author-link" @click=${this.#handleLink}>
            <img
              class="avatar"
              src=${n.author.avatarUrl || '/default-avatar.png'}
              alt=""
              loading="lazy"
            />
            <span class="author-name">${n.author.displayName || n.author.handle}</span>
          </a>
          <span class="action-text">${actionText}</span>
          <span class="time">· ${time}</span>
        </div>
        ${this.#renderContext(n)}
      </li>
    `;
  }

  #renderContext(n) {
    switch (n.reason) {
      case 'gallery-favorite':
        return this.#renderGalleryThumbnail(n.gallery);

      case 'gallery-comment':
      case 'gallery-comment-mention':
        return html`
          <div class="context">
            ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''}
            ${n.focusPhoto?.thumbnailUrl
              ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery)
              : this.#renderGalleryThumbnail(n.gallery)}
          </div>
        `;

      case 'reply':
        return html`
          <div class="context">
            ${n.replyTo ? html`
              <div class="reply-quote">${n.replyTo.text}</div>
            ` : ''}
            ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''}
            ${n.focusPhoto?.thumbnailUrl
              ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery)
              : this.#renderGalleryThumbnail(n.gallery)}
          </div>
        `;

      case 'gallery-mention':
        return html`
          <div class="context">
            ${n.gallery?.description ? html`
              <div class="reply-quote">${n.gallery.description}</div>
            ` : ''}
            ${this.#renderGalleryThumbnail(n.gallery)}
          </div>
        `;

      case 'follow':
        return '';

      default:
        return '';
    }
  }

  #renderGalleryThumbnail(gallery) {
    if (!gallery?.thumbnailUrl) return '';
    const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`;
    return html`
      <a href=${href} class="thumbnail-link" @click=${this.#handleLink}>
        <img class="thumbnail" src=${gallery.thumbnailUrl} alt=${gallery.title || ''} loading="lazy" />
      </a>
    `;
  }

  #renderPhotoThumbnail(photo, gallery) {
    if (!photo?.thumbnailUrl || !gallery) return '';
    const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`;
    return html`
      <a href=${href} class="thumbnail-link" @click=${this.#handleLink}>
        <img class="thumbnail" src=${photo.thumbnailUrl} alt=${photo.alt || ''} loading="lazy" />
      </a>
    `;
  }

  #handleLink(e) {
    e.preventDefault();
    const href = e.currentTarget.getAttribute('href');
    if (href) {
      router.push(href);
    }
  }

  render() {
    if (!this._user) {
      return html`
        <grain-feed-layout>
          <div class="header">Notifications</div>
          <p class="auth-prompt">Log in to see your notifications</p>
        </grain-feed-layout>
      `;
    }

    return html`
      <grain-feed-layout>
        <div class="header">Notifications</div>

        ${this._error ? html`
          <p class="error">${this._error}</p>
        ` : ''}

        ${this._loading ? html`
          <grain-spinner></grain-spinner>
        ` : ''}

        ${!this._loading && !this._error && this._notifications.length === 0 ? html`
          <p class="empty">No notifications yet</p>
        ` : ''}

        ${!this._loading && this._notifications.length > 0 ? html`
          <ul class="notification-list">
            ${this._notifications.map(n => this.#renderNotification(n))}
          </ul>
        ` : ''}
      </grain-feed-layout>
    `;
  }
}

customElements.define('grain-notifications', GrainNotifications);

Step 2: Verify syntax

Run: npm run build to check for syntax errors.

Step 3: Commit

git add src/components/pages/grain-notifications.js
git commit -m "feat: create grain-notifications page component"

Task 4: Register Notifications Route#

Files:

  • Modify: src/components/pages/grain-app.js

Step 1: Add import for grain-notifications

After line 12 (import './grain-explore.js';), add:

import './grain-notifications.js';

Step 2: Register the route

After line 42 (.register('/explore', 'grain-explore')), add:

      .register('/notifications', 'grain-notifications')

Step 3: Verify syntax

Run: npm run build to check for syntax errors.

Step 4: Commit

git add src/components/pages/grain-app.js
git commit -m "feat: register /notifications route"

Task 5: Add Notifications Button to Bottom Nav#

Files:

  • Modify: src/components/organisms/grain-bottom-nav.js

Step 1: Add isNotifications getter

After line 96 (get #isExplore()), add:

  get #isNotifications() {
    return window.location.pathname === '/notifications';
  }

Step 2: Add handleNotifications method

After line 110 (#handleExplore()), add:

  #handleNotifications() {
    router.push('/notifications');
  }

Step 3: Add notifications button to render

In the render method, after the create button block (after line 169 closing the input tag), add:

          <button
            class=${this.#isNotifications ? 'active' : ''}
            @click=${this.#handleNotifications}
          >
            <grain-icon name=${this.#isNotifications ? 'bellFilled' : 'bell'} size="20"></grain-icon>
          </button>

The button should appear between the create (+) input and the profile button.

Step 4: Test manually

Open the app, verify the bell icon appears in the bottom nav between create and profile. Click it to navigate to /notifications.

Step 5: Commit

git add src/components/organisms/grain-bottom-nav.js
git commit -m "feat: add notifications button to bottom nav"

Task 6: Test End-to-End#

Step 1: Test unauthenticated state

  1. Log out of the app
  2. Navigate to /notifications
  3. Verify "Log in to see your notifications" message appears
  4. Verify bell icon is NOT visible in bottom nav (only shows for logged-in users)

Step 2: Test authenticated state

  1. Log in to the app
  2. Click bell icon in bottom nav
  3. Verify notifications page loads
  4. Verify notifications display correctly (or "No notifications yet" if empty)

Step 3: Test each notification type (if data exists)

  • Favorite: Shows gallery thumbnail
  • Comment: Shows comment text + thumbnail
  • Reply: Shows quoted original comment + reply text + thumbnail
  • Mention: Shows gallery description excerpt + thumbnail
  • Follow: Shows just the author info

Step 4: Final commit

git add -A
git commit -m "feat: complete notifications feature"

Summary#

Task Description
1 Add bell icons to grain-icon.js
2 Add getNotifications method to grainApi
3 Create grain-notifications.js page
4 Register /notifications route
5 Add bell button to bottom nav
6 End-to-end testing