WIP PWA for Grain
0
fork

Configure Feed

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

docs: add notifications implementation plan

+814
+814
docs/plans/2025-12-26-notifications.md
··· 1 + # Notifications Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a notifications page showing favorites, comments, mentions, replies, and follows with full data hydration. 6 + 7 + **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. 8 + 9 + **Tech Stack:** Lit, GraphQL (quickslice), existing grainApi service pattern 10 + 11 + --- 12 + 13 + ### Task 1: Add Bell Icons 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-icon.js:4-22` 17 + 18 + **Step 1: Add bell icons to ICONS object** 19 + 20 + In `src/components/atoms/grain-icon.js`, add bell icons after line 17 (searchLine): 21 + 22 + ```javascript 23 + const ICONS = { 24 + heart: 'fa-regular fa-heart', 25 + heartFilled: 'fa-solid fa-heart', 26 + comment: 'fa-regular fa-comment', 27 + share: 'fa-solid fa-paper-plane', 28 + back: 'fa-solid fa-arrow-left', 29 + home: 'fa-solid fa-house', 30 + homeLine: 'fa-regular fa-house', 31 + user: 'fa-regular fa-user', 32 + userFilled: 'fa-solid fa-user', 33 + logout: 'fa-solid fa-right-from-bracket', 34 + plus: 'fa-solid fa-plus', 35 + search: 'fa-solid fa-magnifying-glass', 36 + searchLine: 'fa-solid fa-magnifying-glass', 37 + bell: 'fa-regular fa-bell', 38 + bellFilled: 'fa-solid fa-bell', 39 + ellipsis: 'fa-solid fa-ellipsis', 40 + ellipsisVertical: 'fa-solid fa-ellipsis-vertical', 41 + download: 'fa-solid fa-download', 42 + share: 'fa-solid fa-arrow-up-from-bracket' 43 + }; 44 + ``` 45 + 46 + **Step 2: Verify manually** 47 + 48 + Open the app in browser, inspect a `<grain-icon name="bell">` in devtools console to confirm it renders. 49 + 50 + **Step 3: Commit** 51 + 52 + ```bash 53 + git add src/components/atoms/grain-icon.js 54 + git commit -m "feat: add bell icons to grain-icon" 55 + ``` 56 + 57 + --- 58 + 59 + ### Task 2: Add getNotifications to grainApi 60 + 61 + **Files:** 62 + - Modify: `src/services/grain-api.js` 63 + 64 + **Step 1: Add the getNotifications method** 65 + 66 + Add this method to `GrainApiService` class (before the closing brace of the class): 67 + 68 + ```javascript 69 + async getNotifications(viewerDid, { first = 50 } = {}) { 70 + const query = ` 71 + query Notifications($viewerDid: String!, $first: Int) { 72 + notifications(viewerDid: $viewerDid, first: $first) { 73 + edges { 74 + node { 75 + __typename 76 + ... on SocialGrainFavorite { 77 + uri 78 + did 79 + createdAt 80 + subject 81 + subjectResolved { 82 + ... on SocialGrainGallery { 83 + uri 84 + title 85 + actorHandle 86 + socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { 87 + edges { 88 + node { 89 + itemResolved { 90 + ... on SocialGrainPhoto { 91 + photo { url(preset: "feed_thumbnail") } 92 + } 93 + } 94 + } 95 + } 96 + } 97 + } 98 + } 99 + socialGrainActorProfileByDid { 100 + displayName 101 + actorHandle 102 + avatar { url(preset: "avatar") } 103 + } 104 + } 105 + ... on SocialGrainGraphFollow { 106 + uri 107 + did 108 + createdAt 109 + socialGrainActorProfileByDid { 110 + displayName 111 + actorHandle 112 + avatar { url(preset: "avatar") } 113 + } 114 + } 115 + ... on SocialGrainComment { 116 + uri 117 + did 118 + createdAt 119 + text 120 + subject 121 + focus 122 + replyTo 123 + facets 124 + subjectResolved { 125 + ... on SocialGrainGallery { 126 + uri 127 + title 128 + actorHandle 129 + socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { 130 + edges { 131 + node { 132 + itemResolved { 133 + ... on SocialGrainPhoto { 134 + photo { url(preset: "feed_thumbnail") } 135 + } 136 + } 137 + } 138 + } 139 + } 140 + } 141 + } 142 + focusResolved { 143 + ... on SocialGrainPhoto { 144 + uri 145 + alt 146 + photo { url(preset: "feed_thumbnail") } 147 + } 148 + } 149 + replyToResolved { 150 + ... on SocialGrainComment { 151 + uri 152 + text 153 + actorHandle 154 + } 155 + } 156 + socialGrainActorProfileByDid { 157 + displayName 158 + actorHandle 159 + avatar { url(preset: "avatar") } 160 + } 161 + } 162 + ... on SocialGrainGallery { 163 + uri 164 + did 165 + createdAt 166 + title 167 + description 168 + facets 169 + actorHandle 170 + socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { 171 + edges { 172 + node { 173 + itemResolved { 174 + ... on SocialGrainPhoto { 175 + photo { url(preset: "feed_thumbnail") } 176 + } 177 + } 178 + } 179 + } 180 + } 181 + socialGrainActorProfileByDid { 182 + displayName 183 + actorHandle 184 + avatar { url(preset: "avatar") } 185 + } 186 + } 187 + } 188 + } 189 + } 190 + } 191 + `; 192 + 193 + const response = await this.#execute(query, { viewerDid, first }); 194 + return this.#transformNotificationsResponse(response, viewerDid); 195 + } 196 + 197 + #transformNotificationsResponse(response, viewerDid) { 198 + const connection = response.data?.notifications; 199 + if (!connection) return { notifications: [] }; 200 + 201 + const notifications = connection.edges 202 + .map(edge => { 203 + const node = edge.node; 204 + const reason = this.#getNotificationReason(node, viewerDid); 205 + if (!reason) return null; 206 + 207 + const profile = node.socialGrainActorProfileByDid; 208 + const author = { 209 + handle: profile?.actorHandle || '', 210 + displayName: profile?.displayName || '', 211 + avatarUrl: profile?.avatar?.url || '' 212 + }; 213 + 214 + const base = { 215 + uri: node.uri, 216 + createdAt: node.createdAt, 217 + reason, 218 + author 219 + }; 220 + 221 + switch (node.__typename) { 222 + case 'SocialGrainFavorite': { 223 + const gallery = node.subjectResolved; 224 + const thumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; 225 + return { 226 + ...base, 227 + gallery: gallery ? { 228 + uri: gallery.uri, 229 + title: gallery.title, 230 + handle: gallery.actorHandle, 231 + thumbnailUrl: thumb || '' 232 + } : null 233 + }; 234 + } 235 + case 'SocialGrainGraphFollow': 236 + return base; 237 + case 'SocialGrainComment': { 238 + const gallery = node.subjectResolved; 239 + const galleryThumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; 240 + const focusPhoto = node.focusResolved; 241 + const replyTo = node.replyToResolved; 242 + return { 243 + ...base, 244 + text: node.text, 245 + gallery: gallery ? { 246 + uri: gallery.uri, 247 + title: gallery.title, 248 + handle: gallery.actorHandle, 249 + thumbnailUrl: galleryThumb || '' 250 + } : null, 251 + focusPhoto: focusPhoto ? { 252 + uri: focusPhoto.uri, 253 + alt: focusPhoto.alt, 254 + thumbnailUrl: focusPhoto.photo?.url || '' 255 + } : null, 256 + replyTo: replyTo ? { 257 + uri: replyTo.uri, 258 + text: replyTo.text, 259 + handle: replyTo.actorHandle 260 + } : null 261 + }; 262 + } 263 + case 'SocialGrainGallery': { 264 + const thumb = node.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; 265 + return { 266 + ...base, 267 + gallery: { 268 + uri: node.uri, 269 + title: node.title, 270 + description: node.description, 271 + handle: node.actorHandle, 272 + thumbnailUrl: thumb || '' 273 + } 274 + }; 275 + } 276 + default: 277 + return null; 278 + } 279 + }) 280 + .filter(Boolean); 281 + 282 + return { notifications }; 283 + } 284 + 285 + #getNotificationReason(node, viewerDid) { 286 + switch (node.__typename) { 287 + case 'SocialGrainFavorite': 288 + return 'gallery-favorite'; 289 + case 'SocialGrainGraphFollow': 290 + return 'follow'; 291 + case 'SocialGrainComment': 292 + if (this.#hasMentionFacet(node.facets, viewerDid)) { 293 + return 'gallery-comment-mention'; 294 + } 295 + if (node.replyTo) { 296 + return 'reply'; 297 + } 298 + return 'gallery-comment'; 299 + case 'SocialGrainGallery': 300 + if (this.#hasMentionFacet(node.facets, viewerDid)) { 301 + return 'gallery-mention'; 302 + } 303 + return null; 304 + default: 305 + return null; 306 + } 307 + } 308 + 309 + #hasMentionFacet(facets, viewerDid) { 310 + if (!Array.isArray(facets)) return false; 311 + return facets.some(facet => { 312 + const features = facet.features; 313 + if (!Array.isArray(features)) return false; 314 + return features.some(f => 315 + f.$type === 'app.bsky.richtext.facet#mention' && f.did === viewerDid 316 + ); 317 + }); 318 + } 319 + ``` 320 + 321 + **Step 2: Verify syntax** 322 + 323 + Run: `npm run build` (or equivalent) to check for syntax errors. 324 + 325 + **Step 3: Commit** 326 + 327 + ```bash 328 + git add src/services/grain-api.js 329 + git commit -m "feat: add getNotifications to grainApi" 330 + ``` 331 + 332 + --- 333 + 334 + ### Task 3: Create Notifications Page Component 335 + 336 + **Files:** 337 + - Create: `src/components/pages/grain-notifications.js` 338 + 339 + **Step 1: Create the notifications page** 340 + 341 + Create `src/components/pages/grain-notifications.js`: 342 + 343 + ```javascript 344 + import { LitElement, html, css } from 'lit'; 345 + import { auth } from '../../services/auth.js'; 346 + import { grainApi } from '../../services/grain-api.js'; 347 + import { router } from '../../router.js'; 348 + import '../templates/grain-feed-layout.js'; 349 + import '../atoms/grain-spinner.js'; 350 + 351 + export class GrainNotifications extends LitElement { 352 + static properties = { 353 + _notifications: { state: true }, 354 + _loading: { state: true }, 355 + _error: { state: true }, 356 + _user: { state: true } 357 + }; 358 + 359 + static styles = css` 360 + :host { 361 + display: block; 362 + } 363 + .header { 364 + font-size: 1.25rem; 365 + font-weight: 600; 366 + padding: var(--space-md) var(--space-lg); 367 + border-bottom: 1px solid var(--color-border); 368 + } 369 + .error { 370 + padding: var(--space-lg); 371 + text-align: center; 372 + color: var(--color-error); 373 + } 374 + .empty { 375 + padding: var(--space-xl); 376 + text-align: center; 377 + color: var(--color-text-secondary); 378 + } 379 + .auth-prompt { 380 + padding: var(--space-xl); 381 + text-align: center; 382 + color: var(--color-text-secondary); 383 + } 384 + .notification-list { 385 + list-style: none; 386 + margin: 0; 387 + padding: 0; 388 + } 389 + .notification-item { 390 + display: flex; 391 + flex-direction: column; 392 + gap: var(--space-sm); 393 + padding: var(--space-md) var(--space-lg); 394 + border-bottom: 1px solid var(--color-border); 395 + } 396 + .notification-header { 397 + display: flex; 398 + flex-wrap: wrap; 399 + align-items: center; 400 + gap: var(--space-xs); 401 + } 402 + .author-link { 403 + display: flex; 404 + align-items: center; 405 + gap: var(--space-sm); 406 + text-decoration: none; 407 + color: inherit; 408 + } 409 + .author-link:hover { 410 + text-decoration: underline; 411 + } 412 + .avatar { 413 + width: 32px; 414 + height: 32px; 415 + border-radius: 50%; 416 + object-fit: cover; 417 + background: var(--color-bg-secondary); 418 + } 419 + .author-name { 420 + font-weight: 600; 421 + } 422 + .action-text { 423 + color: var(--color-text-secondary); 424 + } 425 + .time { 426 + color: var(--color-text-tertiary); 427 + } 428 + .context { 429 + margin-top: var(--space-xs); 430 + } 431 + .comment-text { 432 + margin-bottom: var(--space-sm); 433 + } 434 + .reply-quote { 435 + padding-left: var(--space-sm); 436 + border-left: 2px solid var(--color-border); 437 + color: var(--color-text-secondary); 438 + font-size: 0.875rem; 439 + margin-bottom: var(--space-sm); 440 + } 441 + .thumbnail { 442 + width: 100px; 443 + height: 100px; 444 + border-radius: var(--radius-sm); 445 + object-fit: cover; 446 + } 447 + .thumbnail-link { 448 + display: block; 449 + width: fit-content; 450 + } 451 + `; 452 + 453 + constructor() { 454 + super(); 455 + this._notifications = []; 456 + this._loading = true; 457 + this._error = null; 458 + this._user = auth.user; 459 + } 460 + 461 + connectedCallback() { 462 + super.connectedCallback(); 463 + this._unsubscribe = auth.subscribe(user => { 464 + this._user = user; 465 + if (user) { 466 + this.#loadNotifications(); 467 + } 468 + }); 469 + if (this._user) { 470 + this.#loadNotifications(); 471 + } else { 472 + this._loading = false; 473 + } 474 + } 475 + 476 + disconnectedCallback() { 477 + super.disconnectedCallback(); 478 + this._unsubscribe?.(); 479 + } 480 + 481 + async #loadNotifications() { 482 + if (!this._user?.did) { 483 + this._loading = false; 484 + return; 485 + } 486 + 487 + try { 488 + this._loading = true; 489 + this._error = null; 490 + const result = await grainApi.getNotifications(this._user.did); 491 + this._notifications = result.notifications; 492 + } catch (err) { 493 + console.error('Failed to load notifications:', err); 494 + this._error = err.message; 495 + } finally { 496 + this._loading = false; 497 + } 498 + } 499 + 500 + #formatRelativeTime(dateStr) { 501 + const date = new Date(dateStr); 502 + const now = new Date(); 503 + const diffMs = now - date; 504 + const diffSec = Math.floor(diffMs / 1000); 505 + const diffMin = Math.floor(diffSec / 60); 506 + const diffHour = Math.floor(diffMin / 60); 507 + const diffDay = Math.floor(diffHour / 24); 508 + 509 + if (diffSec < 60) return 'now'; 510 + if (diffMin < 60) return `${diffMin}m`; 511 + if (diffHour < 24) return `${diffHour}h`; 512 + if (diffDay < 7) return `${diffDay}d`; 513 + return date.toLocaleDateString(); 514 + } 515 + 516 + #getActionText(reason) { 517 + switch (reason) { 518 + case 'gallery-favorite': return 'favorited your gallery'; 519 + case 'gallery-comment': return 'commented on your gallery'; 520 + case 'gallery-comment-mention': return 'mentioned you in a comment'; 521 + case 'gallery-mention': return 'mentioned you in a gallery'; 522 + case 'reply': return 'replied to your comment'; 523 + case 'follow': return 'followed you'; 524 + default: return ''; 525 + } 526 + } 527 + 528 + #extractRkey(uri) { 529 + if (!uri) return ''; 530 + const parts = uri.split('/'); 531 + return parts[parts.length - 1]; 532 + } 533 + 534 + #renderNotification(n) { 535 + const actionText = this.#getActionText(n.reason); 536 + const time = this.#formatRelativeTime(n.createdAt); 537 + 538 + return html` 539 + <li class="notification-item"> 540 + <div class="notification-header"> 541 + <a href="/profile/${n.author.handle}" class="author-link" @click=${this.#handleLink}> 542 + <img 543 + class="avatar" 544 + src=${n.author.avatarUrl || '/default-avatar.png'} 545 + alt="" 546 + loading="lazy" 547 + /> 548 + <span class="author-name">${n.author.displayName || n.author.handle}</span> 549 + </a> 550 + <span class="action-text">${actionText}</span> 551 + <span class="time">· ${time}</span> 552 + </div> 553 + ${this.#renderContext(n)} 554 + </li> 555 + `; 556 + } 557 + 558 + #renderContext(n) { 559 + switch (n.reason) { 560 + case 'gallery-favorite': 561 + return this.#renderGalleryThumbnail(n.gallery); 562 + 563 + case 'gallery-comment': 564 + case 'gallery-comment-mention': 565 + return html` 566 + <div class="context"> 567 + ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''} 568 + ${n.focusPhoto?.thumbnailUrl 569 + ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery) 570 + : this.#renderGalleryThumbnail(n.gallery)} 571 + </div> 572 + `; 573 + 574 + case 'reply': 575 + return html` 576 + <div class="context"> 577 + ${n.replyTo ? html` 578 + <div class="reply-quote">${n.replyTo.text}</div> 579 + ` : ''} 580 + ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''} 581 + ${n.focusPhoto?.thumbnailUrl 582 + ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery) 583 + : this.#renderGalleryThumbnail(n.gallery)} 584 + </div> 585 + `; 586 + 587 + case 'gallery-mention': 588 + return html` 589 + <div class="context"> 590 + ${n.gallery?.description ? html` 591 + <div class="reply-quote">${n.gallery.description}</div> 592 + ` : ''} 593 + ${this.#renderGalleryThumbnail(n.gallery)} 594 + </div> 595 + `; 596 + 597 + case 'follow': 598 + return ''; 599 + 600 + default: 601 + return ''; 602 + } 603 + } 604 + 605 + #renderGalleryThumbnail(gallery) { 606 + if (!gallery?.thumbnailUrl) return ''; 607 + const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`; 608 + return html` 609 + <a href=${href} class="thumbnail-link" @click=${this.#handleLink}> 610 + <img class="thumbnail" src=${gallery.thumbnailUrl} alt=${gallery.title || ''} loading="lazy" /> 611 + </a> 612 + `; 613 + } 614 + 615 + #renderPhotoThumbnail(photo, gallery) { 616 + if (!photo?.thumbnailUrl || !gallery) return ''; 617 + const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`; 618 + return html` 619 + <a href=${href} class="thumbnail-link" @click=${this.#handleLink}> 620 + <img class="thumbnail" src=${photo.thumbnailUrl} alt=${photo.alt || ''} loading="lazy" /> 621 + </a> 622 + `; 623 + } 624 + 625 + #handleLink(e) { 626 + e.preventDefault(); 627 + const href = e.currentTarget.getAttribute('href'); 628 + if (href) { 629 + router.push(href); 630 + } 631 + } 632 + 633 + render() { 634 + if (!this._user) { 635 + return html` 636 + <grain-feed-layout> 637 + <div class="header">Notifications</div> 638 + <p class="auth-prompt">Log in to see your notifications</p> 639 + </grain-feed-layout> 640 + `; 641 + } 642 + 643 + return html` 644 + <grain-feed-layout> 645 + <div class="header">Notifications</div> 646 + 647 + ${this._error ? html` 648 + <p class="error">${this._error}</p> 649 + ` : ''} 650 + 651 + ${this._loading ? html` 652 + <grain-spinner></grain-spinner> 653 + ` : ''} 654 + 655 + ${!this._loading && !this._error && this._notifications.length === 0 ? html` 656 + <p class="empty">No notifications yet</p> 657 + ` : ''} 658 + 659 + ${!this._loading && this._notifications.length > 0 ? html` 660 + <ul class="notification-list"> 661 + ${this._notifications.map(n => this.#renderNotification(n))} 662 + </ul> 663 + ` : ''} 664 + </grain-feed-layout> 665 + `; 666 + } 667 + } 668 + 669 + customElements.define('grain-notifications', GrainNotifications); 670 + ``` 671 + 672 + **Step 2: Verify syntax** 673 + 674 + Run: `npm run build` to check for syntax errors. 675 + 676 + **Step 3: Commit** 677 + 678 + ```bash 679 + git add src/components/pages/grain-notifications.js 680 + git commit -m "feat: create grain-notifications page component" 681 + ``` 682 + 683 + --- 684 + 685 + ### Task 4: Register Notifications Route 686 + 687 + **Files:** 688 + - Modify: `src/components/pages/grain-app.js` 689 + 690 + **Step 1: Add import for grain-notifications** 691 + 692 + After line 12 (`import './grain-explore.js';`), add: 693 + 694 + ```javascript 695 + import './grain-notifications.js'; 696 + ``` 697 + 698 + **Step 2: Register the route** 699 + 700 + After line 42 (`.register('/explore', 'grain-explore')`), add: 701 + 702 + ```javascript 703 + .register('/notifications', 'grain-notifications') 704 + ``` 705 + 706 + **Step 3: Verify syntax** 707 + 708 + Run: `npm run build` to check for syntax errors. 709 + 710 + **Step 4: Commit** 711 + 712 + ```bash 713 + git add src/components/pages/grain-app.js 714 + git commit -m "feat: register /notifications route" 715 + ``` 716 + 717 + --- 718 + 719 + ### Task 5: Add Notifications Button to Bottom Nav 720 + 721 + **Files:** 722 + - Modify: `src/components/organisms/grain-bottom-nav.js` 723 + 724 + **Step 1: Add isNotifications getter** 725 + 726 + After line 96 (`get #isExplore()`), add: 727 + 728 + ```javascript 729 + get #isNotifications() { 730 + return window.location.pathname === '/notifications'; 731 + } 732 + ``` 733 + 734 + **Step 2: Add handleNotifications method** 735 + 736 + After line 110 (`#handleExplore()`), add: 737 + 738 + ```javascript 739 + #handleNotifications() { 740 + router.push('/notifications'); 741 + } 742 + ``` 743 + 744 + **Step 3: Add notifications button to render** 745 + 746 + In the render method, after the create button block (after line 169 closing the input tag), add: 747 + 748 + ```javascript 749 + <button 750 + class=${this.#isNotifications ? 'active' : ''} 751 + @click=${this.#handleNotifications} 752 + > 753 + <grain-icon name=${this.#isNotifications ? 'bellFilled' : 'bell'} size="20"></grain-icon> 754 + </button> 755 + ``` 756 + 757 + The button should appear between the create (+) input and the profile button. 758 + 759 + **Step 4: Test manually** 760 + 761 + Open the app, verify the bell icon appears in the bottom nav between create and profile. Click it to navigate to /notifications. 762 + 763 + **Step 5: Commit** 764 + 765 + ```bash 766 + git add src/components/organisms/grain-bottom-nav.js 767 + git commit -m "feat: add notifications button to bottom nav" 768 + ``` 769 + 770 + --- 771 + 772 + ### Task 6: Test End-to-End 773 + 774 + **Step 1: Test unauthenticated state** 775 + 776 + 1. Log out of the app 777 + 2. Navigate to /notifications 778 + 3. Verify "Log in to see your notifications" message appears 779 + 4. Verify bell icon is NOT visible in bottom nav (only shows for logged-in users) 780 + 781 + **Step 2: Test authenticated state** 782 + 783 + 1. Log in to the app 784 + 2. Click bell icon in bottom nav 785 + 3. Verify notifications page loads 786 + 4. Verify notifications display correctly (or "No notifications yet" if empty) 787 + 788 + **Step 3: Test each notification type (if data exists)** 789 + 790 + - Favorite: Shows gallery thumbnail 791 + - Comment: Shows comment text + thumbnail 792 + - Reply: Shows quoted original comment + reply text + thumbnail 793 + - Mention: Shows gallery description excerpt + thumbnail 794 + - Follow: Shows just the author info 795 + 796 + **Step 4: Final commit** 797 + 798 + ```bash 799 + git add -A 800 + git commit -m "feat: complete notifications feature" 801 + ``` 802 + 803 + --- 804 + 805 + ## Summary 806 + 807 + | Task | Description | 808 + |------|-------------| 809 + | 1 | Add bell icons to grain-icon.js | 810 + | 2 | Add getNotifications method to grainApi | 811 + | 3 | Create grain-notifications.js page | 812 + | 4 | Register /notifications route | 813 + | 5 | Add bell button to bottom nav | 814 + | 6 | End-to-end testing |