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: add notification preferences with per-category push/inApp filtering

- Add notification settings page under /settings/notifications
- Filter notifications by category (favorites, follows, comments, mentions)
- Support push and inApp toggles per category
- Respect "from follows only" preference for push notifications
- Add settings link in main settings page

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

+305 -20
+6 -1
app/routes/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 - import { UserPen, Shield, ChevronRight } from 'lucide-svelte' 3 + import { UserPen, Shield, Bell, ChevronRight } from 'lucide-svelte' 4 4 </script> 5 5 6 6 <DetailHeader label="Settings" /> ··· 10 10 <a href="/settings/profile" class="settings-row"> 11 11 <UserPen size={18} /> 12 12 <span class="settings-label">Edit Profile</span> 13 + <ChevronRight size={16} class="chevron" /> 14 + </a> 15 + <a href="/settings/notifications" class="settings-row"> 16 + <Bell size={18} /> 17 + <span class="settings-label">Notifications</span> 13 18 <ChevronRight size={16} class="chevron" /> 14 19 </a> 15 20 <a href="/settings/moderation" class="settings-row">
+228
app/routes/settings/notifications/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery, useQueryClient } from '@tanstack/svelte-query' 3 + import { callXrpc } from '$hatk/client' 4 + import { viewer as viewerStore } from '$lib/stores' 5 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 6 + import { Heart, UserPlus, MessageSquare, AtSign } from 'lucide-svelte' 7 + 8 + const queryClient = useQueryClient() 9 + const viewerDid = $derived($viewerStore?.did) 10 + 11 + interface NotifPref { 12 + push: boolean 13 + inApp: boolean 14 + from: 'all' | 'follows' 15 + } 16 + 17 + const defaultPrefs: Record<string, NotifPref> = { 18 + favorites: { push: true, inApp: true, from: 'all' }, 19 + follows: { push: true, inApp: true, from: 'all' }, 20 + comments: { push: true, inApp: true, from: 'all' }, 21 + mentions: { push: true, inApp: true, from: 'all' }, 22 + } 23 + 24 + const prefsQuery = createQuery(() => ({ 25 + queryKey: ['notificationPrefs', viewerDid], 26 + queryFn: async () => { 27 + const res: any = await callXrpc('dev.hatk.getPreferences', {}) 28 + return (res?.preferences?.notificationPrefs as Record<string, NotifPref>) ?? defaultPrefs 29 + }, 30 + enabled: !!viewerDid, 31 + staleTime: Infinity, 32 + })) 33 + 34 + const prefs = $derived({ ...defaultPrefs, ...(prefsQuery.data ?? {}) }) 35 + 36 + async function save(updated: Record<string, NotifPref>) { 37 + const previous = queryClient.getQueryData(['notificationPrefs', viewerDid]) 38 + queryClient.setQueryData(['notificationPrefs', viewerDid], updated) 39 + try { 40 + await callXrpc('dev.hatk.putPreference', { key: 'notificationPrefs', value: updated }) 41 + } catch { 42 + queryClient.setQueryData(['notificationPrefs', viewerDid], previous) 43 + } 44 + } 45 + 46 + function toggle(category: string, field: 'push' | 'inApp') { 47 + const current = { ...prefs } 48 + current[category] = { ...current[category], [field]: !current[category][field] } 49 + void save(current) 50 + } 51 + 52 + function setFrom(category: string, value: 'all' | 'follows') { 53 + const current = { ...prefs } 54 + current[category] = { ...current[category], from: value } 55 + void save(current) 56 + } 57 + 58 + const categories = [ 59 + { key: 'favorites', label: 'Favorites', desc: 'When someone favorites your gallery or story', icon: Heart }, 60 + { key: 'follows', label: 'New followers', desc: 'When someone follows you', icon: UserPlus }, 61 + { key: 'comments', label: 'Comments', desc: 'When someone comments on your gallery or story', icon: MessageSquare }, 62 + { key: 'mentions', label: 'Mentions', desc: 'When someone mentions you', icon: AtSign }, 63 + ] 64 + 65 + let expandedCategory: string | null = $state(null) 66 + 67 + function toggleExpand(key: string) { 68 + expandedCategory = expandedCategory === key ? null : key 69 + } 70 + 71 + function summaryText(pref: NotifPref): string { 72 + const parts: string[] = [] 73 + if (pref.inApp) parts.push('In-app') 74 + if (pref.push) parts.push('Push') 75 + if (parts.length === 0) parts.push('Off') 76 + parts.push(pref.from === 'all' ? 'Everyone' : 'Following') 77 + return parts.join(', ') 78 + } 79 + </script> 80 + 81 + <DetailHeader label="Notifications" /> 82 + 83 + <div class="settings-page"> 84 + <div class="settings-group"> 85 + {#each categories as cat (cat.key)} 86 + {@const pref = prefs[cat.key]} 87 + <button class="settings-row" onclick={() => toggleExpand(cat.key)}> 88 + <div class="row-icon"> 89 + <cat.icon size={18} /> 90 + </div> 91 + <div class="row-content"> 92 + <span class="row-label">{cat.label}</span> 93 + <span class="row-summary">{summaryText(pref)}</span> 94 + </div> 95 + <span class="chevron" class:expanded={expandedCategory === cat.key}>&#x203A;</span> 96 + </button> 97 + {#if expandedCategory === cat.key} 98 + <div class="detail-panel"> 99 + <p class="detail-desc">{cat.desc}</p> 100 + <label class="toggle-row"> 101 + <span>Push notifications</span> 102 + <input type="checkbox" checked={pref.push} onchange={() => toggle(cat.key, 'push')} /> 103 + </label> 104 + <label class="toggle-row"> 105 + <span>In-app notifications</span> 106 + <input type="checkbox" checked={pref.inApp} onchange={() => toggle(cat.key, 'inApp')} /> 107 + </label> 108 + <div class="divider"></div> 109 + <p class="from-label">From</p> 110 + <label class="radio-row"> 111 + <input type="radio" name="from-{cat.key}" checked={pref.from === 'all'} onchange={() => setFrom(cat.key, 'all')} /> 112 + <span>Everyone</span> 113 + </label> 114 + <label class="radio-row"> 115 + <input type="radio" name="from-{cat.key}" checked={pref.from === 'follows'} onchange={() => setFrom(cat.key, 'follows')} /> 116 + <span>People I follow</span> 117 + </label> 118 + </div> 119 + {/if} 120 + {/each} 121 + </div> 122 + </div> 123 + 124 + <style> 125 + .settings-page { 126 + max-width: 600px; 127 + margin: 0 auto; 128 + padding: 16px; 129 + } 130 + .settings-group { 131 + border: 1px solid var(--border); 132 + border-radius: 10px; 133 + overflow: hidden; 134 + } 135 + .settings-row { 136 + display: flex; 137 + align-items: center; 138 + gap: 12px; 139 + padding: 14px 16px; 140 + width: 100%; 141 + background: none; 142 + border: none; 143 + border-bottom: 1px solid var(--border); 144 + color: var(--text-primary); 145 + cursor: pointer; 146 + text-align: left; 147 + font-family: inherit; 148 + transition: background 0.12s; 149 + } 150 + .settings-row:last-child { 151 + border-bottom: none; 152 + } 153 + .settings-row:hover { 154 + background: var(--bg-hover); 155 + } 156 + .row-icon { 157 + flex-shrink: 0; 158 + color: var(--text-secondary); 159 + } 160 + .row-content { 161 + flex: 1; 162 + min-width: 0; 163 + } 164 + .row-label { 165 + display: block; 166 + font-size: 15px; 167 + font-weight: 500; 168 + } 169 + .row-summary { 170 + display: block; 171 + font-size: 12px; 172 + color: var(--text-muted); 173 + margin-top: 1px; 174 + } 175 + .chevron { 176 + color: var(--text-muted); 177 + font-size: 20px; 178 + transition: transform 0.15s; 179 + } 180 + .chevron.expanded { 181 + transform: rotate(90deg); 182 + } 183 + .detail-panel { 184 + padding: 12px 16px 16px; 185 + border-bottom: 1px solid var(--border); 186 + background: var(--bg-elevated); 187 + } 188 + .detail-desc { 189 + font-size: 13px; 190 + color: var(--text-muted); 191 + margin: 0 0 12px; 192 + } 193 + .toggle-row { 194 + display: flex; 195 + align-items: center; 196 + justify-content: space-between; 197 + padding: 8px 0; 198 + font-size: 14px; 199 + cursor: pointer; 200 + } 201 + .toggle-row input[type="checkbox"] { 202 + width: 18px; 203 + height: 18px; 204 + accent-color: var(--link); 205 + } 206 + .divider { 207 + height: 1px; 208 + background: var(--border); 209 + margin: 8px 0; 210 + } 211 + .from-label { 212 + font-size: 13px; 213 + font-weight: 600; 214 + color: var(--text-secondary); 215 + margin: 0 0 6px; 216 + } 217 + .radio-row { 218 + display: flex; 219 + align-items: center; 220 + gap: 8px; 221 + padding: 6px 0; 222 + font-size: 14px; 223 + cursor: pointer; 224 + } 225 + .radio-row input[type="radio"] { 226 + accent-color: var(--link); 227 + } 228 + </style>
+39
server/helpers/notifPrefs.ts
··· 1 + /** 2 + * Check if push notifications are enabled for a given category and actor. 3 + * Returns true if push should be sent (default behavior if no prefs set). 4 + */ 5 + export async function shouldPush( 6 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> }, 7 + recipientDid: string, 8 + actorDid: string, 9 + category: "favorites" | "follows" | "comments" | "mentions", 10 + ): Promise<boolean> { 11 + const rows = (await db.query( 12 + `SELECT value FROM _preferences WHERE did = $1 AND key = 'notificationPrefs'`, 13 + [recipientDid], 14 + )) as { value: string }[]; 15 + 16 + if (!rows[0]) return true; 17 + 18 + let prefs: Record<string, { push: boolean; inApp: boolean; from: string }>; 19 + try { 20 + prefs = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value; 21 + } catch { 22 + return true; 23 + } 24 + 25 + const pref = prefs[category]; 26 + if (!pref) return true; 27 + if (pref.push === false) return false; 28 + 29 + // Check "from" filter 30 + if (pref.from === "follows") { 31 + const followRows = (await db.query( 32 + `SELECT 1 FROM "social.grain.graph.follow" WHERE did = $1 AND subject = $2 LIMIT 1`, 33 + [recipientDid, actorDid], 34 + )) as unknown[]; 35 + return followRows.length > 0; 36 + } 37 + 38 + return true; 39 + }
+25 -18
server/on-commit-comment.ts
··· 1 1 import { defineHook } from "$hatk"; 2 + import { shouldPush } from "./helpers/notifPrefs.ts"; 2 3 3 4 export default defineHook("on-commit", { collections: ["social.grain.comment"] }, 4 5 async ({ action, record, repo, db, lookup, push }) => { ··· 19 20 ) as { author: string }[] 20 21 21 22 if (parent && parent.author !== repo) { 22 - await push.send({ 23 - did: parent.author, 24 - title: "New reply", 25 - body: `${displayName} replied to your comment`, 26 - data: { type: "comment-reply", uri: subject }, 27 - }) 23 + if (await shouldPush(db, parent.author, repo, "comments")) { 24 + await push.send({ 25 + did: parent.author, 26 + title: "New reply", 27 + body: `${displayName} replied to your comment`, 28 + data: { type: "comment-reply", uri: subject }, 29 + }) 30 + } 28 31 } 29 32 } 30 33 ··· 36 39 37 40 if (gallery) { 38 41 if (gallery.author !== repo) { 39 - await push.send({ 40 - did: gallery.author, 41 - title: "New comment", 42 - body: `${displayName} commented on your gallery`, 43 - data: { type: "gallery-comment", uri: subject }, 44 - }) 42 + if (await shouldPush(db, gallery.author, repo, "comments")) { 43 + await push.send({ 44 + did: gallery.author, 45 + title: "New comment", 46 + body: `${displayName} commented on your gallery`, 47 + data: { type: "gallery-comment", uri: subject }, 48 + }) 49 + } 45 50 } 46 51 return 47 52 } ··· 53 58 ) as { author: string }[] 54 59 55 60 if (story && story.author !== repo) { 56 - await push.send({ 57 - did: story.author, 58 - title: "New comment", 59 - body: `${displayName} commented on your story`, 60 - data: { type: "story-comment", uri: subject }, 61 - }) 61 + if (await shouldPush(db, story.author, repo, "comments")) { 62 + await push.send({ 63 + did: story.author, 64 + title: "New comment", 65 + body: `${displayName} commented on your story`, 66 + data: { type: "story-comment", uri: subject }, 67 + }) 68 + } 62 69 } 63 70 } 64 71 )
+3
server/on-commit-favorite.ts
··· 1 1 import { defineHook } from "$hatk"; 2 + import { shouldPush } from "./helpers/notifPrefs.ts"; 2 3 3 4 export default defineHook("on-commit", { collections: ["social.grain.favorite"] }, 4 5 async ({ action, record, repo, db, lookup, push }) => { ··· 13 14 ) as { author: string }[] 14 15 15 16 if (gallery && gallery.author !== repo) { 17 + if (!(await shouldPush(db, gallery.author, repo, "favorites"))) return 16 18 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 17 19 const actor = profiles.get(repo) 18 20 await push.send({ ··· 31 33 ) as { author: string }[] 32 34 33 35 if (story && story.author !== repo) { 36 + if (!(await shouldPush(db, story.author, repo, "favorites"))) return 34 37 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 35 38 const actor = profiles.get(repo) 36 39 await push.send({
+4 -1
server/on-commit-follow.ts
··· 1 1 import { defineHook } from "$hatk"; 2 + import { shouldPush } from "./helpers/notifPrefs.ts"; 2 3 3 4 export default defineHook("on-commit", { collections: ["social.grain.graph.follow"] }, 4 - async ({ action, record, repo, lookup, push }) => { 5 + async ({ action, record, repo, db, lookup, push }) => { 5 6 if (action !== "create" || !record) return 6 7 7 8 const subject = record.subject as string 8 9 if (!subject || subject === repo) return 10 + 11 + if (!(await shouldPush(db, subject, repo, "follows"))) return 9 12 10 13 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 11 14 const actor = profiles.get(repo)