a tool for shared writing and social publishing

wire up comments and implement deep linking to em

+402 -80
+1 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 114 114 pageId?: string; 115 115 }) => { 116 116 return ( 117 - <div className="comment"> 117 + <div id={props.comment.uri} className="comment"> 118 118 <div className="flex gap-2"> 119 119 {props.profile && ( 120 120 <ProfilePopover profile={props.profile} comment={props.comment.uri} />
+4 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 58 58 export const useDrawerOpen = (uri: string) => { 59 59 let params = useSearchParams(); 60 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 + let pageParam = params.get("page"); 61 62 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 63 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 64 return null; 64 65 drawer = 65 66 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 - return { drawer, pageId }; 67 + // Use pageId from state, or fall back to page search param 68 + const resolvedPageId = pageId ?? pageParam ?? undefined; 69 + return { drawer, pageId: resolvedPageId }; 67 70 };
+26 -6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 19 19 import { Fragment, useEffect } from "react"; 20 20 import { flushSync } from "react-dom"; 21 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 - import { useParams } from "next/navigation"; 22 + import { useParams, useSearchParams } from "next/navigation"; 23 23 import { decodeQuotePosition } from "./quotePosition"; 24 24 import { PollData } from "./fetchPollData"; 25 25 import { LinearDocumentPage } from "./LinearDocumentPage"; ··· 32 32 33 33 export const useOpenPages = () => { 34 34 const { quote } = useParams(); 35 + const searchParams = useSearchParams(); 36 + const pageParam = searchParams.get("page"); 35 37 const state = usePostPageUIState((s) => s); 36 38 37 - if (!state.initialized && quote) { 38 - const decodedQuote = decodeQuotePosition(quote as string); 39 - if (decodedQuote?.pageId) { 40 - return [decodedQuote.pageId]; 39 + if (!state.initialized) { 40 + // Check for page search param first (for comment links) 41 + if (pageParam) { 42 + return [pageParam]; 43 + } 44 + // Then check for quote param 45 + if (quote) { 46 + const decodedQuote = decodeQuotePosition(quote as string); 47 + if (decodedQuote?.pageId) { 48 + return [decodedQuote.pageId]; 49 + } 41 50 } 42 51 } 43 52 ··· 46 55 47 56 export const useInitializeOpenPages = () => { 48 57 const { quote } = useParams(); 58 + const searchParams = useSearchParams(); 59 + const pageParam = searchParams.get("page"); 49 60 50 61 useEffect(() => { 51 62 const state = usePostPageUIState.getState(); 52 63 if (!state.initialized) { 64 + // Check for page search param first (for comment links) 65 + if (pageParam) { 66 + usePostPageUIState.setState({ 67 + pages: [pageParam], 68 + initialized: true, 69 + }); 70 + return; 71 + } 72 + // Then check for quote param 53 73 if (quote) { 54 74 const decodedQuote = decodeQuotePosition(quote as string); 55 75 if (decodedQuote?.pageId) { ··· 63 83 // Mark as initialized even if no pageId found 64 84 usePostPageUIState.setState({ initialized: true }); 65 85 } 66 - }, [quote]); 86 + }, [quote, pageParam]); 67 87 }; 68 88 69 89 export const openPage = (
+219
app/p/[didOrHandle]/(profile)/comments/CommentsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useMemo } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 + import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { 12 + getProfileComments, 13 + type ProfileComment, 14 + type Cursor, 15 + } from "../getProfileComments"; 16 + import { timeAgo } from "src/utils/timeAgo"; 17 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 18 + 19 + export const ProfileCommentsContent = (props: { 20 + did: string; 21 + comments: ProfileComment[]; 22 + nextCursor: Cursor | null; 23 + }) => { 24 + const getKey = ( 25 + pageIndex: number, 26 + previousPageData: { 27 + comments: ProfileComment[]; 28 + nextCursor: Cursor | null; 29 + } | null, 30 + ) => { 31 + // Reached the end 32 + if (previousPageData && !previousPageData.nextCursor) return null; 33 + 34 + // First page, we don't have previousPageData 35 + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; 36 + 37 + // Add the cursor to the key 38 + return [ 39 + "profile-comments", 40 + props.did, 41 + previousPageData?.nextCursor, 42 + ] as const; 43 + }; 44 + 45 + const { data, size, setSize, isValidating } = useSWRInfinite( 46 + getKey, 47 + ([_, did, cursor]) => getProfileComments(did, cursor), 48 + { 49 + fallbackData: [ 50 + { comments: props.comments, nextCursor: props.nextCursor }, 51 + ], 52 + revalidateFirstPage: false, 53 + }, 54 + ); 55 + 56 + const loadMoreRef = useRef<HTMLDivElement>(null); 57 + 58 + // Set up intersection observer to load more when trigger element is visible 59 + useEffect(() => { 60 + const observer = new IntersectionObserver( 61 + (entries) => { 62 + if (entries[0].isIntersecting && !isValidating) { 63 + const hasMore = data && data[data.length - 1]?.nextCursor; 64 + if (hasMore) { 65 + setSize(size + 1); 66 + } 67 + } 68 + }, 69 + { threshold: 0.1 }, 70 + ); 71 + 72 + if (loadMoreRef.current) { 73 + observer.observe(loadMoreRef.current); 74 + } 75 + 76 + return () => observer.disconnect(); 77 + }, [data, size, setSize, isValidating]); 78 + 79 + const allComments = data ? data.flatMap((page) => page.comments) : []; 80 + 81 + if (allComments.length === 0 && !isValidating) { 82 + return ( 83 + <div className="text-tertiary text-center py-4">No comments yet</div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div className="flex flex-col gap-2 text-left relative"> 89 + {allComments.map((comment) => ( 90 + <CommentItem key={comment.uri} comment={comment} /> 91 + ))} 92 + {/* Trigger element for loading more comments */} 93 + <div 94 + ref={loadMoreRef} 95 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 + aria-hidden="true" 97 + /> 98 + {isValidating && ( 99 + <div className="text-center text-tertiary py-4"> 100 + Loading more comments... 101 + </div> 102 + )} 103 + </div> 104 + ); 105 + }; 106 + 107 + const CommentItem = ({ comment }: { comment: ProfileComment }) => { 108 + const record = comment.record as PubLeafletComment.Record; 109 + const profile = comment.bsky_profiles?.record as 110 + | AppBskyActorProfile.Record 111 + | undefined; 112 + const displayName = 113 + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 114 + 115 + // Get commenter DID from comment URI 116 + const commenterDid = new AtUri(comment.uri).host; 117 + 118 + const isReply = !!record.reply; 119 + 120 + // Get document title 121 + const docData = comment.document?.data as 122 + | PubLeafletDocument.Record 123 + | undefined; 124 + const postTitle = docData?.title || "Untitled"; 125 + 126 + // Get parent comment info for replies 127 + const parentRecord = comment.parentComment?.record as 128 + | PubLeafletComment.Record 129 + | undefined; 130 + const parentProfile = comment.parentComment?.bsky_profiles?.record as 131 + | AppBskyActorProfile.Record 132 + | undefined; 133 + const parentDisplayName = 134 + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 135 + 136 + // Build direct link to the comment 137 + const commentLink = useMemo(() => { 138 + if (!comment.document) return null; 139 + const docUri = new AtUri(comment.document.uri); 140 + 141 + // Get base URL using getPublicationURL if publication exists, otherwise build path 142 + let baseUrl: string; 143 + if (comment.publication) { 144 + baseUrl = getPublicationURL(comment.publication); 145 + const pubUri = new AtUri(comment.publication.uri); 146 + // If getPublicationURL returns a relative path, append the document rkey 147 + if (baseUrl.startsWith("/")) { 148 + baseUrl = `${baseUrl}/${docUri.rkey}`; 149 + } else { 150 + // For custom domains, append the document rkey 151 + baseUrl = `${baseUrl}/${docUri.rkey}`; 152 + } 153 + } else { 154 + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; 155 + } 156 + 157 + // Build query parameters 158 + const params = new URLSearchParams(); 159 + params.set("interactionDrawer", "comments"); 160 + if (record.onPage) { 161 + params.set("page", record.onPage); 162 + } 163 + 164 + // Use comment URI as hash for direct reference 165 + const commentId = encodeURIComponent(comment.uri); 166 + 167 + return `${baseUrl}?${params.toString()}#${commentId}`; 168 + }, [comment.document, comment.publication, comment.uri, record.onPage]); 169 + 170 + // Get avatar source 171 + const avatarSrc = profile?.avatar?.ref 172 + ? blobRefToSrc(profile.avatar.ref, commenterDid) 173 + : undefined; 174 + 175 + return ( 176 + <div id={comment.uri} className="w-full flex flex-col text-left mb-8"> 177 + <div className="flex gap-2 w-full"> 178 + <Avatar src={avatarSrc} displayName={displayName} /> 179 + <div className="flex flex-col w-full min-w-0 grow"> 180 + <div className="flex flex-row gap-2"> 181 + <div className="text-tertiary text-sm truncate"> 182 + <span className="font-bold text-secondary">{displayName}</span>{" "} 183 + {isReply ? "replied" : "commented"} on{" "} 184 + {commentLink ? ( 185 + <a 186 + href={commentLink} 187 + className="italic text-accent-contrast hover:underline" 188 + > 189 + {postTitle} 190 + </a> 191 + ) : ( 192 + <span className="italic text-accent-contrast">{postTitle}</span> 193 + )} 194 + </div> 195 + </div> 196 + {isReply && parentRecord && ( 197 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 198 + <ReplyTiny className="shrink-0 scale-75" /> 199 + {parentDisplayName && ( 200 + <div className="font-bold shrink-0">{parentDisplayName}</div> 201 + )} 202 + <div className="grow truncate">{parentRecord.plaintext}</div> 203 + </div> 204 + )} 205 + <pre 206 + style={{ wordBreak: "break-word" }} 207 + className="whitespace-pre-wrap text-secondary" 208 + > 209 + <BaseTextBlock 210 + index={[]} 211 + plaintext={record.plaintext} 212 + facets={record.facets} 213 + /> 214 + </pre> 215 + </div> 216 + </div> 217 + </div> 218 + ); 219 + };
+19 -72
app/p/[didOrHandle]/(profile)/comments/page.tsx
··· 1 - import { ReplyTiny } from "components/Icons/ReplyTiny"; 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfileComments } from "../../getProfileComments"; 3 + import { ProfileCommentsContent } from "./CommentsContent"; 2 4 3 - export default function ProfileCommentsPage() { 4 - return <CommentsContent />; 5 - } 5 + export default async function ProfileCommentsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 6 10 7 - const CommentsContent = () => { 8 - let isReply = true; 9 - return ( 10 - <> 11 - <Comment 12 - displayName="celine" 13 - postTitle="Tagging and Flaggin Babyyyy make this super long so it doesn't wrap around please" 14 - 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" 15 - isReply 16 - /> 17 - <Comment 18 - displayName="celine" 19 - postTitle="Another day, another test post eh," 20 - 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" 21 - /> 22 - <Comment 23 - displayName="celine" 24 - postTitle="Some other post title" 25 - 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" 26 - /> 27 - <Comment 28 - displayName="celine" 29 - postTitle="Leaflet Lab Notes" 30 - 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" 31 - isReply 32 - /> 33 - </> 34 - ); 35 - }; 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { comments, nextCursor } = await getProfileComments(did); 36 20 37 - const Comment = (props: { 38 - displayName: React.ReactNode; 39 - postTitle: string; 40 - comment: string; 41 - isReply?: boolean; 42 - }) => { 43 21 return ( 44 - <div className={`w-full flex flex-col text-left mb-8`}> 45 - <div className="flex gap-2 w-full"> 46 - <div className={`rounded-full bg-test shrink-0 w-5 h-5`} /> 47 - <div className={`flex flex-col w-full min-w-0 grow`}> 48 - <div className="flex flex-row gap-2"> 49 - <div className={`text-tertiary text-sm truncate`}> 50 - <span className="font-bold text-secondary"> 51 - {props.displayName} 52 - </span>{" "} 53 - {props.isReply ? "replied" : "commented"} on{" "} 54 - <span className=" italic text-accent-contrast"> 55 - {props.postTitle} 56 - </span> 57 - </div> 58 - </div> 59 - {props.isReply && ( 60 - <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 61 - <ReplyTiny className="shrink-0 scale-75" /> 62 - <div className="font-bold shrink-0">jared</div> 63 - <div className="grow truncate"> 64 - this is the content of what i was saying and its very long so i 65 - can get a good look at what's happening 66 - </div> 67 - </div> 68 - )} 69 - 70 - <div className={`w-full text-left text-secondary `}> 71 - {props.comment} 72 - </div> 73 - </div> 74 - </div> 75 - </div> 22 + <ProfileCommentsContent did={did} comments={comments} nextCursor={nextCursor} /> 76 23 ); 77 - }; 24 + }
+133
app/p/[didOrHandle]/getProfileComments.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { PubLeafletComment } from "lexicons/api"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export type ProfileComment = { 13 + uri: string; 14 + record: Json; 15 + indexed_at: string; 16 + bsky_profiles: { record: Json; handle: string | null } | null; 17 + document: { 18 + uri: string; 19 + data: Json; 20 + } | null; 21 + publication: { 22 + uri: string; 23 + record: Json; 24 + } | null; 25 + // For replies, include the parent comment info 26 + parentComment: { 27 + uri: string; 28 + record: Json; 29 + bsky_profiles: { record: Json; handle: string | null } | null; 30 + } | null; 31 + }; 32 + 33 + export async function getProfileComments( 34 + did: string, 35 + cursor?: Cursor | null, 36 + ): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { 37 + const limit = 20; 38 + 39 + let query = supabaseServerClient 40 + .from("comments_on_documents") 41 + .select( 42 + `*, 43 + bsky_profiles(record, handle), 44 + documents(uri, data, documents_in_publications(publications(*)))`, 45 + ) 46 + .eq("profile", did) 47 + .order("indexed_at", { ascending: false }) 48 + .order("uri", { ascending: false }) 49 + .limit(limit); 50 + 51 + if (cursor) { 52 + query = query.or( 53 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 54 + ); 55 + } 56 + 57 + const { data: rawComments } = await query; 58 + 59 + if (!rawComments || rawComments.length === 0) { 60 + return { comments: [], nextCursor: null }; 61 + } 62 + 63 + // Collect parent comment URIs for replies 64 + const parentUris = rawComments 65 + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 + .filter((uri): uri is string => !!uri); 67 + 68 + // Fetch parent comments if there are any replies 69 + let parentCommentsMap = new Map< 70 + string, 71 + { 72 + uri: string; 73 + record: Json; 74 + bsky_profiles: { record: Json; handle: string | null } | null; 75 + } 76 + >(); 77 + 78 + if (parentUris.length > 0) { 79 + const { data: parentComments } = await supabaseServerClient 80 + .from("comments_on_documents") 81 + .select(`uri, record, bsky_profiles(record, handle)`) 82 + .in("uri", parentUris); 83 + 84 + if (parentComments) { 85 + for (const pc of parentComments) { 86 + parentCommentsMap.set(pc.uri, { 87 + uri: pc.uri, 88 + record: pc.record, 89 + bsky_profiles: pc.bsky_profiles, 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Transform to ProfileComment format 96 + const comments: ProfileComment[] = rawComments.map((comment) => { 97 + const record = comment.record as PubLeafletComment.Record; 98 + const doc = comment.documents; 99 + const pub = doc?.documents_in_publications?.[0]?.publications; 100 + 101 + return { 102 + uri: comment.uri, 103 + record: comment.record, 104 + indexed_at: comment.indexed_at, 105 + bsky_profiles: comment.bsky_profiles, 106 + document: doc 107 + ? { 108 + uri: doc.uri, 109 + data: doc.data, 110 + } 111 + : null, 112 + publication: pub 113 + ? { 114 + uri: pub.uri, 115 + record: pub.record, 116 + } 117 + : null, 118 + parentComment: record.reply?.parent 119 + ? parentCommentsMap.get(record.reply.parent) || null 120 + : null, 121 + }; 122 + }); 123 + 124 + const nextCursor = 125 + comments.length === limit 126 + ? { 127 + indexed_at: comments[comments.length - 1].indexed_at, 128 + uri: comments[comments.length - 1].uri, 129 + } 130 + : null; 131 + 132 + return { comments, nextCursor }; 133 + }