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 comment favorites with notifications

Add ability to favorite comments using the existing social.grain.favorite
lexicon. Adds favCount and viewer state to the comment view, hydrates
favorites in getCommentThread, sends push notifications for comment
favorites, and includes comment-favorite in the notification feed with
grouping support.

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

+192 -19
+2 -1
app/lib/components/atoms/NotificationItem.svelte
··· 15 15 'gallery-comment': 'commented on your gallery', 16 16 'gallery-comment-mention': 'mentioned you in a comment', 17 17 'gallery-mention': 'mentioned you in a gallery', 18 + 'comment-favorite': 'favorited your comment', 18 19 'story-favorite': 'favorited your story', 19 20 'story-comment': 'commented on your story', 20 21 'reply': 'replied to your comment', 21 22 'follow': 'followed you', 22 23 } 23 24 24 - const isFavorite = $derived(notif.reason === 'gallery-favorite' || notif.reason === 'story-favorite') 25 + const isFavorite = $derived(notif.reason === 'gallery-favorite' || notif.reason === 'story-favorite' || notif.reason === 'comment-favorite') 25 26 const isFollow = $derived(notif.reason === 'follow') 26 27 const isComment = $derived(notif.reason === 'gallery-comment' || notif.reason === 'story-comment') 27 28 const isReply = $derived(notif.reason === 'reply')
+76 -2
app/lib/components/molecules/Comment.svelte
··· 1 1 <script lang="ts"> 2 2 import type { CommentView } from '$hatk/client' 3 + import { callXrpc } from '$hatk/client' 4 + import { createMutation, useQueryClient } from '@tanstack/svelte-query' 3 5 import Avatar from '../atoms/Avatar.svelte' 4 6 import RichText from '../atoms/RichText.svelte' 5 7 import { relativeTime } from '$lib/utils' 6 - import { viewer } from '$lib/stores' 7 - import { VolumeX } from 'lucide-svelte' 8 + import { viewer, requireAuth } from '$lib/stores' 9 + import { VolumeX, Heart } from 'lucide-svelte' 8 10 9 11 let { 10 12 comment, ··· 22 24 const timeStr = $derived(relativeTime(comment.createdAt || '')) 23 25 const isReply = $derived(!!comment.replyTo) 24 26 const isMuted = $derived(!!comment.muted && !expanded) 27 + 28 + // Comment favorite state 29 + let favOverride: string | null | undefined = $state(undefined) 30 + const viewerFav = $derived(comment.viewer?.fav ?? null) 31 + const favUri = $derived(favOverride !== undefined ? favOverride : viewerFav) 32 + const isFaved = $derived(!!favUri) 33 + const originallyFaved = $derived(!!viewerFav) 34 + const serverFavCount = $derived(comment.favCount ?? 0) 35 + const countOffset = $derived(isFaved === originallyFaved ? 0 : isFaved ? 1 : -1) 36 + const displayFavCount = $derived(serverFavCount + countOffset) 37 + 38 + const queryClient = useQueryClient() 39 + 40 + const createFavMut = createMutation(() => ({ 41 + mutationFn: async () => { 42 + return await callXrpc('dev.hatk.createRecord', { 43 + collection: 'social.grain.favorite', 44 + record: { subject: comment.uri, createdAt: new Date().toISOString() }, 45 + }) 46 + }, 47 + onMutate: () => { favOverride = 'pending' }, 48 + onSuccess: (data: any) => { favOverride = data.uri ?? null }, 49 + onError: () => { favOverride = undefined }, 50 + })) 51 + 52 + const deleteFavMut = createMutation<void, Error, string>(() => ({ 53 + mutationFn: async (uri) => { 54 + const rkey = uri.split('/').pop()! 55 + await callXrpc('dev.hatk.deleteRecord', { collection: 'social.grain.favorite', rkey }) 56 + }, 57 + onMutate: () => { 58 + const prev = favOverride !== undefined ? favOverride : viewerFav 59 + favOverride = null 60 + return { prev } 61 + }, 62 + onSuccess: () => {}, 63 + onError: (_err: any, _vars: any, context: any) => { favOverride = context?.prev ?? undefined }, 64 + })) 65 + 66 + function toggleFav() { 67 + if (createFavMut.isPending || deleteFavMut.isPending) return 68 + if (!requireAuth()) return 69 + if (isFaved && favUri && favUri !== 'pending') { 70 + deleteFavMut.mutate(favUri) 71 + } else if (!isFaved) { 72 + createFavMut.mutate() 73 + } 74 + } 25 75 </script> 26 76 27 77 {#if comment.muted && !expanded} ··· 41 91 </div> 42 92 <div class="meta"> 43 93 <span class="time">{timeStr}</span> 94 + {#if displayFavCount > 0} 95 + <span class="fav-count">{displayFavCount} {displayFavCount === 1 ? 'fav' : 'favs'}</span> 96 + {/if} 44 97 {#if onReply} 45 98 <button class="meta-btn" onclick={() => onReply?.(comment.replyTo ?? comment.uri, comment.author?.handle ?? '')}>Reply</button> 46 99 {/if} ··· 52 105 {#if comment.focus?.thumb} 53 106 <img class="focus-thumb" src={comment.focus.thumb} alt={comment.focus?.alt ?? ''} /> 54 107 {/if} 108 + <button class="fav-btn" class:faved={isFaved} onclick={toggleFav} title={isFaved ? 'Unfavorite' : 'Favorite'}> 109 + <Heart size={16} fill={isFaved ? 'currentColor' : 'none'} /> 110 + </button> 55 111 </div> 56 112 {/if} 57 113 ··· 122 178 font-family: inherit; 123 179 } 124 180 .muted-toggle:hover { color: var(--text-secondary); } 181 + .fav-btn { 182 + display: flex; 183 + align-items: flex-start; 184 + padding: 10px 0 0 0; 185 + background: none; 186 + border: none; 187 + color: var(--text-muted); 188 + cursor: pointer; 189 + flex-shrink: 0; 190 + transition: color 0.15s; 191 + } 192 + .fav-btn:hover { color: var(--text-secondary); } 193 + .fav-btn.faved { color: #f87171; } 194 + .fav-count { 195 + font-size: 12px; 196 + color: var(--text-muted); 197 + font-weight: 600; 198 + } 125 199 </style>
+1
app/lib/notifications.ts
··· 9 9 const GROUPABLE_REASONS = new Set([ 10 10 "gallery-favorite", 11 11 "story-favorite", 12 + "comment-favorite", 12 13 "follow", 13 14 ]); 14 15
+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, ApplyWrites, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetCommentThread, GetFollowers, GetFollowing, GetGallery, GetKnownFollowers, GetLocations, GetMutes, 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, Create, Update, Delete, CreateResult, UpdateResult, DeleteResult, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, GrainStoryDefsViewerState, BlockItem, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, MuteItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, ApplyWrites, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetCommentThread, GetFollowers, GetFollowing, GetGallery, GetKnownFollowers, GetLocations, GetMutes, 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, Create, Update, Delete, CreateResult, UpdateResult, DeleteResult, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainCommentDefsViewerState, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, GrainStoryDefsViewerState, BlockItem, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, MuteItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 8 const _procedures = new Set(['dev.hatk.applyWrites', 'dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.graph.muteActor', 'social.grain.graph.unmuteActor', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+3 -2
hatk.generated.ts
··· 50 50 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"},"blocking":{"type":"string","format":"at-uri"},"blockedBy":{"type":"boolean"},"muted":{"type":"boolean"}}}}} as const 51 51 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 52 52 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 53 - const grainCommentDefsLex = {"lexicon":1,"id":"social.grain.comment.defs","defs":{"commentView":{"type":"object","required":["uri","cid","author","text","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"record":{"type":"unknown"},"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":"union","refs":["social.grain.gallery.defs#galleryView"],"description":"The subject of the comment, which can be a gallery or a photo."},"focus":{"type":"union","refs":["social.grain.photo.defs#photoView"],"description":"The photo that the comment is focused on, if applicable."},"replyTo":{"type":"string","format":"at-uri","description":"The URI of the comment this comment is replying to, if applicable."},"createdAt":{"type":"string","format":"datetime"},"muted":{"type":"boolean","description":"True if the viewer has muted the comment author. Client should show collapsed with option to expand."}}}}} as const 53 + const grainCommentDefsLex = {"lexicon":1,"id":"social.grain.comment.defs","defs":{"commentView":{"type":"object","required":["uri","cid","author","text","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"record":{"type":"unknown"},"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":"union","refs":["social.grain.gallery.defs#galleryView"],"description":"The subject of the comment, which can be a gallery or a photo."},"focus":{"type":"union","refs":["social.grain.photo.defs#photoView"],"description":"The photo that the comment is focused on, if applicable."},"replyTo":{"type":"string","format":"at-uri","description":"The URI of the comment this comment is replying to, if applicable."},"createdAt":{"type":"string","format":"datetime"},"favCount":{"type":"integer"},"viewer":{"type":"ref","ref":"#viewerState"},"muted":{"type":"boolean","description":"True if the viewer has muted the comment author. Client should show collapsed with option to expand."}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the comment. Only has meaningful content for authed requests.","properties":{"fav":{"type":"string","format":"at-uri"}}}}} as const 54 54 const grainDefsLex = {"lexicon":1,"id":"social.grain.defs","defs":{"aspectRatio":{"type":"object","description":"width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.","required":["width","height"],"properties":{"width":{"type":"integer","minimum":1},"height":{"type":"integer","minimum":1}}}}} as const 55 55 const favoriteLex = {"lexicon":1,"id":"social.grain.favorite","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["createdAt","subject"],"properties":{"createdAt":{"type":"string","format":"datetime"},"subject":{"type":"string","format":"at-uri"}}}}}} as const 56 56 const galleryLex = {"lexicon":1,"id":"social.grain.gallery","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["title","createdAt"],"properties":{"title":{"type":"string","maxLength":100},"description":{"type":"string","maxLength":1000},"facets":{"type":"array","description":"Annotations of description text (mentions, URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"labels":{"type":"union","description":"Self-label values for this post. Effectively content warnings.","refs":["com.atproto.label.defs#selfLabels"]},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"updatedAt":{"type":"string","format":"datetime"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const ··· 77 77 const getKnownFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getKnownFollowers","defs":{"main":{"type":"query","description":"Get followers of a given actor that the viewer also follows.","parameters":{"type":"params","required":["actor","viewer"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":50}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getKnownFollowers#followerItem"}}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 78 78 const getLocationsLex = {"lexicon":1,"id":"social.grain.unspecced.getLocations","defs":{"main":{"type":"query","description":"Get top locations by gallery count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getLocations#locationItem"}}}}}},"locationItem":{"type":"object","required":["name","h3Index","galleryCount"],"properties":{"name":{"type":"string"},"h3Index":{"type":"string"},"galleryCount":{"type":"integer"}}}}} as const 79 79 const getMutesLex = {"lexicon":1,"id":"social.grain.unspecced.getMutes","defs":{"main":{"type":"query","description":"Get the viewer's muted users.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getMutes#muteItem"}},"cursor":{"type":"string"}}}}},"muteItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"}}}}} as const 80 - const getNotificationsLex = {"lexicon":1,"id":"social.grain.unspecced.getNotifications","defs":{"main":{"type":"query","description":"Get notifications for the authenticated user.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"countOnly":{"type":"boolean","description":"If true, only return unseenCount without hydrating notifications."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"#notificationItem"}},"cursor":{"type":"string"},"unseenCount":{"type":"integer"}}}}},"notificationItem":{"type":"object","required":["uri","reason","createdAt","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","story-favorite","story-comment","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"storyUri":{"type":"string","format":"at-uri"},"storyThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const 80 + const getNotificationsLex = {"lexicon":1,"id":"social.grain.unspecced.getNotifications","defs":{"main":{"type":"query","description":"Get notifications for the authenticated user.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"countOnly":{"type":"boolean","description":"If true, only return unseenCount without hydrating notifications."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"#notificationItem"}},"cursor":{"type":"string"},"unseenCount":{"type":"integer"}}}}},"notificationItem":{"type":"object","required":["uri","reason","createdAt","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","comment-favorite","story-favorite","story-comment","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"storyUri":{"type":"string","format":"at-uri"},"storyThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const 81 81 const getStoriesLex = {"lexicon":1,"id":"social.grain.unspecced.getStories","defs":{"main":{"type":"query","description":"Get a user's active stories (posted within the last 24 hours).","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["stories"],"properties":{"stories":{"type":"array","items":{"type":"ref","ref":"social.grain.story.defs#storyView"}}}}}}}} as const 82 82 const getStoryLex = {"lexicon":1,"id":"social.grain.unspecced.getStory","defs":{"main":{"type":"query","parameters":{"type":"params","required":["story"],"properties":{"story":{"type":"string","format":"at-uri"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"story":{"type":"ref","ref":"social.grain.story.defs#storyView"}}}}}}} as const 83 83 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 ··· 387 387 export type GrainActorDefsMessageMe = Prettify<LexDef<typeof grainActorDefsLex, 'messageMe', Registry>> 388 388 export type GrainActorDefsViewerState = Prettify<LexDef<typeof grainActorDefsLex, 'viewerState', Registry>> 389 389 export type CommentView = Prettify<LexDef<typeof grainCommentDefsLex, 'commentView', Registry>> 390 + export type GrainCommentDefsViewerState = Prettify<LexDef<typeof grainCommentDefsLex, 'viewerState', Registry>> 390 391 export type GrainDefsAspectRatio = Prettify<LexDef<typeof grainDefsLex, 'aspectRatio', Registry>> 391 392 export type GalleryView = Prettify<LexDef<typeof grainGalleryDefsLex, 'galleryView', Registry>> 392 393 export type CrossPostInfo = Prettify<LexDef<typeof grainGalleryDefsLex, 'crossPostInfo', Registry>>
+9
lexicons/social/grain/comment/defs.json
··· 42 42 "type": "string", 43 43 "format": "datetime" 44 44 }, 45 + "favCount": { "type": "integer" }, 46 + "viewer": { "type": "ref", "ref": "#viewerState" }, 45 47 "muted": { 46 48 "type": "boolean", 47 49 "description": "True if the viewer has muted the comment author. Client should show collapsed with option to expand." 48 50 } 51 + } 52 + }, 53 + "viewerState": { 54 + "type": "object", 55 + "description": "Metadata about the requesting account's relationship with the comment. Only has meaningful content for authed requests.", 56 + "properties": { 57 + "fav": { "type": "string", "format": "at-uri" } 49 58 } 50 59 } 51 60 }
+1
lexicons/social/grain/unspecced/getNotifications.json
··· 44 44 "gallery-comment", 45 45 "gallery-comment-mention", 46 46 "gallery-mention", 47 + "comment-favorite", 47 48 "story-favorite", 48 49 "story-comment", 49 50 "reply",
+21
server/hooks/on-commit-favorite.ts
··· 47 47 data: { type: "story-favorite", uri: subject }, 48 48 badge, 49 49 }) 50 + return 51 + } 52 + 53 + // Check if the subject is a comment 54 + const [comment] = await db.query( 55 + `SELECT did AS author, subject AS comment_subject FROM "social.grain.comment" WHERE uri = $1`, 56 + [subject], 57 + ) as { author: string; comment_subject: string }[] 58 + 59 + if (comment && comment.author !== repo) { 60 + if (!(await shouldPush(db, comment.author, repo, "favorites"))) return 61 + const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 62 + const actor = profiles.get(repo) 63 + const badge = await getUnseenCount(db, comment.author) + 1 64 + await push.send({ 65 + did: comment.author, 66 + title: "New favorite", 67 + body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your comment`, 68 + data: { type: "comment-favorite", uri: subject }, 69 + badge, 70 + }) 50 71 } 51 72 } 52 73 )
+33
server/xrpc/getCommentThread.ts
··· 84 84 const focusPhotos = 85 85 focusUris.length > 0 ? await getRecords<Photo>("social.grain.photo", focusUris) : new Map(); 86 86 87 + // Hydrate comment favorite counts and viewer favorites 88 + const commentUris = items.map((r) => r.uri); 89 + const [favCounts, viewerFavs] = await Promise.all([ 90 + commentUris.length > 0 91 + ? ( 92 + db.query( 93 + `SELECT subject, COUNT(DISTINCT did) as count FROM "social.grain.favorite" 94 + WHERE subject IN (${commentUris.map((_, i) => `$${i + 1}`).join(",")}) GROUP BY subject`, 95 + commentUris, 96 + ) as Promise<{ subject: string; count: number }[]> 97 + ).then((rows) => { 98 + const m = new Map<string, number>(); 99 + for (const r of rows) m.set(r.subject, Number(r.count)); 100 + return m; 101 + }) 102 + : Promise.resolve(new Map<string, number>()), 103 + viewerDid && commentUris.length > 0 104 + ? ( 105 + db.query( 106 + `SELECT subject, uri FROM "social.grain.favorite" 107 + WHERE did = $1 AND subject IN (${commentUris.map((_, i) => `$${i + 2}`).join(",")})`, 108 + [viewerDid, ...commentUris], 109 + ) as Promise<{ subject: string; uri: string }[]> 110 + ).then((rows) => { 111 + const m = new Map<string, string>(); 112 + for (const r of rows) m.set(r.subject, r.uri); 113 + return m; 114 + }) 115 + : Promise.resolve(new Map<string, string>()), 116 + ]); 117 + 87 118 const comments = items.map((row) => { 88 119 const author = profiles.get(row.did); 89 120 const parsedFacets = row.facets ? JSON.parse(row.facets) : undefined; ··· 123 154 } 124 155 : {}), 125 156 }), 157 + favCount: favCounts.get(row.uri) ?? 0, 158 + ...(viewerFavs.has(row.uri) ? { viewer: { fav: viewerFavs.get(row.uri) } } : {}), 126 159 ...(mutedDids.has(row.did) ? { muted: true } : {}), 127 160 }; 128 161 });
+45 -13
server/xrpc/getNotifications.ts
··· 53 53 ? "uri" 54 54 : "uri, did, created_at, 'story-comment' as source, subject as subject_uri, text, facets, reply_to, focus"; 55 55 56 + const commentFavCols = 57 + select === "count" 58 + ? "uri" 59 + : "uri, did, created_at, 'comment-favorite' as source, subject as subject_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 60 + 56 61 return ` 57 62 SELECT ${favCols} FROM "social.grain.favorite" 58 63 WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) ··· 99 104 SELECT ${storyCommentCols} FROM "social.grain.comment" 100 105 WHERE subject IN (SELECT uri FROM "social.grain.story" WHERE did = $1) 101 106 AND did != $1 AND reply_to IS NULL ${blockMuteNotifFilter()} ${extraFilter} 107 + 108 + UNION ALL 109 + 110 + SELECT ${commentFavCols} FROM "social.grain.favorite" 111 + WHERE subject IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) 112 + AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 102 113 `; 103 114 } 104 115 ··· 160 171 161 172 // Map source to preference category 162 173 function prefCategory(source: string): string | null { 163 - if (source === "favorite" || source === "story-favorite") return "favorites"; 174 + if (source === "favorite" || source === "story-favorite" || source === "comment-favorite") return "favorites"; 164 175 if (source === "follow") return "follows"; 165 176 if (source === "comment" || source === "reply" || source === "story-comment") return "comments"; 166 177 if (source === "comment-mention" || source === "gallery-mention") return "mentions"; ··· 214 225 function getReason(row: (typeof items)[0]): string { 215 226 if (row.source === "favorite") return "gallery-favorite"; 216 227 if (row.source === "story-favorite") return "story-favorite"; 228 + if (row.source === "comment-favorite") return "comment-favorite"; 217 229 if (row.source === "story-comment") return "story-comment"; 218 230 if (row.source === "follow") return "follow"; 219 231 if (row.source === "comment-mention") return "gallery-comment-mention"; ··· 242 254 const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 243 255 const handleMap = await lookupHandles(db, dids); 244 256 245 - // Separate subject URIs into gallery and story URIs 246 - const allSubjectUris = [...new Set(items.map((r) => r.subject_uri).filter(Boolean))] as string[]; 257 + // Hydrate comment-favorite notifications — look up the favorited comment's text and parent subject 258 + const commentFavUris = items.filter((r) => r.source === "comment-favorite" && r.subject_uri).map((r) => r.subject_uri!); 259 + const commentFavMap = new Map<string, { text: string; subject: string }>(); 260 + if (commentFavUris.length > 0) { 261 + const ph = commentFavUris.map((_, i) => `$${i + 1}`).join(","); 262 + const commentRows = (await db.query( 263 + `SELECT uri, text, subject FROM "social.grain.comment" WHERE uri IN (${ph})`, 264 + commentFavUris, 265 + )) as Array<{ uri: string; text: string; subject: string }>; 266 + for (const row of commentRows) commentFavMap.set(row.uri, { text: row.text, subject: row.subject }); 267 + } 268 + 269 + // Separate subject URIs into gallery and story URIs (include parent subjects from comment-favorites) 270 + const allSubjectUris = [...new Set([ 271 + ...items.map((r) => r.subject_uri).filter(Boolean) as string[], 272 + ...[...commentFavMap.values()].map((c) => c.subject), 273 + ])]; 247 274 248 275 // Look up which subjects are galleries vs stories 249 276 const galleryUriSet = new Set<string>(); ··· 326 353 327 354 const notifications = items.map((row) => { 328 355 const author = profiles.get(row.did); 329 - const subjectUri = row.subject_uri; 330 - const isGallery = subjectUri ? galleryUriSet.has(subjectUri) : false; 331 - const isStory = subjectUri ? storyUriSet.has(subjectUri) : false; 356 + const reason = getReason(row); 357 + 358 + // For comment-favorites, resolve the parent gallery/story from the comment 359 + const commentFavInfo = reason === "comment-favorite" && row.subject_uri ? commentFavMap.get(row.subject_uri) : null; 360 + const effectiveSubject = commentFavInfo ? commentFavInfo.subject : row.subject_uri; 361 + 362 + const isGallery = effectiveSubject ? galleryUriSet.has(effectiveSubject) : false; 363 + const isStory = effectiveSubject ? storyUriSet.has(effectiveSubject) : false; 332 364 333 - const gallery = isGallery && subjectUri ? galleries.get(subjectUri) : null; 334 - const photoUri = isGallery && subjectUri ? firstPhotoByGallery.get(subjectUri) : null; 365 + const gallery = isGallery && effectiveSubject ? galleries.get(effectiveSubject) : null; 366 + const photoUri = isGallery && effectiveSubject ? firstPhotoByGallery.get(effectiveSubject) : null; 335 367 const photo = photoUri ? photos.get(photoUri) : null; 336 368 const galleryThumb = photo 337 369 ? (blobUrl(photo.did, photo.value.photo, "avatar") ?? undefined) 338 370 : undefined; 339 371 340 - const storyThumb = isStory && subjectUri ? storyThumbs.get(subjectUri) : undefined; 372 + const storyThumb = isStory && effectiveSubject ? storyThumbs.get(effectiveSubject) : undefined; 341 373 342 374 return { 343 375 uri: row.uri, 344 - reason: getReason(row), 376 + reason, 345 377 createdAt: row.created_at, 346 378 author: author 347 379 ? views.grainActorDefsProfileView({ ··· 357 389 did: row.did, 358 390 handle: handleMap.get(row.did) ?? row.did, 359 391 }), 360 - ...(gallery ? { galleryUri: subjectUri!, galleryTitle: gallery.value.title } : {}), 392 + ...(gallery ? { galleryUri: effectiveSubject!, galleryTitle: gallery.value.title } : {}), 361 393 ...(galleryThumb ? { galleryThumb } : {}), 362 - ...(isStory && subjectUri ? { storyUri: subjectUri } : {}), 394 + ...(isStory && effectiveSubject ? { storyUri: effectiveSubject } : {}), 363 395 ...(storyThumb ? { storyThumb } : {}), 364 - ...(row.text ? { commentText: row.text } : {}), 396 + ...(commentFavInfo ? { commentText: commentFavInfo.text } : row.text ? { commentText: row.text } : {}), 365 397 ...(row.reply_to && replyToTextMap.has(row.reply_to) 366 398 ? { replyToText: replyToTextMap.get(row.reply_to) } 367 399 : {}),