From 216c2870a2d74b845738289c41b2baeec1b4a3fc Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 12 Dec 2025 16:35:55 +0400 Subject: [PATCH] wire up comments and implement deep linking to em --- .../[rkey]/Interactions/Comments/index.tsx | 2 +- .../[rkey]/Interactions/InteractionDrawer.tsx | 5 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 32 ++- .../(profile)/comments/CommentsContent.tsx | 219 ++++++++++++++++++ .../[didOrHandle]/(profile)/comments/page.tsx | 91 ++------ app/p/[didOrHandle]/getProfileComments.ts | 133 +++++++++++ 6 files changed, 402 insertions(+), 80 deletions(-) create mode 100644 app/p/[didOrHandle]/(profile)/comments/CommentsContent.tsx create mode 100644 app/p/[didOrHandle]/getProfileComments.ts diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx index 26f93164..d82aeea4 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -114,7 +114,7 @@ const Comment = (props: { pageId?: string; }) => { return ( -
+
{props.profile && ( diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index a9df0ad5..7771432a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -58,10 +58,13 @@ export const InteractionDrawer = (props: { export const useDrawerOpen = (uri: string) => { let params = useSearchParams(); let interactionDrawerSearchParam = params.get("interactionDrawer"); + let pageParam = params.get("page"); let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); if (open === false || (open === undefined && !interactionDrawerSearchParam)) return null; drawer = drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); - return { drawer, pageId }; + // Use pageId from state, or fall back to page search param + const resolvedPageId = pageId ?? pageParam ?? undefined; + return { drawer, pageId: resolvedPageId }; }; diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 880363f6..2de8d230 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -19,7 +19,7 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { Fragment, useEffect } from "react"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { decodeQuotePosition } from "./quotePosition"; import { PollData } from "./fetchPollData"; import { LinearDocumentPage } from "./LinearDocumentPage"; @@ -32,12 +32,21 @@ const usePostPageUIState = create(() => ({ export const useOpenPages = () => { const { quote } = useParams(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); const state = usePostPageUIState((s) => s); - if (!state.initialized && quote) { - const decodedQuote = decodeQuotePosition(quote as string); - if (decodedQuote?.pageId) { - return [decodedQuote.pageId]; + if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + return [pageParam]; + } + // Then check for quote param + if (quote) { + const decodedQuote = decodeQuotePosition(quote as string); + if (decodedQuote?.pageId) { + return [decodedQuote.pageId]; + } } } @@ -46,10 +55,21 @@ export const useOpenPages = () => { export const useInitializeOpenPages = () => { const { quote } = useParams(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); useEffect(() => { const state = usePostPageUIState.getState(); if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + usePostPageUIState.setState({ + pages: [pageParam], + initialized: true, + }); + return; + } + // Then check for quote param if (quote) { const decodedQuote = decodeQuotePosition(quote as string); if (decodedQuote?.pageId) { @@ -63,7 +83,7 @@ export const useInitializeOpenPages = () => { // Mark as initialized even if no pageId found usePostPageUIState.setState({ initialized: true }); } - }, [quote]); + }, [quote, pageParam]); }; export const openPage = ( diff --git a/app/p/[didOrHandle]/(profile)/comments/CommentsContent.tsx b/app/p/[didOrHandle]/(profile)/comments/CommentsContent.tsx new file mode 100644 index 00000000..f77cffe9 --- /dev/null +++ b/app/p/[didOrHandle]/(profile)/comments/CommentsContent.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useRef, useMemo } from "react"; +import useSWRInfinite from "swr/infinite"; +import { AppBskyActorProfile, AtUri } from "@atproto/api"; +import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; +import { ReplyTiny } from "components/Icons/ReplyTiny"; +import { Avatar } from "components/Avatar"; +import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { + getProfileComments, + type ProfileComment, + type Cursor, +} from "../getProfileComments"; +import { timeAgo } from "src/utils/timeAgo"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; + +export const ProfileCommentsContent = (props: { + did: string; + comments: ProfileComment[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + comments: ProfileComment[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; + + // Add the cursor to the key + return [ + "profile-comments", + props.did, + previousPageData?.nextCursor, + ] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, did, cursor]) => getProfileComments(did, cursor), + { + fallbackData: [ + { comments: props.comments, nextCursor: props.nextCursor }, + ], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allComments = data ? data.flatMap((page) => page.comments) : []; + + if (allComments.length === 0 && !isValidating) { + return ( +
No comments yet
+ ); + } + + return ( +
+ {allComments.map((comment) => ( + + ))} + {/* Trigger element for loading more comments */} + + ); +}; + +const CommentItem = ({ comment }: { comment: ProfileComment }) => { + const record = comment.record as PubLeafletComment.Record; + const profile = comment.bsky_profiles?.record as + | AppBskyActorProfile.Record + | undefined; + const displayName = + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; + + // Get commenter DID from comment URI + const commenterDid = new AtUri(comment.uri).host; + + const isReply = !!record.reply; + + // Get document title + const docData = comment.document?.data as + | PubLeafletDocument.Record + | undefined; + const postTitle = docData?.title || "Untitled"; + + // Get parent comment info for replies + const parentRecord = comment.parentComment?.record as + | PubLeafletComment.Record + | undefined; + const parentProfile = comment.parentComment?.bsky_profiles?.record as + | AppBskyActorProfile.Record + | undefined; + const parentDisplayName = + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; + + // Build direct link to the comment + const commentLink = useMemo(() => { + if (!comment.document) return null; + const docUri = new AtUri(comment.document.uri); + + // Get base URL using getPublicationURL if publication exists, otherwise build path + let baseUrl: string; + if (comment.publication) { + baseUrl = getPublicationURL(comment.publication); + const pubUri = new AtUri(comment.publication.uri); + // If getPublicationURL returns a relative path, append the document rkey + if (baseUrl.startsWith("/")) { + baseUrl = `${baseUrl}/${docUri.rkey}`; + } else { + // For custom domains, append the document rkey + baseUrl = `${baseUrl}/${docUri.rkey}`; + } + } else { + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; + } + + // Build query parameters + const params = new URLSearchParams(); + params.set("interactionDrawer", "comments"); + if (record.onPage) { + params.set("page", record.onPage); + } + + // Use comment URI as hash for direct reference + const commentId = encodeURIComponent(comment.uri); + + return `${baseUrl}?${params.toString()}#${commentId}`; + }, [comment.document, comment.publication, comment.uri, record.onPage]); + + // Get avatar source + const avatarSrc = profile?.avatar?.ref + ? blobRefToSrc(profile.avatar.ref, commenterDid) + : undefined; + + return ( +
+
+ +
+
+
+ {displayName}{" "} + {isReply ? "replied" : "commented"} on{" "} + {commentLink ? ( + + {postTitle} + + ) : ( + {postTitle} + )} +
+
+ {isReply && parentRecord && ( +
+ + {parentDisplayName && ( +
{parentDisplayName}
+ )} +
{parentRecord.plaintext}
+
+ )} +
+            
+          
+
+
+
+ ); +}; diff --git a/app/p/[didOrHandle]/(profile)/comments/page.tsx b/app/p/[didOrHandle]/(profile)/comments/page.tsx index 5df226e8..f67f1c35 100644 --- a/app/p/[didOrHandle]/(profile)/comments/page.tsx +++ b/app/p/[didOrHandle]/(profile)/comments/page.tsx @@ -1,77 +1,24 @@ -import { ReplyTiny } from "components/Icons/ReplyTiny"; +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { getProfileComments } from "../../getProfileComments"; +import { ProfileCommentsContent } from "./CommentsContent"; -export default function ProfileCommentsPage() { - return ; -} +export default async function ProfileCommentsPage(props: { + params: Promise<{ didOrHandle: string }>; +}) { + let params = await props.params; + let didOrHandle = decodeURIComponent(params.didOrHandle); -const CommentsContent = () => { - let isReply = true; - return ( - <> - - - - - - ); -}; + // Resolve handle to DID if necessary + let did = didOrHandle; + if (!didOrHandle.startsWith("did:")) { + let resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) return null; + did = resolved; + } -const Comment = (props: { - displayName: React.ReactNode; - postTitle: string; - comment: string; - isReply?: boolean; -}) => { - return ( -
-
-
-
-
-
- - {props.displayName} - {" "} - {props.isReply ? "replied" : "commented"} on{" "} - - {props.postTitle} - -
-
- {props.isReply && ( -
- -
jared
-
- this is the content of what i was saying and its very long so i - can get a good look at what's happening -
-
- )} + const { comments, nextCursor } = await getProfileComments(did); -
- {props.comment} -
-
-
-
+ return ( + ); -}; +} diff --git a/app/p/[didOrHandle]/getProfileComments.ts b/app/p/[didOrHandle]/getProfileComments.ts new file mode 100644 index 00000000..23e4cdb0 --- /dev/null +++ b/app/p/[didOrHandle]/getProfileComments.ts @@ -0,0 +1,133 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; +import { PubLeafletComment } from "lexicons/api"; + +export type Cursor = { + indexed_at: string; + uri: string; +}; + +export type ProfileComment = { + uri: string; + record: Json; + indexed_at: string; + bsky_profiles: { record: Json; handle: string | null } | null; + document: { + uri: string; + data: Json; + } | null; + publication: { + uri: string; + record: Json; + } | null; + // For replies, include the parent comment info + parentComment: { + uri: string; + record: Json; + bsky_profiles: { record: Json; handle: string | null } | null; + } | null; +}; + +export async function getProfileComments( + did: string, + cursor?: Cursor | null, +): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { + const limit = 20; + + let query = supabaseServerClient + .from("comments_on_documents") + .select( + `*, + bsky_profiles(record, handle), + documents(uri, data, documents_in_publications(publications(*)))`, + ) + .eq("profile", did) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(limit); + + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, + ); + } + + const { data: rawComments } = await query; + + if (!rawComments || rawComments.length === 0) { + return { comments: [], nextCursor: null }; + } + + // Collect parent comment URIs for replies + const parentUris = rawComments + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) + .filter((uri): uri is string => !!uri); + + // Fetch parent comments if there are any replies + let parentCommentsMap = new Map< + string, + { + uri: string; + record: Json; + bsky_profiles: { record: Json; handle: string | null } | null; + } + >(); + + if (parentUris.length > 0) { + const { data: parentComments } = await supabaseServerClient + .from("comments_on_documents") + .select(`uri, record, bsky_profiles(record, handle)`) + .in("uri", parentUris); + + if (parentComments) { + for (const pc of parentComments) { + parentCommentsMap.set(pc.uri, { + uri: pc.uri, + record: pc.record, + bsky_profiles: pc.bsky_profiles, + }); + } + } + } + + // Transform to ProfileComment format + const comments: ProfileComment[] = rawComments.map((comment) => { + const record = comment.record as PubLeafletComment.Record; + const doc = comment.documents; + const pub = doc?.documents_in_publications?.[0]?.publications; + + return { + uri: comment.uri, + record: comment.record, + indexed_at: comment.indexed_at, + bsky_profiles: comment.bsky_profiles, + document: doc + ? { + uri: doc.uri, + data: doc.data, + } + : null, + publication: pub + ? { + uri: pub.uri, + record: pub.record, + } + : null, + parentComment: record.reply?.parent + ? parentCommentsMap.get(record.reply.parent) || null + : null, + }; + }); + + const nextCursor = + comments.length === limit + ? { + indexed_at: comments[comments.length - 1].indexed_at, + uri: comments[comments.length - 1].uri, + } + : null; + + return { comments, nextCursor }; +} -- 2.51.2