···11+"use client";
22+33+import { useEffect, useRef, useMemo } from "react";
44+import useSWRInfinite from "swr/infinite";
55+import { AppBskyActorProfile, AtUri } from "@atproto/api";
66+import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
77+import { ReplyTiny } from "components/Icons/ReplyTiny";
88+import { Avatar } from "components/Avatar";
99+import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
1010+import { blobRefToSrc } from "src/utils/blobRefToSrc";
1111+import {
1212+ getProfileComments,
1313+ type ProfileComment,
1414+ type Cursor,
1515+} from "../getProfileComments";
1616+import { timeAgo } from "src/utils/timeAgo";
1717+import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
1818+1919+export const ProfileCommentsContent = (props: {
2020+ did: string;
2121+ comments: ProfileComment[];
2222+ nextCursor: Cursor | null;
2323+}) => {
2424+ const getKey = (
2525+ pageIndex: number,
2626+ previousPageData: {
2727+ comments: ProfileComment[];
2828+ nextCursor: Cursor | null;
2929+ } | null,
3030+ ) => {
3131+ // Reached the end
3232+ if (previousPageData && !previousPageData.nextCursor) return null;
3333+3434+ // First page, we don't have previousPageData
3535+ if (pageIndex === 0) return ["profile-comments", props.did, null] as const;
3636+3737+ // Add the cursor to the key
3838+ return [
3939+ "profile-comments",
4040+ props.did,
4141+ previousPageData?.nextCursor,
4242+ ] as const;
4343+ };
4444+4545+ const { data, size, setSize, isValidating } = useSWRInfinite(
4646+ getKey,
4747+ ([_, did, cursor]) => getProfileComments(did, cursor),
4848+ {
4949+ fallbackData: [
5050+ { comments: props.comments, nextCursor: props.nextCursor },
5151+ ],
5252+ revalidateFirstPage: false,
5353+ },
5454+ );
5555+5656+ const loadMoreRef = useRef<HTMLDivElement>(null);
5757+5858+ // Set up intersection observer to load more when trigger element is visible
5959+ useEffect(() => {
6060+ const observer = new IntersectionObserver(
6161+ (entries) => {
6262+ if (entries[0].isIntersecting && !isValidating) {
6363+ const hasMore = data && data[data.length - 1]?.nextCursor;
6464+ if (hasMore) {
6565+ setSize(size + 1);
6666+ }
6767+ }
6868+ },
6969+ { threshold: 0.1 },
7070+ );
7171+7272+ if (loadMoreRef.current) {
7373+ observer.observe(loadMoreRef.current);
7474+ }
7575+7676+ return () => observer.disconnect();
7777+ }, [data, size, setSize, isValidating]);
7878+7979+ const allComments = data ? data.flatMap((page) => page.comments) : [];
8080+8181+ if (allComments.length === 0 && !isValidating) {
8282+ return (
8383+ <div className="text-tertiary text-center py-4">No comments yet</div>
8484+ );
8585+ }
8686+8787+ return (
8888+ <div className="flex flex-col gap-2 text-left relative">
8989+ {allComments.map((comment) => (
9090+ <CommentItem key={comment.uri} comment={comment} />
9191+ ))}
9292+ {/* Trigger element for loading more comments */}
9393+ <div
9494+ ref={loadMoreRef}
9595+ className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
9696+ aria-hidden="true"
9797+ />
9898+ {isValidating && (
9999+ <div className="text-center text-tertiary py-4">
100100+ Loading more comments...
101101+ </div>
102102+ )}
103103+ </div>
104104+ );
105105+};
106106+107107+const CommentItem = ({ comment }: { comment: ProfileComment }) => {
108108+ const record = comment.record as PubLeafletComment.Record;
109109+ const profile = comment.bsky_profiles?.record as
110110+ | AppBskyActorProfile.Record
111111+ | undefined;
112112+ const displayName =
113113+ profile?.displayName || comment.bsky_profiles?.handle || "Unknown";
114114+115115+ // Get commenter DID from comment URI
116116+ const commenterDid = new AtUri(comment.uri).host;
117117+118118+ const isReply = !!record.reply;
119119+120120+ // Get document title
121121+ const docData = comment.document?.data as
122122+ | PubLeafletDocument.Record
123123+ | undefined;
124124+ const postTitle = docData?.title || "Untitled";
125125+126126+ // Get parent comment info for replies
127127+ const parentRecord = comment.parentComment?.record as
128128+ | PubLeafletComment.Record
129129+ | undefined;
130130+ const parentProfile = comment.parentComment?.bsky_profiles?.record as
131131+ | AppBskyActorProfile.Record
132132+ | undefined;
133133+ const parentDisplayName =
134134+ parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle;
135135+136136+ // Build direct link to the comment
137137+ const commentLink = useMemo(() => {
138138+ if (!comment.document) return null;
139139+ const docUri = new AtUri(comment.document.uri);
140140+141141+ // Get base URL using getPublicationURL if publication exists, otherwise build path
142142+ let baseUrl: string;
143143+ if (comment.publication) {
144144+ baseUrl = getPublicationURL(comment.publication);
145145+ const pubUri = new AtUri(comment.publication.uri);
146146+ // If getPublicationURL returns a relative path, append the document rkey
147147+ if (baseUrl.startsWith("/")) {
148148+ baseUrl = `${baseUrl}/${docUri.rkey}`;
149149+ } else {
150150+ // For custom domains, append the document rkey
151151+ baseUrl = `${baseUrl}/${docUri.rkey}`;
152152+ }
153153+ } else {
154154+ baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`;
155155+ }
156156+157157+ // Build query parameters
158158+ const params = new URLSearchParams();
159159+ params.set("interactionDrawer", "comments");
160160+ if (record.onPage) {
161161+ params.set("page", record.onPage);
162162+ }
163163+164164+ // Use comment URI as hash for direct reference
165165+ const commentId = encodeURIComponent(comment.uri);
166166+167167+ return `${baseUrl}?${params.toString()}#${commentId}`;
168168+ }, [comment.document, comment.publication, comment.uri, record.onPage]);
169169+170170+ // Get avatar source
171171+ const avatarSrc = profile?.avatar?.ref
172172+ ? blobRefToSrc(profile.avatar.ref, commenterDid)
173173+ : undefined;
174174+175175+ return (
176176+ <div id={comment.uri} className="w-full flex flex-col text-left mb-8">
177177+ <div className="flex gap-2 w-full">
178178+ <Avatar src={avatarSrc} displayName={displayName} />
179179+ <div className="flex flex-col w-full min-w-0 grow">
180180+ <div className="flex flex-row gap-2">
181181+ <div className="text-tertiary text-sm truncate">
182182+ <span className="font-bold text-secondary">{displayName}</span>{" "}
183183+ {isReply ? "replied" : "commented"} on{" "}
184184+ {commentLink ? (
185185+ <a
186186+ href={commentLink}
187187+ className="italic text-accent-contrast hover:underline"
188188+ >
189189+ {postTitle}
190190+ </a>
191191+ ) : (
192192+ <span className="italic text-accent-contrast">{postTitle}</span>
193193+ )}
194194+ </div>
195195+ </div>
196196+ {isReply && parentRecord && (
197197+ <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center">
198198+ <ReplyTiny className="shrink-0 scale-75" />
199199+ {parentDisplayName && (
200200+ <div className="font-bold shrink-0">{parentDisplayName}</div>
201201+ )}
202202+ <div className="grow truncate">{parentRecord.plaintext}</div>
203203+ </div>
204204+ )}
205205+ <pre
206206+ style={{ wordBreak: "break-word" }}
207207+ className="whitespace-pre-wrap text-secondary"
208208+ >
209209+ <BaseTextBlock
210210+ index={[]}
211211+ plaintext={record.plaintext}
212212+ facets={record.facets}
213213+ />
214214+ </pre>
215215+ </div>
216216+ </div>
217217+ </div>
218218+ );
219219+};
+19-72
app/p/[didOrHandle]/(profile)/comments/page.tsx
···11-import { ReplyTiny } from "components/Icons/ReplyTiny";
11+import { idResolver } from "app/(home-pages)/reader/idResolver";
22+import { getProfileComments } from "../../getProfileComments";
33+import { ProfileCommentsContent } from "./CommentsContent";
2433-export default function ProfileCommentsPage() {
44- return <CommentsContent />;
55-}
55+export default async function ProfileCommentsPage(props: {
66+ params: Promise<{ didOrHandle: string }>;
77+}) {
88+ let params = await props.params;
99+ let didOrHandle = decodeURIComponent(params.didOrHandle);
61077-const CommentsContent = () => {
88- let isReply = true;
99- return (
1010- <>
1111- <Comment
1212- displayName="celine"
1313- postTitle="Tagging and Flaggin Babyyyy make this super long so it doesn't wrap around please"
1414- comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol"
1515- isReply
1616- />
1717- <Comment
1818- displayName="celine"
1919- postTitle="Another day, another test post eh,"
2020- comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol"
2121- />
2222- <Comment
2323- displayName="celine"
2424- postTitle="Some other post title"
2525- comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol"
2626- />
2727- <Comment
2828- displayName="celine"
2929- postTitle="Leaflet Lab Notes"
3030- comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol"
3131- isReply
3232- />
3333- </>
3434- );
3535-};
1111+ // Resolve handle to DID if necessary
1212+ let did = didOrHandle;
1313+ if (!didOrHandle.startsWith("did:")) {
1414+ let resolved = await idResolver.handle.resolve(didOrHandle);
1515+ if (!resolved) return null;
1616+ did = resolved;
1717+ }
1818+1919+ const { comments, nextCursor } = await getProfileComments(did);
36203737-const Comment = (props: {
3838- displayName: React.ReactNode;
3939- postTitle: string;
4040- comment: string;
4141- isReply?: boolean;
4242-}) => {
4321 return (
4444- <div className={`w-full flex flex-col text-left mb-8`}>
4545- <div className="flex gap-2 w-full">
4646- <div className={`rounded-full bg-test shrink-0 w-5 h-5`} />
4747- <div className={`flex flex-col w-full min-w-0 grow`}>
4848- <div className="flex flex-row gap-2">
4949- <div className={`text-tertiary text-sm truncate`}>
5050- <span className="font-bold text-secondary">
5151- {props.displayName}
5252- </span>{" "}
5353- {props.isReply ? "replied" : "commented"} on{" "}
5454- <span className=" italic text-accent-contrast">
5555- {props.postTitle}
5656- </span>
5757- </div>
5858- </div>
5959- {props.isReply && (
6060- <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center">
6161- <ReplyTiny className="shrink-0 scale-75" />
6262- <div className="font-bold shrink-0">jared</div>
6363- <div className="grow truncate">
6464- this is the content of what i was saying and its very long so i
6565- can get a good look at what's happening
6666- </div>
6767- </div>
6868- )}
6969-7070- <div className={`w-full text-left text-secondary `}>
7171- {props.comment}
7272- </div>
7373- </div>
7474- </div>
7575- </div>
2222+ <ProfileCommentsContent did={did} comments={comments} nextCursor={nextCursor} />
7623 );
7777-};
2424+}