import { mapDefined } from '@atproto/common' import { AtUriString, DatetimeString, DidString, normalizeDatetimeAlways, } from '@atproto/syntax' import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context.js' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator.js' import { parseString } from '../../../../hydration/util.js' import { app } from '../../../../lexicons/index.js' import { RulesFnInput, createPipeline } from '../../../../pipeline.js' import { uriToDid as creatorFromUri } from '../../../../util/uris.js' import { Views } from '../../../../views/index.js' import { clearlyBadCursor, resHeaders } from '../../../util.js' export default function (server: Server, ctx: AppContext) { const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation) server.add(app.bsky.feed.getLikes, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const { viewer, includeTakedowns, skipViewerBlocks } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, skipViewerBlocks, }) const result = await getLikes({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } const skeleton = async (inputs: { ctx: Context params: Params }): Promise => { const { ctx, params } = inputs const authorDid = creatorFromUri(params.uri) if (clearlyBadCursor(params.cursor)) { return { authorDid, likes: [] } } if (looksLikeNonSortedCursor(params.cursor)) { throw new InvalidRequestError( 'Cursor appear to be out of date, please try reloading.', ) } const likesRes = await ctx.hydrator.dataplane.getLikesBySubjectSorted({ subject: { uri: params.uri, cid: params.cid }, cursor: params.cursor, limit: params.limit, }) return { authorDid, likes: likesRes.uris as AtUriString[], cursor: parseString(likesRes.cursor), } } const hydration = async (inputs: { ctx: Context params: Params skeleton: Skeleton }) => { const { ctx, params, skeleton } = inputs const likesState = await ctx.hydrator.hydrateLikes( skeleton.authorDid, skeleton.likes, params.hydrateCtx, ) return likesState } const noBlocks = (input: RulesFnInput) => { const { ctx, skeleton, hydration } = input skeleton.likes = skeleton.likes.filter((likeUri) => { const like = hydration.likes?.get(likeUri) if (!like) return false const likerDid = creatorFromUri(likeUri) return ( !hydration.likeBlocks?.get(likeUri) && !ctx.views.viewerBlockExists(likerDid, hydration) ) }) return skeleton } const presentation = (inputs: { ctx: Context params: Params skeleton: Skeleton hydration: HydrationState }): app.bsky.feed.getLikes.$OutputBody => { const { ctx, params, skeleton, hydration } = inputs const likeViews = mapDefined(skeleton.likes, (uri) => { const like = hydration.likes?.get(uri) if (!like || !like.record) { return } const creatorDid = creatorFromUri(uri) const actor = ctx.views.profile(creatorDid, hydration) if (!actor) { return } return { actor, createdAt: normalizeDatetimeAlways(like.record.createdAt), indexedAt: like.sortedAt.toISOString() as DatetimeString, } }) return { likes: likeViews, cursor: skeleton.cursor, uri: params.uri, cid: params.cid, } } type Context = { hydrator: Hydrator views: Views } type Params = app.bsky.feed.getLikes.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { authorDid: DidString likes: AtUriString[] cursor?: string } const looksLikeNonSortedCursor = (cursor: string | undefined) => { // the old cursor values used with getLikesBySubject() were dids. // we now use getLikesBySubjectSorted(), whose cursors look like timestamps. return cursor?.startsWith('did:') }