diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts new file mode 100644 index 00000000000..2edc9e2f138 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -0,0 +1,123 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' +import { InvalidRequestError } from '@atproto/xrpc-server' +import AtpAgent from '@atproto/api' +import { AtUri } from '@atproto/syntax' +import { mapDefined } from '@atproto/common' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/searchPosts' +import { Database } from '../../../../db' +import { FeedHydrationState, FeedService } from '../../../../services/feed' +import { ActorService } from '../../../../services/actor' +import { createPipeline } from '../../../../pipeline' + +export default function (server: Server, ctx: AppContext) { + const searchPosts = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) + server.app.bsky.feed.searchPosts({ + auth: ctx.authOptionalVerifier, + handler: async ({ auth, params }) => { + const viewer = auth.credentials.did + const db = ctx.db.getReplica('search') + const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) + const searchAgent = ctx.searchAgent + if (!searchAgent) { + throw new InvalidRequestError('Search not available') + } + + const results = await searchPosts( + { ...params, viewer }, + { db, feedService, actorService, searchAgent }, + ) + + return { + encoding: 'application/json', + body: results, + } + }, + }) +} + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const res = await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton( + params, + ) + return { + params, + postUris: res.data.posts.map((a) => a.uri), + cursor: res.data.cursor, + } +} + +const hydration = async ( + state: SkeletonState, + ctx: Context, +): Promise => { + const { feedService } = ctx + const { params, postUris } = state + const uris = new Set(postUris) + const dids = new Set(postUris.map((uri) => new AtUri(uri).hostname)) + const hydrated = await feedService.feedHydration({ + uris, + dids, + viewer: params.viewer, + }) + return { ...state, ...hydrated } +} + +const noBlocks = (state: HydrationState): HydrationState => { + const { viewer } = state.params + state.postUris = state.postUris.filter((uri) => { + const post = state.posts[uri] + if (!viewer || !post) return true + return !state.bam.block([viewer, post.creator]) + }) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService, actorService } = ctx + const { postUris, profiles, params } = state + const actors = actorService.views.profileBasicPresentation( + Object.keys(profiles), + state, + { viewer: params.viewer }, + ) + + const postViews = mapDefined(postUris, (uri) => + feedService.views.formatPostView( + uri, + actors, + state.posts, + state.threadgates, + state.embeds, + state.labels, + state.lists, + ), + ) + return { posts: postViews } +} + +type Context = { + db: Database + feedService: FeedService + actorService: ActorService + searchAgent: AtpAgent +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + postUris: string[] + cursor?: string +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index 3768ed4da0b..cf2121b7792 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -13,6 +13,7 @@ import getLikes from './app/bsky/feed/getLikes' import getListFeed from './app/bsky/feed/getListFeed' import getPostThread from './app/bsky/feed/getPostThread' import getPosts from './app/bsky/feed/getPosts' +import searchPosts from './app/bsky/feed/searchPosts' import getActorLikes from './app/bsky/feed/getActorLikes' import getProfile from './app/bsky/actor/getProfile' import getProfiles from './app/bsky/actor/getProfiles' @@ -74,6 +75,7 @@ export default function (server: Server, ctx: AppContext) { getListFeed(server, ctx) getPostThread(server, ctx) getPosts(server, ctx) + searchPosts(server, ctx) getActorLikes(server, ctx) getProfile(server, ctx) getProfiles(server, ctx) diff --git a/packages/pds/src/api/app/bsky/feed/index.ts b/packages/pds/src/api/app/bsky/feed/index.ts index 8c4cfaa8b5f..026ce86f612 100644 --- a/packages/pds/src/api/app/bsky/feed/index.ts +++ b/packages/pds/src/api/app/bsky/feed/index.ts @@ -13,6 +13,7 @@ import getPostThread from './getPostThread' import getRepostedBy from './getRepostedBy' import getSuggestedFeeds from './getSuggestedFeeds' import getTimeline from './getTimeline' +import searchPosts from './searchPosts' export default function (server: Server, ctx: AppContext) { getActorFeeds(server, ctx) @@ -28,4 +29,5 @@ export default function (server: Server, ctx: AppContext) { getRepostedBy(server, ctx) getSuggestedFeeds(server, ctx) getTimeline(server, ctx) + searchPosts(server, ctx) } diff --git a/packages/pds/src/api/app/bsky/feed/searchPosts.ts b/packages/pds/src/api/app/bsky/feed/searchPosts.ts new file mode 100644 index 00000000000..85384751ea1 --- /dev/null +++ b/packages/pds/src/api/app/bsky/feed/searchPosts.ts @@ -0,0 +1,19 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.feed.searchPosts({ + auth: ctx.authVerifier.access, + handler: async ({ params, auth }) => { + const requester = auth.credentials.did + const res = await ctx.appViewAgent.api.app.bsky.feed.searchPosts( + params, + await ctx.serviceAuthHeaders(requester), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +}