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

Configure Feed

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

at main 240 lines 7.2 kB view raw
1import { Database } from "../db/index.ts"; 2import { IndexedAtDidKeyset, TimeCidKeyset } from "../db/pagination.ts"; 3import { parsePostSearchQuery } from "../util.ts"; 4import { compositeTime } from "../util.ts"; 5 6// Remove leading @ in case a handle is input that way 7const cleanQuery = (query: string) => query.trim().replace(/^@/g, ""); 8 9export class Search { 10 private db: Database; 11 private indexedAtDidKeyset: IndexedAtDidKeyset; 12 private timeCidKeyset: TimeCidKeyset; 13 14 constructor(db: Database) { 15 this.db = db; 16 this.indexedAtDidKeyset = new IndexedAtDidKeyset(); 17 this.timeCidKeyset = new TimeCidKeyset(); 18 } 19 20 async actors(term: string, limit = 50, cursor?: string) { 21 const cleanedTerm = cleanQuery(term); 22 if (!cleanedTerm) { 23 return { 24 dids: [], 25 cursor: undefined, 26 }; 27 } 28 29 const handlePrefix = cleanedTerm.toLowerCase(); 30 const handleRangeEnd = `${handlePrefix}\uffff`; 31 32 const [matchingActors, matchingProfiles] = await Promise.all([ 33 this.db.models.Actor.find({ 34 handle: { 35 $gte: handlePrefix, 36 $lt: handleRangeEnd, 37 }, 38 }).select("did -_id").lean(), 39 this.db.models.Profile.find({ 40 $text: { $search: cleanedTerm }, 41 }).select("authorDid -_id").lean(), 42 ]); 43 44 const matchingActorDids = matchingActors.map((actor) => actor.did); 45 const matchingProfileDids = matchingProfiles.map((profile) => 46 profile.authorDid 47 ); 48 const dids = Array.from( 49 new Set([...matchingActorDids, ...matchingProfileDids]), 50 ); 51 52 if (dids.length === 0) { 53 return { 54 dids: [], 55 cursor: undefined, 56 }; 57 } 58 59 const profilesQuery = this.db.models.Profile.find({ 60 authorDid: { $in: dids }, 61 }).select("authorDid indexedAt -_id"); 62 63 const paginatedQuery = this.indexedAtDidKeyset.paginate( 64 profilesQuery, 65 { 66 limit: limit + 1, // Fetch one extra to check if more results exist 67 cursor, 68 direction: "desc", 69 }, 70 ); 71 72 const profiles = await paginatedQuery.exec(); 73 74 // Check if there are more results 75 const hasMore = profiles.length > limit; 76 const results = hasMore ? profiles.slice(0, limit) : profiles; 77 78 // Generate cursor from the last item if we have more results 79 let nextCursor: string | undefined; 80 if (hasMore && results.length > 0) { 81 const lastProfile = results[results.length - 1]; 82 nextCursor = this.indexedAtDidKeyset.pack({ 83 primary: lastProfile.indexedAt, 84 secondary: lastProfile.authorDid, 85 }); 86 } 87 88 return { 89 dids: results.map((profile: { authorDid: string; indexedAt: string }) => 90 profile.authorDid 91 ), 92 cursor: nextCursor, 93 }; 94 } 95 96 async actorsTypeahead(term: string, limit = 10, viewerDid?: string | null) { 97 const cleanedTerm = cleanQuery(term); 98 if (!cleanedTerm) { 99 return { 100 dids: [], 101 }; 102 } 103 104 const safeLimit = Math.max(1, Math.min(limit, 100)); 105 const candidateLimit = safeLimit * 3; 106 const handlePrefix = cleanedTerm.toLowerCase(); 107 const handleRangeEnd = `${handlePrefix}\uffff`; 108 109 const matchingActors = await this.db.models.Actor.find({ 110 handle: { 111 $gte: handlePrefix, 112 $lt: handleRangeEnd, 113 }, 114 }) 115 .select("did -_id") 116 .sort({ handle: 1 }) 117 .limit(candidateLimit) 118 .lean(); 119 120 const handleDids = matchingActors.map((actor) => actor.did); 121 const profileQuery = handleDids.length > 0 122 ? { 123 $or: [ 124 { authorDid: { $in: handleDids } }, 125 { $text: { $search: cleanedTerm } }, 126 ], 127 } 128 : { $text: { $search: cleanedTerm } }; 129 const matchingProfiles = await this.db.models.Profile.find(profileQuery) 130 .select("authorDid followersCount -_id") 131 .limit(candidateLimit * 2) 132 .lean(); 133 134 const followerCountMap = new Map<string, number>( 135 matchingProfiles.map((p) => [p.authorDid, p.followersCount ?? 0]), 136 ); 137 138 const handleDidSet = new Set(handleDids); 139 const handleProfileDidSet = new Set( 140 matchingProfiles.map((p) => p.authorDid), 141 ); 142 const handleProfileDids = handleDids.filter((did) => 143 handleProfileDidSet.has(did) 144 ); 145 const includedDids = new Set(handleProfileDids); 146 const textProfileDids = matchingProfiles 147 .map((profile) => profile.authorDid) 148 .filter((did) => !includedDids.has(did) && !handleDidSet.has(did)); 149 150 // Sort each group by follower count descending 151 const byFollowers = (a: string, b: string) => 152 (followerCountMap.get(b) ?? 0) - (followerCountMap.get(a) ?? 0); 153 handleProfileDids.sort(byFollowers); 154 textProfileDids.sort(byFollowers); 155 156 let candidates = [...handleProfileDids, ...textProfileDids].slice( 157 0, 158 safeLimit * 2, 159 ); 160 161 // Boost accounts the viewer already follows to the front 162 if (viewerDid && candidates.length > 0) { 163 const viewerFollows = await this.db.models.Follow.find({ 164 authorDid: viewerDid, 165 subject: { $in: candidates }, 166 }).select("subject -_id").lean(); 167 const followedSet = new Set(viewerFollows.map((f) => f.subject)); 168 candidates = [ 169 ...candidates.filter((did) => followedSet.has(did)), 170 ...candidates.filter((did) => !followedSet.has(did)), 171 ]; 172 } 173 174 return { 175 dids: candidates.slice(0, safeLimit), 176 }; 177 } 178 179 async posts(term: string, limit = 50, cursor?: string) { 180 const { q, author } = parsePostSearchQuery(term); 181 182 let authorDid = author; 183 if (author && !author?.startsWith("did:")) { 184 const actor = await this.db.models.Actor.findOne({ 185 handle: author, 186 }); 187 authorDid = actor?.did; 188 } 189 190 // Build query for posts matching the search term 191 const query: Record<string, unknown> = {}; 192 193 if (q) { 194 // Search in multiple fields for better relevance 195 query.$or = [ 196 { "caption.text": { $regex: q, $options: "i" } }, 197 { "media.images.alt": { $regex: q, $options: "i" } }, 198 { "media.video.alt": { $regex: q, $options: "i" } }, 199 { tags: { $regex: q, $options: "i" } }, 200 ]; 201 } 202 203 if (authorDid) { 204 query.authorDid = authorDid; 205 } 206 207 const postsQuery = this.db.models.Post.find(query); 208 209 // Apply pagination using createdAt + cid (which matches DB schema and indexes) 210 const paginatedQuery = this.timeCidKeyset.paginate(postsQuery, { 211 limit, 212 cursor, 213 direction: "desc", 214 }); 215 216 const posts = await paginatedQuery.exec(); 217 218 // Transform posts to include sortAt for cursor generation 219 const transformedPosts = posts.map((p) => ({ 220 uri: p.uri, 221 cid: p.cid, 222 sortAt: compositeTime(p.createdAt, p.indexedAt) || p.createdAt, 223 })); 224 225 // Generate cursor from the last item if we have a full page 226 let nextCursor: string | undefined; 227 if (transformedPosts.length === limit && transformedPosts.length > 0) { 228 const lastPost = transformedPosts[transformedPosts.length - 1]; 229 nextCursor = this.timeCidKeyset.pack({ 230 primary: lastPost.sortAt, 231 secondary: lastPost.cid, 232 }); 233 } 234 235 return { 236 uris: transformedPosts.map((p) => p.uri), 237 cursor: nextCursor, 238 }; 239 } 240}