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

Configure Feed

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

at main 291 lines 8.3 kB view raw
1import { Database } from "../db/index.ts"; 2import { Code, DataPlaneError } from "../util.ts"; 3 4// Parameter validation 5function validateThreadParams(above: number, below: number) { 6 if (!Number.isInteger(above) || above < 0 || above > 100) { 7 throw new Error("Invalid above: must be an integer between 0 and 100"); 8 } 9 10 if (!Number.isInteger(below) || below < 0 || below > 100) { 11 throw new Error("Invalid below: must be an integer between 0 and 100"); 12 } 13} 14 15// Helper function to get descendants (child replies going down the thread) 16async function getDescendants( 17 db: Database, 18 parentUri: string, 19 maxDepth: number, 20): Promise<string[]> { 21 const descendants: string[] = []; 22 const visited = new Set<string>(); 23 24 // Use BFS to traverse descendants 25 const queue: Array<{ uri: string; depth: number }> = [{ 26 uri: parentUri, 27 depth: 0, 28 }]; 29 30 while (queue.length > 0) { 31 const { uri: currentUri, depth } = queue.shift()!; 32 33 if (depth >= maxDepth || visited.has(currentUri)) { 34 continue; 35 } 36 37 visited.add(currentUri); 38 39 // Find all replies to this post/reply 40 const replies = await db.models.Reply.find({ 41 "reply.parent.uri": currentUri, 42 }) 43 .sort({ createdAt: -1 }); // Most recent first 44 45 for (const reply of replies) { 46 if (!visited.has(reply.uri)) { 47 descendants.push(reply.uri); 48 49 // Add to queue for further traversal if we haven't reached max depth 50 if (depth + 1 < maxDepth) { 51 queue.push({ uri: reply.uri, depth: depth + 1 }); 52 } 53 } 54 } 55 } 56 57 return descendants; 58} 59 60export class Threads { 61 private db: Database; 62 63 constructor(db: Database) { 64 this.db = db; 65 } 66 67 async getThread(postUri: string, above: number = 10, below: number = 50) { 68 validateThreadParams(above, below); 69 70 try { 71 // Check if it's a post or reply 72 const originalPost = await this.db.models.Post.findOne({ uri: postUri }); 73 74 if (originalPost) { 75 // Posts are always root - they don't have ancestors by design 76 // So we only get descendants (replies) 77 const descendants = await getDescendants(this.db, postUri, below); 78 79 // The thread is just the root post + all its descendant replies 80 const uris = [ 81 postUri, // The original post (always root) 82 ...descendants, 83 ]; 84 85 // Remove duplicates while preserving order 86 const uniqueUris = Array.from(new Set(uris)); 87 88 return { 89 uris: uniqueUris, 90 meta: { 91 ancestorCount: 0, // Posts never have ancestors 92 descendantCount: descendants.length, 93 totalCount: uniqueUris.length, 94 }, 95 }; 96 } 97 98 // Check if it's a reply 99 const originalReply = await this.db.models.Reply.findOne({ 100 uri: postUri, 101 }); 102 103 if (!originalReply) { 104 throw new DataPlaneError(Code.NotFound); 105 } 106 107 // Get ancestors (walking up the reply chain) 108 const ancestors: string[] = []; 109 let currentUri = postUri; 110 const visited = new Set<string>([currentUri]); 111 112 for (let i = 0; i < above; i++) { 113 const current = await this.db.models.Reply.findOne({ uri: currentUri }); 114 115 if (!current?.reply?.parent?.uri) { 116 break; 117 } 118 119 const parentUri = current.reply.parent.uri; 120 121 if (visited.has(parentUri)) { 122 break; 123 } 124 125 visited.add(parentUri); 126 ancestors.unshift(parentUri); // Add to beginning to maintain order 127 currentUri = parentUri; 128 } 129 130 // Get descendants (replies to this reply) 131 const descendants = await getDescendants(this.db, postUri, below); 132 133 // Build the full thread: ancestors + anchor + descendants 134 const uris = [ 135 ...ancestors, 136 postUri, // The anchor reply 137 ...descendants, 138 ]; 139 140 // Remove duplicates while preserving order 141 const uniqueUris = Array.from(new Set(uris)); 142 143 return { 144 uris: uniqueUris, 145 meta: { 146 ancestorCount: ancestors.length, 147 descendantCount: descendants.length, 148 totalCount: uniqueUris.length, 149 }, 150 }; 151 } catch (error) { 152 console.error("Error fetching thread:", error); 153 throw new DataPlaneError(Code.InternalError); 154 } 155 } 156 157 async getThreadStructure( 158 postUri: string, 159 above: number = 10, 160 below: number = 50, 161 ) { 162 validateThreadParams(above, below); 163 164 try { 165 // Get the original post 166 const originalPost = await this.db.models.Post.findOne({ uri: postUri }); 167 168 if (!originalPost) { 169 throw new DataPlaneError(Code.NotFound); 170 } 171 172 // Posts don't have ancestors - they are always roots 173 const ancestors: Array<{ uri: string; depth: number }> = []; 174 175 // Get descendants with metadata using BFS 176 const descendants: Array< 177 { uri: string; depth: number; parent: string } 178 > = []; 179 const queue: Array<{ uri: string; depth: number; parent: string }> = [ 180 { uri: postUri, depth: 0, parent: postUri }, 181 ]; 182 const visited = new Set<string>([postUri]); 183 184 while (queue.length > 0) { 185 const { uri: currentUri, depth: currentDepth } = queue.shift()!; 186 187 if (currentDepth >= below) { 188 continue; 189 } 190 191 // Find replies to this post/reply 192 const replies = await this.db.models.Reply.find({ 193 "reply.parent.uri": currentUri, 194 }) 195 .sort({ createdAt: -1 }); 196 197 for (const reply of replies) { 198 if (!visited.has(reply.uri)) { 199 visited.add(reply.uri); 200 const childDepth = currentDepth + 1; 201 202 descendants.push({ 203 uri: reply.uri, 204 depth: childDepth, 205 parent: currentUri, 206 }); 207 208 if (childDepth < below) { 209 queue.push({ 210 uri: reply.uri, 211 depth: childDepth, 212 parent: reply.uri, 213 }); 214 } 215 } 216 } 217 } 218 219 return { 220 root: { 221 uri: postUri, 222 isRoot: true, // Posts are always roots 223 }, 224 ancestors, // Always empty for posts 225 descendants, 226 meta: { 227 ancestorCount: 0, // Posts never have ancestors 228 descendantCount: descendants.length, 229 maxAncestorDepth: 0, // Posts never have ancestors 230 maxDescendantDepth: descendants.length > 0 231 ? Math.max(...descendants.map((d) => d.depth)) 232 : 0, 233 }, 234 }; 235 } catch (error) { 236 console.error("Error fetching thread structure:", error); 237 throw new DataPlaneError(Code.InternalError); 238 } 239 } 240 241 // New method: Get thread starting from a reply (if needed for UI purposes) 242 // This would find the root post and then build the full thread 243 async getThreadFromReply(replyUri: string, below: number = 50) { 244 validateThreadParams(0, below); // No ancestors needed 245 246 try { 247 // Find the reply 248 const reply = await this.db.models.Reply.findOne({ uri: replyUri }); 249 250 if (!reply) { 251 throw new DataPlaneError(Code.NotFound); 252 } 253 254 // Walk up to find the root post 255 let currentUri = replyUri; 256 let rootUri: string | null = null; 257 258 // Keep going up until we find a post (not a reply) 259 while (rootUri === null) { 260 const currentReply = await this.db.models.Reply.findOne({ 261 uri: currentUri, 262 }); 263 264 if (!currentReply || !currentReply.reply?.parent?.uri) { 265 // This shouldn't happen if data integrity is maintained 266 throw new DataPlaneError(Code.NotFound); 267 } 268 269 const parentUri = currentReply.reply.parent.uri; 270 271 // Check if parent is a post (root) or another reply 272 const parentPost = await this.db.models.Post.findOne({ 273 uri: parentUri, 274 }); 275 276 if (parentPost) { 277 rootUri = parentUri; 278 } else { 279 // Parent is another reply, keep going up 280 currentUri = parentUri; 281 } 282 } 283 284 // Now get the full thread starting from the root post 285 return this.getThread(rootUri, 0, below); 286 } catch (error) { 287 console.error("Error fetching thread from reply:", error); 288 throw new Error("Failed to fetch thread from reply"); 289 } 290 } 291}