WIP PWA for Grain
0
fork

Configure Feed

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

docs: add viewer favorites and follows implementation plan

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+544
+544
docs/plans/2025-12-28-viewer-favorites-follows.md
··· 1 + # Viewer Favorites & Follows Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Show filled hearts on galleries the viewer has favorited and follow state on profiles, with interactive toggles. 6 + 7 + **Architecture:** Add `viewerSocialGrainFavoriteViaSubject` to gallery queries and `viewerSocialGrainGraphFollowViaSubject` to profile queries. Components receive viewer state as props, dispatch mutations via auth client, and update cache optimistically. 8 + 9 + **Tech Stack:** Lit 3.x, QuickSlice GraphQL, Font Awesome icons 10 + 11 + --- 12 + 13 + ### Task 1: Add Viewer Favorite to Timeline Query 14 + 15 + **Files:** 16 + - Modify: `src/services/grain-api.js:10-55` 17 + 18 + **Step 1: Add viewer field to timeline query** 19 + 20 + In `getTimeline()`, add after `socialGrainCommentViaSubject`: 21 + 22 + ```javascript 23 + viewerSocialGrainFavoriteViaSubject { 24 + uri 25 + } 26 + ``` 27 + 28 + **Step 2: Transform viewer data** 29 + 30 + In `#transformTimelineResponse()`, add to the return object after `commentCount`: 31 + 32 + ```javascript 33 + viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri, 34 + viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null 35 + ``` 36 + 37 + **Step 3: Commit** 38 + 39 + ```bash 40 + git add src/services/grain-api.js 41 + git commit -m "feat: add viewer favorite state to timeline query" 42 + ``` 43 + 44 + --- 45 + 46 + ### Task 2: Add Viewer Favorite to Gallery Detail Query 47 + 48 + **Files:** 49 + - Modify: `src/services/grain-api.js` (getGalleryDetail method) 50 + 51 + **Step 1: Find and update gallery detail query** 52 + 53 + Add `viewerSocialGrainFavoriteViaSubject { uri }` to the gallery fields. 54 + 55 + **Step 2: Transform viewer data** 56 + 57 + Add to the gallery detail return object: 58 + 59 + ```javascript 60 + viewerHasFavorited: !!gallery.viewerSocialGrainFavoriteViaSubject?.uri, 61 + viewerFavoriteUri: gallery.viewerSocialGrainFavoriteViaSubject?.uri || null 62 + ``` 63 + 64 + **Step 3: Commit** 65 + 66 + ```bash 67 + git add src/services/grain-api.js 68 + git commit -m "feat: add viewer favorite state to gallery detail query" 69 + ``` 70 + 71 + --- 72 + 73 + ### Task 3: Add Viewer Favorite to Profile Galleries Query 74 + 75 + **Files:** 76 + - Modify: `src/services/grain-api.js` (getProfile method) 77 + 78 + **Step 1: Add viewer field to profile galleries** 79 + 80 + In the galleries query within getProfile, add `viewerSocialGrainFavoriteViaSubject { uri }`. 81 + 82 + **Step 2: Transform viewer data** 83 + 84 + Add to each gallery in the profile response: 85 + 86 + ```javascript 87 + viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri, 88 + viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null 89 + ``` 90 + 91 + **Step 3: Commit** 92 + 93 + ```bash 94 + git add src/services/grain-api.js 95 + git commit -m "feat: add viewer favorite state to profile galleries query" 96 + ``` 97 + 98 + --- 99 + 100 + ### Task 4: Add Viewer Follow to Profile Query 101 + 102 + **Files:** 103 + - Modify: `src/services/grain-api.js` (getProfile method) 104 + 105 + **Step 1: Add viewer follow field to profile query** 106 + 107 + On the `socialGrainActorProfile` query, add: 108 + 109 + ```javascript 110 + viewerSocialGrainGraphFollowViaSubject { 111 + uri 112 + } 113 + ``` 114 + 115 + **Step 2: Transform viewer data** 116 + 117 + Add to profile return object: 118 + 119 + ```javascript 120 + viewerIsFollowing: !!profile.viewerSocialGrainGraphFollowViaSubject?.uri, 121 + viewerFollowUri: profile.viewerSocialGrainGraphFollowViaSubject?.uri || null 122 + ``` 123 + 124 + **Step 3: Commit** 125 + 126 + ```bash 127 + git add src/services/grain-api.js 128 + git commit -m "feat: add viewer follow state to profile query" 129 + ``` 130 + 131 + --- 132 + 133 + ### Task 5: Make grain-stat-count Interactive 134 + 135 + **Files:** 136 + - Modify: `src/components/molecules/grain-stat-count.js` 137 + 138 + **Step 1: Add new properties** 139 + 140 + ```javascript 141 + static properties = { 142 + icon: { type: String }, 143 + count: { type: Number }, 144 + filled: { type: Boolean }, 145 + interactive: { type: Boolean } 146 + }; 147 + ``` 148 + 149 + **Step 2: Update constructor** 150 + 151 + ```javascript 152 + constructor() { 153 + super(); 154 + this.count = 0; 155 + this.filled = false; 156 + this.interactive = false; 157 + } 158 + ``` 159 + 160 + **Step 3: Add filled icon logic and click handler** 161 + 162 + ```javascript 163 + #handleClick() { 164 + if (this.interactive) { 165 + this.dispatchEvent(new CustomEvent('stat-click', { bubbles: true, composed: true })); 166 + } 167 + } 168 + 169 + get #iconName() { 170 + if (this.icon === 'heart' && this.filled) { 171 + return 'heartFilled'; 172 + } 173 + return this.icon; 174 + } 175 + ``` 176 + 177 + **Step 4: Update render with filled state and color** 178 + 179 + ```javascript 180 + render() { 181 + const color = this.icon === 'heart' && this.filled ? 'var(--color-heart)' : 'inherit'; 182 + 183 + return html` 184 + <button 185 + type="button" 186 + aria-label=${this.icon} 187 + style="color: ${color}" 188 + @click=${this.#handleClick} 189 + > 190 + <grain-icon name=${this.#iconName} size="16"></grain-icon> 191 + </button> 192 + ${this.count > 0 ? html` 193 + <span class="count">${this.#formatCount(this.count)}</span> 194 + ` : ''} 195 + `; 196 + } 197 + ``` 198 + 199 + **Step 5: Commit** 200 + 201 + ```bash 202 + git add src/components/molecules/grain-stat-count.js 203 + git commit -m "feat: add filled state and click events to grain-stat-count" 204 + ``` 205 + 206 + --- 207 + 208 + ### Task 6: Update grain-engagement-bar with Favorite Toggle 209 + 210 + **Files:** 211 + - Modify: `src/components/organisms/grain-engagement-bar.js` 212 + 213 + **Step 1: Add new properties** 214 + 215 + ```javascript 216 + static properties = { 217 + favoriteCount: { type: Number }, 218 + commentCount: { type: Number }, 219 + url: { type: String }, 220 + galleryUri: { type: String }, 221 + viewerHasFavorited: { type: Boolean }, 222 + viewerFavoriteUri: { type: String }, 223 + _loading: { state: true } 224 + }; 225 + ``` 226 + 227 + **Step 2: Add imports and constructor** 228 + 229 + ```javascript 230 + import { auth } from '../../services/auth.js'; 231 + import { recordCache } from '../../services/record-cache.js'; 232 + ``` 233 + 234 + Update constructor: 235 + 236 + ```javascript 237 + constructor() { 238 + super(); 239 + this.favoriteCount = 0; 240 + this.commentCount = 0; 241 + this.url = ''; 242 + this.galleryUri = ''; 243 + this.viewerHasFavorited = false; 244 + this.viewerFavoriteUri = null; 245 + this._loading = false; 246 + } 247 + ``` 248 + 249 + **Step 3: Add favorite toggle handler** 250 + 251 + ```javascript 252 + async #handleFavoriteClick() { 253 + if (!auth.isAuthenticated || this._loading) return; 254 + 255 + this._loading = true; 256 + const client = auth.getClient(); 257 + 258 + try { 259 + if (this.viewerHasFavorited) { 260 + // Unfavorite 261 + const rkey = this.viewerFavoriteUri.split('/').pop(); 262 + await client.mutate(` 263 + mutation DeleteFavorite($rkey: String!) { 264 + deleteSocialGrainFavorite(rkey: $rkey) { uri } 265 + } 266 + `, { rkey }); 267 + 268 + this.viewerHasFavorited = false; 269 + this.viewerFavoriteUri = null; 270 + this.favoriteCount = Math.max(0, this.favoriteCount - 1); 271 + } else { 272 + // Favorite 273 + const result = await client.mutate(` 274 + mutation CreateFavorite($input: SocialGrainFavoriteInput!) { 275 + createSocialGrainFavorite(input: $input) { uri } 276 + } 277 + `, { input: { subject: { uri: this.galleryUri } } }); 278 + 279 + this.viewerHasFavorited = true; 280 + this.viewerFavoriteUri = result.createSocialGrainFavorite.uri; 281 + this.favoriteCount += 1; 282 + } 283 + 284 + // Update cache 285 + if (this.galleryUri) { 286 + recordCache.set(this.galleryUri, { 287 + viewerHasFavorited: this.viewerHasFavorited, 288 + viewerFavoriteUri: this.viewerFavoriteUri, 289 + favoriteCount: this.favoriteCount 290 + }); 291 + } 292 + } catch (err) { 293 + console.error('Failed to toggle favorite:', err); 294 + this.shadowRoot.querySelector('grain-toast').show('Failed to update'); 295 + } finally { 296 + this._loading = false; 297 + } 298 + } 299 + ``` 300 + 301 + **Step 4: Update render** 302 + 303 + ```javascript 304 + render() { 305 + const isLoggedIn = auth.isAuthenticated; 306 + 307 + return html` 308 + <grain-stat-count 309 + icon="heart" 310 + count=${this.favoriteCount} 311 + ?filled=${this.viewerHasFavorited} 312 + ?interactive=${isLoggedIn} 313 + @stat-click=${this.#handleFavoriteClick} 314 + ></grain-stat-count> 315 + <grain-stat-count 316 + icon="comment" 317 + count=${this.commentCount} 318 + ></grain-stat-count> 319 + <button class="share-button" type="button" aria-label="Share" @click=${this.#handleShare}> 320 + <grain-icon name="paperPlane" size="16"></grain-icon> 321 + </button> 322 + <grain-toast></grain-toast> 323 + `; 324 + } 325 + ``` 326 + 327 + **Step 5: Commit** 328 + 329 + ```bash 330 + git add src/components/organisms/grain-engagement-bar.js 331 + git commit -m "feat: add favorite toggle to engagement bar" 332 + ``` 333 + 334 + --- 335 + 336 + ### Task 7: Pass Viewer Props Through Gallery Card 337 + 338 + **Files:** 339 + - Modify: `src/components/organisms/grain-gallery-card.js` 340 + 341 + **Step 1: Update engagement bar in render** 342 + 343 + Pass the new props: 344 + 345 + ```javascript 346 + <grain-engagement-bar 347 + favoriteCount=${gallery.favoriteCount || 0} 348 + commentCount=${gallery.commentCount || 0} 349 + galleryUri=${gallery.uri || ''} 350 + ?viewerHasFavorited=${gallery.viewerHasFavorited} 351 + viewerFavoriteUri=${gallery.viewerFavoriteUri || ''} 352 + ></grain-engagement-bar> 353 + ``` 354 + 355 + **Step 2: Commit** 356 + 357 + ```bash 358 + git add src/components/organisms/grain-gallery-card.js 359 + git commit -m "feat: pass viewer favorite props to engagement bar" 360 + ``` 361 + 362 + --- 363 + 364 + ### Task 8: Pass Viewer Props Through Gallery Detail 365 + 366 + **Files:** 367 + - Modify: `src/components/pages/grain-gallery-detail.js` 368 + 369 + **Step 1: Update engagement bar in render** 370 + 371 + Pass the new props: 372 + 373 + ```javascript 374 + <grain-engagement-bar 375 + favoriteCount=${this._gallery.favoriteCount} 376 + commentCount=${this._gallery.commentCount} 377 + url=${window.location.href} 378 + galleryUri=${this._gallery.uri || ''} 379 + ?viewerHasFavorited=${this._gallery.viewerHasFavorited} 380 + viewerFavoriteUri=${this._gallery.viewerFavoriteUri || ''} 381 + ></grain-engagement-bar> 382 + ``` 383 + 384 + **Step 2: Commit** 385 + 386 + ```bash 387 + git add src/components/pages/grain-gallery-detail.js 388 + git commit -m "feat: pass viewer favorite props in gallery detail" 389 + ``` 390 + 391 + --- 392 + 393 + ### Task 9: Add Follow Button to Profile Header 394 + 395 + **Files:** 396 + - Modify: `src/components/organisms/grain-profile-header.js` 397 + 398 + **Step 1: Add follow button styles** 399 + 400 + ```css 401 + .follow-button { 402 + background: var(--color-accent); 403 + color: white; 404 + border: none; 405 + border-radius: 8px; 406 + padding: 6px 16px; 407 + font-size: var(--font-size-sm); 408 + font-weight: var(--font-weight-semibold); 409 + cursor: pointer; 410 + } 411 + .follow-button.following { 412 + background: transparent; 413 + border: 1px solid var(--color-border); 414 + color: var(--color-text-primary); 415 + } 416 + .follow-button:disabled { 417 + opacity: 0.5; 418 + } 419 + ``` 420 + 421 + **Step 2: Add state property** 422 + 423 + ```javascript 424 + _followLoading: { state: true } 425 + ``` 426 + 427 + And in constructor: 428 + 429 + ```javascript 430 + this._followLoading = false; 431 + ``` 432 + 433 + **Step 3: Add follow toggle handler** 434 + 435 + ```javascript 436 + async #handleFollowClick() { 437 + if (!this._user || this._followLoading) return; 438 + 439 + this._followLoading = true; 440 + const client = auth.getClient(); 441 + 442 + try { 443 + if (this.profile.viewerIsFollowing) { 444 + // Unfollow 445 + const rkey = this.profile.viewerFollowUri.split('/').pop(); 446 + await client.mutate(` 447 + mutation DeleteFollow($rkey: String!) { 448 + deleteSocialGrainGraphFollow(rkey: $rkey) { uri } 449 + } 450 + `, { rkey }); 451 + 452 + this.profile = { 453 + ...this.profile, 454 + viewerIsFollowing: false, 455 + viewerFollowUri: null, 456 + followerCount: Math.max(0, (this.profile.followerCount || 0) - 1) 457 + }; 458 + } else { 459 + // Follow 460 + const result = await client.mutate(` 461 + mutation CreateFollow($input: SocialGrainGraphFollowInput!) { 462 + createSocialGrainGraphFollow(input: $input) { uri } 463 + } 464 + `, { input: { subject: this.profile.did } }); 465 + 466 + this.profile = { 467 + ...this.profile, 468 + viewerIsFollowing: true, 469 + viewerFollowUri: result.createSocialGrainGraphFollow.uri, 470 + followerCount: (this.profile.followerCount || 0) + 1 471 + }; 472 + } 473 + 474 + // Update cache 475 + recordCache.set(`profile:${this.profile.handle}`, this.profile); 476 + } catch (err) { 477 + console.error('Failed to toggle follow:', err); 478 + this.shadowRoot.querySelector('grain-toast').show('Failed to update'); 479 + } finally { 480 + this._followLoading = false; 481 + } 482 + } 483 + ``` 484 + 485 + **Step 4: Add recordCache import** 486 + 487 + ```javascript 488 + import { recordCache } from '../../services/record-cache.js'; 489 + ``` 490 + 491 + **Step 5: Render follow button (after handle, before menu button)** 492 + 493 + In the `handle-row` div, add after the handle span: 494 + 495 + ```javascript 496 + ${!this.#isOwnProfile && this._user ? html` 497 + <button 498 + class="follow-button ${this.profile.viewerIsFollowing ? 'following' : ''}" 499 + ?disabled=${this._followLoading} 500 + @click=${this.#handleFollowClick} 501 + > 502 + ${this.profile.viewerIsFollowing ? 'Following' : 'Follow'} 503 + </button> 504 + ` : ''} 505 + ``` 506 + 507 + **Step 6: Commit** 508 + 509 + ```bash 510 + git add src/components/organisms/grain-profile-header.js 511 + git commit -m "feat: add follow/unfollow button to profile header" 512 + ``` 513 + 514 + --- 515 + 516 + ### Task 10: Test Full Flow 517 + 518 + **Step 1: Start dev server** 519 + 520 + ```bash 521 + npm run dev 522 + ``` 523 + 524 + **Step 2: Manual testing checklist** 525 + 526 + - [ ] Login with test account 527 + - [ ] Timeline shows outline hearts on galleries not favorited 528 + - [ ] Click heart โ†’ fills, count increments 529 + - [ ] Click filled heart โ†’ unfills, count decrements 530 + - [ ] Navigate to gallery detail โ†’ heart state matches 531 + - [ ] Click heart in detail โ†’ state updates 532 + - [ ] Navigate back to timeline โ†’ state persisted 533 + - [ ] Visit another user's profile โ†’ Follow button visible 534 + - [ ] Click Follow โ†’ button changes to "Following" 535 + - [ ] Click Following โ†’ button changes back to "Follow" 536 + - [ ] Own profile โ†’ no Follow button shown 537 + - [ ] Logged out โ†’ hearts visible but not clickable, no Follow button 538 + 539 + **Step 3: Final commit** 540 + 541 + ```bash 542 + git add -A 543 + git commit -m "feat: complete viewer favorites and follows implementation" 544 + ```