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
- Log out of the app
- Navigate to /notifications
- Verify "Log in to see your notifications" message appears
- Verify bell icon is NOT visible in bottom nav (only shows for logged-in users)
Step 2: Test authenticated state
- Log in to the app
- Click bell icon in bottom nav
- Verify notifications page loads
- 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 |