grain.social is a photo sharing platform built on atproto.

feat: add searchActors api

Changed files
+257 -62
__generated__
types
social
grain
lexicons
social
grain
src
api
lib
routes
+12
__generated__/index.ts
··· 15 15 import * as SocialGrainGalleryGetGallery from './types/social/grain/gallery/getGallery.ts' 16 16 import * as SocialGrainFeedGetTimeline from './types/social/grain/feed/getTimeline.ts' 17 17 import * as SocialGrainActorGetProfile from './types/social/grain/actor/getProfile.ts' 18 + import * as SocialGrainActorSearchActors from './types/social/grain/actor/searchActors.ts' 18 19 19 20 export const APP_BSKY_GRAPH = { 20 21 DefsModlist: 'app.bsky.graph.defs#modlist', ··· 315 316 >, 316 317 ) { 317 318 const nsid = 'social.grain.actor.getProfile' // @ts-ignore 319 + return this._server.xrpc.method(nsid, cfg) 320 + } 321 + 322 + searchActors<AV extends AuthVerifier>( 323 + cfg: ConfigOf< 324 + AV, 325 + SocialGrainActorSearchActors.Handler<ExtractAuth<AV>>, 326 + SocialGrainActorSearchActors.HandlerReqCtx<ExtractAuth<AV>> 327 + >, 328 + ) { 329 + const nsid = 'social.grain.actor.searchActors' // @ts-ignore 318 330 return this._server.xrpc.method(nsid, cfg) 319 331 } 320 332 }
+50
__generated__/lexicons.ts
··· 3337 3337 }, 3338 3338 }, 3339 3339 }, 3340 + SocialGrainActorSearchActors: { 3341 + lexicon: 1, 3342 + id: 'social.grain.actor.searchActors', 3343 + defs: { 3344 + main: { 3345 + type: 'query', 3346 + description: 3347 + 'Find actors (profiles) matching search criteria. Does not require auth.', 3348 + parameters: { 3349 + type: 'params', 3350 + properties: { 3351 + q: { 3352 + type: 'string', 3353 + description: 3354 + 'Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', 3355 + }, 3356 + limit: { 3357 + type: 'integer', 3358 + minimum: 1, 3359 + maximum: 100, 3360 + default: 25, 3361 + }, 3362 + cursor: { 3363 + type: 'string', 3364 + }, 3365 + }, 3366 + }, 3367 + output: { 3368 + encoding: 'application/json', 3369 + schema: { 3370 + type: 'object', 3371 + required: ['actors'], 3372 + properties: { 3373 + cursor: { 3374 + type: 'string', 3375 + }, 3376 + actors: { 3377 + type: 'array', 3378 + items: { 3379 + type: 'ref', 3380 + ref: 'lex:social.grain.actor.defs#profileView', 3381 + }, 3382 + }, 3383 + }, 3384 + }, 3385 + }, 3386 + }, 3387 + }, 3388 + }, 3340 3389 SocialGrainActorProfile: { 3341 3390 lexicon: 1, 3342 3391 id: 'social.grain.actor.profile', ··· 3885 3934 SocialGrainFavorite: 'social.grain.favorite', 3886 3935 SocialGrainActorDefs: 'social.grain.actor.defs', 3887 3936 SocialGrainActorGetProfile: 'social.grain.actor.getProfile', 3937 + SocialGrainActorSearchActors: 'social.grain.actor.searchActors', 3888 3938 SocialGrainActorProfile: 'social.grain.actor.profile', 3889 3939 SocialGrainPhotoDefs: 'social.grain.photo.defs', 3890 3940 SocialGrainPhotoExif: 'social.grain.photo.exif',
+52
__generated__/types/social/grain/actor/searchActors.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { HandlerAuth, HandlerPipeThrough } from "npm:@atproto/xrpc-server"; 5 + import express from "npm:express"; 6 + import { validate as _validate } from "../../../../lexicons.ts"; 7 + import { is$typed as _is$typed } from "../../../../util.ts"; 8 + import type * as SocialGrainActorDefs from "./defs.ts"; 9 + 10 + const is$typed = _is$typed, 11 + validate = _validate; 12 + const id = "social.grain.actor.searchActors"; 13 + 14 + export interface QueryParams { 15 + /** Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. */ 16 + q?: string; 17 + limit: number; 18 + cursor?: string; 19 + } 20 + 21 + export type InputSchema = undefined; 22 + 23 + export interface OutputSchema { 24 + cursor?: string; 25 + actors: SocialGrainActorDefs.ProfileView[]; 26 + } 27 + 28 + export type HandlerInput = undefined; 29 + 30 + export interface HandlerSuccess { 31 + encoding: "application/json"; 32 + body: OutputSchema; 33 + headers?: { [key: string]: string }; 34 + } 35 + 36 + export interface HandlerError { 37 + status: number; 38 + message?: string; 39 + } 40 + 41 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 42 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 43 + auth: HA; 44 + params: QueryParams; 45 + input: HandlerInput; 46 + req: express.Request; 47 + res: express.Response; 48 + resetRouteRateLimits: () => Promise<void>; 49 + }; 50 + export type Handler<HA extends HandlerAuth = never> = ( 51 + ctx: HandlerReqCtx<HA>, 52 + ) => Promise<HandlerOutput> | HandlerOutput;
+43
lexicons/social/grain/actor/searchActors.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.actor.searchActors", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Find actors (profiles) matching search criteria. Does not require auth.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "q": { 12 + "type": "string", 13 + "description": "Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended." 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "minimum": 1, 18 + "maximum": 100, 19 + "default": 25 20 + }, 21 + "cursor": { "type": "string" } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["actors"], 29 + "properties": { 30 + "cursor": { "type": "string" }, 31 + "actors": { 32 + "type": "array", 33 + "items": { 34 + "type": "ref", 35 + "ref": "social.grain.actor.defs#profileView" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + } 43 + }
+40 -1
src/api/mod.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 1 2 import { 2 3 OutputSchema as GetProfileOutputSchema, 3 4 QueryParams as GetProfileQueryParams, 4 5 } from "$lexicon/types/social/grain/actor/getProfile.ts"; 6 + import { 7 + OutputSchema as SearchActorsOutputSchema, 8 + QueryParams as SearchActorsQueryParams, 9 + } from "$lexicon/types/social/grain/actor/searchActors.ts"; 5 10 import { 6 11 OutputSchema as GetTimelineOutputSchema, 7 12 } from "$lexicon/types/social/grain/feed/getTimeline.ts"; ··· 22 27 } from "$lexicon/types/social/grain/notification/getNotifications.ts"; 23 28 import { AtUri } from "@atproto/syntax"; 24 29 import { BffMiddleware, OAUTH_ROUTES, route } from "@bigmoves/bff"; 25 - import { getActorGalleries, getActorProfileDetailed } from "../lib/actor.ts"; 30 + import { 31 + getActorGalleries, 32 + getActorProfileDetailed, 33 + searchActors, 34 + } from "../lib/actor.ts"; 26 35 import { BadRequestError } from "../lib/errors.ts"; 27 36 import { getGallery } from "../lib/gallery.ts"; 28 37 import { getNotifications } from "../lib/notifications.ts"; ··· 117 126 ); 118 127 }, 119 128 ), 129 + route( 130 + "/xrpc/social.grain.actor.searchActors", 131 + (req, _params, ctx) => { 132 + const url = new URL(req.url); 133 + const { q } = searchActorsQueryParams(url); 134 + let results: ProfileView[] = []; 135 + if (!q) { 136 + results = []; 137 + } else { 138 + results = searchActors( 139 + q, 140 + ctx, 141 + ); 142 + } 143 + return ctx.json( 144 + { actors: results } as SearchActorsOutputSchema, 145 + ); 146 + }, 147 + ), 120 148 ]; 121 149 122 150 function getProfileQueryParams(url: URL): GetProfileQueryParams { ··· 146 174 const uri = url.searchParams.get("uri"); 147 175 if (!uri) throw new BadRequestError("Missing uri parameter"); 148 176 return { uri }; 177 + } 178 + 179 + function searchActorsQueryParams(url: URL): SearchActorsQueryParams { 180 + const q = url.searchParams.get("q"); 181 + if (!q) throw new BadRequestError("Missing q parameter"); 182 + const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 183 + if (isNaN(limit) || limit <= 0) { 184 + throw new BadRequestError("Invalid limit parameter"); 185 + } 186 + const cursor = url.searchParams.get("cursor") ?? undefined; 187 + return { q, limit, cursor }; 149 188 } 150 189 151 190 // function getNotificationsQueryParams(url: URL): GetNotificationsQueryParams {
+57
src/lib/actor.ts
··· 322 322 return profileToView(profile, handle); 323 323 }); 324 324 } 325 + 326 + export function searchActors(query: string, ctx: BffContext) { 327 + const actors = ctx.indexService.searchActors(query); 328 + 329 + const { items } = ctx.indexService.getRecords<WithBffMeta<GrainProfile>>( 330 + "social.grain.actor.profile", 331 + { 332 + where: { 333 + OR: [ 334 + ...(actors.length > 0 335 + ? [{ 336 + field: "did", 337 + in: actors.map((actor) => actor.did), 338 + }] 339 + : []), 340 + { 341 + field: "displayName", 342 + contains: query, 343 + }, 344 + { 345 + field: "did", 346 + contains: query, 347 + }, 348 + ], 349 + }, 350 + }, 351 + ); 352 + 353 + const profileMap = new Map<string, WithBffMeta<GrainProfile>>(); 354 + for (const item of items) { 355 + profileMap.set(item.did, item); 356 + } 357 + 358 + const actorMap = new Map(); 359 + actors.forEach((actor) => { 360 + actorMap.set(actor.did, actor); 361 + }); 362 + 363 + const profileViews = []; 364 + 365 + for (const actor of actors) { 366 + if (profileMap.has(actor.did)) { 367 + const profile = profileMap.get(actor.did)!; 368 + profileViews.push(profileToView(profile, actor.handle)); 369 + } 370 + } 371 + 372 + for (const profile of items) { 373 + if (!actorMap.has(profile.did)) { 374 + const handle = ctx.indexService.getActor(profile.did)?.handle; 375 + if (!handle) continue; 376 + profileViews.push(profileToView(profile, handle)); 377 + } 378 + } 379 + 380 + return profileViews; 381 + }
+3 -61
src/routes/explore.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 - import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 2 import { Un$Typed } from "$lexicon/util.ts"; 4 - import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 3 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 5 4 import { ComponentChildren } from "preact"; 6 5 import { ActorAvatar } from "../components/ActorAvatar.tsx"; 7 6 import { Input } from "../components/Input.tsx"; 8 7 import { LabelerAvatar } from "../components/LabelerAvatar.tsx"; 9 - import { profileToView } from "../lib/actor.ts"; 8 + import { searchActors } from "../lib/actor.ts"; 10 9 import { getPageMeta } from "../meta.ts"; 11 10 import type { State } from "../state.ts"; 12 11 ··· 20 19 const query = url.searchParams.get("q") ?? ""; 21 20 ctx.state.meta = [{ title: "Explore — Grain" }, ...getPageMeta("/explore")]; 22 21 if (query) { 23 - const profileViews = doSearch(query, ctx); 22 + const profileViews = searchActors(query, ctx); 24 23 if (req.headers.get("hx-request")) { 25 24 if (profileViews.length === 0) { 26 25 return ctx.html(<p>No results for "{query}"</p>); ··· 104 103 </> 105 104 ); 106 105 } 107 - 108 - function doSearch(query: string, ctx: BffContext<State>) { 109 - const actors = ctx.indexService.searchActors(query); 110 - 111 - const { items } = ctx.indexService.getRecords<WithBffMeta<Profile>>( 112 - "social.grain.actor.profile", 113 - { 114 - where: { 115 - OR: [ 116 - ...(actors.length > 0 117 - ? [{ 118 - field: "did", 119 - in: actors.map((actor) => actor.did), 120 - }] 121 - : []), 122 - { 123 - field: "displayName", 124 - contains: query, 125 - }, 126 - { 127 - field: "did", 128 - contains: query, 129 - }, 130 - ], 131 - }, 132 - }, 133 - ); 134 - 135 - const profileMap = new Map<string, WithBffMeta<Profile>>(); 136 - for (const item of items) { 137 - profileMap.set(item.did, item); 138 - } 139 - 140 - const actorMap = new Map(); 141 - actors.forEach((actor) => { 142 - actorMap.set(actor.did, actor); 143 - }); 144 - 145 - const profileViews = []; 146 - 147 - for (const actor of actors) { 148 - if (profileMap.has(actor.did)) { 149 - const profile = profileMap.get(actor.did)!; 150 - profileViews.push(profileToView(profile, actor.handle)); 151 - } 152 - } 153 - 154 - for (const profile of items) { 155 - if (!actorMap.has(profile.did)) { 156 - const handle = ctx.indexService.getActor(profile.did)?.handle; 157 - if (!handle) continue; 158 - profileViews.push(profileToView(profile, handle)); 159 - } 160 - } 161 - 162 - return profileViews; 163 - }