grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: merged bsky + grain typeahead search for login autocomplete

Users on non-bsky PDSes (e.g. selfhosted.social) weren't showing up
in the login handle autocomplete since it only queried bsky's public
API. New searchActorsTypeahead XRPC merges grain profiles with bsky
results, prioritizing grain users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+106 -6
+3 -5
app/lib/components/organisms/LoginModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { login, createAccount } from '$lib/auth' 3 + import { callXrpc } from '$hatk/client' 3 4 import Modal from '../atoms/Modal.svelte' 4 5 import Button from '../atoms/Button.svelte' 5 6 ··· 40 41 41 42 async function search(q: string) { 42 43 try { 43 - const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=5` 44 - const res = await fetch(url) 45 - if (!res.ok) return 46 - const json = await res.json() 47 - suggestions = json.actors || [] 44 + const result = await callXrpc('social.grain.unspecced.searchActorsTypeahead', { q, limit: 5 }) 45 + suggestions = result.actors || [] 48 46 activeIndex = -1 49 47 } catch { 50 48 // ignore search errors
+1 -1
hatk.generated.client.ts
··· 3 3 // to avoid pulling in server-only dependencies. 4 4 export type { XrpcSchema } from './hatk.generated.ts' 5 5 import type { XrpcSchema } from './hatk.generated.ts' 6 - export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, DeclarationMessageMe, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchActorsTypeahead, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, DeclarationMessageMe, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 8 const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+4
hatk.generated.ts
··· 75 75 const getStoryArchiveLex = {"lexicon":1,"id":"social.grain.unspecced.getStoryArchive","defs":{"main":{"type":"query","description":"Get all stories for an actor, including expired ones. For archive browsing.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["stories"],"properties":{"stories":{"type":"array","items":{"type":"ref","ref":"social.grain.story.defs#storyView"}},"cursor":{"type":"string"}}}}}}} as const 76 76 const getStoryAuthorsLex = {"lexicon":1,"id":"social.grain.unspecced.getStoryAuthors","defs":{"main":{"type":"query","description":"Get authors who have active stories (posted within the last 24 hours).","output":{"encoding":"application/json","schema":{"type":"object","required":["authors"],"properties":{"authors":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getStoryAuthors#storyAuthor"}}}}}},"storyAuthor":{"type":"object","required":["profile","storyCount","latestAt"],"properties":{"profile":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"storyCount":{"type":"integer"},"latestAt":{"type":"string","format":"datetime"}}}}} as const 77 77 const getSuggestedFollowsLex = {"lexicon":1,"id":"social.grain.unspecced.getSuggestedFollows","defs":{"main":{"type":"query","description":"Get suggested profiles to follow based on bsky follow graph.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":20,"default":10}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getSuggestedFollows#suggestedItem"}}}}}},"suggestedItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"followersCount":{"type":"integer"}}}}} as const 78 + const searchActorsTypeaheadLex = {"lexicon":1,"id":"social.grain.unspecced.searchActorsTypeahead","defs":{"main":{"type":"query","description":"Typeahead search for actors, merging bsky and grain profiles.","parameters":{"type":"params","required":["q"],"properties":{"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":10,"default":5}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"actors":{"type":"array","items":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"}}}}}}}}}} as const 78 79 const searchGalleriesLex = {"lexicon":1,"id":"social.grain.unspecced.searchGalleries","defs":{"main":{"type":"query","description":"Full-text search for galleries, returning full gallery views.","parameters":{"type":"params","required":["q"],"properties":{"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"},"fuzzy":{"type":"boolean","default":true}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}},"cursor":{"type":"string"}}}}}}} as const 79 80 const searchProfilesLex = {"lexicon":1,"id":"social.grain.unspecced.searchProfiles","defs":{"main":{"type":"query","description":"Full-text search for user profiles.","parameters":{"type":"params","required":["q"],"properties":{"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"},"fuzzy":{"type":"boolean","default":true}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.searchProfiles#profileSearchResult"}},"cursor":{"type":"string"}}}}},"profileSearchResult":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 80 81 ··· 149 150 'social.grain.unspecced.getStoryArchive': typeof getStoryArchiveLex 150 151 'social.grain.unspecced.getStoryAuthors': typeof getStoryAuthorsLex 151 152 'social.grain.unspecced.getSuggestedFollows': typeof getSuggestedFollowsLex 153 + 'social.grain.unspecced.searchActorsTypeahead': typeof searchActorsTypeaheadLex 152 154 'social.grain.unspecced.searchGalleries': typeof searchGalleriesLex 153 155 'social.grain.unspecced.searchProfiles': typeof searchProfilesLex 154 156 } ··· 196 198 export type GetStoryArchive = Prettify<LexQuery<typeof getStoryArchiveLex, Registry>> 197 199 export type GetStoryAuthors = Prettify<LexQuery<typeof getStoryAuthorsLex, Registry>> 198 200 export type GetSuggestedFollows = Prettify<LexQuery<typeof getSuggestedFollowsLex, Registry>> 201 + export type SearchActorsTypeahead = Prettify<LexQuery<typeof searchActorsTypeaheadLex, Registry>> 199 202 export type SearchGalleries = Prettify<LexQuery<typeof searchGalleriesLex, Registry>> 200 203 export type SearchProfiles = Prettify<LexQuery<typeof searchProfilesLex, Registry>> 201 204 ··· 400 403 'social.grain.unspecced.getStoryArchive': GetStoryArchive 401 404 'social.grain.unspecced.getStoryAuthors': GetStoryAuthors 402 405 'social.grain.unspecced.getSuggestedFollows': GetSuggestedFollows 406 + 'social.grain.unspecced.searchActorsTypeahead': SearchActorsTypeahead 403 407 'social.grain.unspecced.searchGalleries': SearchGalleries 404 408 'social.grain.unspecced.searchProfiles': SearchProfiles 405 409 }
+39
lexicons/social/grain/unspecced/searchActorsTypeahead.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.searchActorsTypeahead", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Typeahead search for actors, merging bsky and grain profiles.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["q"], 11 + "properties": { 12 + "q": { "type": "string", "description": "Search query" }, 13 + "limit": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 } 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "properties": { 21 + "actors": { 22 + "type": "array", 23 + "items": { 24 + "type": "object", 25 + "required": ["did"], 26 + "properties": { 27 + "did": { "type": "string", "format": "did" }, 28 + "handle": { "type": "string" }, 29 + "displayName": { "type": "string" }, 30 + "avatar": { "type": "string" } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+59
server/xrpc/searchActorsTypeahead.ts
··· 1 + import { defineQuery, type GrainActorProfile } from "$hatk"; 2 + 3 + export default defineQuery( 4 + "social.grain.unspecced.searchActorsTypeahead", 5 + async (ctx) => { 6 + const { params, search, resolve, blobUrl, ok } = ctx; 7 + const { q, limit } = params; 8 + 9 + // Fetch from bsky and grain in parallel 10 + const [bskyResult, grainResult] = await Promise.all([ 11 + fetch( 12 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=${limit}` 13 + ) 14 + .then((r) => (r.ok ? r.json() : { actors: [] })) 15 + .catch(() => ({ actors: [] })), 16 + search("social.grain.actor.profile", q, { limit }).catch(() => ({ 17 + records: [], 18 + })), 19 + ]); 20 + 21 + // Build grain profiles map keyed by DID 22 + const grainItems = await resolve<GrainActorProfile>( 23 + grainResult.records.map((r: any) => r.uri) 24 + ); 25 + const seen = new Set<string>(); 26 + const actors: { 27 + did: string; 28 + handle?: string; 29 + displayName?: string; 30 + avatar?: string; 31 + }[] = []; 32 + 33 + // Grain profiles first (prioritize grain users) 34 + for (const item of grainItems) { 35 + if (seen.has(item.did)) continue; 36 + seen.add(item.did); 37 + actors.push({ 38 + did: item.did, 39 + handle: item.handle, 40 + displayName: item.value.displayName, 41 + avatar: blobUrl(item.did, item.value.avatar, "avatar"), 42 + }); 43 + } 44 + 45 + // Merge bsky results, skip duplicates 46 + for (const actor of bskyResult.actors ?? []) { 47 + if (seen.has(actor.did)) continue; 48 + seen.add(actor.did); 49 + actors.push({ 50 + did: actor.did, 51 + handle: actor.handle, 52 + displayName: actor.displayName, 53 + avatar: actor.avatar, 54 + }); 55 + } 56 + 57 + return ok({ actors: actors.slice(0, limit) }); 58 + } 59 + );