diff --git a/api/index.ts b/api/index.ts index 98f56c4..15d2b53 100644 --- a/api/index.ts +++ b/api/index.ts @@ -9,6 +9,7 @@ import getProfile from "./so/sprk/actor/getProfile.ts"; import getAuthorFeed from "./so/sprk/feed/getAuthorFeed.ts"; import getPostThread from "./so/sprk/feed/getPostThread.ts"; import getCrosspostThread from "./so/sprk/feed/getCrosspostThread.ts"; +import getLikes from "./so/sprk/feed/getLikes.ts"; import getActorLikes from "./so/sprk/feed/getActorLikes.ts"; import getActorReposts from "./so/sprk/feed/getActorReposts.ts"; import getAudios from "./so/sprk/sound/getAudios.ts"; @@ -52,6 +53,7 @@ export default function (server: Server, ctx: AppContext) { getAuthorFeed(server, ctx); getPostThread(server, ctx); getCrosspostThread(server, ctx); + getLikes(server, ctx); getActorLikes(server, ctx); getActorReposts(server, ctx); getAudios(server, ctx); diff --git a/api/so/sprk/feed/getLikes.ts b/api/so/sprk/feed/getLikes.ts new file mode 100644 index 0000000..791fdf9 --- /dev/null +++ b/api/so/sprk/feed/getLikes.ts @@ -0,0 +1,153 @@ +import type { AtUriString, CidString, DatetimeString } from "@atp/lex"; +import { Server } from "@atp/xrpc-server"; + +import { AppContext } from "../../../../context.ts"; +import { DataPlane } from "../../../../data-plane/index.ts"; +import { HydrateCtx, Hydrator } from "../../../../hydration/index.ts"; +import { parseString } from "../../../../hydration/util.ts"; +import * as so from "../../../../lex/so.ts"; +import { + $OutputBody, + $Params, + Like, +} from "../../../../lex/so/sprk/feed/getLikes.ts"; +import { + createPipeline, + filterSkeletonList, + type HydrationFnInput, + mapSkeletonList, + type PresentationFnInput, + type RulesFnInput, + type 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 getLikes = createPipeline({ + skeleton, + hydration, + rules: noBlocks, + presentation, + }); + + server.add(so.sprk.feed.getLikes, { + auth: ctx.authVerifier.standardOptional, + handler: async ({ params, auth, req }) => { + const hydrateCtx = await createHydrateCtxFromAuth(ctx, req, auth); + const result = await getLikes({ ...params, hydrateCtx }, ctx); + + return { + encoding: "application/json", + body: result, + headers: resHeaders({ + labelers: hydrateCtx.labelers, + }), + }; + }, + }); +} + +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { ctx, params } = input; + const { uri, cid, limit, cursor } = params; + + if (clearlyBadCursor(cursor)) { + return { uri, cid, likes: [] }; + } + + const likesRes = await ctx.dataplane.likes.bySubject( + { uri, cid }, + limit, + cursor, + ); + + return { + uri, + cid, + likes: likesRes.likes ?? [], + cursor: parseString(likesRes.cursor), + }; +}; + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input; + return await ctx.hydrator.hydrateProfiles( + skeleton.likes.map((like) => like.authorDid), + params.hydrateCtx, + { includeStories: false }, + ); +}; + +const noBlocks = (input: RulesFnInput) => { + const { ctx, hydration, params, skeleton } = input; + return filterSkeletonList(skeleton, "likes", (like) => { + if (!ctx.views.profile(like.authorDid, hydration)) { + return false; + } + if (ctx.views.viewerBlockExists(like.authorDid, hydration)) { + return false; + } + return ( + params.hydrateCtx.includeTakedowns || + !ctx.views.actorIsNoHosted(like.authorDid, hydration) + ); + }); +}; + +const presentation = ( + input: PresentationFnInput, +): $OutputBody => { + const { ctx, hydration, skeleton } = input; + const likes = mapSkeletonList(skeleton, "likes", (like): Like | undefined => { + const actor = ctx.views.profile(like.authorDid, hydration); + if (!actor) return; + return { + indexedAt: like.indexedAt as DatetimeString, + createdAt: like.createdAt as DatetimeString, + actor, + }; + }); + + return { + uri: skeleton.uri as AtUriString, + cid: skeleton.cid as CidString | undefined, + cursor: skeleton.cursor, + likes, + }; +}; + +type Context = { + hydrator: Hydrator; + views: Views; + dataplane: DataPlane; +}; + +type Params = $Params & { + hydrateCtx: HydrateCtx; +}; + +type LikeSkeletonItem = { + uri: string; + cid: string; + authorDid: string; + createdAt: string; + indexedAt: string; + subject: string; + subjectCid: string; +}; + +type Skeleton = { + uri: string; + cid?: string; + likes: LikeSkeletonItem[]; + cursor?: string; +}; diff --git a/data-plane/routes/likes.ts b/data-plane/routes/likes.ts index ef90986..10262cb 100644 --- a/data-plane/routes/likes.ts +++ b/data-plane/routes/likes.ts @@ -19,8 +19,14 @@ export class Likes { return { uris: [], cursor: undefined }; } - // Build query for likes on this subject - const likesQuery = this.db.models.Like.find({ subject: subject.uri }); + const query: { subject: string; subjectCid?: string } = { + subject: subject.uri, + }; + if (subject.cid) { + query.subjectCid = subject.cid; + } + + const likesQuery = this.db.models.Like.find(query); // Apply pagination using TimeCidKeyset const paginatedQuery = this.timeCidKeyset.paginate(likesQuery, { @@ -43,6 +49,15 @@ export class Likes { return { uris: likes.map((l) => l.uri), + likes: likes.map((l) => ({ + uri: l.uri, + cid: l.cid, + authorDid: l.authorDid, + createdAt: l.createdAt, + indexedAt: l.indexedAt, + subject: l.subject, + subjectCid: l.subjectCid, + })), cursor: nextCursor, }; } diff --git a/tests/feed_get_likes_test.ts b/tests/feed_get_likes_test.ts new file mode 100644 index 0000000..ad16a54 --- /dev/null +++ b/tests/feed_get_likes_test.ts @@ -0,0 +1,87 @@ +import { assertEquals } from "@std/assert"; +import { createTestApp, TEST_USERS } from "./util.ts"; +import { $OutputBody } from "../lex/so/sprk/feed/getLikes.ts"; + +Deno.test({ + name: "getLikes endpoint", + sanitizeOps: false, + sanitizeResources: false, + fn: async (t) => { + const { app, ctx, cleanup } = await createTestApp({ + actors: true, + profiles: true, + posts: true, + replies: false, + stories: false, + likes: true, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const postUri = `at://${TEST_USERS[0].did}/app.sprk.post/post1`; + const postCid = + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; + const otherCid = + "bafyreicb5k4mmdf3nm42h6jp7dqz7hd23w5g7xeykvw7q74dgqg7b5ykva"; + + await ctx.db.models.Like.updateMany( + { subject: postUri }, + { subjectCid: postCid }, + ); + + await t.step("returns actors who liked a post", async () => { + const params = new URLSearchParams({ uri: postUri }); + const res = await app.request( + `/xrpc/so.sprk.feed.getLikes?${params.toString()}`, + ); + + assertEquals(res.status, 200); + const body = await res.json() as $OutputBody; + assertEquals(body.uri, postUri); + assertEquals(body.likes.length, 2); + assertEquals( + body.likes.map((like) => String(like.actor.did)), + [TEST_USERS[2].did, TEST_USERS[1].did], + ); + assertEquals(String(body.likes[0].actor.handle), TEST_USERS[2].handle); + assertEquals(typeof body.likes[0].createdAt, "string"); + assertEquals(typeof body.likes[0].indexedAt, "string"); + }); + + await t.step("filters likes by subject CID", async () => { + const params = new URLSearchParams({ uri: postUri, cid: postCid }); + const res = await app.request( + `/xrpc/so.sprk.feed.getLikes?${params.toString()}`, + ); + + assertEquals(res.status, 200); + const body = await res.json() as $OutputBody; + assertEquals(body.cid, postCid); + assertEquals(body.likes.length, 2); + }); + + await t.step("returns no likes for a different subject CID", async () => { + const params = new URLSearchParams({ + uri: postUri, + cid: otherCid, + }); + const res = await app.request( + `/xrpc/so.sprk.feed.getLikes?${params.toString()}`, + ); + + assertEquals(res.status, 200); + const body = await res.json() as $OutputBody; + assertEquals(body.likes, []); + }); + } finally { + await cleanup(); + } + }, +});