[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.

at main 239 lines 6.7 kB view raw
1import { Database } from "../db/index.ts"; 2import { GenericKeyset } from "../db/pagination.ts"; 3 4type SortAtCidResult = { sortAt: string; recordCid: string }; 5type SortAtCidLabeledResult = { primary: string; secondary: string }; 6 7class SortAtCidKeyset extends GenericKeyset< 8 SortAtCidResult, 9 SortAtCidLabeledResult 10> { 11 constructor() { 12 super("sortAt", "recordCid"); 13 } 14 15 labelResult(result: SortAtCidResult): SortAtCidLabeledResult { 16 const sortAt = result.sortAt || new Date().toISOString(); 17 return { primary: sortAt, secondary: result.recordCid }; 18 } 19 20 labeledResultToCursor(labeled: SortAtCidLabeledResult) { 21 const timestamp = new Date(labeled.primary).getTime(); 22 if (isNaN(timestamp)) { 23 throw new Error("Invalid date for cursor"); 24 } 25 const secondsBase36 = Math.floor(timestamp / 1000).toString(36); 26 return { 27 primary: secondsBase36, 28 secondary: labeled.secondary, 29 }; 30 } 31 32 cursorToLabeledResult(cursor: { primary: string; secondary: string }) { 33 const seconds = parseInt(cursor.primary, 36); 34 if (isNaN(seconds)) { 35 throw new Error("Malformed cursor: invalid timestamp"); 36 } 37 const primaryDate = new Date(seconds * 1000); 38 if (isNaN(primaryDate.getTime())) { 39 throw new Error("Malformed cursor: invalid date"); 40 } 41 return { 42 primary: primaryDate.toISOString(), 43 secondary: cursor.secondary, 44 }; 45 } 46} 47 48export interface Notification { 49 recipientDid: string; 50 uri: string; 51 cid: string; 52 reason: string; 53 reasonSubject?: string; 54 sortAt: string; 55 authorDid: string; 56 priority?: boolean; 57} 58 59export class Notifications { 60 private db: Database; 61 private sortAtCidKeyset: SortAtCidKeyset; 62 63 constructor(db: Database) { 64 this.db = db; 65 this.sortAtCidKeyset = new SortAtCidKeyset(); 66 } 67 68 async getNotifications( 69 actorDid: string, 70 limit = 50, 71 cursor?: string, 72 priority?: boolean, 73 ): Promise<{ notifications: Notification[]; cursor?: string }> { 74 // Get follows for priority filtering 75 let priorityDids: string[] | undefined; 76 if (priority) { 77 const follows = await this.db.models.Follow.find({ 78 authorDid: actorDid, 79 }).select("subject"); 80 priorityDids = follows.map((f) => f.subject); 81 if (priorityDids.length === 0) { 82 return { notifications: [], cursor: undefined }; 83 } 84 } 85 86 // Build base query 87 const baseFilter: Record<string, unknown> = { did: actorDid }; 88 89 // If priority, filter to only notifications from followed users 90 if (priorityDids) { 91 baseFilter.author = { $in: priorityDids }; 92 } 93 94 // Get notifications 95 const notifsQuery = this.db.models.Notification.find(baseFilter); 96 97 // Apply pagination 98 const paginatedQuery = this.sortAtCidKeyset.paginate(notifsQuery, { 99 limit, 100 cursor, 101 direction: "desc", 102 }); 103 104 const notifs = await paginatedQuery.exec(); 105 106 // Filter out notifications with missing reasonSubject records 107 const filteredNotifs = await this.filterValidReasonSubjects(notifs); 108 109 // Get priority status for each notification 110 const followedDids = priorityDids ?? await this.getFollowedDids(actorDid); 111 const followedSet = new Set(followedDids); 112 113 // Generate cursor from the last item if we have results 114 let nextCursor: string | undefined; 115 if (notifs.length === limit && notifs.length > 0) { 116 const lastNotif = notifs[notifs.length - 1]; 117 nextCursor = this.sortAtCidKeyset.pack({ 118 primary: lastNotif.sortAt, 119 secondary: lastNotif.recordCid, 120 }); 121 } 122 123 const notifications = filteredNotifs.map((notif) => ({ 124 recipientDid: actorDid, 125 uri: notif.recordUri, 126 cid: notif.recordCid, 127 reason: notif.reason, 128 reasonSubject: notif.reasonSubject ?? undefined, 129 sortAt: notif.sortAt, 130 authorDid: notif.author, 131 priority: followedSet.has(notif.author), 132 })); 133 134 return { 135 notifications, 136 cursor: nextCursor, 137 }; 138 } 139 140 async getNotificationSeen( 141 actorDid: string, 142 _priority?: boolean, 143 ): Promise<{ timestamp?: string }> { 144 const actor = await this.db.models.Actor.findOne({ did: actorDid }); 145 if (!actor || !actor.lastSeenNotifs) { 146 return {}; 147 } 148 149 return { timestamp: actor.lastSeenNotifs }; 150 } 151 152 async getUnreadNotificationCount( 153 actorDid: string, 154 lastSeen?: string, 155 priority?: boolean, 156 ): Promise<{ count: number }> { 157 const baseFilter: Record<string, unknown> = { did: actorDid }; 158 159 // Filter by lastSeen if provided 160 if (lastSeen) { 161 baseFilter.sortAt = { $gt: lastSeen }; 162 } 163 164 // If priority, filter to only notifications from followed users 165 if (priority) { 166 const follows = await this.db.models.Follow.find({ 167 authorDid: actorDid, 168 }).select("subject"); 169 const priorityDids = follows.map((f) => f.subject); 170 if (priorityDids.length === 0) { 171 return { count: 0 }; 172 } 173 baseFilter.author = { $in: priorityDids }; 174 } 175 176 const count = await this.db.models.Notification.countDocuments(baseFilter); 177 178 return { count }; 179 } 180 181 async updateNotificationSeen( 182 actorDid: string, 183 timestamp: string, 184 _priority?: boolean, 185 ): Promise<void> { 186 await this.db.models.Actor.findOneAndUpdate( 187 { did: actorDid }, 188 { $set: { lastSeenNotifs: timestamp } }, 189 { upsert: false }, 190 ); 191 } 192 193 // Helper methods 194 195 private async getFollowedDids(actorDid: string): Promise<string[]> { 196 const follows = await this.db.models.Follow.find({ 197 authorDid: actorDid, 198 }).select("subject"); 199 return follows.map((f) => f.subject); 200 } 201 202 private async filterValidReasonSubjects( 203 notifs: Array<{ 204 recordUri: string; 205 recordCid: string; 206 author: string; 207 reason: string; 208 reasonSubject: string | null; 209 sortAt: string; 210 }>, 211 ): Promise< 212 Array<{ 213 recordUri: string; 214 recordCid: string; 215 author: string; 216 reason: string; 217 reasonSubject: string | null; 218 sortAt: string; 219 }> 220 > { 221 // Filter out notifications where reasonSubject exists but the record doesn't 222 const notifsWithSubject = notifs.filter((n) => n.reasonSubject); 223 if (notifsWithSubject.length === 0) { 224 return notifs; 225 } 226 227 const subjectUris = notifsWithSubject.map((n) => n.reasonSubject as string); 228 229 const existingRecords = await this.db.models.Record.find({ 230 uri: { $in: subjectUris }, 231 }).select("uri").lean(); 232 233 const existingUris = new Set(existingRecords.map((r) => r.uri)); 234 235 return notifs.filter( 236 (n) => !n.reasonSubject || existingUris.has(n.reasonSubject), 237 ); 238 } 239}