grain.social is a photo sharing platform built on atproto.

feat: gallery comments and comment notifications

+99 -1
__generated__/lexicons.ts
··· 2445 2445 type: 'string', 2446 2446 description: 2447 2447 'The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.', 2448 - knownValues: ['follow', 'gallery-favorite', 'unknown'], 2448 + knownValues: [ 2449 + 'follow', 2450 + 'gallery-favorite', 2451 + 'gallery-comment', 2452 + 'unknown', 2453 + ], 2449 2454 }, 2450 2455 reasonSubject: { 2451 2456 type: 'string', ··· 2465 2470 }, 2466 2471 }, 2467 2472 }, 2473 + SocialGrainCommentDefs: { 2474 + lexicon: 1, 2475 + id: 'social.grain.comment.defs', 2476 + defs: { 2477 + commentView: { 2478 + type: 'object', 2479 + required: ['uri', 'cid', 'author', 'text', 'createdAt'], 2480 + properties: { 2481 + uri: { 2482 + type: 'string', 2483 + format: 'at-uri', 2484 + }, 2485 + cid: { 2486 + type: 'string', 2487 + format: 'cid', 2488 + }, 2489 + author: { 2490 + type: 'ref', 2491 + ref: 'lex:social.grain.actor.defs#profileView', 2492 + }, 2493 + text: { 2494 + type: 'string', 2495 + maxLength: 3000, 2496 + maxGraphemes: 300, 2497 + }, 2498 + subject: { 2499 + type: 'union', 2500 + refs: ['lex:social.grain.gallery.defs#galleryView'], 2501 + description: 2502 + 'The subject of the comment, which can be a gallery or a photo.', 2503 + }, 2504 + focus: { 2505 + type: 'union', 2506 + refs: ['lex:social.grain.photo.defs#photoView'], 2507 + description: 2508 + 'The photo that the comment is focused on, if applicable.', 2509 + }, 2510 + replyTo: { 2511 + type: 'string', 2512 + format: 'at-uri', 2513 + description: 2514 + 'The URI of the comment this comment is replying to, if applicable.', 2515 + }, 2516 + createdAt: { 2517 + type: 'string', 2518 + format: 'datetime', 2519 + }, 2520 + }, 2521 + }, 2522 + }, 2523 + }, 2524 + SocialGrainComment: { 2525 + lexicon: 1, 2526 + id: 'social.grain.comment', 2527 + defs: { 2528 + main: { 2529 + type: 'record', 2530 + key: 'tid', 2531 + record: { 2532 + type: 'object', 2533 + required: ['text', 'subject', 'createdAt'], 2534 + properties: { 2535 + text: { 2536 + type: 'string', 2537 + maxLength: 3000, 2538 + maxGraphemes: 300, 2539 + }, 2540 + subject: { 2541 + type: 'string', 2542 + format: 'at-uri', 2543 + }, 2544 + focus: { 2545 + type: 'string', 2546 + format: 'at-uri', 2547 + }, 2548 + replyTo: { 2549 + type: 'string', 2550 + format: 'at-uri', 2551 + }, 2552 + createdAt: { 2553 + type: 'string', 2554 + format: 'datetime', 2555 + }, 2556 + }, 2557 + }, 2558 + }, 2559 + }, 2560 + }, 2468 2561 SocialGrainGalleryItem: { 2469 2562 lexicon: 1, 2470 2563 id: 'social.grain.gallery.item', ··· 2528 2621 }, 2529 2622 }, 2530 2623 favCount: { 2624 + type: 'integer', 2625 + }, 2626 + commentCount: { 2531 2627 type: 'integer', 2532 2628 }, 2533 2629 labels: { ··· 3418 3514 ShTangledActorProfile: 'sh.tangled.actor.profile', 3419 3515 SocialGrainDefs: 'social.grain.defs', 3420 3516 SocialGrainNotificationDefs: 'social.grain.notification.defs', 3517 + SocialGrainCommentDefs: 'social.grain.comment.defs', 3518 + SocialGrainComment: 'social.grain.comment', 3421 3519 SocialGrainGalleryItem: 'social.grain.gallery.item', 3422 3520 SocialGrainGalleryDefs: 'social.grain.gallery.defs', 3423 3521 SocialGrainGallery: 'social.grain.gallery',
+31
__generated__/types/social/grain/comment.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../lexicons.ts' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'social.grain.comment' 12 + 13 + export interface Record { 14 + $type: 'social.grain.comment' 15 + text: string 16 + subject: string 17 + focus?: string 18 + replyTo?: string 19 + createdAt: string 20 + [k: string]: unknown 21 + } 22 + 23 + const hashRecord = 'main' 24 + 25 + export function isRecord<V>(v: V) { 26 + return is$typed(v, id, hashRecord) 27 + } 28 + 29 + export function validateRecord<V>(v: V) { 30 + return validate<Record & V>(v, id, hashRecord, true) 31 + }
+41
__generated__/types/social/grain/comment/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + import type * as SocialGrainActorDefs from '../actor/defs.ts' 13 + import type * as SocialGrainGalleryDefs from '../gallery/defs.ts' 14 + import type * as SocialGrainPhotoDefs from '../photo/defs.ts' 15 + 16 + const is$typed = _is$typed, 17 + validate = _validate 18 + const id = 'social.grain.comment.defs' 19 + 20 + export interface CommentView { 21 + $type?: 'social.grain.comment.defs#commentView' 22 + uri: string 23 + cid: string 24 + author: SocialGrainActorDefs.ProfileView 25 + text: string 26 + subject?: $Typed<SocialGrainGalleryDefs.GalleryView> | { $type: string } 27 + focus?: $Typed<SocialGrainPhotoDefs.PhotoView> | { $type: string } 28 + /** The URI of the comment this comment is replying to, if applicable. */ 29 + replyTo?: string 30 + createdAt: string 31 + } 32 + 33 + const hashCommentView = 'commentView' 34 + 35 + export function isCommentView<V>(v: V) { 36 + return is$typed(v, id, hashCommentView) 37 + } 38 + 39 + export function validateCommentView<V>(v: V) { 40 + return validate<CommentView & V>(v, id, hashCommentView) 41 + }
+1
__generated__/types/social/grain/gallery/defs.ts
··· 25 25 record: { [_ in string]: unknown } 26 26 items?: ($Typed<SocialGrainPhotoDefs.PhotoView> | { $type: string })[] 27 27 favCount?: number 28 + commentCount?: number 28 29 labels?: ComAtprotoLabelDefs.Label[] 29 30 indexedAt: string 30 31 viewer?: ViewerState
+6 -1
__generated__/types/social/grain/notification/defs.ts
··· 21 21 cid: string 22 22 author: SocialGrainActorDefs.ProfileView 23 23 /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 24 - reason: 'follow' | 'gallery-favorite' | 'unknown' | (string & {}) 24 + reason: 25 + | 'follow' 26 + | 'gallery-favorite' 27 + | 'gallery-comment' 28 + | 'unknown' 29 + | (string & {}) 25 30 reasonSubject?: string 26 31 record: { [_ in string]: unknown } 27 32 isRead: boolean
+2 -2
deno.json
··· 27 27 "dev:server": "deno run -A --env-file --watch ./src/main.tsx", 28 28 "dev:tailwind": "deno run -A --node-modules-dir npm:@tailwindcss/cli -i ./src/input.css -o ./build/styles.css --watch", 29 29 "dev:fonts": "rm -rf ./build/fonts && cp -r ./static/fonts/. ./build/fonts", 30 - "sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile --collection-key-map=\"{\"social.grain.favorite\":[\"subject\"],\"social.grain.graph.follow\":[\"subject\"],\"social.grain.gallery.item\":[\"gallery\",\"item\"],\"social.grain.photo.exif\":[\"photo\"]}\"", 31 - "codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.37 lexgen" 30 + "sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif,social.grain.comment --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile --collection-key-map=\"{\\\"social.grain.favorite\\\":[\\\"subject\\\"],\\\"social.grain.graph.follow\\\":[\\\"subject\\\"],\\\"social.grain.gallery.item\\\":[\\\"gallery\\\",\\\"item\\\"],\\\"social.grain.photo.exif\\\":[\\\"photo\\\"],\\\"social.grain.comment\\\":[\\\"subject\\\"]}\"", 31 + "codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.40 lexgen" 32 32 }, 33 33 "compilerOptions": { 34 34 "jsx": "precompile",
+37
lexicons/social/grain/comment/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.comment", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["text", "subject", "createdAt"], 11 + "properties": { 12 + "text": { 13 + "type": "string", 14 + "maxLength": 3000, 15 + "maxGraphemes": 300 16 + }, 17 + "subject": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "focus": { 22 + "type": "string", 23 + "format": "at-uri" 24 + }, 25 + "replyTo": { 26 + "type": "string", 27 + "format": "at-uri" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+46
lexicons/social/grain/comment/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.comment.defs", 4 + "defs": { 5 + "commentView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "author", "text", "createdAt"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "author": { 12 + "type": "ref", 13 + "ref": "social.grain.actor.defs#profileView" 14 + }, 15 + "text": { 16 + "type": "string", 17 + "maxLength": 3000, 18 + "maxGraphemes": 300 19 + }, 20 + "subject": { 21 + "type": "union", 22 + "refs": [ 23 + "social.grain.gallery.defs#galleryView" 24 + ], 25 + "description": "The subject of the comment, which can be a gallery or a photo." 26 + }, 27 + "focus": { 28 + "type": "union", 29 + "refs": [ 30 + "social.grain.photo.defs#photoView" 31 + ], 32 + "description": "The photo that the comment is focused on, if applicable." 33 + }, 34 + "replyTo": { 35 + "type": "string", 36 + "format": "at-uri", 37 + "description": "The URI of the comment this comment is replying to, if applicable." 38 + }, 39 + "createdAt": { 40 + "type": "string", 41 + "format": "datetime" 42 + } 43 + } 44 + } 45 + } 46 + }
+1
lexicons/social/grain/gallery/defs.json
··· 23 23 } 24 24 }, 25 25 "favCount": { "type": "integer" }, 26 + "commentCount": { "type": "integer" }, 26 27 "labels": { 27 28 "type": "array", 28 29 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
+1
lexicons/social/grain/notification/defs.json
··· 26 26 "knownValues": [ 27 27 "follow", 28 28 "gallery-favorite", 29 + "gallery-comment", 29 30 "unknown" 30 31 ] 31 32 },
+6 -3
src/components/ActorInfo.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 2 import { Un$Typed } from "$lexicon/util.ts"; 3 + import { cn } from "@bigmoves/bff/components"; 3 4 import { profileLink } from "../utils.ts"; 4 5 import { ActorAvatar } from "./ActorAvatar.tsx"; 5 6 6 7 export function ActorInfo( 7 - { profile }: Readonly<{ profile: Un$Typed<ProfileView> }>, 8 + { class: classProp, profile, avatarSize = 28 }: Readonly< 9 + { class?: string; profile: Un$Typed<ProfileView>; avatarSize?: number } 10 + >, 8 11 ) { 9 12 return ( 10 - <div class="flex items-center gap-2 min-w-0 flex-1"> 11 - <ActorAvatar profile={profile} size={28} class="shrink-0" /> 13 + <div class={cn("flex items-center gap-2 min-w-0", classProp)}> 14 + <ActorAvatar profile={profile} size={avatarSize} class="shrink-0" /> 12 15 <a 13 16 href={profileLink(profile.handle)} 14 17 class="hover:underline text-zinc-600 dark:text-zinc-500 truncate max-w-[300px] sm:max-w-[400px]"
+3 -3
src/components/Dialog.tsx
··· 38 38 {...{ 39 39 _: `on closeDialog 40 40 remove me 41 - remove .tw:pointer-events-none from document.body 41 + remove .pointer-events-none from document.body 42 42 remove [@data-scroll-locked] from document.body 43 43 on keyup[key is 'Escape'] from <body/> trigger closeDialog 44 44 init 45 - add .tw:pointer-events-none to document.body 46 - add .tw:pointer-events-auto to me 45 + add .pointer-events-none to document.body 46 + add .pointer-events-auto to me 47 47 add [@data-scroll-locked=true] to document.body 48 48 ${_}`, 49 49 }}
+3 -5
src/components/FavoriteButton.tsx
··· 23 23 : undefined; 24 24 return ( 25 25 <Button 26 - variant={variant === "icon-button" ? "ghost" : "primary"} 26 + variant={variant === "icon-button" ? "ghost" : "secondary"} 27 27 class={cn( 28 - "self-start w-full sm:w-fit whitespace-nowrap", 29 - variant === "icon-button" && gallery.viewer?.fav 30 - ? "text-pink-500" 31 - : undefined, 28 + "whitespace-nowrap", 29 + gallery.viewer?.fav ? "text-pink-500" : undefined, 32 30 variantClass, 33 31 classProp, 34 32 )}
+2 -2
src/components/GalleryLayout.tsx
··· 74 74 type="button" 75 75 hx-get={photoDialogLink(gallery, photo)} 76 76 hx-trigger="click" 77 - hx-target="#layout" 78 - hx-swap="afterbegin" 77 + hx-target="#dialog-target" 78 + hx-swap="innerHTML" 79 79 class="gallery-item absolute cursor-pointer" 80 80 data-width={photo.aspectRatio?.width} 81 81 data-height={photo.aspectRatio?.height}
+11 -6
src/components/GalleryPage.tsx
··· 2 2 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 3 import { AtUri } from "@atproto/syntax"; 4 4 import { ModerationDecsion } from "../lib/moderation.ts"; 5 + import { CommentsButton } from "../modules/comments.tsx"; 5 6 import { EditGalleryButton } from "./EditGalleryDialog.tsx"; 6 7 import { FavoriteButton } from "./FavoriteButton.tsx"; 7 8 import { GalleryInfo } from "./GalleryInfo.tsx"; ··· 28 29 <GalleryInfo gallery={gallery} /> 29 30 {isLoggedIn && isCreator 30 31 ? ( 31 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end"> 32 + <div class="flex self-start gap-2 w-full flex-col sm:flex-row sm:justify-end"> 32 33 <EditGalleryButton gallery={gallery} /> 33 - <ShareGalleryDialogButton gallery={gallery} /> 34 - <FavoriteButton gallery={gallery} /> 34 + <div class="flex flex-row gap-2"> 35 + <FavoriteButton class="flex-1" gallery={gallery} /> 36 + <CommentsButton class="flex-1" gallery={gallery} /> 37 + <ShareGalleryDialogButton class="flex-1" gallery={gallery} /> 38 + </div> 35 39 </div> 36 40 ) 37 41 : null} 38 42 {!isCreator 39 43 ? ( 40 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 41 - <ShareGalleryDialogButton gallery={gallery} /> 42 - <FavoriteButton gallery={gallery} /> 44 + <div class="flex self-start gap-2 flex-row"> 45 + <FavoriteButton class="flex-1" gallery={gallery} /> 46 + <CommentsButton class="flex-1" gallery={gallery} /> 47 + <ShareGalleryDialogButton class="flex-1" gallery={gallery} /> 43 48 </div> 44 49 ) 45 50 : null}
+45 -2
src/components/NotificationsPage.tsx
··· 1 + import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 1 2 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 3 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 4 import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 4 5 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 6 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 7 import { Un$Typed } from "$lexicon/util.ts"; 6 8 import { formatRelativeTime, profileLink } from "../utils.ts"; 7 9 import { ActorAvatar } from "./ActorAvatar.tsx"; ··· 9 11 import { Header } from "./Header.tsx"; 10 12 11 13 export function NotificationsPage( 12 - { galleriesMap, notifications }: Readonly< 14 + { photosMap, galleriesMap, notifications }: Readonly< 13 15 { 16 + photosMap: Map<string, Un$Typed<PhotoView>>; 14 17 galleriesMap: Map<string, Un$Typed<GalleryView>>; 15 18 notifications: Un$Typed<NotificationView>[]; 16 19 } ··· 49 52 <> 50 53 favorited your gallery · {formatRelativeTime( 51 54 new Date((notification.record as Favorite).createdAt), 55 + )} 56 + </> 57 + )} 58 + {notification.reason === "gallery-comment" && ( 59 + <> 60 + commented on your gallery · {formatRelativeTime( 61 + new Date((notification.record as Comment).createdAt), 52 62 )} 53 63 </> 54 64 )} ··· 69 79 <GalleryPreviewLink 70 80 gallery={galleriesMap.get( 71 81 (notification.record as Favorite).subject, 72 - ) as Un$Typed<GalleryView>} 82 + ) as GalleryView} 73 83 size="small" 74 84 /> 75 85 </div> 86 + ) 87 + : null} 88 + {notification.reason === "gallery-comment" && galleriesMap.get( 89 + (notification.record as Comment).subject, 90 + ) 91 + ? ( 92 + <> 93 + {(notification.record as Comment).text} 94 + {(notification.record as Comment).focus 95 + ? ( 96 + <div class="w-[200px] pointer-events-none"> 97 + <img 98 + src={photosMap.get( 99 + (notification.record as Comment).focus ?? "", 100 + )?.thumb} 101 + alt={photosMap.get( 102 + (notification.record as Comment).focus ?? "", 103 + )?.alt} 104 + class="rounded-md" 105 + /> 106 + </div> 107 + ) 108 + : ( 109 + <div class="w-[200px]"> 110 + <GalleryPreviewLink 111 + gallery={galleriesMap.get( 112 + (notification.record as Favorite).subject, 113 + ) as GalleryView} 114 + size="small" 115 + /> 116 + </div> 117 + )} 118 + </> 76 119 ) 77 120 : null} 78 121 </li>
+18 -19
src/components/PhotoDialog.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 1 2 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 3 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 4 import { AtUri } from "@atproto/syntax"; 4 5 import { cn } from "@bigmoves/bff/components"; 6 + import { ReplyButton } from "../modules/comments.tsx"; 5 7 import { photoDialogLink } from "../utils.ts"; 6 8 import { Dialog } from "./Dialog.tsx"; 7 9 8 10 export function PhotoDialog({ 11 + userProfile, 9 12 gallery, 10 13 image, 11 14 nextImage, 12 15 prevImage, 13 16 }: Readonly<{ 17 + userProfile?: ProfileView; 14 18 gallery: GalleryView; 15 19 image: PhotoView; 16 20 nextImage?: PhotoView; ··· 50 54 class="absolute inset-0 w-full h-full object-contain" 51 55 /> 52 56 </div> 53 - {image.exif 57 + {image.alt 54 58 ? ( 55 - <div class="hidden sm:block absolute bottom-2 right-2"> 56 - <ExifButton photo={image} /> 59 + <div class="px-4 sm:px-0 py-4 bg-black text-white text-left flex"> 60 + <span class="flex-1 mr-2">{image.alt}</span> 57 61 </div> 58 62 ) 59 63 : null} 60 - {image.alt 64 + {(userProfile || image.exif) 61 65 ? ( 62 - <div class="px-4 sm:px-0 py-4 bg-black text-white text-left flex"> 63 - <span class="flex-1 mr-2">{image.alt}</span> 64 - {image.exif 66 + <div class="flex w-full gap-2 p-2 sm:px-0 sm:py-2"> 67 + {userProfile 65 68 ? ( 66 - <div class="block sm:hidden self-end justify-end -m-2"> 67 - <ExifButton photo={image} /> 68 - </div> 69 + <ReplyButton 70 + class="flex-1 bg-zinc-800 sm:bg-transparent sm:hover:bg-zinc-800 text-zinc-50" 71 + gallery={gallery} 72 + photo={image} 73 + userProfile={userProfile} 74 + /> 69 75 ) 70 - : null} 76 + : <div class="flex-1" />} 77 + {image.exif ? <ExifButton photo={image} /> : null} 71 78 </div> 72 - ) 73 - : null} 74 - {!image.alt && image.exif 75 - ? ( 76 - <ExifButton 77 - photo={image} 78 - class="block sm:hidden absolute bottom-2 right-2 z-100" 79 - /> 80 79 ) 81 80 : null} 82 81 </div>
+6 -3
src/components/ShareGalleryDialog.tsx
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 2 import { AtUri } from "@atproto/syntax"; 3 + import { cn } from "@bigmoves/bff/components"; 3 4 import { publicGalleryLink } from "../utils.ts"; 4 5 import { Button } from "./Button.tsx"; 5 6 import { Dialog } from "./Dialog.tsx"; ··· 53 54 } 54 55 55 56 export function ShareGalleryDialogButton( 56 - { gallery }: Readonly<{ gallery: GalleryView }>, 57 + { class: classProp, gallery }: Readonly< 58 + { class?: string; gallery: GalleryView } 59 + >, 57 60 ) { 58 61 const rkey = new AtUri(gallery.uri).rkey; 59 62 return ( 60 63 <Button 61 - variant="primary" 62 - class="whitespace-nowrap" 64 + variant="secondary" 65 + class={cn("whitespace-nowrap", classProp)} 63 66 hx-get={`/dialogs/${gallery.creator.did}/gallery/${rkey}/share`} 64 67 hx-trigger="click" 65 68 hx-target="#dialog-target"
+1
src/components/Timeline.tsx
··· 16 16 ) { 17 17 return ( 18 18 <div class="px-4 mb-4" id="timeline-page"> 19 + <div id="dialog-target" /> 19 20 {isLoggedIn 20 21 ? ( 21 22 <>
+12 -5
src/components/TimelineItem.tsx
··· 1 1 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 2 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 3 import { type TimelineItem } from "../lib/timeline.ts"; 4 + import { CommentsButton } from "../modules/comments.tsx"; 4 5 import { formatRelativeTime } from "../utils.ts"; 5 6 import { ActorInfo } from "./ActorInfo.tsx"; 6 7 import { FavoriteButton } from "./FavoriteButton.tsx"; ··· 14 15 <li> 15 16 <div class="flex flex-col pb-4 max-w-md"> 16 17 <div class="flex items-center justify-between gap-2 w-full mb-4"> 17 - <ActorInfo profile={item.actor} /> 18 + <ActorInfo profile={item.actor} class="flex-1" /> 18 19 <span class="shrink-0"> 19 20 {formatRelativeTime(new Date(item.createdAt))} 20 21 </span> ··· 47 48 {description} 48 49 </p> 49 50 )} 50 - <FavoriteButton 51 - gallery={item.gallery} 52 - variant="icon-button" 53 - /> 51 + <div class="flex gap-4"> 52 + <FavoriteButton 53 + gallery={item.gallery} 54 + variant="icon-button" 55 + /> 56 + <CommentsButton 57 + gallery={item.gallery} 58 + variant="icon-button" 59 + /> 60 + </div> 54 61 </div> 55 62 </li> 56 63 );
+15
src/input.css
··· 165 165 margin-top: 0; 166 166 margin-right: 0px !important; 167 167 } 168 + 169 + .grain-scroll-area { 170 + overflow-y: auto; 171 + scrollbar-gutter: stable; 172 + scrollbar-width: thin; /* Firefox */ 173 + } 174 + 175 + .grain-scroll-area::-webkit-scrollbar { 176 + width: 8px; /* Chrome/Safari */ 177 + } 178 + 179 + .grain-scroll-area::-webkit-scrollbar-thumb { 180 + background: rgba(100, 100, 100, 0.4); 181 + border-radius: 4px; 182 + } 168 183 }
+23
src/lib/actor.ts
··· 250 250 if (tangledProfiles.length) profiles.push("tangled"); 251 251 return profiles; 252 252 } 253 + 254 + export function getActorProfilesBulk( 255 + dids: string[], 256 + ctx: BffContext, 257 + ) { 258 + const { items: profiles } = ctx.indexService.getRecords< 259 + WithBffMeta<GrainProfile> 260 + >( 261 + "social.grain.actor.profile", 262 + { 263 + where: { 264 + AND: [ 265 + { field: "did", in: dids }, 266 + ], 267 + }, 268 + }, 269 + ); 270 + 271 + return profiles.map((profile) => { 272 + const handle = ctx.indexService.getActor(profile.did)?.handle ?? ""; 273 + return profileToView(profile, handle); 274 + }); 275 + }
+42 -2
src/lib/gallery.ts
··· 16 16 PhotoView, 17 17 } from "$lexicon/types/social/grain/photo/defs.ts"; 18 18 import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 19 - import { Un$Typed } from "$lexicon/util.ts"; 19 + import { $Typed, Un$Typed } from "$lexicon/util.ts"; 20 20 import { AtUri } from "@atproto/syntax"; 21 21 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 22 + import { getGalleryCommentsCount } from "../modules/comments.tsx"; 22 23 import { getActorProfile } from "./actor.ts"; 23 24 import { photoToView } from "./photo.ts"; 24 25 ··· 113 114 114 115 const favs = getGalleryFavs(gallery.uri, ctx); 115 116 117 + const comments = getGalleryCommentsCount(gallery.uri, ctx); 118 + 116 119 let viewerFav: string | undefined = undefined; 117 120 if (ctx.currentUser?.did) { 118 121 const fav = getGalleryFav(ctx.currentUser?.did, gallery.uri, ctx); ··· 127 130 items: galleryPhotosMap.get(gallery.uri) ?? [], 128 131 labels, 129 132 favCount: favs, 133 + commentCount: comments, 130 134 viewerState: { 131 135 fav: viewerFav, 132 136 }, ··· 197 201 items, 198 202 labels = [], 199 203 favCount, 204 + commentCount, 200 205 viewerState, 201 206 }: { 202 207 record: WithBffMeta<Gallery>; ··· 204 209 items: PhotoWithExif[]; 205 210 labels: Label[]; 206 211 favCount?: number; 212 + commentCount?: number; 207 213 viewerState?: ViewerState; 208 - }): Un$Typed<GalleryView> { 214 + }): $Typed<GalleryView> { 209 215 return { 216 + $type: "social.grain.gallery.defs#galleryView", 210 217 uri: record.uri, 211 218 cid: record.cid, 212 219 creator, ··· 217 224 labels, 218 225 indexedAt: record.indexedAt, 219 226 favCount, 227 + commentCount, 220 228 viewer: viewerState, 221 229 }; 222 230 } ··· 344 352 }) 345 353 .filter(isPhotoView); 346 354 } 355 + 356 + export function getGalleriesBulk( 357 + uris: string[], 358 + ctx: BffContext, 359 + ) { 360 + if (!uris.length) return []; 361 + const { items: galleries } = ctx.indexService.getRecords< 362 + WithBffMeta<Gallery> 363 + >( 364 + "social.grain.gallery", 365 + { 366 + where: [{ field: "uri", in: uris }], 367 + }, 368 + ); 369 + if (!galleries.length) return []; 370 + 371 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 372 + 373 + const profile = getActorProfile(galleries[0].did, ctx); 374 + if (!profile) return []; 375 + 376 + const labels = ctx.indexService.queryLabels({ subjects: uris }); 377 + 378 + return galleries.map((gallery) => 379 + galleryToView({ 380 + record: gallery, 381 + creator: profile, 382 + items: galleryPhotosMap.get(gallery.uri) ?? [], 383 + labels, 384 + }) 385 + ); 386 + }
+6 -2
src/lib/notifications.ts
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 2 3 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 4 import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 4 5 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; ··· 6 7 import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 7 8 import { getActorProfile } from "./actor.ts"; 8 9 9 - export type NotificationRecords = WithBffMeta<Favorite | Follow>; 10 + export type NotificationRecords = WithBffMeta<Favorite | Follow | Comment>; 10 11 11 12 export function getNotifications( 12 13 currentUser: ActorTable, ··· 18 19 .filter( 19 20 (notification) => 20 21 notification.$type === "social.grain.favorite" || 21 - notification.$type === "social.grain.graph.follow", 22 + notification.$type === "social.grain.graph.follow" || 23 + notification.$type === "social.grain.comment", 22 24 ) 23 25 .map((notification) => { 24 26 const actor = ctx.indexService.getActor(notification.did); ··· 43 45 reason = "gallery-favorite"; 44 46 } else if (record.$type === "social.grain.graph.follow") { 45 47 reason = "follow"; 48 + } else if (record.$type === "social.grain.comment") { 49 + reason = "gallery-comment"; 46 50 } else { 47 51 reason = "unknown"; 48 52 }
+30 -1
src/lib/photo.ts
··· 241 241 labels: labels ?? [], 242 242 }); 243 243 }) 244 - .filter((g): g is GalleryView => Boolean(g)); 244 + .filter((g): g is $Typed<GalleryView> => Boolean(g)); 245 + } 246 + 247 + export function getPhotosBulk( 248 + uris: string[], 249 + ctx: BffContext, 250 + ) { 251 + if (!uris.length) return []; 252 + const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 253 + "social.grain.photo", 254 + { 255 + where: [{ field: "uri", in: uris }], 256 + }, 257 + ); 258 + if (!photos.length) return []; 259 + const { items: exifItems } = ctx.indexService.getRecords< 260 + WithBffMeta<PhotoExif> 261 + >( 262 + "social.grain.photo.exif", 263 + { 264 + where: [{ field: "photo", in: uris }], 265 + }, 266 + ); 267 + const exifMap = new Map<string, WithBffMeta<PhotoExif>>(); 268 + for (const exif of exifItems) { 269 + exifMap.set(exif.photo, exif); 270 + } 271 + return photos.map((photo) => 272 + photoToView(photo.did, photo, exifMap.get(photo.uri)) 273 + ); 245 274 }
+4
src/lib/timeline.ts
··· 7 7 import { Un$Typed } from "$lexicon/util.ts"; 8 8 import { AtUri } from "@atproto/syntax"; 9 9 import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff"; 10 + import { getGalleryCommentsCount } from "../modules/comments.tsx"; 10 11 import { getActorProfile } from "./actor.ts"; 11 12 import { 12 13 galleryToView, ··· 91 92 } 92 93 } 93 94 95 + const comments = getGalleryCommentsCount(gallery.uri, ctx); 96 + 94 97 const galleryView = galleryToView({ 95 98 record: gallery, 96 99 creator: profile, 97 100 items: galleryPhotos, 98 101 labels, 99 102 favCount: favs, 103 + commentCount: comments, 100 104 viewerState: { 101 105 fav: viewerFav, 102 106 },
+3
src/main.tsx
··· 4 4 import { LoginPage } from "./components/LoginPage.tsx"; 5 5 import { PDS_HOST_URL } from "./env.ts"; 6 6 import { onError } from "./lib/errors.ts"; 7 + import { middlewares as comments } from "./modules/comments.tsx"; 7 8 import * as actions from "./routes/actions.tsx"; 8 9 import { handler as communityGuidelinesHandler } from "./routes/community_guidelines.tsx"; 9 10 import * as dialogs from "./routes/dialogs.tsx"; ··· 46 47 "social.grain.graph.follow": ["subject"], 47 48 "social.grain.gallery.item": ["gallery", "item"], 48 49 "social.grain.photo.exif": ["photo"], 50 + "social.grain.comment": ["subject"], 49 51 }, 50 52 lexicons, 51 53 rootElement: Root, ··· 125 127 route("/actions/profile", ["PUT"], actions.profileUpdate), 126 128 route("/actions/gallery/:rkey/sort", ["POST"], actions.gallerySort), 127 129 route("/actions/get-blob", ["GET"], actions.getBlob), 130 + ...comments, 128 131 route("/:did/:collection/:rkey", recordHandler), 129 132 ], 130 133 });
+515
src/modules/comments.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 3 + import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 4 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 5 + import { 6 + GalleryView, 7 + isGalleryView, 8 + } from "$lexicon/types/social/grain/gallery/defs.ts"; 9 + import { 10 + isPhotoView, 11 + PhotoView, 12 + } from "$lexicon/types/social/grain/photo/defs.ts"; 13 + import { AtUri } from "@atproto/syntax"; 14 + import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff"; 15 + import { cn } from "@bigmoves/bff/components"; 16 + import { Dialog } from "..//components/Dialog.tsx"; 17 + import { ActorAvatar } from "../components/ActorAvatar.tsx"; 18 + import { ActorInfo } from "../components/ActorInfo.tsx"; 19 + import { Button } from "../components/Button.tsx"; 20 + import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx"; 21 + import { Textarea } from "../components/Textarea.tsx"; 22 + import { getActorProfile, getActorProfilesBulk } from "../lib/actor.ts"; 23 + import { getGalleriesBulk, getGallery } from "../lib/gallery.ts"; 24 + import { getPhoto, getPhotosBulk } from "../lib/photo.ts"; 25 + import { formatRelativeTime } from "../utils.ts"; 26 + 27 + export function ReplyDialog({ userProfile, gallery, photo, comment }: Readonly<{ 28 + userProfile: ProfileView; 29 + gallery?: GalleryView; 30 + photo?: PhotoView; 31 + comment?: CommentView; 32 + }>) { 33 + const galleryRkey = gallery ? new AtUri(gallery.uri).rkey : undefined; 34 + const profile = gallery?.creator; 35 + return ( 36 + <Dialog class="z-101"> 37 + <Dialog.Content class="gap-4"> 38 + <Dialog.Title>Add a comment</Dialog.Title> 39 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 40 + <div class="divide-y divide-zinc-200 dark:divide-zinc-800 space-y-4"> 41 + <div class="flex gap-4 pb-4"> 42 + {!comment && profile 43 + ? <ActorAvatar profile={profile} size={42} /> 44 + : null} 45 + {comment 46 + ? <ActorAvatar profile={comment.author} size={42} /> 47 + : null} 48 + <div class="flex flex-col gap-2"> 49 + {profile 50 + ? <div class="font-semibold">{profile.displayName}</div> 51 + : null} 52 + {comment && comment.text} 53 + {!comment && !photo && gallery && 54 + (gallery.record as Gallery).title} 55 + {!comment && !photo && gallery 56 + ? ( 57 + <div class="w-[200px] pointer-events-none"> 58 + <GalleryPreviewLink 59 + gallery={gallery} 60 + size="small" 61 + /> 62 + </div> 63 + ) 64 + : null} 65 + {photo 66 + ? ( 67 + <div class="w-[200px] pointer-events-none"> 68 + <img src={photo.thumb} alt={photo.alt} class="rounded-md" /> 69 + </div> 70 + ) 71 + : null} 72 + </div> 73 + </div> 74 + <form 75 + id="reply-form" 76 + class="flex gap-4" 77 + hx-post={`/actions/comments/${gallery?.creator.did}/gallery/${galleryRkey}`} 78 + hx-target="#dialog-target" 79 + hx-swap="innerHTML" 80 + _="on htmx:afterOnLoad 81 + if event.detail.xhr.status != 200 82 + alert('Error: ' + event.detail.xhr.responseText)" 83 + > 84 + <ActorAvatar profile={userProfile} size={42} /> 85 + {!comment && photo 86 + ? <input type="hidden" name="focus" value={photo.uri} /> 87 + : null} 88 + {comment 89 + ? <input type="hidden" name="replyTo" value={comment.uri} /> 90 + : null} 91 + <Textarea 92 + class="flex-1" 93 + name="text" 94 + placeholder="Add a comment" 95 + rows={5} 96 + autoFocus 97 + /> 98 + </form> 99 + </div> 100 + <div class="flex flex-col gap-2"> 101 + <Button type="submit" form="reply-form" variant="primary"> 102 + Reply 103 + </Button> 104 + <Dialog.Close variant="secondary">Cancel</Dialog.Close> 105 + </div> 106 + </Dialog.Content> 107 + </Dialog> 108 + ); 109 + } 110 + 111 + export function GalleryCommentsDialog( 112 + { userProfile, comments, gallery }: Readonly<{ 113 + userProfile: ProfileView; 114 + comments: CommentView[]; 115 + gallery: GalleryView; 116 + }>, 117 + ) { 118 + const { topLevel, repliesByParent } = groupComments(comments); 119 + return ( 120 + <Dialog> 121 + <Dialog.Content class="flex flex-col max-h-[90vh] overflow-hidden"> 122 + <div> 123 + <Dialog.Title>Comments</Dialog.Title> 124 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 125 + </div> 126 + <div> 127 + <div class="flex gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800"> 128 + {gallery.creator 129 + ? <ActorAvatar profile={gallery.creator} size={42} /> 130 + : null} 131 + <div class="flex flex-col gap-2"> 132 + {gallery.creator 133 + ? <div class="font-semibold">{gallery.creator.displayName}</div> 134 + : null} 135 + {(gallery.record as Gallery).title} 136 + <div class="w-[200px] pointer-events-none"> 137 + <GalleryPreviewLink 138 + gallery={gallery} 139 + size="small" 140 + /> 141 + </div> 142 + </div> 143 + </div> 144 + <div class="py-1 border-b border-zinc-200 dark:border-zinc-800"> 145 + {gallery 146 + ? ( 147 + <ReplyButton 148 + class="w-full bg-zinc-100 dark:bg-zinc-800 sm:bg-transparent dark:sm:bg-transparent sm:hover:bg-zinc-100 dark:sm:hover:bg-zinc-800" 149 + userProfile={userProfile} 150 + gallery={gallery} 151 + /> 152 + ) 153 + : null} 154 + </div> 155 + </div> 156 + {topLevel && topLevel.length > 0 157 + ? ( 158 + <div class="flex-1 flex flex-col py-4 gap-6 overflow-y-scroll grain-scroll-area"> 159 + {topLevel.map((comment) => ( 160 + <div key={comment.cid} class="flex flex-col gap-4"> 161 + <CommentBlock comment={comment} /> 162 + 163 + {repliesByParent.get(comment.uri)?.map((reply) => ( 164 + <div key={reply.cid} class="ml-6"> 165 + <CommentBlock comment={reply} /> 166 + </div> 167 + ))} 168 + </div> 169 + ))} 170 + </div> 171 + ) 172 + : <div class="py-4">No comments yet.</div>} 173 + <div class="pt-2 border-t border-zinc-200 dark:border-zinc-800"> 174 + <Dialog.Close 175 + variant="secondary" 176 + class="w-full" 177 + > 178 + Close 179 + </Dialog.Close> 180 + </div> 181 + </Dialog.Content> 182 + </Dialog> 183 + ); 184 + } 185 + 186 + function CommentBlock({ comment }: Readonly<{ comment: CommentView }>) { 187 + const gallery = isGalleryView(comment.subject) ? comment.subject : undefined; 188 + const rkey = gallery ? new AtUri(gallery.uri).rkey : undefined; 189 + return ( 190 + <div class="flex gap-3 items-start"> 191 + <div class="flex flex-col flex-1 min-w-0"> 192 + <div class="flex items-center gap-2 min-w-0 text-sm text-zinc-500"> 193 + <ActorInfo profile={comment.author} avatarSize={22} /> 194 + <span class="shrink-0">·</span> 195 + <span class="shrink-0"> 196 + {comment.createdAt 197 + ? formatRelativeTime(new Date(comment.createdAt)) 198 + : ""} 199 + </span> 200 + </div> 201 + 202 + <div class="mt-1">{comment.text}</div> 203 + 204 + {isPhotoView(comment.focus) && ( 205 + <img 206 + src={comment.focus.thumb} 207 + alt={comment.focus.alt} 208 + class="mt-2 rounded-md max-w-[200px] max-h-[150px] object-contain w-fit" 209 + /> 210 + )} 211 + 212 + {!comment.replyTo 213 + ? ( 214 + <button 215 + type="button" 216 + class="w-fit p-0 mt-2 cursor-pointer text-zinc-600 dark:text-zinc-500 font-semibold text-sm" 217 + hx-get={`/ui/comments/${gallery?.creator.did}/gallery/${rkey}/reply?comment=${ 218 + encodeURIComponent(comment.uri) 219 + }`} 220 + hx-trigger="click" 221 + hx-target="#dialog-target" 222 + hx-swap="innerHTML" 223 + > 224 + Reply 225 + </button> 226 + ) 227 + : null} 228 + </div> 229 + </div> 230 + ); 231 + } 232 + 233 + export function CommentsButton( 234 + { class: classProp, variant, gallery }: Readonly<{ 235 + class?: string; 236 + variant: "button" | "icon-button"; 237 + gallery: GalleryView; 238 + }>, 239 + ) { 240 + const variantClass = variant === "icon-button" 241 + ? "flex w-fit items-center gap-2 m-0 p-0 mt-2" 242 + : undefined; 243 + const rkey = new AtUri(gallery.uri).rkey; 244 + return ( 245 + <Button 246 + type="button" 247 + variant={variant === "icon-button" ? "ghost" : "secondary"} 248 + class={cn("whitespace-nowrap", variantClass, classProp)} 249 + hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}`} 250 + hx-trigger="click" 251 + hx-target="#dialog-target" 252 + hx-swap="innerHTML" 253 + > 254 + <i class="fa-regular fa-comment" /> {gallery.commentCount ?? 0} 255 + </Button> 256 + ); 257 + } 258 + 259 + export function ReplyButton( 260 + { class: classProp, userProfile, gallery, photo }: Readonly<{ 261 + class?: string; 262 + userProfile: ProfileView; 263 + gallery: GalleryView; 264 + photo?: PhotoView; 265 + }>, 266 + ) { 267 + const rkey = new AtUri(gallery.uri).rkey; 268 + return ( 269 + <button 270 + type="button" 271 + class={cn( 272 + "flex items-center gap-4 p-3 rounded-full cursor-pointer", 273 + classProp, 274 + )} 275 + hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}/reply${ 276 + photo ? `?photo=${encodeURIComponent(photo.uri)}` : "" 277 + }`} 278 + hx-trigger="click" 279 + hx-target="#dialog-target" 280 + hx-swap="innerHTML" 281 + _="on click halt" 282 + > 283 + <ActorAvatar profile={userProfile} size={22} /> 284 + Add a comment 285 + </button> 286 + ); 287 + } 288 + 289 + export const middlewares: BffMiddleware[] = [ 290 + // Actions 291 + route( 292 + "/actions/comments/:creatorDid/gallery/:rkey", 293 + ["POST"], 294 + async (req, params, ctx) => { 295 + const { did } = ctx.requireAuth(); 296 + const profile = getActorProfile(did, ctx); 297 + if (!profile) return ctx.next(); 298 + 299 + const creatorDid = params.creatorDid; 300 + const rkey = params.rkey; 301 + 302 + const gallery = getGallery(creatorDid, rkey, ctx); 303 + if (!gallery) return ctx.next(); 304 + 305 + const form = await req.formData(); 306 + const text = form.get("text") as string; 307 + const focus = form.get("focus") as string; 308 + const replyTo = form.get("replyTo") as string; 309 + 310 + if (typeof text !== "string" || text.length === 0) { 311 + return new Response("Text is required", { status: 400 }); 312 + } 313 + 314 + try { 315 + await ctx.createRecord<WithBffMeta<Comment>>( 316 + "social.grain.comment", 317 + { 318 + text, 319 + subject: gallery.uri, 320 + focus: focus ?? undefined, 321 + replyTo: replyTo ?? undefined, 322 + createdAt: new Date().toISOString(), 323 + }, 324 + ); 325 + } catch (error) { 326 + console.error("Error creating comment:", error); 327 + } 328 + 329 + const comments = getGalleryComments(gallery.uri, ctx); 330 + 331 + return ctx.html( 332 + <GalleryCommentsDialog 333 + userProfile={profile} 334 + comments={comments} 335 + gallery={gallery} 336 + />, 337 + ); 338 + }, 339 + ), 340 + 341 + // UI 342 + route( 343 + "/ui/comments/:creatorDid/gallery/:rkey", 344 + (_req, params, ctx) => { 345 + const { did } = ctx.requireAuth(); 346 + const profile = getActorProfile(did, ctx); 347 + if (!profile) return ctx.next(); 348 + const creatorDid = params.creatorDid; 349 + const rkey = params.rkey; 350 + const gallery = getGallery(creatorDid, rkey, ctx); 351 + if (!gallery) return ctx.next(); 352 + const comments = getGalleryComments(gallery.uri, ctx); 353 + return ctx.html( 354 + <GalleryCommentsDialog 355 + userProfile={profile} 356 + comments={comments} 357 + gallery={gallery} 358 + />, 359 + ); 360 + }, 361 + ), 362 + route( 363 + "/ui/comments/:creatorDid/gallery/:rkey/reply", 364 + (req, params, ctx) => { 365 + const { did } = ctx.requireAuth(); 366 + const profile = getActorProfile(did, ctx); 367 + if (!profile) return ctx.next(); 368 + const url = new URL(req.url); 369 + const photoUri = url.searchParams.get("photo"); 370 + const commentUri = url.searchParams.get("comment"); 371 + if (commentUri) { 372 + const comment = getComment(commentUri, ctx); 373 + if (comment) { 374 + const gallery = isGalleryView(comment.subject) 375 + ? comment.subject 376 + : undefined; 377 + const photo = isPhotoView(comment.focus) ? comment.focus : undefined; 378 + return ctx.html( 379 + <ReplyDialog 380 + userProfile={profile} 381 + comment={comment} 382 + gallery={gallery} 383 + photo={photo} 384 + />, 385 + ); 386 + } 387 + } 388 + const creatorDid = params.creatorDid; 389 + const rkey = params.rkey; 390 + let photo: PhotoView | undefined; 391 + if (photoUri) { 392 + const p = getPhoto(photoUri, ctx); 393 + photo = p ?? undefined; 394 + } 395 + const gallery = getGallery(creatorDid, rkey, ctx); 396 + if (!gallery) return ctx.next(); 397 + return ctx.html( 398 + <ReplyDialog userProfile={profile} photo={photo} gallery={gallery} />, 399 + ); 400 + }, 401 + ), 402 + ]; 403 + 404 + function groupComments(comments: CommentView[]) { 405 + const repliesByParent = new Map<string, CommentView[]>(); 406 + const topLevel: CommentView[] = []; 407 + 408 + for (const comment of comments) { 409 + if (comment.replyTo) { 410 + if (!repliesByParent.has(comment.replyTo)) { 411 + repliesByParent.set(comment.replyTo, []); 412 + } 413 + repliesByParent.get(comment.replyTo)!.push(comment); 414 + } else { 415 + topLevel.push(comment); 416 + } 417 + } 418 + 419 + return { topLevel, repliesByParent }; 420 + } 421 + 422 + export function getGalleryCommentsCount(uri: string, ctx: BffContext): number { 423 + return ctx.indexService.countRecords( 424 + "social.grain.comment", 425 + { 426 + where: { 427 + "AND": [{ field: "subject", equals: uri }], 428 + }, 429 + limit: 0, 430 + }, 431 + ); 432 + } 433 + 434 + function getGalleryComments(uri: string, ctx: BffContext): CommentView[] { 435 + const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 436 + "social.grain.comment", 437 + { 438 + orderBy: [{ field: "createdAt", direction: "desc" }], 439 + where: { 440 + "AND": [{ field: "subject", equals: uri }], 441 + }, 442 + limit: 100, 443 + }, 444 + ); 445 + 446 + // Batch fetch all authors, subjects, and focus photos for comments using bulk functions 447 + const authorDids = Array.from(new Set(comments.map((c) => c.did))); 448 + const subjectUris = Array.from(new Set(comments.map((c) => c.subject))); 449 + const focusUris: string[] = Array.from( 450 + new Set( 451 + comments.map((c) => typeof c.focus === "string" ? c.focus : undefined) 452 + .filter((uri): uri is string => !!uri), 453 + ), 454 + ); 455 + 456 + const authorProfiles = getActorProfilesBulk(authorDids, ctx); 457 + const authorMap = new Map(authorProfiles.map((p) => [p.did, p])); 458 + const subjectViews = getGalleriesBulk(subjectUris, ctx); 459 + const subjectMap = new Map(subjectViews.map((g) => [g.uri, g])); 460 + const focusViews = getPhotosBulk(focusUris, ctx); 461 + const focusMap = new Map(focusViews.map((p) => [p.uri, p])); 462 + 463 + return comments.reduce<CommentView[]>((acc, comment) => { 464 + const author = authorMap.get(comment.did); 465 + if (!author) return acc; 466 + const subject = subjectMap.get(comment.subject); 467 + if (!subject) return acc; 468 + let focus: PhotoView | undefined = undefined; 469 + if (comment.focus) { 470 + focus = focusMap.get(comment.focus); 471 + } 472 + acc.push(commentToView(comment, author, subject, focus)); 473 + return acc; 474 + }, []); 475 + } 476 + 477 + function getComment(uri: string, ctx: BffContext) { 478 + const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 479 + "social.grain.comment", 480 + { 481 + where: [{ field: "uri", equals: uri }], 482 + }, 483 + ); 484 + if (comments.length === 0) return undefined; 485 + const comment = comments[0]; 486 + const author = getActorProfile(comment.did, ctx); 487 + if (!author) return undefined; 488 + const subjectDid = new AtUri(comment.subject).hostname; 489 + const subjectRkey = new AtUri(comment.subject).rkey; 490 + const subject = getGallery(subjectDid, subjectRkey, ctx); 491 + if (!subject) return undefined; 492 + let focus: PhotoView | undefined = undefined; 493 + if (comment.focus) { 494 + focus = getPhoto(comment.focus, ctx) ?? undefined; 495 + } 496 + return commentToView(comment, author, subject, focus); 497 + } 498 + 499 + function commentToView( 500 + record: WithBffMeta<Comment>, 501 + author: ProfileView, 502 + subject?: GalleryView, 503 + focus?: PhotoView, 504 + ): CommentView { 505 + return { 506 + uri: record.uri, 507 + cid: record.cid, 508 + text: record.text, 509 + subject: isGalleryView(subject) ? subject : undefined, 510 + focus: isPhotoView(focus) ? focus : undefined, 511 + replyTo: record.replyTo, 512 + author, 513 + createdAt: record.createdAt, 514 + }; 515 + }
+7
src/routes/dialogs.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 1 2 import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 2 3 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 3 4 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; ··· 134 135 const next = wrap(0, gallery.items.length, imageAtIndex + 1); 135 136 const prev = wrap(0, gallery.items.length, imageAtIndex - 1); 136 137 if (!image) return ctx.next(); 138 + let userProfile: ProfileView | undefined; 139 + if (ctx.currentUser) { 140 + const profile = getActorProfile(ctx.currentUser.did, ctx); 141 + userProfile = profile ?? undefined; 142 + } 137 143 return ctx.html( 138 144 <PhotoDialog 145 + userProfile={userProfile} 139 146 gallery={gallery} 140 147 image={image} 141 148 nextImage={gallery.items.filter(isPhotoView).at(next)}
+34 -15
src/routes/notifications.tsx
··· 1 + import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 1 2 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 3 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 4 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 5 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 4 6 import { Un$Typed } from "$lexicon/util.ts"; 5 - import { AtUri } from "@atproto/syntax"; 6 7 import { BffContext, RouteHandler } from "@bigmoves/bff"; 7 8 import { NotificationsPage } from "../components/NotificationsPage.tsx"; 8 - import { getGallery } from "../lib/gallery.ts"; 9 + import { getGalleriesBulk } from "../lib/gallery.ts"; 10 + import { getPhotosBulk } from "../lib/photo.ts"; 9 11 import type { State } from "../state.ts"; 10 12 11 13 export const handler: RouteHandler = ( ··· 17 19 ctx.state.meta = [ 18 20 { title: "Notifications — Grain" }, 19 21 ]; 22 + const galleryUris = getGalleriesUrisForNotifications( 23 + ctx.state.notifications ?? [], 24 + ); 25 + const galleries = getGalleriesBulk(galleryUris, ctx); 20 26 const galleriesMap = new Map<string, GalleryView>(); 21 - const galleryUris = getGalleriesUrisForNotifications( 27 + for (const gallery of galleries) { 28 + galleriesMap.set(gallery.uri, gallery); 29 + } 30 + const photoUris = getPhotoUrisForNotifications( 22 31 ctx.state.notifications ?? [], 23 32 ); 24 - for (const uri of galleryUris) { 25 - const gallery = getGallery( 26 - new AtUri(uri).hostname, 27 - new AtUri(uri).rkey, 28 - ctx, 29 - ); 30 - if (gallery) { 31 - galleriesMap.set(uri, gallery); 32 - } 33 + const photos = getPhotosBulk(photoUris, ctx); 34 + const photosMap = new Map<string, PhotoView>(); 35 + for (const photo of photos) { 36 + photosMap.set(photo.uri, photo); 33 37 } 34 38 return ctx.render( 35 39 <NotificationsPage 40 + photosMap={photosMap} 36 41 galleriesMap={galleriesMap} 37 42 notifications={ctx.state.notifications ?? []} 38 43 />, 39 44 ); 40 45 }; 46 + 47 + type WithSubject = Favorite | Comment; 41 48 42 49 function getGalleriesUrisForNotifications( 43 50 notifications: Un$Typed<NotificationView>[], 44 51 ): string[] { 45 52 const uris = notifications 46 - .filter((n) => n.record.$type === "social.grain.favorite") 47 53 .filter((n) => 48 - (n.record as Favorite).subject.includes("social.grain.gallery") 54 + n.record.$type === "social.grain.favorite" || 55 + n.record.$type === "social.grain.comment" 49 56 ) 50 - .map((n) => (n.record as Favorite).subject); 57 + .filter((n) => 58 + (n.record as WithSubject).subject.includes("social.grain.gallery") 59 + ) 60 + .map((n) => (n.record as WithSubject).subject); 51 61 return uris; 52 62 } 63 + 64 + function getPhotoUrisForNotifications( 65 + notifications: Un$Typed<NotificationView>[], 66 + ): string[] { 67 + return notifications 68 + .filter((n) => n.record.$type === "social.grain.comment") 69 + .map((n) => (n.record as Comment).focus) 70 + .filter((focus): focus is string => typeof focus === "string" && !!focus); 71 + }
+4 -4
sync.sh
··· 2 2 3 3 # Helpful when running local-infra. Specify the repos you've created on a local pds instance. 4 4 5 - DB="backup-2025-06-18.db" 6 - REPOS="" 7 - COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif" 5 + DB="grain.db" 6 + REPOS="did:plc:gdvspmipkels2qp43m4czqhp" 7 + COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif,social.grain.comment" 8 8 EXTERNAL_COLLECTIONS="app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile" 9 - COLLECTION_KEY_MAP='{"social.grain.favorite":["subject"],"social.grain.graph.follow":["subject"],"social.grain.gallery.item":["gallery","item"],"social.grain.photo.exif":["photo"]}' 9 + COLLECTION_KEY_MAP='{"social.grain.favorite":["subject"],"social.grain.graph.follow":["subject"],"social.grain.gallery.item":["gallery","item"],"social.grain.photo.exif":["photo"],"social.grain.comment":["subject"]}' 10 10 11 11 deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync \ 12 12 --db="$DB" \