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