[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 17a4453b336a3693a4e2b55eced2d8a4a0cf1bd5 290 lines 7.2 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 const parentReply = await db.models.Reply.findOne({ uri: currentUri }) 101 .lean(); 102 if (!parentReply) break; 103 104 ancestors.push({ 105 uri: parentReply.uri, 106 height, 107 }); 108 109 currentUri = parentReply.reply?.parent?.uri; 110 height++; 111 } 112 113 return ancestors; 114}; 115 116export const invalidReplyRoot = ( 117 reply: ReplyRef, 118 parent: { 119 record: ReplyRecord; 120 invalidReplyRoot: boolean | null; 121 }, 122) => { 123 const replyRoot = reply.root.uri; 124 const replyParent = reply.parent.uri; 125 // if parent is not a valid reply, transitively this is not a valid one either 126 if (parent.invalidReplyRoot) { 127 return true; 128 } 129 // replying to root post: ensure the root looks correct 130 if (replyParent === replyRoot) { 131 return !!parent.record.reply; 132 } 133 // replying to a reply: ensure the parent is a reply for the same root post 134 return parent.record.reply?.root.uri !== replyRoot; 135}; 136 137const getDid = (doc: DidDocument) => doc.id; 138const getHandle = (doc: DidDocument) => 139 doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 140 141export const getResultFromDoc = (doc: DidDocument) => { 142 const keys: Record<string, { Type: string; PublicKeyMultibase: string }> = {}; 143 doc.verificationMethod?.forEach((method) => { 144 const id = method.id.split("#").at(1); 145 if (!id) return; 146 keys[id] = { 147 Type: method.type, 148 PublicKeyMultibase: method.publicKeyMultibase || "", 149 }; 150 }); 151 const services: Record<string, { Type: string; URL: string }> = {}; 152 doc.service?.forEach((service) => { 153 const id = service.id.split("#").at(1); 154 if (!id) return; 155 if (typeof service.serviceEndpoint !== "string") return; 156 services[id] = { 157 Type: service.type, 158 URL: service.serviceEndpoint, 159 }; 160 }); 161 return { 162 did: getDid(doc), 163 handle: getHandle(doc), 164 keys: new TextEncoder().encode(JSON.stringify(keys)), 165 services: new TextEncoder().encode(JSON.stringify(services)), 166 updated: new Date(), 167 }; 168}; 169 170export enum Code { 171 NotFound = "Not Found", 172 InvalidRequest = "Invalid Request", 173 InternalError = "Internal Error", 174} 175 176export class DataPlaneError extends Error { 177 public code: Code; 178 179 constructor(message: Code) { 180 super(); 181 this.name = "DataPlaneError"; 182 this.code = message; 183 } 184} 185 186export function isDataPlaneError(error: unknown, code?: Code): boolean { 187 return error instanceof DataPlaneError && (!code || error.code === code); 188} 189 190export const unpackIdentityServices = (services: string) => { 191 if (!services) return {}; 192 return JSON.parse(services) as UnpackedServices; 193}; 194 195export const unpackIdentityKeys = (keysBytes: Uint8Array) => { 196 const keysStr = bytes.toString(keysBytes, "utf8"); 197 if (!keysStr) return {}; 198 return JSON.parse(keysStr) as UnpackedKeys; 199}; 200 201export const getServiceEndpoint = ( 202 services: UnpackedServices, 203 opts: { id: string; type: string }, 204) => { 205 const endpoint = services[opts.id] && 206 services[opts.id].Type === opts.type && 207 validateUrl(services[opts.id].URL); 208 return endpoint || undefined; 209}; 210 211type UnpackedServices = Record<string, { Type: string; URL: string }>; 212 213type UnpackedKeys = Record< 214 string, 215 { Type: string; PublicKeyMultibase: string } 216>; 217 218const validateUrl = (urlStr: string): string | undefined => { 219 let url; 220 try { 221 url = new URL(urlStr); 222 } catch { 223 return undefined; 224 } 225 if (!["http:", "https:"].includes(url.protocol)) { 226 return undefined; 227 } else if (!url.hostname) { 228 return undefined; 229 } else { 230 return urlStr; 231 } 232}; 233 234// @NOTE: This type is not complete with all supported options. 235// Only the ones that we needed to apply custom logic on are currently present. 236export type PostSearchQuery = { 237 q: string; 238 author: string | undefined; 239}; 240 241export const parsePostSearchQuery = ( 242 qParam: string, 243 params?: { 244 author?: string; 245 }, 246): PostSearchQuery => { 247 // Accept individual params, but give preference to options embedded in `q`. 248 let author = params?.author; 249 250 const parts: string[] = []; 251 let curr = ""; 252 let quoted = false; 253 for (const c of qParam) { 254 if (c === " " && !quoted) { 255 curr.trim() && parts.push(curr); 256 curr = ""; 257 continue; 258 } 259 260 if (c === '"') { 261 quoted = !quoted; 262 } 263 curr += c; 264 } 265 curr.trim() && parts.push(curr); 266 267 const qParts: string[] = []; 268 for (const p of parts) { 269 const tokens = p.split(":"); 270 if (tokens[0] === "did") { 271 author = p; 272 } else if (tokens[0] === "author" || tokens[0] === "from") { 273 author = tokens[1]; 274 } else { 275 qParts.push(p); 276 } 277 } 278 279 return { 280 q: qParts.join(" "), 281 author, 282 }; 283}; 284 285// Helper function for composite time 286export function compositeTime(ts1?: string, ts2?: string): string | undefined { 287 if (!ts1) return ts2; 288 if (!ts2) return ts1; 289 return new Date(ts1) < new Date(ts2) ? ts1 : ts2; 290}