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: add Leaflet mention service integration

Add mention search XRPC handler, embeddable gallery card endpoint,
did:web DID document, and lexicon for parts.page.mention.search.
Searching in Leaflet returns grain users with gallery subscopes,
and selecting a gallery returns an OG collage embed.

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

+287 -1
+22
app/routes/.well-known/did.json/+server.ts
··· 1 + import type { RequestHandler } from "./$types"; 2 + 3 + const DID_DOCUMENT = { 4 + "@context": ["https://www.w3.org/ns/did/v1"], 5 + id: "did:web:grain.social", 6 + service: [ 7 + { 8 + id: "#mention_search", 9 + type: "MentionSearchService", 10 + serviceEndpoint: "https://grain.social", 11 + }, 12 + ], 13 + }; 14 + 15 + export const GET: RequestHandler = async () => { 16 + return new Response(JSON.stringify(DID_DOCUMENT, null, 2), { 17 + headers: { 18 + "Content-Type": "application/json", 19 + "Cache-Control": "public, max-age=3600", 20 + }, 21 + }); 22 + };
+34
app/routes/embed/gallery/[did]/[rkey]/+server.ts
··· 1 + import type { RequestHandler } from "./$types"; 2 + 3 + export const GET: RequestHandler = async ({ params, url }) => { 4 + const { did, rkey } = params; 5 + const galleryUrl = `/profile/${did}/gallery/${rkey}`; 6 + const ogImageUrl = `${url.origin}/og/profile/${did}/gallery/${rkey}`; 7 + 8 + const html = `<!DOCTYPE html> 9 + <html> 10 + <head> 11 + <meta charset="utf-8"> 12 + <meta name="viewport" content="width=device-width,initial-scale=1"> 13 + <style> 14 + * { margin: 0; padding: 0; box-sizing: border-box; } 15 + body { background: #fff; overflow: hidden; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; } 16 + a { display: flex; max-width: 100%; max-height: 100%; } 17 + img { max-width: 100%; max-height: 100vh; object-fit: contain; } 18 + </style> 19 + </head> 20 + <body> 21 + <a href="${galleryUrl}" target="_top"> 22 + <img src="${ogImageUrl}" alt="Gallery" /> 23 + </a> 24 + </body> 25 + </html>`; 26 + 27 + return new Response(html, { 28 + headers: { 29 + "Content-Type": "text/html", 30 + "X-Frame-Options": "ALLOWALL", 31 + "Content-Security-Policy": "frame-ancestors *", 32 + }, 33 + }); 34 + };
+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, GetActorFavorites, 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' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorFavorites, 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, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, 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'])
+9
hatk.generated.ts
··· 45 45 const putRecordLex = {"lexicon":1,"id":"dev.hatk.putRecord","defs":{"main":{"type":"procedure","description":"Create or update a record via the user's PDS.","input":{"encoding":"application/json","schema":{"type":"object","required":["collection","rkey","record"],"properties":{"collection":{"type":"string"},"rkey":{"type":"string"},"record":{"type":"unknown"},"repo":{"type":"string","format":"did"}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"}}}}}}} as const 46 46 const searchRecordsLex = {"lexicon":1,"id":"dev.hatk.searchRecords","defs":{"main":{"type":"query","description":"Full-text search across a collection.","parameters":{"type":"params","required":["collection","q"],"properties":{"collection":{"type":"string"},"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"fuzzy":{"type":"boolean","default":true}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"unknown"}},"cursor":{"type":"string"}}}}}}} as const 47 47 const uploadBlobLex = {"lexicon":1,"id":"dev.hatk.uploadBlob","defs":{"main":{"type":"procedure","description":"Upload a blob via the user's PDS.","input":{"encoding":"*/*"},"output":{"encoding":"application/json","schema":{"type":"object","required":["blob"],"properties":{"blob":{"type":"blob"}}}}}}} as const 48 + const searchLex = {"lexicon":1,"id":"parts.page.mention.search","defs":{"main":{"type":"query","description":"Search a mention service for matching results.","parameters":{"type":"params","required":["service","search"],"properties":{"service":{"type":"string","format":"at-uri","description":"AT URI of the parts.page.mention.service record"},"search":{"type":"string","description":"Search query string"},"scope":{"type":"string","description":"Optional scope identifier to narrow results"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":20}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"type":"ref","ref":"#result"},"maxLength":50}}}}},"result":{"type":"object","required":["uri","name"],"properties":{"uri":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"labels":{"type":"array","items":{"type":"ref","ref":"#mentionLabel"}},"href":{"type":"string","format":"uri"},"icon":{"type":"string","format":"uri"},"embed":{"type":"ref","ref":"#embedInfo"},"subscope":{"type":"ref","ref":"#subscopeInfo"}}},"mentionLabel":{"type":"object","properties":{"text":{"type":"string"}}},"embedInfo":{"type":"object","required":["src"],"properties":{"src":{"type":"string","format":"uri"},"width":{"type":"integer","minimum":16,"maximum":3200},"height":{"type":"integer","minimum":16,"maximum":3200},"aspectRatio":{"type":"ref","ref":"#aspectRatio"}}},"aspectRatio":{"type":"object","required":["width","height"],"properties":{"width":{"type":"integer"},"height":{"type":"integer"}}},"subscopeInfo":{"type":"object","required":["scope","label"],"properties":{"scope":{"type":"string"},"label":{"type":"string","maxLength":100}}}}} as const 48 49 const grainActorDefsLex = {"lexicon":1,"id":"social.grain.actor.defs","defs":{"profileView":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxLength":2560,"maxGraphemes":256},"labels":{"type":"array","items":{"ref":"com.atproto.label.defs#label","type":"ref"}},"avatar":{"type":"string","format":"uri"},"createdAt":{"type":"string","format":"datetime"}}},"profileViewDetailed":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxGraphemes":256,"maxLength":2560},"avatar":{"type":"string","format":"uri"},"cameras":{"type":"array","items":{"type":"string"},"description":"List of camera make and models used by this actor derived from EXIF data of photos linked to galleries."},"followersCount":{"type":"integer"},"followsCount":{"type":"integer"},"galleryCount":{"type":"integer"},"indexedAt":{"type":"string","format":"datetime"},"createdAt":{"type":"string","format":"datetime"},"messageMe":{"type":"ref","ref":"#messageMe"},"viewer":{"type":"ref","ref":"#viewerState"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}}}},"messageMe":{"type":"object","required":["showButtonTo","messageMeUrl"],"properties":{"messageMeUrl":{"type":"string","format":"uri"},"showButtonTo":{"type":"string","knownValues":["usersIFollow","everyone"]}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.","properties":{"following":{"type":"string","format":"at-uri"},"followedBy":{"type":"string","format":"at-uri"}}}}} as const 49 50 const grainActorProfileLex = {"lexicon":1,"id":"social.grain.actor.profile","defs":{"main":{"type":"record","description":"A declaration of a basic account profile.","key":"literal:self","record":{"type":"object","properties":{"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","description":"Free-form profile description text.","maxGraphemes":256,"maxLength":2560},"avatar":{"type":"blob","description":"Small image to be displayed next to posts from account. AKA, 'profile picture'","accept":["image/png","image/jpeg"],"maxSize":1000000},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 50 51 const commentLex = {"lexicon":1,"id":"social.grain.comment","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["text","subject","createdAt"],"properties":{"text":{"type":"string","maxLength":3000,"maxGraphemes":300},"facets":{"type":"array","description":"Annotations of description text (mentions and URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"subject":{"type":"string","format":"at-uri"},"focus":{"type":"string","format":"at-uri"},"replyTo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const ··· 121 122 'dev.hatk.putRecord': typeof putRecordLex 122 123 'dev.hatk.searchRecords': typeof searchRecordsLex 123 124 'dev.hatk.uploadBlob': typeof uploadBlobLex 125 + 'parts.page.mention.search': typeof searchLex 124 126 'social.grain.actor.defs': typeof grainActorDefsLex 125 127 'social.grain.actor.profile': typeof grainActorProfileLex 126 128 'social.grain.comment': typeof commentLex ··· 176 178 export type PutPreference = Prettify<LexProcedure<typeof putPreferenceLex, Registry>> 177 179 export type SearchRecords = Prettify<LexQuery<typeof searchRecordsLex, Registry>> 178 180 export type UploadBlob = Prettify<LexProcedure<typeof uploadBlobLex, Registry>> 181 + export type Search = Prettify<LexQuery<typeof searchLex, Registry>> 179 182 export type GrainActorProfile = Prettify<LexRecord<typeof grainActorProfileLex, Registry>> 180 183 export type Comment = Prettify<LexRecord<typeof commentLex, Registry>> 181 184 export type Favorite = Prettify<LexRecord<typeof favoriteLex, Registry>> ··· 349 352 export type RepoRef = Prettify<LexDef<typeof createReportLex, 'repoRef', Registry>> 350 353 export type LabelDefinition = Prettify<LexDef<typeof describeLabelsLex, 'labelDefinition', Registry>> 351 354 export type LabelLocale = Prettify<LexDef<typeof describeLabelsLex, 'labelLocale', Registry>> 355 + export type Result = Prettify<LexDef<typeof searchLex, 'result', Registry>> 356 + export type MentionLabel = Prettify<LexDef<typeof searchLex, 'mentionLabel', Registry>> 357 + export type EmbedInfo = Prettify<LexDef<typeof searchLex, 'embedInfo', Registry>> 358 + export type SearchAspectRatio = Prettify<LexDef<typeof searchLex, 'aspectRatio', Registry>> 359 + export type SubscopeInfo = Prettify<LexDef<typeof searchLex, 'subscopeInfo', Registry>> 352 360 export type GrainActorDefsProfileView = Prettify<LexDef<typeof grainActorDefsLex, 'profileView', Registry>> 353 361 export type GrainActorDefsProfileViewDetailed = Prettify<LexDef<typeof grainActorDefsLex, 'profileViewDetailed', Registry>> 354 362 export type GrainActorDefsMessageMe = Prettify<LexDef<typeof grainActorDefsLex, 'messageMe', Registry>> ··· 391 399 'dev.hatk.putRecord': PutRecord 392 400 'dev.hatk.searchRecords': SearchRecords 393 401 'dev.hatk.uploadBlob': UploadBlob 402 + 'parts.page.mention.search': Search 394 403 'social.grain.unspecced.deleteGallery': DeleteGallery 395 404 'social.grain.unspecced.getActorFavorites': GetActorFavorites 396 405 'social.grain.unspecced.getActorProfile': GetActorProfile
+98
lexicons/parts/page/mention/search.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "parts.page.mention.search", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Search a mention service for matching results.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["service", "search"], 11 + "properties": { 12 + "service": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "AT URI of the parts.page.mention.service record" 16 + }, 17 + "search": { 18 + "type": "string", 19 + "description": "Search query string" 20 + }, 21 + "scope": { 22 + "type": "string", 23 + "description": "Optional scope identifier to narrow results" 24 + }, 25 + "limit": { 26 + "type": "integer", 27 + "minimum": 1, 28 + "maximum": 50, 29 + "default": 20 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["results"], 38 + "properties": { 39 + "results": { 40 + "type": "array", 41 + "items": { "type": "ref", "ref": "#result" }, 42 + "maxLength": 50 43 + } 44 + } 45 + } 46 + } 47 + }, 48 + "result": { 49 + "type": "object", 50 + "required": ["uri", "name"], 51 + "properties": { 52 + "uri": { "type": "string" }, 53 + "name": { "type": "string" }, 54 + "description": { "type": "string" }, 55 + "labels": { 56 + "type": "array", 57 + "items": { "type": "ref", "ref": "#mentionLabel" } 58 + }, 59 + "href": { "type": "string", "format": "uri" }, 60 + "icon": { "type": "string", "format": "uri" }, 61 + "embed": { "type": "ref", "ref": "#embedInfo" }, 62 + "subscope": { "type": "ref", "ref": "#subscopeInfo" } 63 + } 64 + }, 65 + "mentionLabel": { 66 + "type": "object", 67 + "properties": { 68 + "text": { "type": "string" } 69 + } 70 + }, 71 + "embedInfo": { 72 + "type": "object", 73 + "required": ["src"], 74 + "properties": { 75 + "src": { "type": "string", "format": "uri" }, 76 + "width": { "type": "integer", "minimum": 16, "maximum": 3200 }, 77 + "height": { "type": "integer", "minimum": 16, "maximum": 3200 }, 78 + "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } 79 + } 80 + }, 81 + "aspectRatio": { 82 + "type": "object", 83 + "required": ["width", "height"], 84 + "properties": { 85 + "width": { "type": "integer" }, 86 + "height": { "type": "integer" } 87 + } 88 + }, 89 + "subscopeInfo": { 90 + "type": "object", 91 + "required": ["scope", "label"], 92 + "properties": { 93 + "scope": { "type": "string" }, 94 + "label": { "type": "string", "maxLength": 100 } 95 + } 96 + } 97 + } 98 + }
+123
server/xrpc/mentionSearch.ts
··· 1 + import { defineQuery, type GrainActorProfile, type Photo, type Gallery } from "$hatk"; 2 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 3 + 4 + const isProd = process.env.NODE_ENV === "production"; 5 + const prodDomain = process.env.RAILWAY_PUBLIC_DOMAIN; 6 + const baseUrl = isProd && prodDomain ? `https://${prodDomain}` : "http://127.0.0.1:3000"; 7 + 8 + export default defineQuery("parts.page.mention.search", async (ctx) => { 9 + const { params, search, db, blobUrl, ok } = ctx; 10 + const { search: query, scope, limit = 20 } = params; 11 + 12 + // Scoped to a user DID → search their galleries 13 + if (scope) { 14 + return searchGalleries(ctx, query, scope, limit); 15 + } 16 + 17 + // Default → search users 18 + return searchUsers(ctx, query, limit); 19 + }); 20 + 21 + async function searchUsers(ctx: any, query: string, limit: number) { 22 + const { search, blobUrl, ok } = ctx; 23 + 24 + if (!query.trim()) return ok({ results: [] }); 25 + 26 + const result = await search("social.grain.actor.profile", query, { limit, fuzzy: true }); 27 + const items = await ctx.resolve(result.records.map((r: any) => r.uri)); 28 + 29 + const results = items.map((item: any) => ({ 30 + uri: `at://${item.did}/social.grain.actor.profile/self`, 31 + name: item.value.displayName || item.handle || item.did, 32 + description: item.value.description || undefined, 33 + href: `${baseUrl}/profile/${item.did}`, 34 + icon: blobUrl(item.did, item.value.avatar, "avatar") || undefined, 35 + subscope: { 36 + scope: item.did, 37 + label: "Galleries", 38 + }, 39 + })); 40 + 41 + return ok({ results }); 42 + } 43 + 44 + async function searchGalleries(ctx: any, query: string, did: string, limit: number) { 45 + const { db, blobUrl, ok } = ctx; 46 + 47 + // If query is empty, show all galleries by this user 48 + let galleryRows: { uri: string; cid: string; did: string; title: string; description: string; created_at: string }[]; 49 + 50 + if (!query.trim()) { 51 + galleryRows = (await db.query( 52 + `SELECT t.uri, t.cid, t.did, t.title, t.description, t.created_at 53 + FROM "social.grain.gallery" t 54 + LEFT JOIN _repos r ON t.did = r.did 55 + WHERE t.did = $1 56 + AND (r.status IS NULL OR r.status != 'takendown') 57 + AND ${hideLabelsFilter("t.uri")} 58 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 59 + ORDER BY t.created_at DESC 60 + LIMIT $2`, 61 + [did, limit], 62 + )) as typeof galleryRows; 63 + } else { 64 + const result = await ctx.search("social.grain.gallery", query, { limit, fuzzy: true }); 65 + // Filter to only this user's galleries 66 + galleryRows = result.records 67 + .filter((r: any) => r.did === did) 68 + .map((r: any) => ({ 69 + uri: r.uri, 70 + cid: r.cid, 71 + did: r.did, 72 + title: r.value.title, 73 + description: r.value.description, 74 + created_at: r.value.createdAt, 75 + })); 76 + } 77 + 78 + if (galleryRows.length === 0) return ok({ results: [] }); 79 + 80 + // Get first photo for each gallery (for icon/thumbnail) 81 + const galleryUris = galleryRows.map((r) => r.uri); 82 + const itemRows = (await db.query( 83 + `SELECT gi.gallery, gi.item 84 + FROM "social.grain.gallery.item" gi 85 + WHERE gi.gallery IN (${galleryUris.map((_: any, i: number) => `$${i + 1}`).join(",")}) 86 + ORDER BY gi.position ASC`, 87 + galleryUris, 88 + )) as { gallery: string; item: string }[]; 89 + 90 + const firstPhotoUri = new Map<string, string>(); 91 + for (const row of itemRows) { 92 + if (!firstPhotoUri.has(row.gallery)) firstPhotoUri.set(row.gallery, row.item); 93 + } 94 + 95 + const photoUris = [...new Set(firstPhotoUri.values())]; 96 + const photos = 97 + photoUris.length > 0 98 + ? await ctx.getRecords("social.grain.photo", photoUris) 99 + : new Map(); 100 + 101 + const results = galleryRows.map((gallery) => { 102 + const photoUri = firstPhotoUri.get(gallery.uri); 103 + const photo = photoUri ? photos.get(photoUri) : null; 104 + const thumb = photo ? blobUrl(photo.did, photo.value.photo, "feed_thumbnail") : undefined; 105 + const rkey = gallery.uri.split("/").pop(); 106 + const galleryUrl = `${baseUrl}/profile/${gallery.did}/gallery/${rkey}`; 107 + const embedUrl = `${baseUrl}/embed/gallery/${gallery.did}/${rkey}`; 108 + 109 + return { 110 + uri: gallery.uri, 111 + name: gallery.title, 112 + description: gallery.description || undefined, 113 + href: galleryUrl, 114 + icon: thumb || undefined, 115 + embed: { 116 + src: embedUrl, 117 + aspectRatio: { width: 16, height: 9 }, 118 + }, 119 + }; 120 + }); 121 + 122 + return ok({ results }); 123 + }