[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
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}