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 favorites tab to own profile via getActorFavorites xrpc

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

+142 -28
+10
app/lib/queries.ts
··· 56 56 staleTime: 5 * 60_000, 57 57 }); 58 58 59 + // ─── Favorites ────────────────────────────────────────────────────── 60 + 61 + export const actorFavoritesQuery = (did: string, f?: Fetch) => 62 + queryOptions({ 63 + queryKey: ["actorFavorites", did], 64 + queryFn: () => 65 + callXrpc("social.grain.unspecced.getActorFavorites", { actor: did }, f), 66 + staleTime: 60_000, 67 + }); 68 + 59 69 // ─── Stories ──────────────────────────────────────────────────────── 60 70 61 71 export const storyAuthorsQuery = (f?: Fetch) =>
+36 -26
app/routes/profile/[did]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import FeedList from '$lib/components/organisms/FeedList.svelte' 3 2 import GalleryGrid from '$lib/components/organisms/GalleryGrid.svelte' 4 3 import Avatar from '$lib/components/atoms/Avatar.svelte' 5 4 import AvatarLightbox from '$lib/components/atoms/AvatarLightbox.svelte' ··· 8 7 import Skeleton from '$lib/components/atoms/Skeleton.svelte' 9 8 import FollowButton from '$lib/components/molecules/FollowButton.svelte' 10 9 import RichText from '$lib/components/atoms/RichText.svelte' 11 - import { ExternalLink, Grid3x3, List, Clock } from 'lucide-svelte' 10 + import { ArrowUpRight, Grid3x3, Heart, Clock } from 'lucide-svelte' 12 11 import { createQuery } from '@tanstack/svelte-query' 13 - import { actorProfileQuery, actorFeedQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 12 + import { actorProfileQuery, actorFeedQuery, actorFavoritesQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 14 13 import { viewer as viewerStore } from '$lib/stores' 15 14 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 16 15 import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' 17 16 18 17 let { data } = $props() 19 18 let lightboxSrc: string | null = $state(null) 20 - let viewMode: 'grid' | 'list' | 'stories' = $state('grid') 19 + let viewMode: 'grid' | 'favorites' | 'stories' = $state('grid') 21 20 let followersOffset = $state(0) 22 21 let showStoryViewer = $state(false) 23 22 const did = $derived(data.did) ··· 27 26 28 27 const profile = createQuery(() => actorProfileQuery(did, viewerDid)) 29 28 const feed = createQuery(() => actorFeedQuery(did)) 29 + const favorites = createQuery(() => ({ 30 + ...actorFavoritesQuery(did), 31 + enabled: isOwnProfile, 32 + })) 30 33 const stories = createQuery(() => storiesQuery(did)) 31 34 const hasStory = $derived((stories.data?.length ?? 0) > 0) 32 35 const knownFollowers = createQuery(() => ({ ··· 94 97 {/if} 95 98 <div class="links-row"> 96 99 <a class="link-pill" href="https://bsky.app/profile/{p.handle || did}" target="_blank" rel="noopener noreferrer"> 97 - Bluesky <ExternalLink size={12} /> 100 + Bluesky <ArrowUpRight size={14} /> 98 101 </a> 99 102 {#if showGermButton && germUrl} 100 - <a class="link-pill germ-pill" href={germUrl} target="_blank" rel="noopener noreferrer"> 101 - <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM 103 + <a class="link-pill" href={germUrl} target="_blank" rel="noopener noreferrer"> 104 + <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM <ArrowUpRight size={14} /> 102 105 </a> 103 106 {/if} 104 107 </div> ··· 129 132 130 133 <div class="view-toggle"> 131 134 <button class="toggle-btn" class:active={viewMode === 'grid'} onclick={() => (viewMode = 'grid')} aria-label="Grid view"> 132 - <Grid3x3 size={18} /> 133 - </button> 134 - <button class="toggle-btn" class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')} aria-label="List view"> 135 - <List size={18} /> 135 + <Grid3x3 size={20} /> 136 136 </button> 137 137 {#if isOwnProfile} 138 + <button class="toggle-btn" class:active={viewMode === 'favorites'} onclick={() => (viewMode = 'favorites')} aria-label="Favorites"> 139 + <Heart size={20} /> 140 + </button> 138 141 <button class="toggle-btn" class:active={viewMode === 'stories'} onclick={() => (viewMode = 'stories')} aria-label="Story archive"> 139 - <Clock size={18} /> 142 + <Clock size={20} /> 140 143 </button> 141 144 {/if} 142 145 </div> 143 146 144 147 {#if viewMode === 'stories' && isOwnProfile} 145 148 <StoryArchive {did} /> 146 - {:else if viewMode === 'grid'} 147 - <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 149 + {:else if viewMode === 'favorites' && isOwnProfile} 150 + <GalleryGrid items={favorites.data?.items ?? []} loading={favorites.isLoading} /> 148 151 {:else} 149 - {#if feed.isLoading} 150 - <FeedList feed="actor" params={{ actor: did }} skeleton /> 151 - {:else} 152 - <FeedList feed="actor" params={{ actor: did }} initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 153 - {/if} 152 + <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 154 153 {/if} 155 154 156 155 {#if showStoryViewer} ··· 209 208 font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.12s; 210 209 } 211 210 .link-pill:hover { background: var(--bg-hover); color: var(--text-primary); } 212 - .germ-pill { color: var(--grain); border-color: var(--grain); } 213 - .germ-pill:hover { color: var(--text-primary); } 214 211 .germ-logo { width: 14px; height: 14px; object-fit: contain; } 215 212 .view-toggle { 216 213 display: flex; 217 214 justify-content: center; 218 215 gap: 4px; 219 - padding: 10px 16px; 216 + padding: 8px 16px; 220 217 border-bottom: 1px solid var(--border); 221 218 } 222 219 .toggle-btn { 223 220 display: flex; 224 221 align-items: center; 225 222 justify-content: center; 226 - padding: 6px 16px; 223 + padding: 8px 16px; 227 224 background: none; 228 225 border: none; 229 226 color: var(--text-muted); 230 227 cursor: pointer; 231 - border-bottom: 2px solid transparent; 232 - transition: color 0.15s, border-bottom-color 0.15s; 228 + position: relative; 229 + transition: color 0.15s; 230 + } 231 + .toggle-btn::after { 232 + content: ''; 233 + position: absolute; 234 + bottom: 0; 235 + left: 50%; 236 + transform: translateX(-50%); 237 + width: 28px; 238 + height: 2.5px; 239 + border-radius: 2px; 240 + background: transparent; 241 + transition: background 0.15s; 233 242 } 234 243 .toggle-btn:hover { color: var(--text-secondary); } 235 - .toggle-btn.active { color: var(--text-primary); border-bottom-color: var(--text-primary); } 244 + .toggle-btn.active { color: var(--text-primary); } 245 + .toggle-btn.active::after { background: var(--grain); } 236 246 .not-found { text-align: center; color: var(--text-muted); padding: 48px 16px; font-size: 14px; } 237 247 </style>
+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, 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, 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' 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'])
+5 -1
hatk.generated.ts
··· 58 58 const photoLex = {"lexicon":1,"id":"social.grain.photo","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["photo","aspectRatio","createdAt"],"properties":{"photo":{"type":"blob","accept":["image/*"],"maxSize":1000000},"alt":{"type":"string","description":"Alt text description of the image, for accessibility."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 59 59 const grainPhotoDefsLex = {"lexicon":1,"id":"social.grain.photo.defs","defs":{"photoView":{"type":"object","required":["uri","cid","thumb","fullsize","aspectRatio"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"thumb":{"type":"string","format":"uri","description":"Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View."},"fullsize":{"type":"string","format":"uri","description":"Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View."},"alt":{"type":"string","description":"Alt text description of the image, for accessibility."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"exif":{"type":"ref","ref":"social.grain.photo.defs#exifView","description":"EXIF metadata for the photo, if available."},"gallery":{"type":"ref","ref":"#galleryState"}}},"exifView":{"type":"object","required":["uri","cid","photo","record","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"photo":{"type":"string","format":"at-uri"},"record":{"type":"unknown"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string"},"exposureTime":{"type":"string"},"fNumber":{"type":"string"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"string"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}},"galleryState":{"type":"object","required":["item","itemCreatedAt","itemPosition"],"description":"Metadata about the photo's relationship with the subject content. Only has meaningful content when photo is attached to a gallery.","properties":{"item":{"type":"string","format":"at-uri"},"itemCreatedAt":{"type":"string","format":"datetime"},"itemPosition":{"type":"integer"}}}}} as const 60 60 const exifLex = {"lexicon":1,"id":"social.grain.photo.exif","defs":{"main":{"type":"record","description":"Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.","key":"tid","record":{"type":"object","required":["photo","createdAt"],"properties":{"photo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string","format":"datetime"},"exposureTime":{"type":"integer"},"fNumber":{"type":"integer"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"integer"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}}}}} as const 61 - const storyLex = {"lexicon":1,"id":"social.grain.story","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["media","aspectRatio","createdAt"],"properties":{"media":{"type":"blob","accept":["image/*","video/*"],"maxSize":5000000},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 61 + const storyLex = {"lexicon":1,"id":"social.grain.story","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["media","aspectRatio","createdAt"],"properties":{"media":{"type":"blob","accept":["image/*","video/*"],"maxSize":5000000},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"labels":{"type":"union","description":"Self-label values for this story. Effectively content warnings.","refs":["com.atproto.label.defs#selfLabels"]},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 62 62 const grainStoryDefsLex = {"lexicon":1,"id":"social.grain.story.defs","defs":{"storyView":{"type":"object","required":["uri","cid","creator","thumb","fullsize","aspectRatio","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"creator":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"thumb":{"type":"string","format":"uri","description":"Thumbnail URL for the story image."},"fullsize":{"type":"string","format":"uri","description":"Full-size URL for the story image."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"createdAt":{"type":"string","format":"datetime"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}},"crossPost":{"type":"ref","ref":"social.grain.gallery.defs#crossPostInfo"}}}}} as const 63 63 const deleteGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.deleteGallery","defs":{"main":{"type":"procedure","description":"Delete a gallery and all associated records (items, photos, EXIF, favorites, comments).","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey"],"properties":{"rkey":{"type":"string","description":"Record key of the gallery to delete."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 64 + const getActorFavoritesLex = {"lexicon":1,"id":"social.grain.unspecced.getActorFavorites","defs":{"main":{"type":"query","description":"Get galleries favorited by the authenticated actor. Only the actor themselves can view their favorites.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}},"cursor":{"type":"string"}}}}}}} as const 64 65 const getActorProfileLex = {"lexicon":1,"id":"social.grain.unspecced.getActorProfile","defs":{"main":{"type":"query","description":"Get an actor's profile with gallery stats and follow relationships.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"social.grain.actor.defs#profileViewDetailed"}}}}} as const 65 66 const getCamerasLex = {"lexicon":1,"id":"social.grain.unspecced.getCameras","defs":{"main":{"type":"query","description":"Get top cameras by photo count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"cameras":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getCameras#cameraItem"}}}}}},"cameraItem":{"type":"object","required":["camera","photoCount"],"properties":{"camera":{"type":"string"},"photoCount":{"type":"integer"}}}}} as const 66 67 const getFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowers","defs":{"main":{"type":"query","description":"Get followers for a given user.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowers#followerItem"}},"cursor":{"type":"string"}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowers#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const ··· 136 137 'social.grain.story': typeof storyLex 137 138 'social.grain.story.defs': typeof grainStoryDefsLex 138 139 'social.grain.unspecced.deleteGallery': typeof deleteGalleryLex 140 + 'social.grain.unspecced.getActorFavorites': typeof getActorFavoritesLex 139 141 'social.grain.unspecced.getActorProfile': typeof getActorProfileLex 140 142 'social.grain.unspecced.getCameras': typeof getCamerasLex 141 143 'social.grain.unspecced.getFollowers': typeof getFollowersLex ··· 184 186 export type Exif = Prettify<LexRecord<typeof exifLex, Registry>> 185 187 export type Story = Prettify<LexRecord<typeof storyLex, Registry>> 186 188 export type DeleteGallery = Prettify<LexProcedure<typeof deleteGalleryLex, Registry>> 189 + export type GetActorFavorites = Prettify<LexQuery<typeof getActorFavoritesLex, Registry>> 187 190 export type GetActorProfile = Prettify<LexQuery<typeof getActorProfileLex, Registry>> 188 191 export type GetCameras = Prettify<LexQuery<typeof getCamerasLex, Registry>> 189 192 export type GetFollowers = Prettify<LexQuery<typeof getFollowersLex, Registry>> ··· 389 392 'dev.hatk.searchRecords': SearchRecords 390 393 'dev.hatk.uploadBlob': UploadBlob 391 394 'social.grain.unspecced.deleteGallery': DeleteGallery 395 + 'social.grain.unspecced.getActorFavorites': GetActorFavorites 392 396 'social.grain.unspecced.getActorProfile': GetActorProfile 393 397 'social.grain.unspecced.getCameras': GetCameras 394 398 'social.grain.unspecced.getFollowers': GetFollowers
+33
lexicons/social/grain/unspecced/getActorFavorites.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.getActorFavorites", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get galleries favorited by the authenticated actor. Only the actor themselves can view their favorites.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { "type": "string", "format": "did" }, 13 + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, 14 + "cursor": { "type": "string" } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["items"], 22 + "properties": { 23 + "items": { 24 + "type": "array", 25 + "items": { "type": "ref", "ref": "social.grain.gallery.defs#galleryView" } 26 + }, 27 + "cursor": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+57
server/xrpc/getActorFavorites.ts
··· 1 + import { defineQuery } from "$hatk"; 2 + import type { Gallery } from "$hatk"; 3 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 4 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 5 + 6 + export default defineQuery("social.grain.unspecced.getActorFavorites", async (ctx) => { 7 + const { db, ok, getRecords } = ctx; 8 + const actor = ctx.params.actor; 9 + if (!actor) return ok({ items: [] }); 10 + 11 + // Only the actor themselves can view their favorites 12 + if (ctx.viewer?.did !== actor) return ok({ items: [] }); 13 + 14 + const limit = Math.min(Number(ctx.params.limit) || 30, 100); 15 + const cursor = ctx.params.cursor as string | undefined; 16 + 17 + const queryParams: (string | number)[] = [actor, limit + 1]; 18 + let cursorClause = ""; 19 + if (cursor) { 20 + cursorClause = ` AND f.created_at < $3`; 21 + queryParams.push(cursor); 22 + } 23 + 24 + const rows = (await db.query( 25 + `SELECT f.subject, f.created_at 26 + FROM "social.grain.favorite" f 27 + JOIN "social.grain.gallery" t ON t.uri = f.subject 28 + LEFT JOIN _repos r ON t.did = r.did 29 + WHERE f.did = $1 30 + AND (r.status IS NULL OR r.status != 'takendown') 31 + AND ${hideLabelsFilter("t.uri")} 32 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 33 + ${cursorClause} 34 + ORDER BY f.created_at DESC 35 + LIMIT $2`, 36 + queryParams, 37 + )) as { subject: string; created_at: string }[]; 38 + 39 + const hasMore = rows.length > limit; 40 + const pageRows = hasMore ? rows.slice(0, limit) : rows; 41 + 42 + const recordsMap = await getRecords<Gallery>( 43 + "social.grain.gallery", 44 + pageRows.map((r) => r.subject), 45 + ); 46 + 47 + // Preserve favorite ordering 48 + const galleryRows = pageRows 49 + .map((r) => recordsMap.get(r.subject)) 50 + .filter((r) => r != null); 51 + 52 + const items = await hydrateGalleries(ctx, galleryRows); 53 + 54 + const nextCursor = hasMore ? pageRows[pageRows.length - 1].created_at : undefined; 55 + 56 + return ok({ items, ...(nextCursor ? { cursor: nextCursor } : {}) }); 57 + });