From 8ec932cfaa6d4be79fa3241e3bada3c7f6362202 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sun, 26 Apr 2026 13:58:23 -0400 Subject: [PATCH] feat: getKnownFollowers --- api/index.ts | 2 + api/so/sprk/graph/getKnownFollowers.ts | 135 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 api/so/sprk/graph/getKnownFollowers.ts diff --git a/api/index.ts b/api/index.ts index 7539b13..7e516fa 100644 --- a/api/index.ts +++ b/api/index.ts @@ -15,6 +15,7 @@ import getAudios from "./so/sprk/sound/getAudios.ts"; import getAudioPosts from "./so/sprk/sound/getAudioPosts.ts"; import getFollows from "./so/sprk/graph/getFollows.ts"; import getFollowers from "./so/sprk/graph/getFollowers.ts"; +import getKnownFollowers from "./so/sprk/graph/getKnownFollowers.ts"; import getBlocks from "./so/sprk/graph/getBlocks.ts"; import putPreferences from "./so/sprk/actor/putPreferences.ts"; import getPreferences from "./so/sprk/actor/getPreferences.ts"; @@ -56,6 +57,7 @@ export default function (server: Server, ctx: AppContext) { getAudioPosts(server, ctx); getFollows(server, ctx); getFollowers(server, ctx); + getKnownFollowers(server, ctx); getBlocks(server, ctx); putPreferences(server, ctx); getPreferences(server, ctx); diff --git a/api/so/sprk/graph/getKnownFollowers.ts b/api/so/sprk/graph/getKnownFollowers.ts new file mode 100644 index 0000000..a1765ea --- /dev/null +++ b/api/so/sprk/graph/getKnownFollowers.ts @@ -0,0 +1,135 @@ +import { InvalidRequestError, Server } from "@atp/xrpc-server"; + +import { AppContext } from "../../../../context.ts"; +import { HydrateCtx, Hydrator } from "../../../../hydration/index.ts"; +import { $Params } from "../../../../lex/so/sprk/graph/getKnownFollowers.ts"; +import * as so from "../../../../lex/so.ts"; +import { + createPipeline, + filterSkeletonList, + HydrationFnInput, + mapSkeletonList, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, +} from "../../../../pipeline.ts"; +import { Views } from "../../../../views/index.ts"; +import { + clearlyBadCursor, + createHydrateCtxFromAuth, + resHeaders, +} from "../../../util.ts"; + +export default function (server: Server, ctx: AppContext) { + const getKnownFollowers = createPipeline({ + skeleton, + hydration, + rules: noBlocks, + presentation, + }); + server.add(so.sprk.graph.getKnownFollowers, { + auth: ctx.authVerifier.standard, + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss; + const hydrateCtx = await createHydrateCtxFromAuth( + ctx, + req, + auth, + { viewer }, + ); + + const result = await getKnownFollowers( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, + ); + + return { + encoding: "application/json", + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), + }; + }, + }); +} + +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input; + const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]); + if (!subjectDid) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`); + } + if (clearlyBadCursor(params.cursor)) { + return { subjectDid, knownFollowers: [], cursor: undefined }; + } + + const res = await ctx.hydrator.dataplane.follows.getFollowsFollowing( + params.hydrateCtx.viewer, + [subjectDid], + ); + const result = res.results.at(0); + const knownFollowers = result ? result.dids.slice(0, params.limit ?? 50) : []; + + return { + subjectDid, + knownFollowers, + cursor: undefined, + }; +}; + +const hydration = ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input; + return ctx.hydrator.hydrateProfiles( + [skeleton.subjectDid, ...skeleton.knownFollowers], + params.hydrateCtx, + ); +}; + +const noBlocks = (input: RulesFnInput) => { + const { ctx, hydration, skeleton } = input; + return filterSkeletonList(skeleton, "knownFollowers", (did) => { + return !ctx.views.viewerBlockExists(did, hydration); + }); +}; + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, params, skeleton } = input; + const isNoHosted = (did: string) => ctx.views.actorIsNoHosted(did, hydration); + + const subject = ctx.views.profile(skeleton.subjectDid, hydration); + if ( + !subject || + (!params.hydrateCtx.includeTakedowns && isNoHosted(skeleton.subjectDid)) + ) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`); + } + + const followers = mapSkeletonList(skeleton, "knownFollowers", (did) => { + if (!params.hydrateCtx.includeTakedowns && isNoHosted(did)) { + return; + } + return ctx.views.profile(did, hydration); + }); + + return { subject, followers, cursor: undefined }; +}; + +type Context = { + hydrator: Hydrator; + views: Views; +}; + +type Params = $Params & { + hydrateCtx: HydrateCtx & { viewer: string }; +}; + +type SkeletonState = { + subjectDid: string; + knownFollowers: string[]; + cursor?: string; +}; -- 2.51.2