grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: redesign notifications with grouping, filled icons, and native-aligned layout

- Add notification grouping (favorites, follows) with overlapping avatars
- Show author name + @handle + timestamp on first line, action on second
- Add gallery title context line for gallery-related notifications
- Use filled icons for heart, follow, and comment
- Include author description in API response for notification profiles
- Use avatar preset for gallery/story thumbnails

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

+410 -52
+252 -28
app/lib/components/atoms/NotificationItem.svelte
··· 1 1 <script lang="ts"> 2 2 import Avatar from './Avatar.svelte' 3 + import { Heart, UserPlus, MessageSquare, AtSign, CornerDownRight, ChevronDown, ChevronUp } from 'lucide-svelte' 3 4 import { relativeTime } from '$lib/utils' 5 + import type { GroupedNotification } from '$lib/notifications' 4 6 5 - let { notif }: { notif: any } = $props() 7 + let { group }: { group: GroupedNotification } = $props() 8 + let expanded = $state(false) 9 + 10 + const notif = $derived(group.notification) 11 + const isGrouped = $derived(group.authorCount > 1) 6 12 7 13 const reasonText: Record<string, string> = { 8 14 'gallery-favorite': 'favorited your gallery', ··· 15 21 'follow': 'followed you', 16 22 } 17 23 24 + const isFavorite = $derived(notif.reason === 'gallery-favorite' || notif.reason === 'story-favorite') 25 + const isFollow = $derived(notif.reason === 'follow') 26 + const isComment = $derived(notif.reason === 'gallery-comment' || notif.reason === 'story-comment') 27 + const isReply = $derived(notif.reason === 'reply') 28 + const isMention = $derived(notif.reason === 'gallery-comment-mention' || notif.reason === 'gallery-mention') 29 + 18 30 const action = $derived(reasonText[notif.reason] ?? '') 19 31 const timeStr = $derived(relativeTime(notif.createdAt || '')) 20 32 const authorDid = $derived(notif.author?.did ?? '') 21 33 const authorName = $derived(notif.author?.displayName || notif.author?.handle || authorDid.slice(0, 18)) 34 + const authorHandle = $derived(notif.author?.handle ?? authorDid.slice(0, 18)) 22 35 const authorAvatar = $derived(notif.author?.avatar ?? null) 23 36 const contentHref = $derived( 24 37 notif.galleryUri ··· 28 41 : `/profile/${authorDid}` 29 42 ) 30 43 const profileHref = $derived(`/profile/${authorDid}`) 44 + 45 + 46 + // All unique authors for grouped display 47 + const allAuthors = $derived.by(() => { 48 + if (!isGrouped) return [] 49 + const authors = [ 50 + { did: notif.author?.did, avatar: notif.author?.avatar, name: authorName, handle: notif.author?.handle }, 51 + ...group.additional.map((n: any) => ({ 52 + did: n.author?.did, 53 + avatar: n.author?.avatar, 54 + name: n.author?.displayName || n.author?.handle || n.author?.did?.slice(0, 18), 55 + handle: n.author?.handle, 56 + })), 57 + ] 58 + const seen = new Set<string>() 59 + return authors.filter((a) => { 60 + if (seen.has(a.did)) return false 61 + seen.add(a.did) 62 + return true 63 + }) 64 + }) 65 + 66 + const groupActionText = $derived.by(() => { 67 + if (!isGrouped) return '' 68 + const othersCount = group.authorCount - 1 69 + const others = othersCount === 1 ? '1 other' : `${othersCount} others` 70 + return `${authorName} and ${others} ${action}` 71 + }) 31 72 </script> 32 73 33 - <div class="notif" role="group"> 34 - <a class="notif-avatar" href={profileHref}> 35 - <Avatar did={authorDid} src={authorAvatar} name={authorName} size={38} /> 36 - </a> 37 - <a class="notif-body" href={contentHref}> 38 - <div class="notif-header"> 39 - <span class="notif-author">{authorName}</span> 40 - <span class="notif-action">{action}</span> 41 - <span class="notif-time">{timeStr}</span> 74 + {#if isGrouped} 75 + <!-- Grouped notification --> 76 + <div class="notif grouped" role="group"> 77 + <div class="notif-icon icon-grain"> 78 + {#if isFavorite}<Heart size={18} fill="currentColor" /> 79 + {:else if isFollow}<UserPlus size={18} fill="currentColor" /> 80 + {:else if isComment}<MessageSquare size={16} fill="currentColor" /> 81 + {:else if isReply}<CornerDownRight size={18} /> 82 + {:else if isMention}<AtSign size={18} /> 83 + {/if} 42 84 </div> 43 - {#if notif.reason === 'reply' && notif.replyToText} 44 - <div class="notif-quote">{notif.replyToText}</div> 45 - {/if} 46 - {#if notif.commentText} 47 - <div class="notif-comment">{notif.commentText}</div> 85 + <div class="notif-body"> 86 + {#if expanded} 87 + <button class="expand-toggle" onclick={() => expanded = false}> 88 + <ChevronUp size={14} /> 89 + <span>Hide</span> 90 + </button> 91 + <div class="expanded-authors"> 92 + {#each allAuthors as author (author.did)} 93 + <a href="/profile/{author.did}" class="expanded-author-row"> 94 + <Avatar did={author.did} src={author.avatar} name={author.name} size={32} /> 95 + <div class="expanded-author-info"> 96 + <span class="expanded-author-name">{author.name}</span> 97 + <span class="expanded-author-handle">@{author.handle ?? author.did.slice(0, 18)}</span> 98 + </div> 99 + </a> 100 + {/each} 101 + </div> 102 + {:else} 103 + <div class="grouped-avatars"> 104 + {#each allAuthors.slice(0, 5) as author (author.did)} 105 + <a href="/profile/{author.did}" class="grouped-avatar-link" onclick={(e) => e.stopPropagation()}> 106 + <Avatar did={author.did} src={author.avatar} name={author.name} size={32} /> 107 + </a> 108 + {/each} 109 + {#if group.authorCount > 5} 110 + <span class="more-count">+{group.authorCount - 5}</span> 111 + {/if} 112 + <button class="expand-toggle-chevron" onclick={() => expanded = true}> 113 + <ChevronDown size={14} /> 114 + </button> 115 + </div> 116 + {/if} 117 + <a href={contentHref} class="notif-link"> 118 + <div class="notif-header"> 119 + <span class="notif-text">{groupActionText}</span> 120 + <span class="notif-time">{timeStr}</span> 121 + </div> 122 + {#if notif.galleryTitle} 123 + <div class="notif-gallery-title">{notif.galleryTitle}</div> 124 + {/if} 125 + </a> 126 + </div> 127 + {#if !expanded && (notif.galleryThumb || notif.storyThumb)} 128 + <a href={contentHref}><img src={notif.galleryThumb ?? notif.storyThumb} alt="" class="notif-thumb" loading="lazy" /></a> 48 129 {/if} 49 - {#if notif.galleryTitle && notif.reason !== 'follow'} 50 - <div class="notif-gallery-title">{notif.galleryTitle}</div> 130 + </div> 131 + {:else} 132 + <!-- Single notification --> 133 + <div class="notif" role="group"> 134 + <div class="notif-icon icon-grain"> 135 + {#if isFavorite}<Heart size={18} fill="currentColor" /> 136 + {:else if isFollow}<UserPlus size={18} fill="currentColor" /> 137 + {:else if isComment}<MessageSquare size={16} fill="currentColor" /> 138 + {:else if isReply}<CornerDownRight size={18} /> 139 + {:else if isMention}<AtSign size={18} /> 140 + {/if} 141 + </div> 142 + <a class="notif-avatar" href={profileHref}> 143 + <Avatar did={authorDid} src={authorAvatar} name={authorName} size={38} /> 144 + </a> 145 + <a class="notif-body" href={contentHref}> 146 + <div class="notif-header"> 147 + <span class="notif-author">{authorName}</span> 148 + <span class="notif-handle">@{authorHandle}</span> 149 + <span class="notif-time">{timeStr}</span> 150 + </div> 151 + <div class="notif-action">{action}</div> 152 + {#if notif.reason === 'reply' && notif.replyToText} 153 + <div class="notif-quote">{notif.replyToText}</div> 154 + {/if} 155 + {#if notif.commentText} 156 + <div class="notif-comment">{notif.commentText}</div> 157 + {/if} 158 + {#if notif.galleryTitle && notif.reason !== 'follow'} 159 + <div class="notif-gallery-title">{notif.galleryTitle}</div> 160 + {/if} 161 + </a> 162 + {#if notif.galleryThumb || notif.storyThumb} 163 + <a href={contentHref}><img src={notif.galleryThumb ?? notif.storyThumb} alt="" class="notif-thumb" loading="lazy" /></a> 51 164 {/if} 52 - </a> 53 - {#if notif.galleryThumb || notif.storyThumb} 54 - <a href={contentHref}><img src={notif.galleryThumb ?? notif.storyThumb} alt="" class="notif-thumb" loading="lazy" /></a> 55 - {/if} 56 - </div> 165 + </div> 166 + {/if} 57 167 58 168 <style> 59 169 .notif { ··· 63 173 border-bottom: 1px solid var(--border); 64 174 color: inherit; 65 175 transition: background 0.12s; 66 - align-items: flex-start; 176 + align-items: center; 67 177 } 68 178 .notif:hover { 69 179 background: var(--bg-hover); 70 180 } 181 + .notif-icon { 182 + flex-shrink: 0; 183 + width: 28px; 184 + display: flex; 185 + justify-content: center; 186 + padding-top: 2px; 187 + } 188 + .icon-grain { 189 + color: var(--grain); 190 + } 71 191 .notif-avatar { 72 192 flex-shrink: 0; 73 193 text-decoration: none; ··· 79 199 color: inherit; 80 200 } 81 201 .notif-header { 202 + display: flex; 203 + align-items: baseline; 82 204 font-size: 13px; 83 205 line-height: 1.4; 206 + min-width: 0; 84 207 } 85 208 .notif-author { 86 209 font-weight: 600; 87 210 color: var(--text-primary); 211 + flex-shrink: 0; 88 212 } 89 213 .notif-avatar:hover + .notif-body .notif-author { 90 214 text-decoration: underline; 91 215 } 216 + .notif-handle { 217 + color: var(--text-muted); 218 + margin-left: 6px; 219 + flex: 1; 220 + min-width: 0; 221 + overflow: hidden; 222 + text-overflow: ellipsis; 223 + white-space: nowrap; 224 + } 92 225 .notif-action { 93 226 color: var(--text-secondary); 94 - margin-left: 4px; 227 + font-size: 13px; 228 + line-height: 1.4; 229 + margin-top: 1px; 95 230 } 96 231 .notif-time { 97 232 color: var(--text-muted); 98 - margin-left: 4px; 233 + margin-left: 6px; 99 234 font-size: 12px; 235 + flex-shrink: 0; 100 236 } 101 237 .notif-quote { 102 238 font-size: 12px; ··· 123 259 } 124 260 .notif-thumb { 125 261 width: 48px; 126 - height: 48px; 127 - border-radius: 6px; 128 - object-fit: cover; 262 + border-radius: 0; 263 + object-fit: contain; 129 264 flex-shrink: 0; 265 + } 266 + 267 + /* Grouped notification styles */ 268 + .grouped-avatars { 269 + display: flex; 270 + align-items: center; 271 + gap: 0; 272 + margin-bottom: 6px; 273 + } 274 + .grouped-avatar-link { 275 + margin-right: -4px; 276 + text-decoration: none; 277 + position: relative; 278 + } 279 + .grouped-avatar-link:hover { 280 + z-index: 1; 281 + } 282 + .more-count { 283 + font-size: 12px; 284 + color: var(--text-muted); 285 + margin-left: 8px; 286 + } 287 + .notif-text { 288 + color: var(--text-secondary); 289 + font-size: 13px; 290 + line-height: 1.4; 291 + } 292 + .notif-link { 293 + text-decoration: none; 294 + color: inherit; 295 + } 296 + .expand-toggle { 297 + display: flex; 298 + align-items: center; 299 + gap: 4px; 300 + background: none; 301 + border: none; 302 + color: var(--text-muted); 303 + font-size: 13px; 304 + cursor: pointer; 305 + padding: 4px 0; 306 + } 307 + .expand-toggle:hover { 308 + color: var(--text-secondary); 309 + } 310 + .expand-toggle-chevron { 311 + display: flex; 312 + align-items: center; 313 + justify-content: center; 314 + background: none; 315 + border: none; 316 + color: var(--text-muted); 317 + cursor: pointer; 318 + padding: 4px 8px; 319 + margin-left: 8px; 320 + } 321 + .expand-toggle-chevron:hover { 322 + color: var(--text-secondary); 323 + } 324 + .expanded-authors { 325 + display: flex; 326 + flex-direction: column; 327 + padding: 4px 0; 328 + } 329 + .expanded-author-row { 330 + display: flex; 331 + align-items: center; 332 + gap: 10px; 333 + padding: 5px 0; 334 + text-decoration: none; 335 + color: inherit; 336 + border-radius: 6px; 337 + } 338 + .expanded-author-row:hover { 339 + background: var(--bg-hover); 340 + } 341 + .expanded-author-info { 342 + display: flex; 343 + flex-direction: column; 344 + gap: 1px; 345 + } 346 + .expanded-author-name { 347 + font-size: 13px; 348 + font-weight: 600; 349 + color: var(--text-primary); 350 + } 351 + .expanded-author-handle { 352 + font-size: 12px; 353 + color: var(--text-muted); 130 354 } 131 355 </style>
+70
app/lib/notifications.ts
··· 1 + /** 2 + * Client-side notification grouping. 3 + * 4 + * Groupable reasons: gallery-favorite, story-favorite, follow. 5 + * Notifications are grouped when they share the same reason AND the same 6 + * subject (or no subject, for follows) within a 48-hour window. 7 + */ 8 + 9 + const GROUPABLE_REASONS = new Set([ 10 + "gallery-favorite", 11 + "story-favorite", 12 + "follow", 13 + ]); 14 + 15 + const MS_2DAY = 1e3 * 60 * 60 * 48; 16 + 17 + export interface GroupedNotification { 18 + /** Primary notification (most recent in the group). */ 19 + notification: any; 20 + /** Additional grouped notifications (oldest first). */ 21 + additional: any[]; 22 + /** Total author count (1 + additional unique authors). */ 23 + authorCount: number; 24 + } 25 + 26 + export function groupNotifications(notifs: any[]): GroupedNotification[] { 27 + const groups: GroupedNotification[] = []; 28 + 29 + for (const notif of notifs) { 30 + if (!GROUPABLE_REASONS.has(notif.reason)) { 31 + groups.push({ notification: notif, additional: [], authorCount: 1 }); 32 + continue; 33 + } 34 + 35 + const ts = +new Date(notif.createdAt); 36 + let matched = false; 37 + 38 + for (const group of groups) { 39 + const gts = +new Date(group.notification.createdAt); 40 + if ( 41 + Math.abs(gts - ts) < MS_2DAY && 42 + notif.reason === group.notification.reason && 43 + subjectKey(notif) === subjectKey(group.notification) && 44 + notif.author?.did !== group.notification.author?.did 45 + ) { 46 + // Don't add duplicate authors 47 + const alreadyHas = group.additional.some( 48 + (a: any) => a.author?.did === notif.author?.did, 49 + ); 50 + if (!alreadyHas) { 51 + group.additional.push(notif); 52 + group.authorCount = 1 + group.additional.length; 53 + } 54 + matched = true; 55 + break; 56 + } 57 + } 58 + 59 + if (!matched) { 60 + groups.push({ notification: notif, additional: [], authorCount: 1 }); 61 + } 62 + } 63 + 64 + return groups; 65 + } 66 + 67 + function subjectKey(notif: any): string { 68 + if (notif.reason === "follow") return "__follow__"; 69 + return notif.galleryUri ?? notif.storyUri ?? notif.uri; 70 + }
+24 -14
app/routes/notifications/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte' 2 + import { untrack } from 'svelte' 3 3 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 4 4 import { infiniteScroll } from '$lib/actions/infinite-scroll' 5 5 import { notificationsQuery } from '$lib/queries' 6 6 import { markNotificationsSeen } from '$lib/preferences' 7 7 import { viewer as viewerStore } from '$lib/stores' 8 + import { groupNotifications, type GroupedNotification } from '$lib/notifications' 8 9 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 9 10 import NotificationItem from '$lib/components/atoms/NotificationItem.svelte' 10 11 import Spinner from '$lib/components/atoms/Spinner.svelte' 11 12 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 12 13 import { callXrpc } from '$hatk/client' 13 - import type { NotificationItem as NotifItem } from '$hatk' 14 14 15 15 const viewerDid = $derived($viewerStore?.did) 16 16 const queryClient = useQueryClient() ··· 21 21 })) 22 22 23 23 let loadingMore = $state(false) 24 - let allItems: NotifItem[] = $state([]) 24 + let allItems: any[] = $state([]) 25 25 let currentCursor: string | undefined = $state(undefined) 26 26 let hasMore = $state(true) 27 + 28 + let grouped: GroupedNotification[] = $state([]) 27 29 28 30 $effect(() => { 29 - if (notifications.data) { 30 - allItems = notifications.data.notifications ?? [] 31 - currentCursor = notifications.data.cursor 32 - hasMore = !!notifications.data.cursor 31 + const data = notifications.data 32 + if (data) { 33 + untrack(() => { 34 + allItems = data.notifications ?? [] 35 + grouped = groupNotifications(allItems) 36 + currentCursor = data.cursor 37 + hasMore = !!data.cursor 38 + }) 33 39 } 34 40 }) 35 41 36 - onMount(async () => { 37 - if (viewerDid) { 38 - await markNotificationsSeen() 39 - queryClient.setQueryData(['unseenNotificationCount', viewerDid], 0) 42 + let hasMark = false 43 + $effect(() => { 44 + if (viewerDid && !hasMark) { 45 + hasMark = true 46 + markNotificationsSeen().then(() => { 47 + queryClient.setQueryData(['unseenNotificationCount', viewerDid], 0) 48 + }) 40 49 } 41 50 }) 42 51 ··· 49 58 cursor: currentCursor, 50 59 }) 51 60 allItems = [...allItems, ...result.notifications] 61 + grouped = groupNotifications(allItems) 52 62 currentCursor = result.cursor 53 63 hasMore = !!result.cursor 54 64 } finally { ··· 63 73 64 74 {#if notifications.isLoading} 65 75 <div class="center"><Spinner /></div> 66 - {:else if allItems.length === 0} 76 + {:else if grouped.length === 0} 67 77 <div class="empty">No notifications yet</div> 68 78 {:else} 69 79 <div class="notification-list"> 70 - {#each allItems as notif (notif.uri)} 71 - <NotificationItem {notif} /> 80 + {#each grouped as group (group.notification.uri)} 81 + <NotificationItem {group} /> 72 82 {/each} 73 83 {#if hasMore} 74 84 <div use:infiniteScroll={() => { if (!loadingMore) loadMore() }} class="sentinel">
+64 -10
server/xrpc/getNotifications.ts
··· 108 108 const viewer = viewerObj.did; 109 109 const { limit = 20, cursor, countOnly } = params; 110 110 111 - // Get lastSeenNotifications from preferences 111 + // Get preferences 112 112 const prefRows = (await db.query( 113 - `SELECT value FROM _preferences WHERE did = $1 AND key = 'lastSeenNotifications'`, 113 + `SELECT key, value FROM _preferences WHERE did = $1 AND key IN ('lastSeenNotifications', 'notificationPrefs')`, 114 114 [viewer], 115 - )) as { value: string }[]; 116 - const rawValue = prefRows[0]?.value ?? null; 117 - const lastSeen = rawValue ? JSON.parse(rawValue) : null; 115 + )) as { key: string; value: string }[]; 116 + let lastSeen: string | null = null; 117 + let notifPrefs: Record<string, { push: boolean; inApp: boolean; from: string }> | null = null; 118 + for (const row of prefRows) { 119 + const val = typeof row.value === "string" ? JSON.parse(row.value) : row.value; 120 + if (row.key === "lastSeenNotifications") lastSeen = val; 121 + if (row.key === "notificationPrefs") notifPrefs = val; 122 + } 118 123 119 124 // Count unseen — if no lastSeen, all notifications are unseen 120 125 const timeFilter = lastSeen ? `AND created_at > $2` : ""; ··· 153 158 focus: string | null; 154 159 }>; 155 160 156 - const hasMore = rows.length > limit; 157 - const items = hasMore ? rows.slice(0, limit) : rows; 158 - const nextCursor = hasMore ? items[items.length - 1]?.created_at : undefined; 161 + // Map source to preference category 162 + function prefCategory(source: string): string | null { 163 + if (source === "favorite" || source === "story-favorite") return "favorites"; 164 + if (source === "follow") return "follows"; 165 + if (source === "comment" || source === "reply" || source === "story-comment") return "comments"; 166 + if (source === "comment-mention" || source === "gallery-mention") return "mentions"; 167 + return null; 168 + } 169 + 170 + // Filter by inApp preference 171 + let filtered = rows; 172 + if (notifPrefs) { 173 + filtered = rows.filter((row) => { 174 + const cat = prefCategory(row.source); 175 + if (!cat || !notifPrefs![cat]) return true; 176 + return notifPrefs![cat].inApp !== false; 177 + }); 178 + } 179 + 180 + // Filter by "from" preference (follows only) 181 + let followingSet: Set<string> | null = null; 182 + if (notifPrefs) { 183 + const needsFollowCheck = filtered.some((row) => { 184 + const cat = prefCategory(row.source); 185 + return cat && notifPrefs![cat]?.from === "follows"; 186 + }); 187 + if (needsFollowCheck) { 188 + const followDids = filtered.map((r) => r.did); 189 + const uniq = [...new Set(followDids)]; 190 + if (uniq.length > 0) { 191 + const ph = uniq.map((_, i) => `$${i + 2}`).join(","); 192 + const followRows = (await db.query( 193 + `SELECT subject FROM "social.grain.graph.follow" WHERE did = $1 AND subject IN (${ph})`, 194 + [viewer, ...uniq], 195 + )) as { subject: string }[]; 196 + followingSet = new Set(followRows.map((r) => r.subject)); 197 + } 198 + } 199 + if (followingSet) { 200 + filtered = filtered.filter((row) => { 201 + const cat = prefCategory(row.source); 202 + if (!cat || notifPrefs![cat]?.from !== "follows") return true; 203 + return followingSet!.has(row.did); 204 + }); 205 + } 206 + } 207 + 208 + // Use cursor from unfiltered rows to avoid skipping notifications 209 + const hasMoreRows = rows.length > limit; 210 + const nextCursor = hasMoreRows ? rows[rows.length - 1]?.created_at : undefined; 211 + const items = filtered.slice(0, limit); 159 212 160 213 // Determine notification reason 161 214 function getReason(row: (typeof items)[0]): string { ··· 253 306 } catch { 254 307 blobRef = row.media; 255 308 } 256 - const thumb = blobUrl(row.did, blobRef, "feed_thumbnail"); 309 + const thumb = blobUrl(row.did, blobRef, "avatar"); 257 310 if (thumb) storyThumbs.set(row.uri, thumb); 258 311 } 259 312 } ··· 281 334 const photoUri = isGallery && subjectUri ? firstPhotoByGallery.get(subjectUri) : null; 282 335 const photo = photoUri ? photos.get(photoUri) : null; 283 336 const galleryThumb = photo 284 - ? (blobUrl(photo.did, photo.value.photo, "feed_thumbnail") ?? undefined) 337 + ? (blobUrl(photo.did, photo.value.photo, "avatar") ?? undefined) 285 338 : undefined; 286 339 287 340 const storyThumb = isStory && subjectUri ? storyThumbs.get(subjectUri) : undefined; ··· 296 349 did: author.did, 297 350 handle: author.handle ?? handleMap.get(author.did) ?? author.did, 298 351 displayName: author.value.displayName, 352 + description: author.value.description, 299 353 avatar: blobUrl(author.did, author.value.avatar) ?? undefined, 300 354 }) 301 355 : views.grainActorDefsProfileView({