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 story favorites, comments, and notifications

- Hydrate stories with expired, commentCount, and viewer.fav fields
- Handle story favorite and comment commits with notifications
- Rename getGalleryThread to getCommentThread for reuse with stories
- Add story favorite/comment notification UI and deep links
- Add favorite toggle, double-tap to favorite, and comment sheet to StoryViewer
- Add contained mode to CommentSheet for use inside story viewer
- Add engagement section to story detail page
- Gate comment UI behind requireAuth for unauthenticated users

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

+549 -113
+11 -3
app/lib/components/atoms/NotificationItem.svelte
··· 9 9 'gallery-comment': 'commented on your gallery', 10 10 'gallery-comment-mention': 'mentioned you in a comment', 11 11 'gallery-mention': 'mentioned you in a gallery', 12 + 'story-favorite': 'favorited your story', 13 + 'story-comment': 'commented on your story', 12 14 'reply': 'replied to your comment', 13 15 'follow': 'followed you', 14 16 } ··· 18 20 const authorDid = $derived(notif.author?.did ?? '') 19 21 const authorName = $derived(notif.author?.displayName || notif.author?.handle || authorDid.slice(0, 18)) 20 22 const authorAvatar = $derived(notif.author?.avatar ?? null) 21 - const contentHref = $derived(notif.galleryUri ? `/profile/${notif.galleryUri.split('/')[2]}/gallery/${notif.galleryUri.split('/').pop()}` : `/profile/${authorDid}`) 23 + const contentHref = $derived( 24 + notif.galleryUri 25 + ? `/profile/${notif.galleryUri.split('/')[2]}/gallery/${notif.galleryUri.split('/').pop()}` 26 + : notif.storyUri 27 + ? `/profile/${notif.storyUri.split('/')[2]}/story/${notif.storyUri.split('/').pop()}` 28 + : `/profile/${authorDid}` 29 + ) 22 30 const profileHref = $derived(`/profile/${authorDid}`) 23 31 </script> 24 32 ··· 42 50 <div class="notif-gallery-title">{notif.galleryTitle}</div> 43 51 {/if} 44 52 </a> 45 - {#if notif.galleryThumb} 46 - <a href={contentHref}><img src={notif.galleryThumb} alt="" class="notif-thumb" loading="lazy" /></a> 53 + {#if notif.galleryThumb || notif.storyThumb} 54 + <a href={contentHref}><img src={notif.galleryThumb ?? notif.storyThumb} alt="" class="notif-thumb" loading="lazy" /></a> 47 55 {/if} 48 56 </div> 49 57
+28 -12
app/lib/components/organisms/CommentSheet.svelte
··· 10 10 11 11 let { 12 12 open = false, 13 - galleryUri, 13 + subjectUri, 14 14 onClose, 15 + contained = false, 15 16 }: { 16 17 open: boolean 17 - galleryUri: string 18 + subjectUri: string 18 19 onClose: () => void 20 + contained?: boolean 19 21 } = $props() 20 22 21 23 let comments = $state<CommentView[]>([]) ··· 54 56 55 57 // Constrain sheet to center column on desktop 56 58 $effect(() => { 57 - if (!open || !sheetEl) return 59 + if (!open || !sheetEl || contained) return 58 60 const col = sheetEl.closest('main') 59 61 if (!col) return 60 62 function position() { ··· 69 71 70 72 // Load comments when sheet opens 71 73 $effect(() => { 72 - if (open && galleryUri) { 74 + if (open && subjectUri) { 73 75 loadComments() 74 76 } 75 77 }) ··· 78 80 loading = true 79 81 error = null 80 82 try { 81 - const res = await callXrpc('social.grain.unspecced.getGalleryThread', { 82 - gallery: galleryUri, 83 + const res = await callXrpc('social.grain.unspecced.getCommentThread', { 84 + subject: subjectUri, 83 85 limit: 20, 84 86 } as any) 85 87 comments = (res as any).comments ?? [] ··· 97 99 if (!cursor || loadingMore) return 98 100 loadingMore = true 99 101 try { 100 - const res = await callXrpc('social.grain.unspecced.getGalleryThread', { 101 - gallery: galleryUri, 102 + const res = await callXrpc('social.grain.unspecced.getCommentThread', { 103 + subject: subjectUri, 102 104 limit: 20, 103 105 cursor, 104 106 } as any) ··· 130 132 collection: 'social.grain.comment', 131 133 record: { 132 134 text, 133 - subject: galleryUri, 135 + subject: subjectUri, 134 136 ...(facets ? { facets } : {}), 135 137 ...(replyToUri ? { replyTo: replyToUri } : {}), 136 138 createdAt: now, ··· 200 202 </script> 201 203 202 204 {#if open} 203 - <div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="button" tabindex="-1"></div> 204 - <div class="sheet" bind:this={sheetEl}> 205 + {#if !contained} 206 + <div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="button" tabindex="-1"></div> 207 + {/if} 208 + <div class="sheet" class:contained bind:this={sheetEl}> 205 209 <div class="sheet-header"> 206 210 <span class="sheet-title">Comments ({totalCount})</span> 207 211 <button class="close-btn" onclick={onClose}> ··· 288 292 display: flex; 289 293 flex-direction: column; 290 294 } 295 + .sheet.contained { 296 + position: absolute; 297 + max-height: 60%; 298 + background: rgba(30, 30, 30, 0.95); 299 + backdrop-filter: blur(12px); 300 + z-index: 20; 301 + } 302 + .sheet.contained .sheet-header, 303 + .sheet.contained .input-bar, 304 + .sheet.contained .reply-bar { 305 + border-color: rgba(255, 255, 255, 0.1); 306 + } 291 307 @media (max-width: 600px) { 292 - .sheet { 308 + .sheet:not(.contained) { 293 309 bottom: calc(50px + env(safe-area-inset-bottom, 0px)); 294 310 } 295 311 }
+1 -1
app/lib/components/organisms/FeedList.svelte
··· 102 102 103 103 <CommentSheet 104 104 open={commentSheetOpen} 105 - galleryUri={commentGalleryUri} 105 + subjectUri={commentGalleryUri} 106 106 onClose={() => { commentSheetOpen = false }} 107 107 /> 108 108
+252 -33
app/lib/components/organisms/StoryViewer.svelte
··· 1 1 <script lang="ts"> 2 2 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 3 - import { X, MapPin, Trash2, AlertTriangle, Info } from 'lucide-svelte' 3 + import { X, MapPin, Trash2, AlertTriangle, Info, Heart } from 'lucide-svelte' 4 4 import { goto } from '$app/navigation' 5 5 import { callXrpc } from '$hatk/client' 6 - import { storiesQuery, storyAuthorsQuery, storyQuery } from '$lib/queries' 6 + import { storiesQuery, storyAuthorsQuery, storyQuery, commentThreadQuery } from '$lib/queries' 7 7 import { viewer as viewerStore } from '$lib/stores' 8 8 import { resolveLabels, labelDefsQuery } from '$lib/labels' 9 9 import ReportButton from '$lib/components/molecules/ReportButton.svelte' 10 + import CommentSheet from '$lib/components/organisms/CommentSheet.svelte' 10 11 import BskyIcon from '$lib/components/atoms/BskyIcon.svelte' 12 + import { requireAuth } from '$lib/stores' 11 13 12 14 let { 13 15 initialDid, ··· 69 71 const totalStories = $derived(singleStory ? 1 : (stories.data?.length ?? 0)) 70 72 const isOwn = $derived(currentDid === $viewerStore?.did) 71 73 const bskyUrl = $derived((currentStory as any)?.crossPost?.url ?? null) 74 + const isExpired = $derived(currentStory?.expired ?? false) 72 75 73 76 let deleting = $state(false) 74 77 ··· 175 178 } 176 179 } 177 180 178 - // NOTE: ReportButton uses e.stopPropagation() on its trigger to prevent this 179 - // handler from firing when opening the modal. The dialog guard below handles 180 - // clicks inside the modal itself, which bubble through the DOM even though 181 - // <dialog> renders in the top layer. Both are needed for correct behavior. 181 + function doFavoriteOnly() { 182 + if (isFaved) return 183 + showHeartAnim = true 184 + setTimeout(() => (showHeartAnim = false), 800) 185 + toggleFav() 186 + } 187 + 188 + let lastTapTime = 0 182 189 function handleTap(e: MouseEvent) { 183 190 if ((e.target as HTMLElement).closest('dialog')) return 184 - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() 185 - const x = e.clientX - rect.left 186 - if (x < rect.width / 3) { 187 - prev() 188 - } else { 189 - next() 191 + const now = Date.now() 192 + if (now - lastTapTime < 300) { 193 + // Double tap — favorite only 194 + lastTapTime = 0 195 + doFavoriteOnly() 196 + return 190 197 } 198 + lastTapTime = now 199 + setTimeout(() => { 200 + if (lastTapTime === 0) return // was consumed by double tap 201 + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() 202 + const x = e.clientX - rect.left 203 + if (x < rect.width / 3) { 204 + prev() 205 + } else { 206 + next() 207 + } 208 + }, 300) 191 209 } 192 210 193 211 function handleKeydown(e: KeyboardEvent) { ··· 202 220 return () => stopTimer() 203 221 }) 204 222 223 + // Favorite state — keyed by story URI so it persists across author switches 224 + let favOverrides = $state<Record<string, string | null>>({}) 225 + const viewerFav = $derived(currentStory?.viewer?.fav ?? null) 226 + const storyFavOverride = $derived(currentStory?.uri ? favOverrides[currentStory.uri] : undefined) 227 + const favUri = $derived(storyFavOverride !== undefined ? storyFavOverride : viewerFav) 228 + const isFaved = $derived(!!favUri) 229 + let favPending = $state(false) 230 + let showHeartAnim = $state(false) 231 + 232 + async function toggleFav() { 233 + if (favPending || !currentStory) return 234 + if (!requireAuth()) return 235 + const uri = currentStory.uri 236 + favPending = true 237 + try { 238 + if (isFaved && favUri && favUri !== 'pending') { 239 + // Unfavorite — capture URI before optimistic update clears it 240 + const deleteFavUri = favUri 241 + favOverrides[uri] = null 242 + const rkey = deleteFavUri.split('/').pop()! 243 + await callXrpc('dev.hatk.deleteRecord', { collection: 'social.grain.favorite', rkey }) 244 + queryClient.invalidateQueries({ queryKey: ['stories', currentDid], refetchType: 'none' }) 245 + queryClient.invalidateQueries({ queryKey: ['getStory'], refetchType: 'none' }) 246 + } else if (!isFaved) { 247 + // Favorite 248 + favOverrides[uri] = 'pending' 249 + const res: any = await callXrpc('dev.hatk.createRecord', { 250 + collection: 'social.grain.favorite', 251 + record: { subject: uri, createdAt: new Date().toISOString() }, 252 + }) 253 + favOverrides[uri] = res?.uri ?? null 254 + queryClient.invalidateQueries({ queryKey: ['stories', currentDid], refetchType: 'none' }) 255 + queryClient.invalidateQueries({ queryKey: ['getStory'], refetchType: 'none' }) 256 + } 257 + } catch { 258 + delete favOverrides[uri] // rolls back to server value 259 + } finally { 260 + favPending = false 261 + } 262 + } 263 + 264 + // Comment sheet 265 + let commentSheetOpen = $state(false) 266 + const commentCount = $derived(currentStory?.commentCount ?? 0) 267 + const commentsQ = createQuery(() => ({ 268 + ...commentThreadQuery(currentStory?.uri ?? ''), 269 + enabled: !!currentStory?.uri, 270 + })) 271 + const latestComment = $derived.by(() => { 272 + const comments = (commentsQ.data as any)?.comments 273 + if (!Array.isArray(comments) || comments.length === 0) return null 274 + return comments[comments.length - 1] 275 + }) 276 + 205 277 let wrapper: HTMLDivElement = $state()! 206 278 207 279 $effect(() => { ··· 243 315 {#if currentStory.creator.avatar} 244 316 <img class="author-avatar" src={currentStory.creator.avatar} alt="" /> 245 317 {/if} 246 - <span class="author-name"> 247 - {currentStory.creator.displayName ?? currentStory.creator.handle} 248 - </span> 318 + <div class="author-text"> 319 + <span class="author-name-row"> 320 + <span class="author-name"> 321 + {currentStory.creator.displayName ?? currentStory.creator.handle} 322 + </span> 323 + <span class="story-time"> 324 + {timeAgo(currentStory.createdAt)} 325 + </span> 326 + </span> 327 + {#if currentStory.location} 328 + <span class="header-location"> 329 + {currentStory.location.name} 330 + </span> 331 + {/if} 332 + </div> 249 333 </a> 250 - <span class="story-time"> 251 - {timeAgo(currentStory.createdAt)} 252 - </span> 253 334 </div> 254 335 <div class="header-actions"> 255 336 {#if isOwn} ··· 282 363 alt="" 283 364 style="aspect-ratio: {currentStory.aspectRatio.width}/{currentStory.aspectRatio.height}" 284 365 /> 366 + {#if showHeartAnim} 367 + <div class="heart-anim"> 368 + <Heart size={64} fill="currentColor" /> 369 + </div> 370 + {/if} 285 371 </div> 286 372 287 373 <!-- Bluesky cross-post link --> ··· 291 377 </a> 292 378 {/if} 293 379 294 - <!-- Location overlay --> 295 - {#if currentStory.location} 296 - <div class="story-location"> 297 - <MapPin size={12} /> 298 - <span>{currentStory.location.name}</span> 380 + <!-- Bottom input bar --> 381 + {#if !isExpired} 382 + <div class="story-bottom-bar" onclick={(e) => e.stopPropagation()}> 383 + {#if latestComment} 384 + <div class="latest-comment"> 385 + {#if latestComment.author?.avatar} 386 + <img class="comment-avatar" src={latestComment.author.avatar} alt="" /> 387 + {/if} 388 + <span class="comment-text">{latestComment.text}</span> 389 + </div> 390 + {/if} 391 + <div class="input-row"> 392 + <button class="input-placeholder" onclick={() => { if (!requireAuth()) return; paused = true; stopTimer(); commentSheetOpen = true }}> 393 + Add a comment... 394 + </button> 395 + <button class="fav-btn" class:faved={isFaved} onclick={() => toggleFav()}> 396 + <Heart size={24} fill={isFaved ? 'currentColor' : 'none'} /> 397 + </button> 299 398 </div> 399 + </div> 300 400 {/if} 401 + {/if} 402 + 403 + {#if commentSheetOpen && currentStory} 404 + <div class="contained-sheet-wrapper" onclick={(e) => e.stopPropagation()} role="presentation"> 405 + <div class="contained-sheet-backdrop" onclick={() => { commentSheetOpen = false; paused = false; startTimer() }} role="button" tabindex="-1"></div> 406 + <CommentSheet 407 + open={commentSheetOpen} 408 + subjectUri={currentStory.uri} 409 + onClose={() => { commentSheetOpen = false; paused = false; startTimer() }} 410 + contained 411 + /> 412 + </div> 301 413 {/if} 302 414 </div> 303 415 </div> ··· 379 491 border-radius: 50%; 380 492 object-fit: cover; 381 493 } 494 + .author-text { 495 + display: flex; 496 + flex-direction: column; 497 + gap: 1px; 498 + } 499 + .author-name-row { 500 + display: flex; 501 + align-items: center; 502 + gap: 6px; 503 + } 382 504 .author-name { 383 505 color: white; 384 506 font-size: 14px; ··· 390 512 font-size: 12px; 391 513 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); 392 514 } 515 + .header-location { 516 + display: flex; 517 + align-items: center; 518 + gap: 3px; 519 + color: rgba(255, 255, 255, 0.7); 520 + font-size: 11px; 521 + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); 522 + } 393 523 .header-actions { 394 524 display: flex; 395 525 align-items: center; ··· 405 535 } 406 536 407 537 /* Image */ 538 + .heart-anim { 539 + position: absolute; 540 + inset: 0; 541 + display: flex; 542 + align-items: center; 543 + justify-content: center; 544 + pointer-events: none; 545 + color: var(--grain); 546 + animation: heart-pop 0.8s ease-out forwards; 547 + z-index: 5; 548 + } 549 + @keyframes heart-pop { 550 + 0% { opacity: 0; transform: scale(0); } 551 + 15% { opacity: 1; transform: scale(1.2); } 552 + 30% { transform: scale(0.95); } 553 + 45% { transform: scale(1); } 554 + 70% { opacity: 1; } 555 + 100% { opacity: 0; transform: scale(1); } 556 + } 408 557 .story-image-wrapper { 409 558 flex: 1; 410 559 display: flex; ··· 483 632 backdrop-filter: blur(4px); 484 633 } 485 634 486 - /* Location */ 487 - .story-location { 635 + /* Bottom input bar */ 636 + .story-bottom-bar { 488 637 position: absolute; 489 - bottom: 24px; 490 - left: 12px; 638 + bottom: 0; 639 + left: 0; 640 + right: 0; 641 + z-index: 10; 642 + display: flex; 643 + flex-direction: column; 644 + gap: 10px; 645 + padding: 14px 14px 24px; 646 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); 647 + } 648 + .latest-comment { 491 649 display: flex; 492 650 align-items: center; 493 - gap: 4px; 651 + gap: 8px; 494 652 color: white; 495 - font-size: 13px; 496 - background: rgba(0, 0, 0, 0.4); 497 - padding: 6px 10px; 498 - border-radius: 16px; 499 - backdrop-filter: blur(4px); 653 + font-size: 14px; 654 + overflow: hidden; 655 + } 656 + .comment-text { 657 + background: rgba(255, 255, 255, 0.15); 658 + border-radius: 999px; 659 + padding: 4px 10px; 660 + overflow: hidden; 661 + text-overflow: ellipsis; 662 + white-space: nowrap; 663 + } 664 + .comment-avatar { 665 + width: 24px; 666 + height: 24px; 667 + border-radius: 50%; 668 + object-fit: cover; 669 + flex-shrink: 0; 670 + } 671 + .comment-author { 672 + font-weight: 600; 673 + flex-shrink: 0; 674 + } 675 + .input-row { 676 + display: flex; 677 + align-items: center; 678 + gap: 10px; 679 + } 680 + .input-placeholder { 681 + flex: 1; 682 + background: none; 683 + border: 1.5px solid rgba(255, 255, 255, 0.35); 684 + border-radius: 20px; 685 + padding: 8px 16px; 686 + color: rgba(255, 255, 255, 0.5); 687 + font-size: 14px; 688 + font-family: inherit; 689 + cursor: pointer; 690 + text-align: left; 691 + } 692 + .input-placeholder:hover { 693 + border-color: rgba(255, 255, 255, 0.5); 694 + } 695 + .fav-btn { 696 + background: none; 697 + border: none; 698 + color: white; 699 + cursor: pointer; 700 + padding: 0; 701 + display: flex; 702 + flex-shrink: 0; 703 + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4)); 704 + transition: color 0.15s; 705 + } 706 + .fav-btn:hover { opacity: 0.8; } 707 + .fav-btn.faved { color: var(--grain); } 708 + 709 + /* Contained comment sheet */ 710 + .contained-sheet-wrapper { 711 + position: absolute; 712 + inset: 0; 713 + z-index: 20; 714 + } 715 + .contained-sheet-backdrop { 716 + position: absolute; 717 + inset: 0; 718 + background: rgba(0, 0, 0, 0.3); 500 719 } 501 720 </style>
+3 -3
app/lib/queries.ts
··· 229 229 230 230 // ─── Gallery Thread (Comments) ────────────────────────────────────── 231 231 232 - export const galleryThreadQuery = (galleryUri: string, f?: Fetch) => 232 + export const commentThreadQuery = (subjectUri: string, f?: Fetch) => 233 233 queryOptions({ 234 - queryKey: ["getGalleryThread", galleryUri], 235 - queryFn: () => callXrpc("social.grain.unspecced.getGalleryThread", { gallery: galleryUri }, f), 234 + queryKey: ["getCommentThread", subjectUri], 235 + queryFn: () => callXrpc("social.grain.unspecced.getCommentThread", { subject: subjectUri }, f), 236 236 staleTime: 30_000, 237 237 }); 238 238
+1 -1
app/routes/profile/[did]/gallery/[rkey]/+page.svelte
··· 48 48 49 49 <CommentSheet 50 50 open={commentSheetOpen} 51 - galleryUri={gallery.uri} 51 + subjectUri={gallery.uri} 52 52 onClose={() => { commentSheetOpen = false }} 53 53 /> 54 54 {/if}
+58 -1
app/routes/profile/[did]/story/[rkey]/+page.svelte
··· 2 2 import { createQuery } from '@tanstack/svelte-query' 3 3 import { storyQuery } from '$lib/queries' 4 4 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 5 + import CommentSheet from '$lib/components/organisms/CommentSheet.svelte' 6 + import FavoriteButton from '$lib/components/molecules/FavoriteButton.svelte' 5 7 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 6 - import { MapPin } from 'lucide-svelte' 8 + import { MapPin, MessageCircle, Send } from 'lucide-svelte' 9 + import { share } from '$lib/utils/share' 10 + import { requireAuth } from '$lib/stores' 11 + import Toast from '$lib/components/atoms/Toast.svelte' 7 12 import type { StoryView } from '$hatk/client' 8 13 9 14 let { data } = $props() ··· 12 17 const storyQ = createQuery(() => storyQuery(storyUri)) 13 18 const story = $derived((storyQ.data as StoryView) ?? null) 14 19 const bskyUrl = $derived((story as any)?.crossPost?.url ?? null) 20 + const commentCount = $derived(story?.commentCount ?? 0) 21 + 22 + let commentSheetOpen = $state(false) 23 + let showToast = $state(false) 24 + 25 + async function handleShare() { 26 + const url = `${window.location.origin}/profile/${data.did}/story/${data.rkey}` 27 + const result = await share(url) 28 + if (result.success && result.method === 'clipboard') { 29 + showToast = true 30 + } 31 + } 15 32 </script> 16 33 17 34 <OGMeta ··· 57 74 <span>{story.location.name}</span> 58 75 </div> 59 76 {/if} 77 + {#if !story.expired} 78 + <div class="engagement"> 79 + <FavoriteButton galleryUri={story.uri} viewerFav={story.viewer?.fav ?? null} favCount={0} /> 80 + <button class="stat" type="button" onclick={() => requireAuth() && (commentSheetOpen = true)}> 81 + <MessageCircle size={20} /> 82 + {#if commentCount > 0}<span class="stat-count">{commentCount}</span>{/if} 83 + </button> 84 + <button class="stat" type="button" onclick={handleShare} aria-label="Share"> 85 + <Send size={20} /> 86 + </button> 87 + </div> 88 + {/if} 60 89 <time class="time">{new Date(story.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })}</time> 61 90 </div> 91 + 92 + <CommentSheet 93 + open={commentSheetOpen} 94 + subjectUri={story.uri} 95 + onClose={() => { commentSheetOpen = false }} 96 + /> 97 + 98 + <Toast message="Link copied" bind:visible={showToast} /> 62 99 {/if} 63 100 </div> 64 101 ··· 112 149 color: var(--text-secondary); 113 150 font-size: 13px; 114 151 } 152 + .engagement { 153 + display: flex; 154 + align-items: center; 155 + gap: 16px; 156 + } 157 + .stat { 158 + display: flex; 159 + align-items: center; 160 + gap: 6px; 161 + background: none; 162 + border: none; 163 + color: var(--text-primary); 164 + cursor: pointer; 165 + padding: 0; 166 + font-family: inherit; 167 + font-size: 13px; 168 + transition: opacity 0.15s; 169 + } 170 + .stat:hover { opacity: 0.7; } 171 + .stat-count { color: var(--text-secondary); } 115 172 .time { 116 173 color: var(--text-muted); 117 174 font-size: 13px;
+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, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, 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, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, 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, 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, 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' 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.graph.muteActor', 'social.grain.graph.unmuteActor', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+7 -6
hatk.generated.ts
··· 63 63 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 64 64 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 65 65 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 66 - 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 66 + 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"}},"expired":{"type":"boolean","description":"Whether the story has passed its 24-hour window."},"commentCount":{"type":"integer"},"viewer":{"type":"ref","ref":"#viewerState"},"crossPost":{"type":"ref","ref":"social.grain.gallery.defs#crossPostInfo"}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the story.","properties":{"fav":{"type":"string","format":"at-uri"}}}}} as const 67 67 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 68 68 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 69 69 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 70 70 const getBlocksLex = {"lexicon":1,"id":"social.grain.unspecced.getBlocks","defs":{"main":{"type":"query","description":"Get the viewer's blocked 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.getBlocks#blockItem"}},"cursor":{"type":"string"}}}}},"blockItem":{"type":"object","required":["did","blockUri"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"},"blockUri":{"type":"string","format":"at-uri"}}}}} as const 71 71 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 72 + const getCommentThreadLex = {"lexicon":1,"id":"social.grain.unspecced.getCommentThread","defs":{"main":{"type":"query","description":"Get comments for a subject (gallery or story), sorted oldest-first with author profiles.","parameters":{"type":"params","required":["subject"],"properties":{"subject":{"type":"string","format":"at-uri","description":"The subject URI to fetch comments for."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["comments"],"properties":{"comments":{"type":"array","items":{"type":"ref","ref":"social.grain.comment.defs#commentView"}},"cursor":{"type":"string"},"totalCount":{"type":"integer"}}}}}}} as const 72 73 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 73 74 const getFollowingLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowing","defs":{"main":{"type":"query","description":"Get users that a given user follows.","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.getFollowing#followingItem"}},"cursor":{"type":"string"}}}}},"followingItem":{"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.getFollowing#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const 74 75 const getGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.getGallery","defs":{"main":{"type":"query","description":"Get a single gallery view by AT URI.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery AT URI."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["gallery"],"properties":{"gallery":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}}}}}}} as const 75 - const getGalleryThreadLex = {"lexicon":1,"id":"social.grain.unspecced.getGalleryThread","defs":{"main":{"type":"query","description":"Get comments for a gallery, sorted oldest-first with author profiles.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery URI to fetch comments for."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["comments"],"properties":{"comments":{"type":"array","items":{"type":"ref","ref":"social.grain.comment.defs#commentView"}},"cursor":{"type":"string"},"totalCount":{"type":"integer"}}}}}}} as const 76 76 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 77 77 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 78 78 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 79 - 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","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"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const 79 + 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 80 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 81 81 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 82 82 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 ··· 151 151 'social.grain.unspecced.getActorProfile': typeof getActorProfileLex 152 152 'social.grain.unspecced.getBlocks': typeof getBlocksLex 153 153 'social.grain.unspecced.getCameras': typeof getCamerasLex 154 + 'social.grain.unspecced.getCommentThread': typeof getCommentThreadLex 154 155 'social.grain.unspecced.getFollowers': typeof getFollowersLex 155 156 'social.grain.unspecced.getFollowing': typeof getFollowingLex 156 157 'social.grain.unspecced.getGallery': typeof getGalleryLex 157 - 'social.grain.unspecced.getGalleryThread': typeof getGalleryThreadLex 158 158 'social.grain.unspecced.getKnownFollowers': typeof getKnownFollowersLex 159 159 'social.grain.unspecced.getLocations': typeof getLocationsLex 160 160 'social.grain.unspecced.getMutes': typeof getMutesLex ··· 206 206 export type GetActorProfile = Prettify<LexQuery<typeof getActorProfileLex, Registry>> 207 207 export type GetBlocks = Prettify<LexQuery<typeof getBlocksLex, Registry>> 208 208 export type GetCameras = Prettify<LexQuery<typeof getCamerasLex, Registry>> 209 + export type GetCommentThread = Prettify<LexQuery<typeof getCommentThreadLex, Registry>> 209 210 export type GetFollowers = Prettify<LexQuery<typeof getFollowersLex, Registry>> 210 211 export type GetFollowing = Prettify<LexQuery<typeof getFollowingLex, Registry>> 211 212 export type GetGallery = Prettify<LexQuery<typeof getGalleryLex, Registry>> 212 - export type GetGalleryThread = Prettify<LexQuery<typeof getGalleryThreadLex, Registry>> 213 213 export type GetKnownFollowers = Prettify<LexQuery<typeof getKnownFollowersLex, Registry>> 214 214 export type GetLocations = Prettify<LexQuery<typeof getLocationsLex, Registry>> 215 215 export type GetMutes = Prettify<LexQuery<typeof getMutesLex, Registry>> ··· 386 386 export type ExifView = Prettify<LexDef<typeof grainPhotoDefsLex, 'exifView', Registry>> 387 387 export type GalleryState = Prettify<LexDef<typeof grainPhotoDefsLex, 'galleryState', Registry>> 388 388 export type StoryView = Prettify<LexDef<typeof grainStoryDefsLex, 'storyView', Registry>> 389 + export type GrainStoryDefsViewerState = Prettify<LexDef<typeof grainStoryDefsLex, 'viewerState', Registry>> 389 390 export type BlockItem = Prettify<LexDef<typeof getBlocksLex, 'blockItem', Registry>> 390 391 export type CameraItem = Prettify<LexDef<typeof getCamerasLex, 'cameraItem', Registry>> 391 392 export type GetFollowersFollowerItem = Prettify<LexDef<typeof getFollowersLex, 'followerItem', Registry>> ··· 425 426 'social.grain.unspecced.getActorProfile': GetActorProfile 426 427 'social.grain.unspecced.getBlocks': GetBlocks 427 428 'social.grain.unspecced.getCameras': GetCameras 429 + 'social.grain.unspecced.getCommentThread': GetCommentThread 428 430 'social.grain.unspecced.getFollowers': GetFollowers 429 431 'social.grain.unspecced.getFollowing': GetFollowing 430 432 'social.grain.unspecced.getGallery': GetGallery 431 - 'social.grain.unspecced.getGalleryThread': GetGalleryThread 432 433 'social.grain.unspecced.getKnownFollowers': GetKnownFollowers 433 434 'social.grain.unspecced.getLocations': GetLocations 434 435 'social.grain.unspecced.getMutes': GetMutes
+10
lexicons/social/grain/story/defs.json
··· 27 27 "type": "array", 28 28 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 29 29 }, 30 + "expired": { "type": "boolean", "description": "Whether the story has passed its 24-hour window." }, 31 + "commentCount": { "type": "integer" }, 32 + "viewer": { "type": "ref", "ref": "#viewerState" }, 30 33 "crossPost": { "type": "ref", "ref": "social.grain.gallery.defs#crossPostInfo" } 34 + } 35 + }, 36 + "viewerState": { 37 + "type": "object", 38 + "description": "Metadata about the requesting account's relationship with the story.", 39 + "properties": { 40 + "fav": { "type": "string", "format": "at-uri" } 31 41 } 32 42 } 33 43 }
+5 -5
lexicons/social/grain/unspecced/getGalleryThread.json lexicons/social/grain/unspecced/getCommentThread.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.grain.unspecced.getGalleryThread", 3 + "id": "social.grain.unspecced.getCommentThread", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Get comments for a gallery, sorted oldest-first with author profiles.", 7 + "description": "Get comments for a subject (gallery or story), sorted oldest-first with author profiles.", 8 8 "parameters": { 9 9 "type": "params", 10 - "required": ["gallery"], 10 + "required": ["subject"], 11 11 "properties": { 12 - "gallery": { 12 + "subject": { 13 13 "type": "string", 14 14 "format": "at-uri", 15 - "description": "The gallery URI to fetch comments for." 15 + "description": "The subject URI to fetch comments for." 16 16 }, 17 17 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, 18 18 "cursor": { "type": "string" }
+4
lexicons/social/grain/unspecced/getNotifications.json
··· 44 44 "gallery-comment", 45 45 "gallery-comment-mention", 46 46 "gallery-mention", 47 + "story-favorite", 48 + "story-comment", 47 49 "reply", 48 50 "follow" 49 51 ] ··· 53 55 "galleryUri": { "type": "string", "format": "at-uri" }, 54 56 "galleryTitle": { "type": "string" }, 55 57 "galleryThumb": { "type": "string" }, 58 + "storyUri": { "type": "string", "format": "at-uri" }, 59 + "storyThumb": { "type": "string" }, 56 60 "commentText": { "type": "string" }, 57 61 "replyToText": { "type": "string" } 58 62 }
+23 -4
server/hydrate/stories.ts
··· 1 1 import { views } from "$hatk"; 2 2 import type { GrainActorProfile, Story, Label, Row, BaseContext } from "$hatk"; 3 3 import { HIDE_LABELS } from "../labels/_hidden.ts"; 4 + import { countComments } from "./comments.ts"; 4 5 import { lookupCrossPosts } from "./galleries.ts"; 5 6 import { lookupHandles } from "../helpers/lookupHandles.ts"; 6 7 ··· 20 21 * Resolves the author profile, filters by label moderation, and maps to views. 21 22 */ 22 23 export async function hydrateStories(ctx: BaseContext, actor: string, rows: StoryRow[]) { 23 - // Resolve author profile 24 + const storyUris = rows.map((r) => r.uri); 25 + 26 + // Resolve author profile + fav/comment counts + viewer favs 27 + const viewerFavs = new Map<string, string>(); 28 + if (ctx.viewer?.did && storyUris.length > 0) { 29 + const favRows = (await ctx.db.query( 30 + `SELECT subject, uri FROM "social.grain.favorite" 31 + WHERE did = $1 AND subject IN (${storyUris.map((_, i) => `$${i + 2}`).join(",")})`, 32 + [ctx.viewer.did, ...storyUris], 33 + )) as { subject: string; uri: string }[]; 34 + for (const row of favRows) viewerFavs.set(row.subject, row.uri); 35 + } 36 + 24 37 const [profiles, handleMap] = await Promise.all([ 25 38 ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [actor]), 26 39 lookupHandles(ctx.db, [actor]), ··· 41 54 }); 42 55 43 56 // Hydrate external labels 44 - const storyUris = rows.map((r) => r.uri); 45 57 const labelsByUri = 46 58 storyUris.length > 0 47 59 ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) ··· 74 86 return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg); 75 87 }); 76 88 77 - // Cross-post lookup 78 - const crossPosts = await lookupCrossPosts(ctx.db, visibleRows, "story"); 89 + // Comment counts 90 + const visibleUris = visibleRows.map((r) => r.uri); 91 + const [commentCounts, crossPosts] = await Promise.all([ 92 + countComments(ctx.db, visibleUris), 93 + lookupCrossPosts(ctx.db, visibleRows, "story"), 94 + ]); 79 95 80 96 const stories = visibleRows.map((row) => { 81 97 let blobRef: any; ··· 127 143 createdAt: row.created_at, 128 144 ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 129 145 ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), 146 + expired: Date.now() - new Date(row.created_at).getTime() > 24 * 60 * 60 * 1000, 147 + commentCount: commentCounts.get(row.uri) ?? 0, 148 + ...(viewerFavs.has(row.uri) ? { viewer: { fav: viewerFavs.get(row.uri) } } : {}), 130 149 }); 131 150 }); 132 151
+29 -14
server/on-commit-comment.ts
··· 6 6 const subject = record.subject as string 7 7 if (!subject) return 8 8 9 - // Find the gallery author (comment.subject is the gallery URI) 10 - const [gallery] = await db.query( 11 - `SELECT did AS author FROM "social.grain.gallery" WHERE uri = $1`, 12 - [subject], 13 - ) as { author: string }[] 14 - 15 - if (!gallery) return 16 - 17 9 // Look up commenter's profile 18 10 const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 19 11 const actor = profiles.get(repo) 20 12 const displayName = (actor?.value as any)?.displayName ?? "Someone" 21 13 22 - // If this is a reply, notify the parent comment author instead 14 + // If this is a reply, notify the parent comment author 23 15 if (record.replyTo) { 24 16 const [parent] = await db.query( 25 17 `SELECT did AS author FROM "social.grain.comment" WHERE uri = $1`, ··· 36 28 } 37 29 } 38 30 39 - // Notify the gallery author (unless they're the commenter) 40 - if (gallery.author !== repo) { 31 + // Check if the subject is a gallery 32 + const [gallery] = await db.query( 33 + `SELECT did AS author FROM "social.grain.gallery" WHERE uri = $1`, 34 + [subject], 35 + ) as { author: string }[] 36 + 37 + if (gallery) { 38 + if (gallery.author !== repo) { 39 + await push.send({ 40 + did: gallery.author, 41 + title: "New comment", 42 + body: `${displayName} commented on your gallery`, 43 + data: { type: "gallery-comment", uri: subject }, 44 + }) 45 + } 46 + return 47 + } 48 + 49 + // Check if the subject is a story 50 + const [story] = await db.query( 51 + `SELECT did AS author FROM "social.grain.story" WHERE uri = $1`, 52 + [subject], 53 + ) as { author: string }[] 54 + 55 + if (story && story.author !== repo) { 41 56 await push.send({ 42 - did: gallery.author, 57 + did: story.author, 43 58 title: "New comment", 44 - body: `${displayName} commented on your gallery`, 45 - data: { type: "gallery-comment", uri: subject }, 59 + body: `${displayName} commented on your story`, 60 + data: { type: "story-comment", uri: subject }, 46 61 }) 47 62 } 48 63 }
+27 -9
server/on-commit-favorite.ts
··· 6 6 const subject = record.subject as string 7 7 if (!subject) return 8 8 9 + // Check if the subject is a gallery 9 10 const [gallery] = await db.query( 10 11 `SELECT did AS author FROM "social.grain.gallery" WHERE uri = $1`, 11 12 [subject], 12 13 ) as { author: string }[] 13 14 14 - if (!gallery || gallery.author === repo) return 15 + if (gallery && gallery.author !== repo) { 16 + const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 17 + const actor = profiles.get(repo) 18 + await push.send({ 19 + did: gallery.author, 20 + title: "New favorite", 21 + body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your gallery`, 22 + data: { type: "gallery-favorite", uri: subject }, 23 + }) 24 + return 25 + } 15 26 16 - const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 17 - const actor = profiles.get(repo) 27 + // Check if the subject is a story 28 + const [story] = await db.query( 29 + `SELECT did AS author FROM "social.grain.story" WHERE uri = $1`, 30 + [subject], 31 + ) as { author: string }[] 18 32 19 - await push.send({ 20 - did: gallery.author, 21 - title: "New favorite", 22 - body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your gallery`, 23 - data: { type: "gallery-favorite", uri: subject }, 24 - }) 33 + if (story && story.author !== repo) { 34 + const profiles = await lookup("social.grain.actor.profile", "did", [repo]) 35 + const actor = profiles.get(repo) 36 + await push.send({ 37 + did: story.author, 38 + title: "New favorite", 39 + body: `${(actor?.value as any)?.displayName ?? "Someone"} favorited your story`, 40 + data: { type: "story-favorite", uri: subject }, 41 + }) 42 + } 25 43 } 26 44 )
+5 -5
server/xrpc/getGalleryThread.ts server/xrpc/getCommentThread.ts
··· 5 5 import { blockFilter } from "../filters/blockMute.ts"; 6 6 import { lookupHandles } from "../helpers/lookupHandles.ts"; 7 7 8 - export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx) => { 8 + export default defineQuery("social.grain.unspecced.getCommentThread", async (ctx) => { 9 9 const { ok, params, db, lookup, blobUrl, getRecords, viewer } = ctx; 10 - const { gallery, limit = 20, cursor } = params; 10 + const { subject, limit = 20, cursor } = params; 11 11 12 12 const viewerDid = viewer?.did; 13 13 14 14 // Build block filter — blocked comments are removed entirely 15 - const countParams: any[] = [gallery]; 15 + const countParams: any[] = [subject]; 16 16 let countBmParam = ""; 17 17 if (viewerDid) { 18 18 countParams.push(viewerDid); 19 19 countBmParam = `AND ${blockFilter("c.did", `$${countParams.length}`)}`; 20 20 } 21 21 22 - // Count total comments for this gallery, excluding orphaned replies 22 + // Count total comments for this subject, excluding orphaned replies 23 23 const countRows = (await db.query( 24 24 `SELECT count(*) as cnt FROM "social.grain.comment" c 25 25 WHERE c.subject = $1 AND ${NOT_ORPHANED} ${countBmParam}`, ··· 28 28 const totalCount = countRows[0]?.cnt ?? 0; 29 29 30 30 // Fetch comments with cursor-based pagination (oldest first), excluding orphaned replies 31 - const queryParams: any[] = [gallery]; 31 + const queryParams: any[] = [subject]; 32 32 let query = `SELECT c.uri, c.did, c.cid, c.text, c.facets, c.focus, c.reply_to, c.created_at 33 33 FROM "social.grain.comment" c 34 34 WHERE c.subject = $1 AND ${NOT_ORPHANED}`;
+84 -15
server/xrpc/getNotifications.ts
··· 1 1 import { defineQuery, InvalidRequestError } from "$hatk"; 2 - import type { GrainActorProfile, Photo, Gallery } from "$hatk"; 2 + import type { GrainActorProfile, Photo, Gallery, Story } from "$hatk"; 3 3 import { views } from "$hatk"; 4 4 import { lookupHandles } from "../helpers/lookupHandles.ts"; 5 5 ··· 16 16 const favCols = 17 17 select === "count" 18 18 ? "uri" 19 - : "uri, did, created_at, 'favorite' as source, subject as gallery_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 19 + : "uri, did, created_at, 'favorite' as source, subject as subject_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 20 20 21 21 const commentCols = 22 22 select === "count" 23 23 ? "uri" 24 - : "uri, did, created_at, 'comment' as source, subject as gallery_uri, text, facets, reply_to, focus"; 24 + : "uri, did, created_at, 'comment' as source, subject as subject_uri, text, facets, reply_to, focus"; 25 25 26 26 const replyCols = 27 27 select === "count" 28 28 ? "c.uri" 29 - : "c.uri, c.did, c.created_at, 'reply' as source, c.subject as gallery_uri, c.text, c.facets, c.reply_to, c.focus"; 29 + : "c.uri, c.did, c.created_at, 'reply' as source, c.subject as subject_uri, c.text, c.facets, c.reply_to, c.focus"; 30 30 31 31 const followCols = 32 32 select === "count" 33 33 ? "uri" 34 - : "uri, did, created_at, 'follow' as source, NULL as gallery_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 34 + : "uri, did, created_at, 'follow' as source, NULL as subject_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 35 35 36 36 const mentionCommentCols = 37 37 select === "count" 38 38 ? "uri" 39 - : "uri, did, created_at, 'comment-mention' as source, subject as gallery_uri, text, facets, NULL as reply_to, focus"; 39 + : "uri, did, created_at, 'comment-mention' as source, subject as subject_uri, text, facets, NULL as reply_to, focus"; 40 40 41 41 const mentionGalleryCols = 42 42 select === "count" 43 43 ? "uri" 44 - : "uri, did, created_at, 'gallery-mention' as source, uri as gallery_uri, description as text, facets, NULL as reply_to, NULL as focus"; 44 + : "uri, did, created_at, 'gallery-mention' as source, uri as subject_uri, description as text, facets, NULL as reply_to, NULL as focus"; 45 + 46 + const storyFavCols = 47 + select === "count" 48 + ? "uri" 49 + : "uri, did, created_at, 'story-favorite' as source, subject as subject_uri, NULL as text, NULL as facets, NULL as reply_to, NULL as focus"; 50 + 51 + const storyCommentCols = 52 + select === "count" 53 + ? "uri" 54 + : "uri, did, created_at, 'story-comment' as source, subject as subject_uri, text, facets, reply_to, focus"; 45 55 46 56 return ` 47 57 SELECT ${favCols} FROM "social.grain.favorite" ··· 77 87 78 88 SELECT ${mentionGalleryCols} FROM "social.grain.gallery" 79 89 WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 90 + 91 + UNION ALL 92 + 93 + SELECT ${storyFavCols} FROM "social.grain.favorite" 94 + WHERE subject IN (SELECT uri FROM "social.grain.story" WHERE did = $1) 95 + AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 96 + 97 + UNION ALL 98 + 99 + SELECT ${storyCommentCols} FROM "social.grain.comment" 100 + WHERE subject IN (SELECT uri FROM "social.grain.story" WHERE did = $1) 101 + AND did != $1 AND reply_to IS NULL ${blockMuteNotifFilter()} ${extraFilter} 80 102 `; 81 103 } 82 104 ··· 124 146 did: string; 125 147 created_at: string; 126 148 source: string; 127 - gallery_uri: string | null; 149 + subject_uri: string | null; 128 150 text: string | null; 129 151 facets: string | null; 130 152 reply_to: string | null; ··· 138 160 // Determine notification reason 139 161 function getReason(row: (typeof items)[0]): string { 140 162 if (row.source === "favorite") return "gallery-favorite"; 163 + if (row.source === "story-favorite") return "story-favorite"; 164 + if (row.source === "story-comment") return "story-comment"; 141 165 if (row.source === "follow") return "follow"; 142 166 if (row.source === "comment-mention") return "gallery-comment-mention"; 143 167 if (row.source === "gallery-mention") return "gallery-mention"; ··· 163 187 // Hydrate author profiles 164 188 const dids = [...new Set(items.map((r) => r.did))]; 165 189 const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 190 + const handleMap = await lookupHandles(db, dids); 166 191 167 - const handleMap = await lookupHandles(db, dids); 192 + // Separate subject URIs into gallery and story URIs 193 + const allSubjectUris = [...new Set(items.map((r) => r.subject_uri).filter(Boolean))] as string[]; 194 + 195 + // Look up which subjects are galleries vs stories 196 + const galleryUriSet = new Set<string>(); 197 + const storyUriSet = new Set<string>(); 198 + if (allSubjectUris.length > 0) { 199 + const ph = allSubjectUris.map((_, i) => `$${i + 1}`).join(","); 200 + const [galRows, storyRows] = await Promise.all([ 201 + db.query(`SELECT uri FROM "social.grain.gallery" WHERE uri IN (${ph})`, allSubjectUris) as Promise<{ uri: string }[]>, 202 + db.query(`SELECT uri FROM "social.grain.story" WHERE uri IN (${ph})`, allSubjectUris) as Promise<{ uri: string }[]>, 203 + ]); 204 + for (const r of galRows) galleryUriSet.add(r.uri); 205 + for (const r of storyRows) storyUriSet.add(r.uri); 206 + } 207 + 208 + const galleryUris = [...galleryUriSet]; 209 + const storyUris = [...storyUriSet]; 168 210 169 211 // Hydrate galleries for thumbnails 170 - const galleryUris = [...new Set(items.map((r) => r.gallery_uri).filter(Boolean))] as string[]; 171 212 const galleries = 172 213 galleryUris.length > 0 173 214 ? await getRecords<Gallery>("social.grain.gallery", galleryUris) ··· 197 238 ? await getRecords<Photo>("social.grain.photo", allPhotoUris) 198 239 : new Map(); 199 240 241 + // Hydrate story thumbnails 242 + const storyThumbs = new Map<string, string>(); 243 + if (storyUris.length > 0) { 244 + const storyRows = (await db.query( 245 + `SELECT uri, did, media FROM "social.grain.story" 246 + WHERE uri IN (${storyUris.map((_, i) => `$${i + 1}`).join(",")})`, 247 + storyUris, 248 + )) as Array<{ uri: string; did: string; media: string }>; 249 + for (const row of storyRows) { 250 + let blobRef: any; 251 + try { 252 + blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; 253 + } catch { 254 + blobRef = row.media; 255 + } 256 + const thumb = blobUrl(row.did, blobRef, "feed_thumbnail"); 257 + if (thumb) storyThumbs.set(row.uri, thumb); 258 + } 259 + } 260 + 200 261 // Hydrate reply-to texts 201 262 const replyToUris = items.map((r) => r.reply_to).filter(Boolean) as string[]; 202 263 const replyToComments = ··· 212 273 213 274 const notifications = items.map((row) => { 214 275 const author = profiles.get(row.did); 215 - const gallery = row.gallery_uri ? galleries.get(row.gallery_uri) : null; 216 - const photoUri = row.gallery_uri ? firstPhotoByGallery.get(row.gallery_uri) : null; 276 + const subjectUri = row.subject_uri; 277 + const isGallery = subjectUri ? galleryUriSet.has(subjectUri) : false; 278 + const isStory = subjectUri ? storyUriSet.has(subjectUri) : false; 279 + 280 + const gallery = isGallery && subjectUri ? galleries.get(subjectUri) : null; 281 + const photoUri = isGallery && subjectUri ? firstPhotoByGallery.get(subjectUri) : null; 217 282 const photo = photoUri ? photos.get(photoUri) : null; 218 - const thumb = photo 283 + const galleryThumb = photo 219 284 ? (blobUrl(photo.did, photo.value.photo, "feed_thumbnail") ?? undefined) 220 285 : undefined; 286 + 287 + const storyThumb = isStory && subjectUri ? storyThumbs.get(subjectUri) : undefined; 221 288 222 289 return { 223 290 uri: row.uri, ··· 236 303 did: row.did, 237 304 handle: handleMap.get(row.did) ?? row.did, 238 305 }), 239 - ...(gallery ? { galleryUri: row.gallery_uri!, galleryTitle: gallery.value.title } : {}), 240 - ...(thumb ? { galleryThumb: thumb } : {}), 306 + ...(gallery ? { galleryUri: subjectUri!, galleryTitle: gallery.value.title } : {}), 307 + ...(galleryThumb ? { galleryThumb } : {}), 308 + ...(isStory && subjectUri ? { storyUri: subjectUri } : {}), 309 + ...(storyThumb ? { storyThumb } : {}), 241 310 ...(row.text ? { commentText: row.text } : {}), 242 311 ...(row.reply_to && replyToTextMap.has(row.reply_to) 243 312 ? { replyToText: replyToTextMap.get(row.reply_to) }