[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

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

feat(push): badge management

+170 -2
+6
api/so/sprk/notification/updateSeen.ts
··· 21 21 true, 22 22 ), 23 23 ]); 24 + 25 + // Reset badge count on iOS devices 26 + // Fire and forget - don't block the response 27 + ctx.pushService.sendBadgeReset(viewer).catch((err) => { 28 + ctx.logger.error("Failed to send badge reset", { err, viewer }); 29 + }); 24 30 }, 25 31 }); 26 32 }
+2
context.ts
··· 7 7 import { AuthVerifier } from "./auth-verifier.ts"; 8 8 import { ServerConfig } from "./config.ts"; 9 9 import { ParsedLabelers } from "./util.ts"; 10 + import { PushService } from "./utils/push.ts"; 10 11 11 12 export type AppContext = { 12 13 db: Database; ··· 18 19 authVerifier: AuthVerifier; 19 20 cfg: ServerConfig; 20 21 reqLabelers: (req: Request) => ParsedLabelers; 22 + pushService: PushService; 21 23 }; 22 24 23 25 export type AppEnv = {
+8
main.ts
··· 16 16 import { AppContext, AppEnv } from "./context.ts"; 17 17 import { ServerConfig } from "./config.ts"; 18 18 import { defaultLabelerHeader, parseLabelerHeader } from "./util.ts"; 19 + import { PushService } from "./utils/push.ts"; 19 20 20 21 await configureLogger(); 21 22 ··· 78 79 return parsed; 79 80 }; 80 81 82 + // Create push service for badge management 83 + const pushService = new PushService(dataplane.pushTokens, db, { 84 + enabled: cfg.pushEnabled, 85 + fcmServiceAccount: cfg.fcmServiceAccount, 86 + }); 87 + 81 88 const ctx = { 82 89 db, 83 90 dataplane, ··· 88 95 cfg, 89 96 authVerifier, 90 97 reqLabelers, 98 + pushService, 91 99 }; 92 100 93 101 const app = createApp(ctx);
+11
tests/util.ts
··· 13 13 import { IdResolver } from "@atp/identity"; 14 14 import { ServerConfig, ServerConfigValues } from "../config.ts"; 15 15 import { defaultLabelerHeader } from "../util.ts"; 16 + import { PushService } from "../utils/push.ts"; 16 17 17 18 // Configure mongodb-memory-server to use a specific download directory 18 19 // This prevents issues with empty paths when running with restricted permissions ··· 283 284 modServiceDid: cfg.modServiceDid, 284 285 adminPasses: cfg.adminPasswords, 285 286 }); 287 + const pushService = new PushService(dataplane.pushTokens, mockDb, { 288 + enabled: cfg.pushEnabled, 289 + fcmServiceAccount: cfg.fcmServiceAccount, 290 + }); 286 291 287 292 return { 288 293 db: mockDb, ··· 294 299 cfg, 295 300 authVerifier, 296 301 reqLabelers: () => defaultLabelerHeader(cfg.labelsFromIssuerDids), 302 + pushService, 297 303 }; 298 304 } 299 305 ··· 361 367 modServiceDid: cfg.modServiceDid, 362 368 adminPasses: cfg.adminPasswords, 363 369 }); 370 + const pushService = new PushService(dataplane.pushTokens, db, { 371 + enabled: cfg.pushEnabled, 372 + fcmServiceAccount: cfg.fcmServiceAccount, 373 + }); 364 374 365 375 const ctx: AppContext = { 366 376 db, ··· 372 382 cfg, 373 383 authVerifier, 374 384 reqLabelers: () => defaultLabelerHeader(cfg.labelsFromIssuerDids), 385 + pushService, 375 386 }; 376 387 377 388 const cleanup = async () => {
+143 -2
utils/push.ts
··· 60 60 return; 61 61 } 62 62 63 + // Get unread count for badge 64 + const badgeCount = await this.getUnreadCount(did); 65 + 63 66 const invalidTokens: string[] = []; 64 67 65 68 for (const token of tokens) { 66 69 try { 67 - const success = await this.sendFcm(token, payload); 70 + const success = await this.sendFcm(token, payload, badgeCount); 68 71 if (!success) { 69 72 invalidTokens.push(token.token); 70 73 } ··· 86 89 } 87 90 } 88 91 92 + /** 93 + * Send a silent push to reset the badge count to 0 94 + * Called when notifications are marked as seen 95 + */ 96 + async sendBadgeReset(did: string): Promise<void> { 97 + if (!this.config.enabled) { 98 + return; 99 + } 100 + 101 + const tokens = await this.pushTokens.getTokensForDid(did); 102 + if (tokens.length === 0) { 103 + return; 104 + } 105 + 106 + const invalidTokens: string[] = []; 107 + 108 + for (const token of tokens) { 109 + // Only iOS needs badge reset (Android handles badges differently) 110 + if (token.platform !== "ios") { 111 + continue; 112 + } 113 + 114 + try { 115 + const success = await this.sendSilentBadgeUpdate(token, 0); 116 + if (!success) { 117 + invalidTokens.push(token.token); 118 + } 119 + } catch (err) { 120 + this.logger.error("Failed to send badge reset", { 121 + err, 122 + did, 123 + }); 124 + } 125 + } 126 + 127 + // Clean up invalid tokens 128 + if (invalidTokens.length > 0) { 129 + await this.pushTokens.deleteInvalidTokens(invalidTokens); 130 + } 131 + } 132 + 133 + /** 134 + * Get unread notification count for a user 135 + */ 136 + private async getUnreadCount(did: string): Promise<number> { 137 + try { 138 + // Get last seen timestamp 139 + const actor = await this.db.models.Actor.findOne({ did }).lean(); 140 + const lastSeen = actor?.lastSeenNotifs; 141 + 142 + // Build query for unread notifications 143 + const filter: Record<string, unknown> = { did }; 144 + if (lastSeen) { 145 + filter.sortAt = { $gt: lastSeen }; 146 + } 147 + 148 + const count = await this.db.models.Notification.countDocuments(filter); 149 + return count; 150 + } catch (err) { 151 + this.logger.error("Failed to get unread count", { err, did }); 152 + return 1; // Default to 1 if we can't get the count 153 + } 154 + } 155 + 156 + /** 157 + * Send a silent push to update badge without showing notification 158 + */ 159 + private async sendSilentBadgeUpdate( 160 + token: PushToken, 161 + badge: number, 162 + ): Promise<boolean> { 163 + if (!this.fcmServiceAccount) { 164 + return true; 165 + } 166 + 167 + const accessToken = await this.getFcmAccessToken(); 168 + if (!accessToken) { 169 + return true; 170 + } 171 + 172 + // Silent push with only badge update (no notification content) 173 + const message = { 174 + message: { 175 + token: token.token, 176 + apns: { 177 + headers: { 178 + "apns-push-type": "background", 179 + "apns-priority": "5", // Low priority for background 180 + }, 181 + payload: { 182 + aps: { 183 + "content-available": 1, 184 + badge: badge, 185 + }, 186 + }, 187 + }, 188 + }, 189 + }; 190 + 191 + const projectId = this.fcmServiceAccount.project_id; 192 + const url = 193 + `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; 194 + 195 + try { 196 + const response = await fetch(url, { 197 + method: "POST", 198 + headers: { 199 + "Authorization": `Bearer ${accessToken}`, 200 + "Content-Type": "application/json", 201 + }, 202 + body: JSON.stringify(message), 203 + }); 204 + 205 + if (!response.ok) { 206 + const error = await response.json(); 207 + if ( 208 + error.error?.details?.some( 209 + (d: { errorCode?: string }) => 210 + d.errorCode === "UNREGISTERED" || 211 + d.errorCode === "INVALID_ARGUMENT", 212 + ) 213 + ) { 214 + return false; 215 + } 216 + this.logger.error("Badge reset FCM request failed", { 217 + error, 218 + status: response.status, 219 + }); 220 + } 221 + 222 + return true; 223 + } catch (err) { 224 + this.logger.error("Badge reset FCM request error", { err }); 225 + return true; 226 + } 227 + } 228 + 89 229 private async sendFcm( 90 230 token: PushToken, 91 231 payload: PushPayload, 232 + badgeCount: number, 92 233 ): Promise<boolean> { 93 234 if (!this.fcmServiceAccount) { 94 235 this.logger.warn("FCM service account not configured"); ··· 129 270 payload: { 130 271 aps: { 131 272 sound: "default", 132 - badge: 1, 273 + badge: badgeCount, 133 274 }, 134 275 }, 135 276 };