WIP PWA for Grain

fix: cache notifications/explore pages and add pull-to-refresh

Fixes mobile scroll freezing on notifications page when navigating
away and back. Adds page caching for notifications and explore routes,
and wraps notifications content in pull-to-refresh for consistent
touch handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+43 -16
+39 -14
src/components/pages/grain-notifications.js
··· 3 3 import { grainApi } from '../../services/grain-api.js'; 4 4 import { router } from '../../router.js'; 5 5 import '../templates/grain-feed-layout.js'; 6 + import '../molecules/grain-pull-to-refresh.js'; 6 7 import '../atoms/grain-spinner.js'; 7 8 import '../atoms/grain-avatar.js'; 8 9 ··· 10 11 static properties = { 11 12 _notifications: { state: true }, 12 13 _loading: { state: true }, 14 + _refreshing: { state: true }, 13 15 _error: { state: true }, 14 16 _user: { state: true }, 15 17 _hasMore: { state: true }, ··· 132 134 super(); 133 135 this._notifications = []; 134 136 this._loading = true; 137 + this._refreshing = false; 135 138 this._error = null; 136 139 this._user = auth.user; 137 140 this._hasMore = true; ··· 223 226 } 224 227 } 225 228 229 + async #handleRefresh() { 230 + if (!this._user?.did) return; 231 + 232 + this._refreshing = true; 233 + try { 234 + const result = await grainApi.getNotifications(this._user.did, { first: 20 }); 235 + this._notifications = result.notifications; 236 + this._hasMore = result.pageInfo.hasNextPage; 237 + this._cursor = result.pageInfo.endCursor; 238 + this._error = null; 239 + } catch (err) { 240 + console.error('Failed to refresh notifications:', err); 241 + this._error = err.message; 242 + } finally { 243 + this._refreshing = false; 244 + } 245 + } 246 + 226 247 #formatRelativeTime(dateStr) { 227 248 const date = new Date(dateStr); 228 249 const now = new Date(); ··· 387 408 return html` 388 409 <grain-feed-layout> 389 410 <div class="header">Notifications</div> 411 + <grain-pull-to-refresh 412 + ?refreshing=${this._refreshing} 413 + @refresh=${this.#handleRefresh} 414 + > 415 + ${this._error ? html` 416 + <p class="error">${this._error}</p> 417 + ` : ''} 390 418 391 - ${this._error ? html` 392 - <p class="error">${this._error}</p> 393 - ` : ''} 419 + ${!this._loading && !this._error && this._notifications.length === 0 ? html` 420 + <p class="empty">No notifications yet</p> 421 + ` : ''} 394 422 395 - ${!this._loading && !this._error && this._notifications.length === 0 ? html` 396 - <p class="empty">No notifications yet</p> 397 - ` : ''} 398 - 399 - ${this._notifications.length > 0 ? html` 400 - <ul class="notification-list"> 401 - ${this._notifications.map(n => this.#renderNotification(n))} 402 - </ul> 403 - ` : ''} 423 + ${this._notifications.length > 0 ? html` 424 + <ul class="notification-list"> 425 + ${this._notifications.map(n => this.#renderNotification(n))} 426 + </ul> 427 + ` : ''} 404 428 405 - <div id="sentinel"></div> 429 + <div id="sentinel"></div> 406 430 407 - ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 431 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 432 + </grain-pull-to-refresh> 408 433 </grain-feed-layout> 409 434 `; 410 435 }
+4 -2
src/router.js
··· 5 5 #pageCache = new Map(); // path -> { element } 6 6 #scrollCache = new Map(); // path -> scrollY (persists after element eviction) 7 7 8 - // Only cache these route patterns (timeline and profiles) 8 + // Only cache these route patterns (timeline, profiles, notifications, explore) 9 9 #cacheablePatterns = [ 10 10 /^\/$/, // timeline 11 - /^\/profile\/[^/]+$/ // profile (not followers/following/gallery) 11 + /^\/profile\/[^/]+$/, // profile (not followers/following/gallery) 12 + /^\/notifications$/, // notifications 13 + /^\/explore$/ // explore/search 12 14 ]; 13 15 14 16 register(path, componentTag) {