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 badge count to push notifications

Query unseen notification count at push time and include it in the
APNs payload so the app icon badge updates on every push.

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

+82 -5
+4 -4
package-lock.json
··· 6 6 "": { 7 7 "name": "grain", 8 8 "dependencies": { 9 - "@hatk/hatk": "^0.0.1-alpha.54", 9 + "@hatk/hatk": "^0.0.1-alpha.55", 10 10 "@sveltejs/adapter-node": "^5.5.4", 11 11 "@sveltejs/kit": "^2.55.0", 12 12 "@tanstack/svelte-query": "^6.1.0", ··· 163 163 } 164 164 }, 165 165 "node_modules/@hatk/hatk": { 166 - "version": "0.0.1-alpha.54", 167 - "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.54.tgz", 168 - "integrity": "sha512-ywPdR2FEi9N2Pg0R6IbWFRapn/U9yUXwILIgFpNlYUKR/i90zNRuha2+jeWgJgKk1xbJAb2DkipRP4Jukg5MKg==", 166 + "version": "0.0.1-alpha.55", 167 + "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.55.tgz", 168 + "integrity": "sha512-N4hTZyYQlJcHtRV2BLwqRDj49qtG6Tv4sBZPV3fSUps3DIvW75plq6KdWdsO8VCCad9zlsWHYJ2RXR+9eogxIA==", 169 169 "license": "MIT", 170 170 "dependencies": { 171 171 "@bigmoves/lexicon": "^0.2.2",
+1 -1
package.json
··· 12 12 "test:browser": "npx playwright test" 13 13 }, 14 14 "dependencies": { 15 - "@hatk/hatk": "^0.0.1-alpha.54", 15 + "@hatk/hatk": "^0.0.1-alpha.55", 16 16 "@sveltejs/adapter-node": "^5.5.4", 17 17 "@sveltejs/kit": "^2.55.0", 18 18 "@tanstack/svelte-query": "^6.1.0",
+62
server/helpers/unseenCount.ts
··· 1 + /** 2 + * Query the unseen notification count for a DID. 3 + * Used to set the badge number on push notifications. 4 + */ 5 + 6 + function blockMuteFilter(didCol = "did"): string { 7 + return ` 8 + AND ${didCol} NOT IN (SELECT subject FROM "social.grain.graph.block" WHERE did = $1) 9 + AND ${didCol} NOT IN (SELECT did FROM "social.grain.graph.block" WHERE subject = $1) 10 + AND ${didCol} NOT IN (SELECT subject FROM _mutes WHERE did = $1) 11 + `; 12 + } 13 + 14 + export async function getUnseenCount( 15 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> }, 16 + did: string, 17 + ): Promise<number> { 18 + const prefRows = (await db.query( 19 + `SELECT value FROM _preferences WHERE did = $1 AND key = 'lastSeenNotifications'`, 20 + [did], 21 + )) as { value: string }[]; 22 + 23 + let lastSeen: string | null = null; 24 + if (prefRows[0]) { 25 + try { 26 + lastSeen = typeof prefRows[0].value === "string" ? JSON.parse(prefRows[0].value) : prefRows[0].value; 27 + } catch {} 28 + } 29 + 30 + const timeFilter = lastSeen ? `AND created_at > $2` : ""; 31 + const params = lastSeen ? [did, lastSeen] : [did]; 32 + 33 + const rows = (await db.query( 34 + `SELECT count(*) as cnt FROM ( 35 + SELECT uri FROM "social.grain.favorite" 36 + WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) 37 + AND did != $1 ${blockMuteFilter()} ${timeFilter} 38 + UNION ALL 39 + SELECT uri FROM "social.grain.comment" 40 + WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) 41 + AND did != $1 AND reply_to IS NULL ${blockMuteFilter()} ${timeFilter} 42 + UNION ALL 43 + SELECT c.uri FROM "social.grain.comment" c 44 + WHERE c.reply_to IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) 45 + AND c.did != $1 ${blockMuteFilter("c.did")} ${timeFilter} 46 + UNION ALL 47 + SELECT uri FROM "social.grain.graph.follow" 48 + WHERE subject = $1 AND did != $1 ${blockMuteFilter()} ${timeFilter} 49 + UNION ALL 50 + SELECT uri FROM "social.grain.favorite" 51 + WHERE subject IN (SELECT uri FROM "social.grain.story" WHERE did = $1) 52 + AND did != $1 ${blockMuteFilter()} ${timeFilter} 53 + UNION ALL 54 + SELECT uri FROM "social.grain.comment" 55 + WHERE subject IN (SELECT uri FROM "social.grain.story" WHERE did = $1) 56 + AND did != $1 AND reply_to IS NULL ${blockMuteFilter()} ${timeFilter} 57 + )`, 58 + params, 59 + )) as { cnt: number }[]; 60 + 61 + return rows[0]?.cnt ?? 0; 62 + }
+7
server/on-commit-comment.ts
··· 1 1 import { defineHook } from "$hatk"; 2 2 import { shouldPush } from "./helpers/notifPrefs.ts"; 3 + import { getUnseenCount } from "./helpers/unseenCount.ts"; 3 4 4 5 export default defineHook("on-commit", { collections: ["social.grain.comment"] }, 5 6 async ({ action, record, repo, db, lookup, push }) => { ··· 21 22 22 23 if (parent && parent.author !== repo) { 23 24 if (await shouldPush(db, parent.author, repo, "comments")) { 25 + const badge = await getUnseenCount(db, parent.author) + 1 24 26 await push.send({ 25 27 did: parent.author, 26 28 title: "New reply", 27 29 body: `${displayName} replied to your comment`, 28 30 data: { type: "comment-reply", uri: subject }, 31 + badge, 29 32 }) 30 33 } 31 34 } ··· 40 43 if (gallery) { 41 44 if (gallery.author !== repo) { 42 45 if (await shouldPush(db, gallery.author, repo, "comments")) { 46 + const badge = await getUnseenCount(db, gallery.author) + 1 43 47 await push.send({ 44 48 did: gallery.author, 45 49 title: "New comment", 46 50 body: `${displayName} commented on your gallery`, 47 51 data: { type: "gallery-comment", uri: subject }, 52 + badge, 48 53 }) 49 54 } 50 55 } ··· 59 64 60 65 if (story && story.author !== repo) { 61 66 if (await shouldPush(db, story.author, repo, "comments")) { 67 + const badge = await getUnseenCount(db, story.author) + 1 62 68 await push.send({ 63 69 did: story.author, 64 70 title: "New comment", 65 71 body: `${displayName} commented on your story`, 66 72 data: { type: "story-comment", uri: subject }, 73 + badge, 67 74 }) 68 75 } 69 76 }
+5
server/on-commit-favorite.ts
··· 1 1 import { defineHook } from "$hatk"; 2 2 import { shouldPush } from "./helpers/notifPrefs.ts"; 3 + import { getUnseenCount } from "./helpers/unseenCount.ts"; 3 4 4 5 export default defineHook("on-commit", { collections: ["social.grain.favorite"] }, 5 6 async ({ action, record, repo, db, lookup, push }) => { ··· 17 18 if (!(await shouldPush(db, gallery.author, repo, "favorites"))) return 18 19 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 19 20 const actor = profiles.get(repo) 21 + const badge = await getUnseenCount(db, gallery.author) + 1 20 22 await push.send({ 21 23 did: gallery.author, 22 24 title: "New favorite", 23 25 body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your gallery`, 24 26 data: { type: "gallery-favorite", uri: subject }, 27 + badge, 25 28 }) 26 29 return 27 30 } ··· 36 39 if (!(await shouldPush(db, story.author, repo, "favorites"))) return 37 40 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 38 41 const actor = profiles.get(repo) 42 + const badge = await getUnseenCount(db, story.author) + 1 39 43 await push.send({ 40 44 did: story.author, 41 45 title: "New favorite", 42 46 body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your story`, 43 47 data: { type: "story-favorite", uri: subject }, 48 + badge, 44 49 }) 45 50 } 46 51 }
+3
server/on-commit-follow.ts
··· 1 1 import { defineHook } from "$hatk"; 2 2 import { shouldPush } from "./helpers/notifPrefs.ts"; 3 + import { getUnseenCount } from "./helpers/unseenCount.ts"; 3 4 4 5 export default defineHook("on-commit", { collections: ["social.grain.graph.follow"] }, 5 6 async ({ action, record, repo, db, lookup, push }) => { ··· 13 14 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 14 15 const actor = profiles.get(repo) 15 16 17 + const badge = await getUnseenCount(db, subject) + 1 16 18 await push.send({ 17 19 did: subject, 18 20 title: "New follower", 19 21 body: `${(actor?.value as any)?.displayName ?? "Someone"} followed you`, 20 22 data: { type: "follow", did: repo }, 23 + badge, 21 24 }) 22 25 } 23 26 )