[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 321 lines 9.9 kB view raw
1import { Database } from "../db/index.ts"; 2 3// Types for MongoDB aggregation results 4interface AggregationResult { 5 _id: string; 6 count: number; 7} 8 9export interface KnownInteraction { 10 type: "like" | "repost" | "reply"; 11 uri: string; 12 cid: string; 13 authorDid: string; 14 indexedAt: string; 15 text?: string; 16} 17 18export class Interactions { 19 private db: Database; 20 21 constructor(db: Database) { 22 this.db = db; 23 } 24 25 async getInteractionCounts(refs: Array<{ uri: string }>) { 26 const uris = refs.map((ref) => ref.uri); 27 if (uris.length === 0) { 28 return { likes: [], replies: [], reposts: [], quotes: [] }; 29 } 30 31 // Get pre-computed counts from Post, Reply, and Generator documents 32 const [posts, replies, crosspostReplies, generators] = await Promise.all([ 33 this.db.models.Post.find( 34 { uri: { $in: uris } }, 35 { uri: 1, likeCount: 1, replyCount: 1, repostCount: 1 }, 36 ), 37 this.db.models.Reply.find( 38 { uri: { $in: uris } }, 39 { uri: 1, likeCount: 1, replyCount: 1 }, 40 ), 41 this.db.models.CrosspostReply.find( 42 { uri: { $in: uris } }, 43 { uri: 1, likeCount: 1, replyCount: 1 }, 44 ), 45 this.db.models.Generator.find( 46 { uri: { $in: uris } }, 47 { uri: 1, likeCount: 1 }, 48 ), 49 ]); 50 51 // Create lookup maps from pre-computed counts 52 const likesMap = new Map<string, number>(); 53 const repliesMap = new Map<string, number>(); 54 const repostsMap = new Map<string, number>(); 55 56 for (const post of posts) { 57 likesMap.set(post.uri, post.likeCount ?? 0); 58 repliesMap.set(post.uri, post.replyCount ?? 0); 59 repostsMap.set(post.uri, post.repostCount ?? 0); 60 } 61 62 for (const reply of replies) { 63 likesMap.set(reply.uri, reply.likeCount ?? 0); 64 repliesMap.set(reply.uri, reply.replyCount ?? 0); 65 } 66 67 for (const reply of crosspostReplies) { 68 likesMap.set(reply.uri, reply.likeCount ?? 0); 69 repliesMap.set(reply.uri, reply.replyCount ?? 0); 70 } 71 72 for (const generator of generators) { 73 likesMap.set(generator.uri, generator.likeCount ?? 0); 74 } 75 76 return { 77 likes: uris.map((uri) => likesMap.get(uri) ?? 0), 78 replies: uris.map((uri) => repliesMap.get(uri) ?? 0), 79 reposts: uris.map((uri) => repostsMap.get(uri) ?? 0), 80 }; 81 } 82 83 async getCountsForUsers(dids: string[]) { 84 if (dids.length === 0) { 85 return { 86 followers: [], 87 following: [], 88 posts: [], 89 feeds: [], 90 }; 91 } 92 93 const [followers, following, posts, feeds] = await Promise.all([ 94 // Count followers for each DID 95 this.db.models.Follow.aggregate([ 96 { $match: { subject: { $in: dids } } }, 97 { $group: { _id: "$subject", count: { $sum: 1 } } }, 98 ]), 99 // Count following for each DID 100 this.db.models.Follow.aggregate([ 101 { $match: { authorDid: { $in: dids } } }, 102 { $group: { _id: "$authorDid", count: { $sum: 1 } } }, 103 ]), 104 // Count posts for each DID 105 this.db.models.Post.aggregate([ 106 { $match: { authorDid: { $in: dids } } }, 107 { $group: { _id: "$authorDid", count: { $sum: 1 } } }, 108 ]), 109 // Count generators for each DID 110 this.db.models.Generator.aggregate([ 111 { $match: { authorDid: { $in: dids } } }, 112 { $group: { _id: "$authorDid", count: { $sum: 1 } } }, 113 ]), 114 ]); 115 116 // Create lookup maps 117 const followersMap = new Map( 118 followers.map((item: AggregationResult) => [item._id, item.count]), 119 ); 120 const followingMap = new Map( 121 following.map((item: AggregationResult) => [item._id, item.count]), 122 ); 123 const postsMap = new Map( 124 posts.map((item: AggregationResult) => [item._id, item.count]), 125 ); 126 const feedsMap = new Map( 127 feeds.map((item: AggregationResult) => [item._id, item.count]), 128 ); 129 130 return { 131 followers: dids.map((did) => followersMap.get(did) ?? 0), 132 following: dids.map((did) => followingMap.get(did) ?? 0), 133 posts: dids.map((did) => postsMap.get(did) ?? 0), 134 feeds: dids.map((did) => feedsMap.get(did) ?? 0), 135 }; 136 } 137 138 async getSoundUsageCounts(uris: string[]) { 139 if (uris.length === 0) { 140 return { uses: [] }; 141 } 142 143 // Count how many posts reference each sound URI 144 const usageAgg = await this.db.models.Post.aggregate([ 145 { $match: { "sound.uri": { $in: uris } } }, 146 { $group: { _id: "$sound.uri", count: { $sum: 1 } } }, 147 ]); 148 149 const usageMap = new Map( 150 usageAgg.map((item: AggregationResult) => [item._id, item.count]), 151 ); 152 153 return { 154 uses: uris.map((uri) => usageMap.get(uri) ?? 0), 155 }; 156 } 157 158 /** 159 * Get interactions (likes, reposts, replies) on subject URIs by users the viewer follows. 160 * Returns interactions sorted by indexedAt descending (most recent first). 161 */ 162 async getKnownInteractions( 163 viewerDid: string, 164 subjectUris: string[], 165 ): Promise<{ results: Map<string, KnownInteraction[]> }> { 166 if (subjectUris.length === 0) { 167 return { results: new Map() }; 168 } 169 170 // Get all DIDs the viewer follows (use lean() for faster queries) 171 const viewerFollows = await this.db.models.Follow.find({ 172 authorDid: viewerDid, 173 }) 174 .select("subject") 175 .lean(); 176 const followedDids = viewerFollows.map((f) => f.subject); 177 178 if (followedDids.length === 0) { 179 return { results: new Map() }; 180 } 181 182 // Query likes, reposts, and replies by followed users on the subject URIs 183 // All queries are batched and parallelized for optimal performance 184 const [likes, reposts, replies, crosspostReplies] = await Promise.all([ 185 this.db.models.Like.find({ 186 subject: { $in: subjectUris }, 187 authorDid: { $in: followedDids }, 188 }) 189 .select("uri cid subject authorDid indexedAt") 190 .sort({ indexedAt: -1 }) 191 .lean(), 192 this.db.models.Repost.find({ 193 subject: { $in: subjectUris }, 194 authorDid: { $in: followedDids }, 195 }) 196 .select("uri cid subject authorDid indexedAt") 197 .sort({ indexedAt: -1 }) 198 .lean(), 199 this.db.models.Reply.find({ 200 "reply.parent.uri": { $in: subjectUris }, 201 authorDid: { $in: followedDids }, 202 }) 203 .select("uri cid reply.parent.uri authorDid indexedAt text") 204 .sort({ indexedAt: -1 }) 205 .lean(), 206 this.db.models.CrosspostReply.find({ 207 "reply.parent.uri": { $in: subjectUris }, 208 authorDid: { $in: followedDids }, 209 }) 210 .select("uri cid reply.parent.uri authorDid indexedAt text") 211 .sort({ indexedAt: -1 }) 212 .lean(), 213 ]); 214 215 // Build result map keyed by subject URI - pre-initialize for all subject URIs 216 const results = new Map<string, KnownInteraction[]>(); 217 for (const uri of subjectUris) { 218 results.set(uri, []); 219 } 220 221 // Process all interactions in a single pass for better performance 222 // Add likes 223 for (const like of likes) { 224 const interactions = results.get(like.subject); 225 if (interactions) { 226 interactions.push({ 227 type: "like", 228 uri: like.uri, 229 cid: like.cid, 230 authorDid: like.authorDid, 231 indexedAt: String(like.indexedAt), 232 }); 233 } 234 } 235 236 // Add reposts 237 for (const repost of reposts) { 238 const interactions = results.get(repost.subject); 239 if (interactions) { 240 interactions.push({ 241 type: "repost", 242 uri: repost.uri, 243 cid: repost.cid, 244 authorDid: repost.authorDid, 245 indexedAt: String(repost.indexedAt), 246 }); 247 } 248 } 249 250 // Add replies 251 for (const reply of replies) { 252 const parentUri = reply.reply?.parent?.uri; 253 if (!parentUri) continue; 254 const interactions = results.get(parentUri); 255 if (interactions) { 256 interactions.push({ 257 type: "reply", 258 uri: reply.uri, 259 cid: reply.cid, 260 authorDid: reply.authorDid, 261 indexedAt: String(reply.indexedAt), 262 text: reply.text, 263 }); 264 } 265 } 266 267 for (const reply of crosspostReplies) { 268 const parentUri = reply.reply?.parent?.uri; 269 if (!parentUri) continue; 270 const interactions = results.get(parentUri); 271 if (interactions) { 272 interactions.push({ 273 type: "reply", 274 uri: reply.uri, 275 cid: reply.cid, 276 authorDid: reply.authorDid, 277 indexedAt: String(reply.indexedAt), 278 text: reply.text, 279 }); 280 } 281 } 282 283 // Dedupe: keep one interaction per actor with priority: repost > reply > like 284 // Sort order: repost → like → reply 285 const keepPriority: Record<KnownInteraction["type"], number> = { 286 repost: 0, 287 reply: 1, 288 like: 2, 289 }; 290 291 for (const [uri, interactions] of results) { 292 // Group by author, keep highest priority interaction per author 293 const byAuthor = new Map<string, KnownInteraction>(); 294 for (const interaction of interactions) { 295 const existing = byAuthor.get(interaction.authorDid); 296 if ( 297 !existing || 298 keepPriority[interaction.type] < keepPriority[existing.type] 299 ) { 300 byAuthor.set(interaction.authorDid, interaction); 301 } 302 } 303 304 // Bucket into 3 arrays by type (avoids sorting) 305 const repostBucket: KnownInteraction[] = []; 306 const likeBucket: KnownInteraction[] = []; 307 const replyBucket: KnownInteraction[] = []; 308 309 for (const interaction of byAuthor.values()) { 310 if (interaction.type === "repost") repostBucket.push(interaction); 311 else if (interaction.type === "like") likeBucket.push(interaction); 312 else replyBucket.push(interaction); 313 } 314 315 // Concatenate in desired order: repost → like → reply 316 results.set(uri, [...repostBucket, ...likeBucket, ...replyBucket]); 317 } 318 319 return { results }; 320 } 321}