import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
import {
GalleryView,
isGalleryView,
} from "$lexicon/types/social/grain/gallery/defs.ts";
import {
isPhotoView,
PhotoView,
} from "$lexicon/types/social/grain/photo/defs.ts";
import { $Typed } from "$lexicon/util.ts";
import { Facet } from "@atproto/api";
import { AtUri } from "@atproto/syntax";
import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff";
import { cn } from "@bigmoves/bff/components";
import { Dialog } from "..//components/Dialog.tsx";
import { ActorAvatar } from "../components/ActorAvatar.tsx";
import { ActorInfo } from "../components/ActorInfo.tsx";
import { Button } from "../components/Button.tsx";
import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx";
import { RenderFacetedText } from "../components/RenderFacetedText.tsx";
import { Textarea } from "../components/Textarea.tsx";
import { getActorProfile, getActorProfilesBulk } from "../lib/actor.ts";
import { BadRequestError } from "../lib/errors.ts";
import { getGalleriesBulk, getGallery } from "../lib/gallery.ts";
import { getPhoto, getPhotosBulk } from "../lib/photo.ts";
import { parseFacetedText } from "../lib/rich_text.ts";
import { formatRelativeTime } from "../utils.ts";
export function ReplyDialog({ userProfile, gallery, photo, comment }: Readonly<{
userProfile: ProfileView;
gallery?: GalleryView;
photo?: PhotoView;
comment?: CommentView;
}>) {
const galleryRkey = gallery ? new AtUri(gallery.uri).rkey : undefined;
const profile = gallery?.creator;
return (
);
}
export function GalleryCommentsDialog(
{ userProfile, comments, gallery }: Readonly<{
userProfile: ProfileView;
comments: CommentView[];
gallery: GalleryView;
}>,
) {
const { topLevel, repliesByParent } = groupComments(comments);
return (
);
}
function CommentBlock(
{ userProfile, comment }: Readonly<
{ userProfile: ProfileView; comment: CommentView }
>,
) {
const gallery = isGalleryView(comment.subject) ? comment.subject : undefined;
const rkey = gallery ? new AtUri(gallery.uri).rkey : undefined;
return (
ยท
{comment.createdAt
? formatRelativeTime(new Date(comment.createdAt))
: ""}
{isPhotoView(comment.focus) && (

)}
{!comment.replyTo
? (
)
: null}
{userProfile.did === comment.author.did
? (
)
: null}
);
}
export function CommentsButton(
{ class: classProp, variant, gallery }: Readonly<{
class?: string;
variant?: "button" | "icon-button";
gallery: GalleryView;
}>,
) {
const variantClass = variant === "icon-button"
? "flex w-fit items-center gap-2 m-0 p-0 mt-2"
: undefined;
const rkey = new AtUri(gallery.uri).rkey;
return (
);
}
export function ReplyButton(
{ class: classProp, userProfile, gallery, photo }: Readonly<{
class?: string;
userProfile: ProfileView;
gallery: GalleryView;
photo?: PhotoView;
}>,
) {
const rkey = new AtUri(gallery.uri).rkey;
return (
);
}
export function createComment(
data: Partial,
ctx: BffContext,
): Promise {
let facets: Facet[] | undefined = undefined;
const resp = parseFacetedText(data.text ?? "", ctx);
facets = resp.facets;
return ctx.createRecord>(
"social.grain.comment",
{
...data,
facets,
createdAt: new Date().toISOString(),
},
);
}
export const middlewares: BffMiddleware[] = [
// Actions
route(
"/actions/comments/:creatorDid/gallery/:rkey",
["POST"],
async (req, params, ctx) => {
const { did } = ctx.requireAuth();
const profile = getActorProfile(did, ctx);
if (!profile) return ctx.next();
const creatorDid = params.creatorDid;
const rkey = params.rkey;
const gallery = getGallery(creatorDid, rkey, ctx);
if (!gallery) return ctx.next();
const form = await req.formData();
const text = form.get("text") as string;
const focus = form.get("focus") as string ?? undefined;
const replyTo = form.get("replyTo") as string ?? undefined;
if (typeof text !== "string" || text.length === 0) {
return new Response("Text is required", { status: 400 });
}
try {
await createComment({
subject: gallery.uri,
text,
focus,
replyTo,
}, ctx);
} catch (error) {
if (error instanceof BadRequestError) {
return new Response(error.message, { status: 400 });
}
throw error;
}
const comments = getGalleryComments(gallery.uri, ctx);
return ctx.html(
,
);
},
),
route(
"/actions/comments/:creatorDid/gallery/:rkey",
["DELETE"],
async (req, params, ctx) => {
const { did } = ctx.requireAuth();
const profile = getActorProfile(did, ctx);
if (!profile) return ctx.next();
const url = new URL(req.url);
const commentUri = url.searchParams.get("comment");
if (!commentUri) {
return new Response("Comment URI is required", { status: 400 });
}
const creatorDid = params.creatorDid;
const rkey = params.rkey;
const gallery = getGallery(creatorDid, rkey, ctx);
if (!gallery) return ctx.next();
try {
await ctx.deleteRecord(commentUri);
} catch (error) {
console.error("Error deleting comment:", error);
}
const comments = getGalleryComments(gallery.uri, ctx);
return ctx.html(
,
);
},
),
// UI
route(
"/ui/comments/:creatorDid/gallery/:rkey",
(_req, params, ctx) => {
const { did } = ctx.requireAuth();
const profile = getActorProfile(did, ctx);
if (!profile) return ctx.next();
const creatorDid = params.creatorDid;
const rkey = params.rkey;
const gallery = getGallery(creatorDid, rkey, ctx);
if (!gallery) return ctx.next();
const comments = getGalleryComments(gallery.uri, ctx);
return ctx.html(
,
);
},
),
route(
"/ui/comments/:creatorDid/gallery/:rkey/reply",
(req, params, ctx) => {
const { did } = ctx.requireAuth();
const profile = getActorProfile(did, ctx);
if (!profile) return ctx.next();
const url = new URL(req.url);
const photoUri = url.searchParams.get("photo");
const commentUri = url.searchParams.get("comment");
if (commentUri) {
const comment = getComment(commentUri, ctx);
if (comment) {
const gallery = isGalleryView(comment.subject)
? comment.subject
: undefined;
const photo = isPhotoView(comment.focus) ? comment.focus : undefined;
return ctx.html(
,
);
}
}
const creatorDid = params.creatorDid;
const rkey = params.rkey;
let photo: PhotoView | undefined;
if (photoUri) {
const p = getPhoto(photoUri, ctx);
photo = p ?? undefined;
}
const gallery = getGallery(creatorDid, rkey, ctx);
if (!gallery) return ctx.next();
return ctx.html(
,
);
},
),
];
function hydrateComments(
comments: WithBffMeta[],
ctx: BffContext,
): CommentView[] {
const authorDids = Array.from(new Set(comments.map((c) => c.did)));
const subjectUris = Array.from(new Set(comments.map((c) => c.subject)));
const focusUris: string[] = Array.from(
new Set(
comments.map((c) => typeof c.focus === "string" ? c.focus : undefined)
.filter((uri): uri is string => !!uri),
),
);
const authorProfiles = getActorProfilesBulk(authorDids, ctx);
const authorMap = new Map(authorProfiles.map((p) => [p.did, p]));
const subjectViews = getGalleriesBulk(subjectUris, ctx);
const subjectMap = new Map(subjectViews.map((g) => [g.uri, g]));
const focusViews = getPhotosBulk(focusUris, ctx);
const focusMap = new Map(focusViews.map((p) => [p.uri, p]));
return comments.reduce((acc, comment) => {
const author = authorMap.get(comment.did);
if (!author) return acc;
const subject = subjectMap.get(comment.subject);
if (!subject) return acc;
let focus: PhotoView | undefined = undefined;
if (comment.focus) {
focus = focusMap.get(comment.focus);
}
acc.push(commentToView(comment, author, subject, focus));
return acc;
}, []);
}
export function getGalleryComments(
uri: string,
ctx: BffContext,
): CommentView[] {
const { items: comments } = ctx.indexService.getRecords>(
"social.grain.comment",
{
orderBy: [{ field: "createdAt", direction: "desc" }],
where: {
"AND": [{ field: "subject", equals: uri }],
},
limit: 100,
},
);
return hydrateComments(comments, ctx);
}
export function getCommentsBulk(
uris: string[],
ctx: BffContext,
): CommentView[] {
const { items: comments } = ctx.indexService.getRecords>(
"social.grain.comment",
{
where: [{ field: "uri", in: uris }],
},
);
return hydrateComments(comments, ctx);
}
function groupComments(comments: CommentView[]) {
const repliesByParent = new Map();
const topLevel: CommentView[] = [];
for (const comment of comments) {
if (comment.replyTo) {
if (!repliesByParent.has(comment.replyTo)) {
repliesByParent.set(comment.replyTo, []);
}
repliesByParent.get(comment.replyTo)!.push(comment);
} else {
topLevel.push(comment);
}
}
return { topLevel, repliesByParent };
}
export function getGalleryCommentsCount(uri: string, ctx: BffContext): number {
return ctx.indexService.countRecords(
"social.grain.comment",
{
where: {
"AND": [{ field: "subject", equals: uri }],
},
limit: 0,
},
);
}
export function getComment(
uri: string,
ctx: BffContext,
) {
const { items: comments } = ctx.indexService.getRecords>(
"social.grain.comment",
{
where: [{ field: "uri", equals: uri }],
},
);
if (comments.length === 0) return undefined;
const comment = comments[0];
const author = getActorProfile(comment.did, ctx);
if (!author) return undefined;
const subjectDid = new AtUri(comment.subject).hostname;
const subjectRkey = new AtUri(comment.subject).rkey;
const subject = getGallery(subjectDid, subjectRkey, ctx);
if (!subject) return undefined;
let focus: PhotoView | undefined = undefined;
if (comment.focus) {
focus = getPhoto(comment.focus, ctx) ?? undefined;
}
return commentToView(comment, author, subject, focus);
}
export function commentToView(
record: WithBffMeta,
author: ProfileView,
subject?: GalleryView,
focus?: PhotoView,
): $Typed {
return {
$type: "social.grain.comment.defs#commentView",
uri: record.uri,
cid: record.cid,
text: record.text,
facets: record.facets,
subject: isGalleryView(subject) ? subject : undefined,
focus: isPhotoView(focus) ? focus : undefined,
replyTo: record.replyTo,
author,
record,
createdAt: record.createdAt,
};
}