grain.social is a photo sharing platform built on atproto.
grain.social
atproto
photography
appview
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}