[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 3b73895e29748ca524bbe040b656ddb4e167104b 328 lines 8.6 kB view raw
1import { 2 Record as ReplyRecord, 3 ReplyRef, 4} from "../lex/types/so/sprk/feed/reply.ts"; 5import { Database } from "./db/index.ts"; 6import { DidDocument } from "@atp/identity"; 7import * as bytes from "@atp/bytes"; 8 9export const getDescendents = async ( 10 db: Database, 11 opts: { 12 uri: string; 13 depth: number; // required, protects against cycles 14 }, 15) => { 16 const { uri, depth } = opts; 17 const descendents: Array<{ 18 uri: string; 19 depth: number; 20 cid: string; 21 creator: string; 22 sortAt: string; 23 }> = []; 24 25 // Get direct replies (depth 1) 26 const [directReplies, directCrosspostReplies] = await Promise.all([ 27 db.models.Reply.find({ 28 "reply.parent.uri": uri, 29 }).lean(), 30 db.models.CrosspostReply.find({ 31 "reply.parent.uri": uri, 32 }).lean(), 33 ]); 34 const directChildren = [...directReplies, ...directCrosspostReplies]; 35 36 for (const reply of directChildren) { 37 descendents.push({ 38 uri: reply.uri, 39 depth: 1, 40 cid: reply.cid, 41 creator: reply.authorDid, 42 sortAt: reply.createdAt, 43 }); 44 } 45 46 // Get nested replies (depth > 1) 47 if (depth > 1) { 48 const processedUris = new Set(directChildren.map((r) => r.uri)); 49 const toProcess = [...directChildren.map((r) => ({ uri: r.uri, depth: 1 }))]; 50 51 while (toProcess.length > 0) { 52 const current = toProcess.shift()!; 53 if (current.depth >= depth) continue; 54 55 const [nestedReplies, nestedCrosspostReplies] = await Promise.all([ 56 db.models.Reply.find({ 57 "reply.parent.uri": current.uri, 58 }).lean(), 59 db.models.CrosspostReply.find({ 60 "reply.parent.uri": current.uri, 61 }).lean(), 62 ]); 63 const nestedChildren = [...nestedReplies, ...nestedCrosspostReplies]; 64 65 for (const reply of nestedChildren) { 66 if (processedUris.has(reply.uri)) continue; 67 processedUris.add(reply.uri); 68 69 descendents.push({ 70 uri: reply.uri, 71 depth: current.depth + 1, 72 cid: reply.cid, 73 creator: reply.authorDid, 74 sortAt: reply.createdAt, 75 }); 76 77 toProcess.push({ uri: reply.uri, depth: current.depth + 1 }); 78 } 79 } 80 } 81 82 return descendents; 83}; 84 85export const getAncestorsAndSelf = async ( 86 db: Database, 87 opts: { 88 uri: string; 89 parentHeight: number; // required, protects against cycles 90 }, 91) => { 92 const { uri, parentHeight } = opts; 93 const ancestors: Array<{ 94 uri: string; 95 height: number; 96 }> = []; 97 98 // Start with the current post 99 const [currentReply, currentCrosspostReply] = await Promise.all([ 100 db.models.Reply.findOne({ uri }).lean(), 101 db.models.CrosspostReply.findOne({ uri }).lean(), 102 ]); 103 const currentPost = currentReply || currentCrosspostReply; 104 if (!currentPost) return ancestors; 105 106 ancestors.push({ 107 uri: currentPost.uri, 108 height: 0, 109 }); 110 111 // Traverse up the reply chain 112 let currentUri = currentPost.reply?.parent?.uri; 113 let height = 1; 114 115 while (currentUri && height <= parentHeight) { 116 // Check if parent is a Post (root) or Reply 117 const [parentPost, parentReply, parentCrosspostReply] = await Promise.all([ 118 db.models.Post.findOne({ uri: currentUri }).lean(), 119 db.models.Reply.findOne({ uri: currentUri }).lean(), 120 db.models.CrosspostReply.findOne({ uri: currentUri }).lean(), 121 ]); 122 123 if (parentPost) { 124 // Found root post - add it and stop traversing 125 ancestors.push({ 126 uri: parentPost.uri, 127 height, 128 }); 129 break; 130 } else if (parentReply) { 131 // Found a reply - add it and continue traversing 132 ancestors.push({ 133 uri: parentReply.uri, 134 height, 135 }); 136 currentUri = parentReply.reply?.parent?.uri; 137 height++; 138 } else if (parentCrosspostReply) { 139 ancestors.push({ 140 uri: parentCrosspostReply.uri, 141 height, 142 }); 143 currentUri = parentCrosspostReply.reply?.parent?.uri; 144 height++; 145 } else { 146 // Parent not found - stop traversing 147 break; 148 } 149 } 150 151 return ancestors; 152}; 153 154export const invalidReplyRoot = ( 155 reply: ReplyRef, 156 parent: { 157 record: ReplyRecord; 158 invalidReplyRoot: boolean | null; 159 }, 160) => { 161 const replyRoot = reply.root.uri; 162 const replyParent = reply.parent.uri; 163 // if parent is not a valid reply, transitively this is not a valid one either 164 if (parent.invalidReplyRoot) { 165 return true; 166 } 167 // replying to root post: ensure the root looks correct 168 if (replyParent === replyRoot) { 169 return !!parent.record.reply; 170 } 171 // replying to a reply: ensure the parent is a reply for the same root post 172 return parent.record.reply?.root.uri !== replyRoot; 173}; 174 175const getDid = (doc: DidDocument) => doc.id; 176const getHandle = (doc: DidDocument) => 177 doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 178 179export const getResultFromDoc = (doc: DidDocument) => { 180 const keys: Record<string, { Type: string; PublicKeyMultibase: string }> = {}; 181 doc.verificationMethod?.forEach((method) => { 182 const id = method.id.split("#").at(1); 183 if (!id) return; 184 keys[id] = { 185 Type: method.type, 186 PublicKeyMultibase: method.publicKeyMultibase || "", 187 }; 188 }); 189 const services: Record<string, { Type: string; URL: string }> = {}; 190 doc.service?.forEach((service) => { 191 const id = service.id.split("#").at(1); 192 if (!id) return; 193 if (typeof service.serviceEndpoint !== "string") return; 194 services[id] = { 195 Type: service.type, 196 URL: service.serviceEndpoint, 197 }; 198 }); 199 return { 200 did: getDid(doc), 201 handle: getHandle(doc), 202 keys: new TextEncoder().encode(JSON.stringify(keys)), 203 services: new TextEncoder().encode(JSON.stringify(services)), 204 updated: new Date(), 205 }; 206}; 207 208export enum Code { 209 NotFound = "Not Found", 210 InvalidRequest = "Invalid Request", 211 InternalError = "Internal Error", 212} 213 214export class DataPlaneError extends Error { 215 public code: Code; 216 217 constructor(message: Code) { 218 super(); 219 this.name = "DataPlaneError"; 220 this.code = message; 221 } 222} 223 224export function isDataPlaneError(error: unknown, code?: Code): boolean { 225 return error instanceof DataPlaneError && (!code || error.code === code); 226} 227 228export const unpackIdentityServices = (services: string) => { 229 if (!services) return {}; 230 return JSON.parse(services) as UnpackedServices; 231}; 232 233export const unpackIdentityKeys = (keysBytes: Uint8Array) => { 234 const keysStr = bytes.toString(keysBytes, "utf8"); 235 if (!keysStr) return {}; 236 return JSON.parse(keysStr) as UnpackedKeys; 237}; 238 239export const getServiceEndpoint = ( 240 services: UnpackedServices, 241 opts: { id: string; type: string }, 242) => { 243 const endpoint = services[opts.id] && 244 services[opts.id].Type === opts.type && 245 validateUrl(services[opts.id].URL); 246 return endpoint || undefined; 247}; 248 249type UnpackedServices = Record<string, { Type: string; URL: string }>; 250 251type UnpackedKeys = Record< 252 string, 253 { Type: string; PublicKeyMultibase: string } 254>; 255 256const validateUrl = (urlStr: string): string | undefined => { 257 let url; 258 try { 259 url = new URL(urlStr); 260 } catch { 261 return undefined; 262 } 263 if (!["http:", "https:"].includes(url.protocol)) { 264 return undefined; 265 } else if (!url.hostname) { 266 return undefined; 267 } else { 268 return urlStr; 269 } 270}; 271 272// @NOTE: This type is not complete with all supported options. 273// Only the ones that we needed to apply custom logic on are currently present. 274export type PostSearchQuery = { 275 q: string; 276 author: string | undefined; 277}; 278 279export const parsePostSearchQuery = ( 280 qParam: string, 281 params?: { 282 author?: string; 283 }, 284): PostSearchQuery => { 285 // Accept individual params, but give preference to options embedded in `q`. 286 let author = params?.author; 287 288 const parts: string[] = []; 289 let curr = ""; 290 let quoted = false; 291 for (const c of qParam) { 292 if (c === " " && !quoted) { 293 curr.trim() && parts.push(curr); 294 curr = ""; 295 continue; 296 } 297 298 if (c === '"') { 299 quoted = !quoted; 300 } 301 curr += c; 302 } 303 curr.trim() && parts.push(curr); 304 305 const qParts: string[] = []; 306 for (const p of parts) { 307 const tokens = p.split(":"); 308 if (tokens[0] === "did") { 309 author = p; 310 } else if (tokens[0] === "author" || tokens[0] === "from") { 311 author = tokens[1]; 312 } else { 313 qParts.push(p); 314 } 315 } 316 317 return { 318 q: qParts.join(" "), 319 author, 320 }; 321}; 322 323// Helper function for composite time 324export function compositeTime(ts1?: string, ts2?: string): string | undefined { 325 if (!ts1) return ts2; 326 if (!ts2) return ts1; 327 return new Date(ts1) < new Date(ts2) ? ts1 : ts2; 328}