[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.

searchActors

+89 -125
+86 -122
api/so/sprk/actor/searchActors.ts
··· 1 + import { mapDefined } from "@atp/common"; 2 + import { AppContext } from "../../../../context.ts"; 3 + import { DataPlane } from "../../../../data-plane/index.ts"; 4 + import { HydrateCtx, Hydrator } from "../../../../hydration/index.ts"; 5 + import { parseString } from "../../../../hydration/util.ts"; 1 6 import { Server } from "../../../../lex/index.ts"; 2 - import { AppContext } from "../../../../context.ts"; 3 - import type * as SoSprkActorSearch from "../../../../lex/types/so/sprk/actor/searchActors.ts"; 4 - import { getProfileViews } from "../../../../utils/profile-helper.ts"; 5 - 6 - // Helper to escape user input for safe RegExp usage 7 - function escapeRegExp(str: string): string { 8 - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 9 - } 7 + import { QueryParams } from "../../../../lex/types/so/sprk/actor/searchActors.ts"; 8 + import { 9 + createPipeline, 10 + HydrationFnInput, 11 + PresentationFnInput, 12 + RulesFnInput, 13 + SkeletonFnInput, 14 + } from "../../../../pipeline.ts"; 15 + import { Views } from "../../../../views/index.ts"; 16 + import { resHeaders } from "../../../util.ts"; 10 17 11 18 export default function (server: Server, ctx: AppContext) { 19 + const searchActors = createPipeline( 20 + skeleton, 21 + hydration, 22 + noBlocks, 23 + presentation, 24 + ); 12 25 server.so.sprk.actor.searchActors({ 13 26 auth: ctx.authVerifier.standardOptional, 14 - handler: async ({ params, auth }) => { 15 - const { q, limit: limitParam = 25, cursor: cursorParam } = params; 16 - const userDid = auth.credentials.type === "standard" 17 - ? auth.credentials.iss 18 - : undefined; 19 - 20 - if (!q?.trim()) { 21 - throw new Error("Search query (q) is required"); 22 - } 23 - 24 - let limit = typeof limitParam === "string" 25 - ? parseInt(limitParam) 26 - : limitParam; 27 - if (isNaN(limit)) limit = 25; 28 - if (limit < 1 || limit > 100) { 29 - throw new Error("Limit must be between 1 and 100"); 30 - } 31 - 32 - let skip = 0; 33 - if (cursorParam) { 34 - skip = parseInt(cursorParam); 35 - if (isNaN(skip) || skip < 0) { 36 - throw new Error("Invalid cursor"); 37 - } 38 - } 39 - 40 - const escaped = escapeRegExp(q.trim()); 41 - const regex = new RegExp(escaped, "i"); 42 - 43 - // Build aggregation pipeline to search both profiles and actors efficiently 44 - const profilesPromise = ctx.db.models.Profile.aggregate([ 45 - { 46 - $match: { 47 - $or: [ 48 - { displayName: regex }, 49 - { description: regex }, 50 - ], 51 - }, 52 - }, 53 - { 54 - $lookup: { 55 - from: "actors", 56 - localField: "authorDid", 57 - foreignField: "did", 58 - as: "actor", 59 - }, 60 - }, 61 - { 62 - $match: { 63 - "actor.0": { $exists: true }, // Only include profiles with valid actors 64 - }, 65 - }, 66 - { $sort: { createdAt: -1 } }, 67 - { $skip: skip }, 68 - { $limit: limit }, 69 - ]); 70 - 71 - // Also search by handle in actors 72 - const actorsPromise = ctx.db.models.Actor.aggregate([ 73 - { 74 - $match: { 75 - handle: regex, 76 - }, 77 - }, 78 - { 79 - $lookup: { 80 - from: "profiles", 81 - localField: "did", 82 - foreignField: "authorDid", 83 - as: "profile", 84 - }, 85 - }, 86 - { $sort: { createdAt: -1 } }, 87 - { $skip: skip }, 88 - { $limit: limit }, 89 - ]); 27 + handler: async ({ auth, params }) => { 28 + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); 29 + const hydrateCtx = ctx.hydrator.createContext({ 30 + viewer, 31 + includeTakedowns, 32 + }); 33 + const results = await searchActors({ ...params, hydrateCtx }, ctx); 34 + return { 35 + encoding: "application/json", 36 + body: results, 37 + headers: resHeaders({}), 38 + }; 39 + }, 40 + }); 41 + } 90 42 91 - const [profileResults, actorResults] = await Promise.all([ 92 - profilesPromise, 93 - actorsPromise, 94 - ]); 43 + const skeleton = async (inputs: SkeletonFnInput<Context, Params>) => { 44 + const { ctx, params } = inputs; 45 + const term = params.q ?? ""; 95 46 96 - // Combine and deduplicate results 97 - const seenDids = new Set<string>(); 98 - const allCandidates: string[] = []; 47 + const res = await ctx.dataplane.search.actors( 48 + term, 49 + params.limit, 50 + params.cursor, 51 + ); 52 + return { 53 + dids: res.dids, 54 + cursor: parseString(res.cursor), 55 + }; 56 + }; 99 57 100 - // Add profiles from profile search 101 - for (const result of profileResults) { 102 - if (!seenDids.has(result.authorDid)) { 103 - seenDids.add(result.authorDid); 104 - allCandidates.push(result.authorDid); 105 - } 106 - } 58 + const hydration = async ( 59 + inputs: HydrationFnInput<Context, Params, Skeleton>, 60 + ) => { 61 + const { ctx, params, skeleton } = inputs; 62 + return await ctx.hydrator.hydrateProfiles(skeleton.dids, params.hydrateCtx); 63 + }; 107 64 108 - // Add profiles from actor search 109 - for (const result of actorResults) { 110 - if (!seenDids.has(result.did) && result.profile?.[0]) { 111 - seenDids.add(result.did); 112 - allCandidates.push(result.did); 113 - } 114 - } 65 + const noBlocks = (inputs: RulesFnInput<Context, Params, Skeleton>) => { 66 + const { ctx, skeleton, hydration } = inputs; 67 + skeleton.dids = skeleton.dids.filter( 68 + (did) => !ctx.views.viewerBlockExists(did, hydration), 69 + ); 70 + return skeleton; 71 + }; 115 72 116 - // Limit final results 117 - const finalDids = allCandidates.slice(0, limit); 73 + const presentation = ( 74 + inputs: PresentationFnInput<Context, Params, Skeleton>, 75 + ) => { 76 + const { ctx, skeleton, hydration } = inputs; 77 + const actors = mapDefined( 78 + skeleton.dids, 79 + (did) => ctx.views.profile(did, hydration), 80 + ); 81 + return { 82 + actors, 83 + cursor: skeleton.cursor, 84 + }; 85 + }; 118 86 119 - // Batch fetch profile views using the optimized function 120 - const actors = await getProfileViews(ctx, finalDids, userDid); 87 + type Context = { 88 + dataplane: DataPlane; 89 + hydrator: Hydrator; 90 + views: Views; 91 + }; 121 92 122 - const nextCursor = allCandidates.length === limit 123 - ? String(skip + limit) 124 - : undefined; 93 + type Params = QueryParams & { hydrateCtx: HydrateCtx }; 125 94 126 - return { 127 - encoding: "application/json", 128 - body: { 129 - actors, 130 - ...(nextCursor ? { cursor: nextCursor } : {}), 131 - } as SoSprkActorSearch.OutputSchema, 132 - }; 133 - }, 134 - }); 135 - } 95 + type Skeleton = { 96 + dids: string[]; 97 + hitsTotal?: number; 98 + cursor?: string; 99 + };
+1 -1
api/so/sprk/feed/searchPosts.ts
··· 50 50 const { ctx, params } = inputs; 51 51 const parsedQuery = parsePostSearchQuery(params.q); 52 52 53 - const res = await ctx.dataplane.search.searchPosts( 53 + const res = await ctx.dataplane.search.posts( 54 54 params.q, 55 55 params.limit, 56 56 params.cursor,
+2 -2
data-plane/routes/search.ts
··· 18 18 } 19 19 20 20 // @TODO actor search endpoints still fall back to search service 21 - async searchActors(term: string, limit = 50, cursor?: string) { 21 + async actors(term: string, limit = 50, cursor?: string) { 22 22 const cleanedTerm = cleanQuery(term); 23 23 const regex = new RegExp(cleanedTerm, "i"); 24 24 ··· 51 51 } 52 52 53 53 // @TODO post search endpoint still falls back to search service 54 - async searchPosts(term: string, limit = 50, cursor?: string) { 54 + async posts(term: string, limit = 50, cursor?: string) { 55 55 const { q, author } = parsePostSearchQuery(term); 56 56 57 57 let authorDid = author;