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

fix: issue with incorrect gallery link for reply notifications, display the replyTo focus if available

Changed files
+271 -105
src
+166 -51
src/components/NotificationsPage.tsx
··· 1 1 import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 2 + import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 2 3 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 4 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 4 5 import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 5 6 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 6 - import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 7 + import { 8 + isPhotoView, 9 + PhotoView, 10 + } from "$lexicon/types/social/grain/photo/defs.ts"; 7 11 import { Un$Typed } from "$lexicon/util.ts"; 8 - import { formatRelativeTime, profileLink } from "../utils.ts"; 12 + import { AtUri } from "@atproto/syntax"; 13 + import { formatRelativeTime, galleryLink, profileLink } from "../utils.ts"; 9 14 import { ActorAvatar } from "./ActorAvatar.tsx"; 10 15 import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx"; 11 16 import { Header } from "./Header.tsx"; 12 17 13 18 export function NotificationsPage( 14 - { photosMap, galleriesMap, notifications }: Readonly< 19 + { photosMap, galleriesMap, notifications, commentsMap }: Readonly< 15 20 { 16 21 photosMap: Map<string, Un$Typed<PhotoView>>; 17 22 galleriesMap: Map<string, Un$Typed<GalleryView>>; 23 + commentsMap: Map<string, Un$Typed<CommentView>>; 18 24 notifications: Un$Typed<NotificationView>[]; 19 25 } 20 26 >, ··· 78 84 )} 79 85 </span> 80 86 </div> 81 - {notification.reason === "gallery-favorite" && galleriesMap.get( 82 - (notification.record as Favorite).subject, 83 - ) 84 - ? ( 85 - <div class="w-[200px]"> 86 - <GalleryPreviewLink 87 - gallery={galleriesMap.get( 88 - (notification.record as Favorite).subject, 89 - ) as GalleryView} 90 - size="small" 91 - /> 92 - </div> 93 - ) 94 - : null} 95 - {(notification.reason === "gallery-comment" || 96 - notification.reason === "reply") && galleriesMap.get( 97 - (notification.record as Comment).subject, 98 - ) 99 - ? ( 100 - <> 101 - {(notification.record as Comment).text} 102 - {(notification.record as Comment).focus 103 - ? ( 104 - <div class="w-[200px] pointer-events-none"> 105 - <img 106 - src={photosMap.get( 107 - (notification.record as Comment).focus ?? "", 108 - )?.thumb} 109 - alt={photosMap.get( 110 - (notification.record as Comment).focus ?? "", 111 - )?.alt} 112 - class="rounded-md" 113 - /> 114 - </div> 115 - ) 116 - : ( 117 - <div class="w-[200px]"> 118 - <GalleryPreviewLink 119 - gallery={galleriesMap.get( 120 - (notification.record as Favorite).subject, 121 - ) as GalleryView} 122 - size="small" 123 - /> 124 - </div> 125 - )} 126 - </> 127 - ) 128 - : null} 87 + {notification.reason === "gallery-favorite" && 88 + ( 89 + <GalleryFavoriteNotification 90 + notification={notification} 91 + galleriesMap={galleriesMap} 92 + /> 93 + )} 94 + {notification.reason === "gallery-comment" && 95 + ( 96 + <GalleryCommentNotification 97 + notification={notification} 98 + galleriesMap={galleriesMap} 99 + photosMap={photosMap} 100 + /> 101 + )} 102 + {notification.reason === "reply" && 103 + ( 104 + <ReplyNotification 105 + notification={notification} 106 + galleriesMap={galleriesMap} 107 + photosMap={photosMap} 108 + commentsMap={commentsMap} 109 + /> 110 + )} 129 111 </li> 130 112 )) 131 113 ) ··· 134 116 </div> 135 117 ); 136 118 } 119 + 120 + function GalleryCommentNotification( 121 + { notification, galleriesMap, photosMap }: { 122 + notification: NotificationView; 123 + galleriesMap: Map<string, GalleryView>; 124 + photosMap: Map<string, PhotoView>; 125 + }, 126 + ) { 127 + const comment = notification.record as Comment; 128 + const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined; 129 + if (!gallery) return null; 130 + return ( 131 + <> 132 + {comment.text} 133 + {comment.focus 134 + ? ( 135 + <a 136 + href={galleryLink( 137 + gallery.creator.handle, 138 + new AtUri(gallery.uri).rkey, 139 + )} 140 + class="w-[200px]" 141 + > 142 + <img 143 + src={photosMap.get(comment.focus ?? "")?.thumb} 144 + alt={photosMap.get(comment.focus ?? "")?.alt} 145 + class="rounded-md" 146 + /> 147 + </a> 148 + ) 149 + : ( 150 + <div class="w-[200px]"> 151 + <GalleryPreviewLink 152 + gallery={gallery} 153 + size="small" 154 + /> 155 + </div> 156 + )} 157 + </> 158 + ); 159 + } 160 + 161 + function ReplyNotification( 162 + { notification, galleriesMap, photosMap, commentsMap }: { 163 + notification: NotificationView; 164 + galleriesMap: Map<string, GalleryView>; 165 + photosMap: Map<string, PhotoView>; 166 + commentsMap: Map<string, CommentView>; 167 + }, 168 + ) { 169 + const comment = notification.record as Comment; 170 + const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined; 171 + let replyToComment: CommentView | undefined = undefined; 172 + if (comment.replyTo) { 173 + replyToComment = commentsMap.get(comment.replyTo); 174 + } 175 + if (!gallery) return null; 176 + return ( 177 + <> 178 + {replyToComment && ( 179 + <div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2 max-w-[200px]"> 180 + {replyToComment.text} 181 + {isPhotoView(replyToComment?.focus) 182 + ? ( 183 + <a 184 + class="block mt-2" 185 + href={galleryLink( 186 + gallery.creator.handle, 187 + new AtUri(gallery.uri).rkey, 188 + )} 189 + > 190 + <img 191 + src={photosMap.get(replyToComment.focus.uri)?.thumb} 192 + alt={photosMap.get(replyToComment.focus.uri)?.alt} 193 + class="rounded-md" 194 + /> 195 + </a> 196 + ) 197 + : ( 198 + <GalleryPreviewLink 199 + gallery={gallery} 200 + size="small" 201 + /> 202 + )} 203 + </div> 204 + )} 205 + {comment.text} 206 + {comment.focus 207 + ? ( 208 + <a 209 + href={galleryLink( 210 + gallery.creator.handle, 211 + new AtUri(gallery.uri).rkey, 212 + )} 213 + class="max-w-[200px]" 214 + > 215 + <img 216 + src={photosMap.get(comment.focus ?? "")?.thumb} 217 + alt={photosMap.get(comment.focus ?? "")?.alt} 218 + class="rounded-md" 219 + /> 220 + </a> 221 + ) 222 + : !replyToComment 223 + ? ( 224 + <div class="w-[200px]"> 225 + <GalleryPreviewLink 226 + gallery={gallery} 227 + size="small" 228 + /> 229 + </div> 230 + ) 231 + : null} 232 + </> 233 + ); 234 + } 235 + 236 + function GalleryFavoriteNotification({ notification, galleriesMap }: { 237 + notification: NotificationView; 238 + galleriesMap: Map<string, GalleryView>; 239 + }) { 240 + const favorite = notification.record as Favorite; 241 + const gallery = galleriesMap.get(favorite.subject) as GalleryView | undefined; 242 + if (!gallery) return null; 243 + return ( 244 + <div class="w-[200px]"> 245 + <GalleryPreviewLink 246 + gallery={gallery} 247 + size="small" 248 + /> 249 + </div> 250 + ); 251 + }
+15 -10
src/lib/gallery.ts
··· 20 20 import { AtUri } from "@atproto/syntax"; 21 21 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 22 22 import { getGalleryCommentsCount } from "../modules/comments.tsx"; 23 - import { getActorProfile } from "./actor.ts"; 23 + import { getActorProfile, getActorProfilesBulk } from "./actor.ts"; 24 24 import { photoToView } from "./photo.ts"; 25 25 26 26 type PhotoWithExif = WithBffMeta<Photo> & { ··· 370 370 371 371 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 372 372 373 - const profile = getActorProfile(galleries[0].did, ctx); 374 - if (!profile) return []; 373 + const uniqueDids = Array.from(new Set(galleries.map((g) => g.did))); 374 + const creators = getActorProfilesBulk(uniqueDids, ctx); 375 + const creatorMap = new Map(creators.map((c) => [c.did, c])); 375 376 376 377 const labels = ctx.indexService.queryLabels({ subjects: uris }); 377 378 378 - return galleries.map((gallery) => 379 - galleryToView({ 380 - record: gallery, 381 - creator: profile, 382 - items: galleryPhotosMap.get(gallery.uri) ?? [], 383 - labels, 379 + return galleries 380 + .map((gallery) => { 381 + const creator = creatorMap.get(gallery.did); 382 + if (!creator) return null; 383 + return galleryToView({ 384 + record: gallery, 385 + creator, 386 + items: galleryPhotosMap.get(gallery.uri) ?? [], 387 + labels, 388 + }); 384 389 }) 385 - ); 390 + .filter((g): g is ReturnType<typeof galleryToView> => g !== null); 386 391 }
+61 -43
src/modules/comments.tsx
··· 466 466 ), 467 467 ]; 468 468 469 - function groupComments(comments: CommentView[]) { 470 - const repliesByParent = new Map<string, CommentView[]>(); 471 - const topLevel: CommentView[] = []; 472 - 473 - for (const comment of comments) { 474 - if (comment.replyTo) { 475 - if (!repliesByParent.has(comment.replyTo)) { 476 - repliesByParent.set(comment.replyTo, []); 477 - } 478 - repliesByParent.get(comment.replyTo)!.push(comment); 479 - } else { 480 - topLevel.push(comment); 481 - } 482 - } 483 - 484 - return { topLevel, repliesByParent }; 485 - } 486 - 487 - export function getGalleryCommentsCount(uri: string, ctx: BffContext): number { 488 - return ctx.indexService.countRecords( 489 - "social.grain.comment", 490 - { 491 - where: { 492 - "AND": [{ field: "subject", equals: uri }], 493 - }, 494 - limit: 0, 495 - }, 496 - ); 497 - } 498 - 499 - function getGalleryComments(uri: string, ctx: BffContext): CommentView[] { 500 - const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 501 - "social.grain.comment", 502 - { 503 - orderBy: [{ field: "createdAt", direction: "desc" }], 504 - where: { 505 - "AND": [{ field: "subject", equals: uri }], 506 - }, 507 - limit: 100, 508 - }, 509 - ); 510 - 511 - // Batch fetch all authors, subjects, and focus photos for comments using bulk functions 469 + function hydrateComments( 470 + comments: WithBffMeta<Comment>[], 471 + ctx: BffContext, 472 + ): CommentView[] { 512 473 const authorDids = Array.from(new Set(comments.map((c) => c.did))); 513 474 const subjectUris = Array.from(new Set(comments.map((c) => c.subject))); 514 475 const focusUris: string[] = Array.from( ··· 537 498 acc.push(commentToView(comment, author, subject, focus)); 538 499 return acc; 539 500 }, []); 501 + } 502 + 503 + function getGalleryComments(uri: string, ctx: BffContext): CommentView[] { 504 + const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 505 + "social.grain.comment", 506 + { 507 + orderBy: [{ field: "createdAt", direction: "desc" }], 508 + where: { 509 + "AND": [{ field: "subject", equals: uri }], 510 + }, 511 + limit: 100, 512 + }, 513 + ); 514 + return hydrateComments(comments, ctx); 515 + } 516 + 517 + export function getCommentsBulk( 518 + uris: string[], 519 + ctx: BffContext, 520 + ): CommentView[] { 521 + const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 522 + "social.grain.comment", 523 + { 524 + where: [{ field: "uri", in: uris }], 525 + }, 526 + ); 527 + return hydrateComments(comments, ctx); 528 + } 529 + 530 + function groupComments(comments: CommentView[]) { 531 + const repliesByParent = new Map<string, CommentView[]>(); 532 + const topLevel: CommentView[] = []; 533 + 534 + for (const comment of comments) { 535 + if (comment.replyTo) { 536 + if (!repliesByParent.has(comment.replyTo)) { 537 + repliesByParent.set(comment.replyTo, []); 538 + } 539 + repliesByParent.get(comment.replyTo)!.push(comment); 540 + } else { 541 + topLevel.push(comment); 542 + } 543 + } 544 + 545 + return { topLevel, repliesByParent }; 546 + } 547 + 548 + export function getGalleryCommentsCount(uri: string, ctx: BffContext): number { 549 + return ctx.indexService.countRecords( 550 + "social.grain.comment", 551 + { 552 + where: { 553 + "AND": [{ field: "subject", equals: uri }], 554 + }, 555 + limit: 0, 556 + }, 557 + ); 540 558 } 541 559 542 560 function getComment(uri: string, ctx: BffContext) {
+29 -1
src/routes/notifications.tsx
··· 1 1 import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 2 + import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 2 3 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 4 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 4 5 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 5 - import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 6 + import { 7 + isPhotoView, 8 + PhotoView, 9 + } from "$lexicon/types/social/grain/photo/defs.ts"; 6 10 import { Un$Typed } from "$lexicon/util.ts"; 7 11 import { BffContext, RouteHandler } from "@bigmoves/bff"; 8 12 import { NotificationsPage } from "../components/NotificationsPage.tsx"; 9 13 import { getGalleriesBulk } from "../lib/gallery.ts"; 10 14 import { getPhotosBulk } from "../lib/photo.ts"; 15 + import { getCommentsBulk } from "../modules/comments.tsx"; 11 16 import type { State } from "../state.ts"; 12 17 13 18 export const handler: RouteHandler = ( ··· 35 40 for (const photo of photos) { 36 41 photosMap.set(photo.uri, photo); 37 42 } 43 + const commentUris = getReplyToUrisForNotifications( 44 + ctx.state.notifications ?? [], 45 + ); 46 + const comments = getCommentsBulk(commentUris, ctx); 47 + const commentsMap = new Map<string, CommentView>(); 48 + for (const comment of comments) { 49 + commentsMap.set(comment.uri, comment); 50 + if (isPhotoView(comment.focus)) { 51 + photosMap.set(comment.focus.uri, comment.focus); 52 + } 53 + } 38 54 return ctx.render( 39 55 <NotificationsPage 40 56 photosMap={photosMap} 41 57 galleriesMap={galleriesMap} 58 + commentsMap={commentsMap} 42 59 notifications={ctx.state.notifications ?? []} 43 60 />, 44 61 ); ··· 69 86 .map((n) => (n.record as Comment).focus) 70 87 .filter((focus): focus is string => typeof focus === "string" && !!focus); 71 88 } 89 + 90 + function getReplyToUrisForNotifications( 91 + notifications: Un$Typed<NotificationView>[], 92 + ): string[] { 93 + return notifications 94 + .filter((n) => n.record.$type === "social.grain.comment") 95 + .map((n) => (n.record as Comment).replyTo) 96 + .filter((replyTo): replyTo is string => 97 + typeof replyTo === "string" && !!replyTo 98 + ); 99 + }