WIP PWA for Grain
0
fork

Configure Feed

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

feat: create grain-notifications page component

+326
+326
src/components/pages/grain-notifications.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { auth } from '../../services/auth.js'; 3 + import { grainApi } from '../../services/grain-api.js'; 4 + import { router } from '../../router.js'; 5 + import '../templates/grain-feed-layout.js'; 6 + import '../atoms/grain-spinner.js'; 7 + 8 + export class GrainNotifications extends LitElement { 9 + static properties = { 10 + _notifications: { state: true }, 11 + _loading: { state: true }, 12 + _error: { state: true }, 13 + _user: { state: true } 14 + }; 15 + 16 + static styles = css` 17 + :host { 18 + display: block; 19 + } 20 + .header { 21 + font-size: 1.25rem; 22 + font-weight: 600; 23 + padding: var(--space-md) var(--space-lg); 24 + border-bottom: 1px solid var(--color-border); 25 + } 26 + .error { 27 + padding: var(--space-lg); 28 + text-align: center; 29 + color: var(--color-error); 30 + } 31 + .empty { 32 + padding: var(--space-xl); 33 + text-align: center; 34 + color: var(--color-text-secondary); 35 + } 36 + .auth-prompt { 37 + padding: var(--space-xl); 38 + text-align: center; 39 + color: var(--color-text-secondary); 40 + } 41 + .notification-list { 42 + list-style: none; 43 + margin: 0; 44 + padding: 0; 45 + } 46 + .notification-item { 47 + display: flex; 48 + flex-direction: column; 49 + gap: var(--space-sm); 50 + padding: var(--space-md) var(--space-lg); 51 + border-bottom: 1px solid var(--color-border); 52 + } 53 + .notification-header { 54 + display: flex; 55 + flex-wrap: wrap; 56 + align-items: center; 57 + gap: var(--space-xs); 58 + } 59 + .author-link { 60 + display: flex; 61 + align-items: center; 62 + gap: var(--space-sm); 63 + text-decoration: none; 64 + color: inherit; 65 + } 66 + .author-link:hover { 67 + text-decoration: underline; 68 + } 69 + .avatar { 70 + width: 32px; 71 + height: 32px; 72 + border-radius: 50%; 73 + object-fit: cover; 74 + background: var(--color-bg-secondary); 75 + } 76 + .author-name { 77 + font-weight: 600; 78 + } 79 + .action-text { 80 + color: var(--color-text-secondary); 81 + } 82 + .time { 83 + color: var(--color-text-tertiary); 84 + } 85 + .context { 86 + margin-top: var(--space-xs); 87 + } 88 + .comment-text { 89 + margin-bottom: var(--space-sm); 90 + } 91 + .reply-quote { 92 + padding-left: var(--space-sm); 93 + border-left: 2px solid var(--color-border); 94 + color: var(--color-text-secondary); 95 + font-size: 0.875rem; 96 + margin-bottom: var(--space-sm); 97 + } 98 + .thumbnail { 99 + width: 100px; 100 + height: 100px; 101 + border-radius: var(--radius-sm); 102 + object-fit: cover; 103 + } 104 + .thumbnail-link { 105 + display: block; 106 + width: fit-content; 107 + } 108 + `; 109 + 110 + constructor() { 111 + super(); 112 + this._notifications = []; 113 + this._loading = true; 114 + this._error = null; 115 + this._user = auth.user; 116 + } 117 + 118 + connectedCallback() { 119 + super.connectedCallback(); 120 + this._unsubscribe = auth.subscribe(user => { 121 + this._user = user; 122 + if (user) { 123 + this.#loadNotifications(); 124 + } 125 + }); 126 + if (this._user) { 127 + this.#loadNotifications(); 128 + } else { 129 + this._loading = false; 130 + } 131 + } 132 + 133 + disconnectedCallback() { 134 + super.disconnectedCallback(); 135 + this._unsubscribe?.(); 136 + } 137 + 138 + async #loadNotifications() { 139 + if (!this._user?.did) { 140 + this._loading = false; 141 + return; 142 + } 143 + 144 + try { 145 + this._loading = true; 146 + this._error = null; 147 + const result = await grainApi.getNotifications(this._user.did); 148 + this._notifications = result.notifications; 149 + } catch (err) { 150 + console.error('Failed to load notifications:', err); 151 + this._error = err.message; 152 + } finally { 153 + this._loading = false; 154 + } 155 + } 156 + 157 + #formatRelativeTime(dateStr) { 158 + const date = new Date(dateStr); 159 + const now = new Date(); 160 + const diffMs = now - date; 161 + const diffSec = Math.floor(diffMs / 1000); 162 + const diffMin = Math.floor(diffSec / 60); 163 + const diffHour = Math.floor(diffMin / 60); 164 + const diffDay = Math.floor(diffHour / 24); 165 + 166 + if (diffSec < 60) return 'now'; 167 + if (diffMin < 60) return `${diffMin}m`; 168 + if (diffHour < 24) return `${diffHour}h`; 169 + if (diffDay < 7) return `${diffDay}d`; 170 + return date.toLocaleDateString(); 171 + } 172 + 173 + #getActionText(reason) { 174 + switch (reason) { 175 + case 'gallery-favorite': return 'favorited your gallery'; 176 + case 'gallery-comment': return 'commented on your gallery'; 177 + case 'gallery-comment-mention': return 'mentioned you in a comment'; 178 + case 'gallery-mention': return 'mentioned you in a gallery'; 179 + case 'reply': return 'replied to your comment'; 180 + case 'follow': return 'followed you'; 181 + default: return ''; 182 + } 183 + } 184 + 185 + #extractRkey(uri) { 186 + if (!uri) return ''; 187 + const parts = uri.split('/'); 188 + return parts[parts.length - 1]; 189 + } 190 + 191 + #renderNotification(n) { 192 + const actionText = this.#getActionText(n.reason); 193 + const time = this.#formatRelativeTime(n.createdAt); 194 + 195 + return html` 196 + <li class="notification-item"> 197 + <div class="notification-header"> 198 + <a href="/profile/${n.author.handle}" class="author-link" @click=${this.#handleLink}> 199 + <img 200 + class="avatar" 201 + src=${n.author.avatarUrl || '/default-avatar.png'} 202 + alt="" 203 + loading="lazy" 204 + /> 205 + <span class="author-name">${n.author.displayName || n.author.handle}</span> 206 + </a> 207 + <span class="action-text">${actionText}</span> 208 + <span class="time">· ${time}</span> 209 + </div> 210 + ${this.#renderContext(n)} 211 + </li> 212 + `; 213 + } 214 + 215 + #renderContext(n) { 216 + switch (n.reason) { 217 + case 'gallery-favorite': 218 + return this.#renderGalleryThumbnail(n.gallery); 219 + 220 + case 'gallery-comment': 221 + case 'gallery-comment-mention': 222 + return html` 223 + <div class="context"> 224 + ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''} 225 + ${n.focusPhoto?.thumbnailUrl 226 + ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery) 227 + : this.#renderGalleryThumbnail(n.gallery)} 228 + </div> 229 + `; 230 + 231 + case 'reply': 232 + return html` 233 + <div class="context"> 234 + ${n.replyTo ? html` 235 + <div class="reply-quote">${n.replyTo.text}</div> 236 + ` : ''} 237 + ${n.text ? html`<div class="comment-text">${n.text}</div>` : ''} 238 + ${n.focusPhoto?.thumbnailUrl 239 + ? this.#renderPhotoThumbnail(n.focusPhoto, n.gallery) 240 + : this.#renderGalleryThumbnail(n.gallery)} 241 + </div> 242 + `; 243 + 244 + case 'gallery-mention': 245 + return html` 246 + <div class="context"> 247 + ${n.gallery?.description ? html` 248 + <div class="reply-quote">${n.gallery.description}</div> 249 + ` : ''} 250 + ${this.#renderGalleryThumbnail(n.gallery)} 251 + </div> 252 + `; 253 + 254 + case 'follow': 255 + return ''; 256 + 257 + default: 258 + return ''; 259 + } 260 + } 261 + 262 + #renderGalleryThumbnail(gallery) { 263 + if (!gallery?.thumbnailUrl) return ''; 264 + const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`; 265 + return html` 266 + <a href=${href} class="thumbnail-link" @click=${this.#handleLink}> 267 + <img class="thumbnail" src=${gallery.thumbnailUrl} alt=${gallery.title || ''} loading="lazy" /> 268 + </a> 269 + `; 270 + } 271 + 272 + #renderPhotoThumbnail(photo, gallery) { 273 + if (!photo?.thumbnailUrl || !gallery) return ''; 274 + const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`; 275 + return html` 276 + <a href=${href} class="thumbnail-link" @click=${this.#handleLink}> 277 + <img class="thumbnail" src=${photo.thumbnailUrl} alt=${photo.alt || ''} loading="lazy" /> 278 + </a> 279 + `; 280 + } 281 + 282 + #handleLink(e) { 283 + e.preventDefault(); 284 + const href = e.currentTarget.getAttribute('href'); 285 + if (href) { 286 + router.push(href); 287 + } 288 + } 289 + 290 + render() { 291 + if (!this._user) { 292 + return html` 293 + <grain-feed-layout> 294 + <div class="header">Notifications</div> 295 + <p class="auth-prompt">Log in to see your notifications</p> 296 + </grain-feed-layout> 297 + `; 298 + } 299 + 300 + return html` 301 + <grain-feed-layout> 302 + <div class="header">Notifications</div> 303 + 304 + ${this._error ? html` 305 + <p class="error">${this._error}</p> 306 + ` : ''} 307 + 308 + ${this._loading ? html` 309 + <grain-spinner></grain-spinner> 310 + ` : ''} 311 + 312 + ${!this._loading && !this._error && this._notifications.length === 0 ? html` 313 + <p class="empty">No notifications yet</p> 314 + ` : ''} 315 + 316 + ${!this._loading && this._notifications.length > 0 ? html` 317 + <ul class="notification-list"> 318 + ${this._notifications.map(n => this.#renderNotification(n))} 319 + </ul> 320 + ` : ''} 321 + </grain-feed-layout> 322 + `; 323 + } 324 + } 325 + 326 + customElements.define('grain-notifications', GrainNotifications);