grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 1baae70fe003692cb7ded7979bca56315001d01a 636 lines 20 kB view raw
1import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 3import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 4import { 5 GalleryView, 6 isGalleryView, 7} from "$lexicon/types/social/grain/gallery/defs.ts"; 8import { 9 isPhotoView, 10 PhotoView, 11} from "$lexicon/types/social/grain/photo/defs.ts"; 12import { $Typed } from "$lexicon/util.ts"; 13import { Facet } from "@atproto/api"; 14import { AtUri } from "@atproto/syntax"; 15import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff"; 16import { cn } from "@bigmoves/bff/components"; 17import { Dialog } from "..//components/Dialog.tsx"; 18import { ActorAvatar } from "../components/ActorAvatar.tsx"; 19import { ActorInfo } from "../components/ActorInfo.tsx"; 20import { Button } from "../components/Button.tsx"; 21import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx"; 22import { RenderFacetedText } from "../components/RenderFacetedText.tsx"; 23import { Textarea } from "../components/Textarea.tsx"; 24import { getActorProfile, getActorProfilesBulk } from "../lib/actor.ts"; 25import { BadRequestError } from "../lib/errors.ts"; 26import { getGalleriesBulk, getGallery } from "../lib/gallery.ts"; 27import { getPhoto, getPhotosBulk } from "../lib/photo.ts"; 28import { parseFacetedText } from "../lib/rich_text.ts"; 29import { formatRelativeTime } from "../utils.ts"; 30 31export function ReplyDialog({ userProfile, gallery, photo, comment }: Readonly<{ 32 userProfile: ProfileView; 33 gallery?: GalleryView; 34 photo?: PhotoView; 35 comment?: CommentView; 36}>) { 37 const galleryRkey = gallery ? new AtUri(gallery.uri).rkey : undefined; 38 const profile = gallery?.creator; 39 return ( 40 <Dialog class="z-101"> 41 <Dialog.Content class="gap-4"> 42 <Dialog.Title>Add a comment</Dialog.Title> 43 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 44 <div class="divide-y divide-zinc-200 dark:divide-zinc-800 space-y-4"> 45 <div class="flex gap-4 pb-4"> 46 {!comment && profile 47 ? <ActorAvatar profile={profile} size={42} /> 48 : null} 49 {comment 50 ? <ActorAvatar profile={comment.author} size={42} /> 51 : null} 52 <div class="flex flex-col gap-2"> 53 {comment?.author 54 ? <div class="font-semibold">{comment.author.displayName}</div> 55 : ( 56 <div class="font-semibold"> 57 {gallery?.creator.displayName} 58 </div> 59 )} 60 {comment?.text && ( 61 <RenderFacetedText 62 text={comment.text} 63 facets={(comment.record as Comment).facets} 64 /> 65 )} 66 {!comment && !photo && gallery && 67 gallery.title} 68 {!comment && !photo && gallery 69 ? ( 70 <div class="w-[200px] pointer-events-none"> 71 <GalleryPreviewLink 72 gallery={gallery} 73 size="small" 74 /> 75 </div> 76 ) 77 : null} 78 {photo 79 ? ( 80 <div class="w-[200px] pointer-events-none"> 81 <img src={photo.thumb} alt={photo.alt} class="rounded-md" /> 82 </div> 83 ) 84 : null} 85 </div> 86 </div> 87 <form 88 id="reply-form" 89 class="flex gap-4" 90 hx-post={`/actions/comments/${gallery?.creator.did}/gallery/${galleryRkey}`} 91 hx-target="#dialog-target" 92 hx-swap="innerHTML" 93 _="on htmx:afterOnLoad 94 if event.detail.xhr.status != 200 95 alert('Error: ' + event.detail.xhr.responseText)" 96 > 97 <ActorAvatar profile={userProfile} size={42} /> 98 {!comment && photo 99 ? <input type="hidden" name="focus" value={photo.uri} /> 100 : null} 101 {comment 102 ? <input type="hidden" name="replyTo" value={comment.uri} /> 103 : null} 104 <Textarea 105 class="flex-1" 106 name="text" 107 placeholder={comment ? "Write a reply" : "Add a comment"} 108 rows={5} 109 autoFocus 110 /> 111 </form> 112 </div> 113 <div class="flex flex-col gap-2"> 114 <Button type="submit" form="reply-form" variant="primary"> 115 {comment ? "Reply" : "Add comment"} 116 </Button> 117 <Dialog.Close variant="secondary">Cancel</Dialog.Close> 118 </div> 119 </Dialog.Content> 120 </Dialog> 121 ); 122} 123 124export function GalleryCommentsDialog( 125 { userProfile, comments, gallery }: Readonly<{ 126 userProfile: ProfileView; 127 comments: CommentView[]; 128 gallery: GalleryView; 129 }>, 130) { 131 const { topLevel, repliesByParent } = groupComments(comments); 132 return ( 133 <Dialog> 134 <Dialog.Content class="flex flex-col max-h-[90vh] overflow-hidden"> 135 <div> 136 <Dialog.Title>Comments</Dialog.Title> 137 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 138 </div> 139 <div> 140 <div class="flex gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800"> 141 {gallery.creator 142 ? <ActorAvatar profile={gallery.creator} size={42} /> 143 : null} 144 <div class="flex flex-col gap-2"> 145 {gallery.creator 146 ? <div class="font-semibold">{gallery.creator.displayName}</div> 147 : null} 148 {gallery.title} 149 <div class="w-[200px] pointer-events-none"> 150 <GalleryPreviewLink 151 gallery={gallery} 152 size="small" 153 /> 154 </div> 155 </div> 156 </div> 157 <div class="py-1 border-b border-zinc-200 dark:border-zinc-800"> 158 {gallery 159 ? ( 160 <ReplyButton 161 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" 162 userProfile={userProfile} 163 gallery={gallery} 164 /> 165 ) 166 : null} 167 </div> 168 </div> 169 {topLevel && topLevel.length > 0 170 ? ( 171 <div class="flex-1 flex flex-col py-4 gap-6 overflow-y-scroll grain-scroll-area"> 172 {topLevel.map((comment) => ( 173 <div key={comment.cid} class="flex flex-col gap-4"> 174 <CommentBlock userProfile={userProfile} comment={comment} /> 175 176 {repliesByParent.get(comment.uri)?.map((reply) => ( 177 <div key={reply.cid} class="ml-6"> 178 <CommentBlock userProfile={userProfile} comment={reply} /> 179 </div> 180 ))} 181 </div> 182 ))} 183 </div> 184 ) 185 : <div class="py-4">No comments yet.</div>} 186 <div class="pt-2 border-t border-zinc-200 dark:border-zinc-800"> 187 <Dialog.Close 188 variant="secondary" 189 class="w-full" 190 > 191 Close 192 </Dialog.Close> 193 </div> 194 </Dialog.Content> 195 </Dialog> 196 ); 197} 198 199function CommentBlock( 200 { userProfile, comment }: Readonly< 201 { userProfile: ProfileView; comment: CommentView } 202 >, 203) { 204 const gallery = isGalleryView(comment.subject) ? comment.subject : undefined; 205 const rkey = gallery ? new AtUri(gallery.uri).rkey : undefined; 206 return ( 207 <div class="flex gap-3 items-start"> 208 <div class="flex flex-col flex-1 min-w-0"> 209 <div class="flex items-center gap-2 min-w-0 text-sm text-zinc-500"> 210 <ActorInfo profile={comment.author} avatarSize={22} /> 211 <span class="shrink-0">·</span> 212 <span class="shrink-0"> 213 {comment.createdAt 214 ? formatRelativeTime(new Date(comment.createdAt)) 215 : ""} 216 </span> 217 </div> 218 219 <div class="mt-1"> 220 <RenderFacetedText 221 text={comment.text} 222 facets={(comment.record as Comment).facets} 223 /> 224 </div> 225 226 {isPhotoView(comment.focus) && ( 227 <img 228 src={comment.focus.thumb} 229 alt={comment.focus.alt} 230 class="mt-2 rounded-md max-w-[200px] max-h-[150px] object-contain w-fit" 231 /> 232 )} 233 234 <div class="flex gap-2"> 235 {!comment.replyTo 236 ? ( 237 <button 238 type="button" 239 class="w-fit p-0 mt-2 cursor-pointer text-zinc-600 dark:text-zinc-500 font-semibold text-sm" 240 hx-get={`/ui/comments/${gallery?.creator.did}/gallery/${rkey}/reply?comment=${ 241 encodeURIComponent(comment.uri) 242 }`} 243 hx-trigger="click" 244 hx-target="#dialog-target" 245 hx-swap="innerHTML" 246 > 247 Reply 248 </button> 249 ) 250 : null} 251 {userProfile.did === comment.author.did 252 ? ( 253 <button 254 type="button" 255 class="w-fit p-0 mt-2 cursor-pointer text-zinc-600 dark:text-zinc-500 font-semibold text-sm" 256 hx-delete={`/actions/comments/${gallery?.creator.did}/gallery/${rkey}?comment=${ 257 encodeURIComponent(comment.uri) 258 }`} 259 hx-confirm="Are you sure you want to delete this comment?" 260 hx-target="#dialog-target" 261 hx-swap="innerHTML" 262 > 263 Delete 264 </button> 265 ) 266 : null} 267 </div> 268 </div> 269 </div> 270 ); 271} 272 273export function CommentsButton( 274 { class: classProp, variant, gallery }: Readonly<{ 275 class?: string; 276 variant?: "button" | "icon-button"; 277 gallery: GalleryView; 278 }>, 279) { 280 const variantClass = variant === "icon-button" 281 ? "flex w-fit items-center gap-2 m-0 p-0 mt-2" 282 : undefined; 283 const rkey = new AtUri(gallery.uri).rkey; 284 return ( 285 <Button 286 type="button" 287 variant={variant === "icon-button" ? "ghost" : "secondary"} 288 class={cn("whitespace-nowrap", variantClass, classProp)} 289 hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}`} 290 hx-trigger="click" 291 hx-target="#dialog-target" 292 hx-swap="innerHTML" 293 > 294 <i class="fa-regular fa-comment" /> {gallery.commentCount ?? 0} 295 </Button> 296 ); 297} 298 299export function ReplyButton( 300 { class: classProp, userProfile, gallery, photo }: Readonly<{ 301 class?: string; 302 userProfile: ProfileView; 303 gallery: GalleryView; 304 photo?: PhotoView; 305 }>, 306) { 307 const rkey = new AtUri(gallery.uri).rkey; 308 return ( 309 <button 310 type="button" 311 class={cn( 312 "flex items-center gap-4 p-3 rounded-full cursor-pointer", 313 classProp, 314 )} 315 hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}/reply${ 316 photo ? `?photo=${encodeURIComponent(photo.uri)}` : "" 317 }`} 318 hx-trigger="click" 319 hx-target="#dialog-target" 320 hx-swap="innerHTML" 321 _="on click halt" 322 > 323 <ActorAvatar profile={userProfile} size={22} /> 324 Add a comment 325 </button> 326 ); 327} 328 329export function createComment( 330 data: Partial<Comment>, 331 ctx: BffContext, 332): Promise<string> { 333 let facets: Facet[] | undefined = undefined; 334 const resp = parseFacetedText(data.text ?? "", ctx); 335 facets = resp.facets; 336 return ctx.createRecord<WithBffMeta<Comment>>( 337 "social.grain.comment", 338 { 339 ...data, 340 facets, 341 createdAt: new Date().toISOString(), 342 }, 343 ); 344} 345 346export const middlewares: BffMiddleware[] = [ 347 // Actions 348 route( 349 "/actions/comments/:creatorDid/gallery/:rkey", 350 ["POST"], 351 async (req, params, ctx) => { 352 const { did } = ctx.requireAuth(); 353 const profile = getActorProfile(did, ctx); 354 if (!profile) return ctx.next(); 355 356 const creatorDid = params.creatorDid; 357 const rkey = params.rkey; 358 359 const gallery = getGallery(creatorDid, rkey, ctx); 360 if (!gallery) return ctx.next(); 361 362 const form = await req.formData(); 363 const text = form.get("text") as string; 364 const focus = form.get("focus") as string ?? undefined; 365 const replyTo = form.get("replyTo") as string ?? undefined; 366 367 if (typeof text !== "string" || text.length === 0) { 368 return new Response("Text is required", { status: 400 }); 369 } 370 try { 371 await createComment({ 372 subject: gallery.uri, 373 text, 374 focus, 375 replyTo, 376 }, ctx); 377 } catch (error) { 378 if (error instanceof BadRequestError) { 379 return new Response(error.message, { status: 400 }); 380 } 381 throw error; 382 } 383 384 const comments = getGalleryComments(gallery.uri, ctx); 385 386 return ctx.html( 387 <GalleryCommentsDialog 388 userProfile={profile} 389 comments={comments} 390 gallery={gallery} 391 />, 392 ); 393 }, 394 ), 395 396 route( 397 "/actions/comments/:creatorDid/gallery/:rkey", 398 ["DELETE"], 399 async (req, params, ctx) => { 400 const { did } = ctx.requireAuth(); 401 const profile = getActorProfile(did, ctx); 402 if (!profile) return ctx.next(); 403 404 const url = new URL(req.url); 405 const commentUri = url.searchParams.get("comment"); 406 407 if (!commentUri) { 408 return new Response("Comment URI is required", { status: 400 }); 409 } 410 411 const creatorDid = params.creatorDid; 412 const rkey = params.rkey; 413 414 const gallery = getGallery(creatorDid, rkey, ctx); 415 if (!gallery) return ctx.next(); 416 417 try { 418 await ctx.deleteRecord(commentUri); 419 } catch (error) { 420 console.error("Error deleting comment:", error); 421 } 422 423 const comments = getGalleryComments(gallery.uri, ctx); 424 425 return ctx.html( 426 <GalleryCommentsDialog 427 userProfile={profile} 428 comments={comments} 429 gallery={gallery} 430 />, 431 ); 432 }, 433 ), 434 435 // UI 436 route( 437 "/ui/comments/:creatorDid/gallery/:rkey", 438 (_req, params, ctx) => { 439 const { did } = ctx.requireAuth(); 440 const profile = getActorProfile(did, ctx); 441 if (!profile) return ctx.next(); 442 const creatorDid = params.creatorDid; 443 const rkey = params.rkey; 444 const gallery = getGallery(creatorDid, rkey, ctx); 445 if (!gallery) return ctx.next(); 446 const comments = getGalleryComments(gallery.uri, ctx); 447 return ctx.html( 448 <GalleryCommentsDialog 449 userProfile={profile} 450 comments={comments} 451 gallery={gallery} 452 />, 453 ); 454 }, 455 ), 456 route( 457 "/ui/comments/:creatorDid/gallery/:rkey/reply", 458 (req, params, ctx) => { 459 const { did } = ctx.requireAuth(); 460 const profile = getActorProfile(did, ctx); 461 if (!profile) return ctx.next(); 462 const url = new URL(req.url); 463 const photoUri = url.searchParams.get("photo"); 464 const commentUri = url.searchParams.get("comment"); 465 if (commentUri) { 466 const comment = getComment(commentUri, ctx); 467 if (comment) { 468 const gallery = isGalleryView(comment.subject) 469 ? comment.subject 470 : undefined; 471 const photo = isPhotoView(comment.focus) ? comment.focus : undefined; 472 return ctx.html( 473 <ReplyDialog 474 userProfile={profile} 475 comment={comment} 476 gallery={gallery} 477 photo={photo} 478 />, 479 ); 480 } 481 } 482 const creatorDid = params.creatorDid; 483 const rkey = params.rkey; 484 let photo: PhotoView | undefined; 485 if (photoUri) { 486 const p = getPhoto(photoUri, ctx); 487 photo = p ?? undefined; 488 } 489 const gallery = getGallery(creatorDid, rkey, ctx); 490 if (!gallery) return ctx.next(); 491 return ctx.html( 492 <ReplyDialog userProfile={profile} photo={photo} gallery={gallery} />, 493 ); 494 }, 495 ), 496]; 497 498function hydrateComments( 499 comments: WithBffMeta<Comment>[], 500 ctx: BffContext, 501): CommentView[] { 502 const authorDids = Array.from(new Set(comments.map((c) => c.did))); 503 const subjectUris = Array.from(new Set(comments.map((c) => c.subject))); 504 const focusUris: string[] = Array.from( 505 new Set( 506 comments.map((c) => typeof c.focus === "string" ? c.focus : undefined) 507 .filter((uri): uri is string => !!uri), 508 ), 509 ); 510 511 const authorProfiles = getActorProfilesBulk(authorDids, ctx); 512 const authorMap = new Map(authorProfiles.map((p) => [p.did, p])); 513 const subjectViews = getGalleriesBulk(subjectUris, ctx); 514 const subjectMap = new Map(subjectViews.map((g) => [g.uri, g])); 515 const focusViews = getPhotosBulk(focusUris, ctx); 516 const focusMap = new Map(focusViews.map((p) => [p.uri, p])); 517 518 return comments.reduce<CommentView[]>((acc, comment) => { 519 const author = authorMap.get(comment.did); 520 if (!author) return acc; 521 const subject = subjectMap.get(comment.subject); 522 if (!subject) return acc; 523 let focus: PhotoView | undefined = undefined; 524 if (comment.focus) { 525 focus = focusMap.get(comment.focus); 526 } 527 acc.push(commentToView(comment, author, subject, focus)); 528 return acc; 529 }, []); 530} 531 532export function getGalleryComments( 533 uri: string, 534 ctx: BffContext, 535): CommentView[] { 536 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 537 "social.grain.comment", 538 { 539 orderBy: [{ field: "createdAt", direction: "desc" }], 540 where: { 541 "AND": [{ field: "subject", equals: uri }], 542 }, 543 limit: 100, 544 }, 545 ); 546 return hydrateComments(comments, ctx); 547} 548 549export function getCommentsBulk( 550 uris: string[], 551 ctx: BffContext, 552): CommentView[] { 553 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 554 "social.grain.comment", 555 { 556 where: [{ field: "uri", in: uris }], 557 }, 558 ); 559 return hydrateComments(comments, ctx); 560} 561 562function groupComments(comments: CommentView[]) { 563 const repliesByParent = new Map<string, CommentView[]>(); 564 const topLevel: CommentView[] = []; 565 566 for (const comment of comments) { 567 if (comment.replyTo) { 568 if (!repliesByParent.has(comment.replyTo)) { 569 repliesByParent.set(comment.replyTo, []); 570 } 571 repliesByParent.get(comment.replyTo)!.push(comment); 572 } else { 573 topLevel.push(comment); 574 } 575 } 576 577 return { topLevel, repliesByParent }; 578} 579 580export function getGalleryCommentsCount(uri: string, ctx: BffContext): number { 581 return ctx.indexService.countRecords( 582 "social.grain.comment", 583 { 584 where: { 585 "AND": [{ field: "subject", equals: uri }], 586 }, 587 limit: 0, 588 }, 589 ); 590} 591 592export function getComment( 593 uri: string, 594 ctx: BffContext, 595) { 596 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 597 "social.grain.comment", 598 { 599 where: [{ field: "uri", equals: uri }], 600 }, 601 ); 602 if (comments.length === 0) return undefined; 603 const comment = comments[0]; 604 const author = getActorProfile(comment.did, ctx); 605 if (!author) return undefined; 606 const subjectDid = new AtUri(comment.subject).hostname; 607 const subjectRkey = new AtUri(comment.subject).rkey; 608 const subject = getGallery(subjectDid, subjectRkey, ctx); 609 if (!subject) return undefined; 610 let focus: PhotoView | undefined = undefined; 611 if (comment.focus) { 612 focus = getPhoto(comment.focus, ctx) ?? undefined; 613 } 614 return commentToView(comment, author, subject, focus); 615} 616 617export function commentToView( 618 record: WithBffMeta<Comment>, 619 author: ProfileView, 620 subject?: GalleryView, 621 focus?: PhotoView, 622): $Typed<CommentView> { 623 return { 624 $type: "social.grain.comment.defs#commentView", 625 uri: record.uri, 626 cid: record.cid, 627 text: record.text, 628 facets: record.facets, 629 subject: isGalleryView(subject) ? subject : undefined, 630 focus: isPhotoView(focus) ? focus : undefined, 631 replyTo: record.replyTo, 632 author, 633 record, 634 createdAt: record.createdAt, 635 }; 636}