diff --git a/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx b/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx index 42b9491d..a01ee752 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx +++ b/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx @@ -1,13 +1,11 @@ -import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; +import { PubLeafletComment } from "lexicons/api"; import { HydratedCommentMentionNotification } from "src/notifications"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { MentionTiny } from "components/Icons/MentionTiny"; import { CommentInNotification, ContentLayout, Notification, } from "./Notification"; -import { AtUri } from "@atproto/api"; import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; export const CommentMentionNotification = ( @@ -17,8 +15,7 @@ export const CommentMentionNotification = ( if (!docRecord) return null; const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const profile = props.commentData.profile; const pubRecord = props.normalizedPublication; const href = @@ -66,17 +63,9 @@ export const CommentMentionNotification = ( { const docRecord = props.normalizedDocument; const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const profile = props.commentData.profile; if (!docRecord) return null; const displayName = - profileRecord?.displayName || - props.commentData.bsky_profiles?.handle || - "Someone"; + profile?.displayName || profile?.handle || "Someone"; const pubRecord = props.normalizedPublication; const href = @@ -40,13 +33,7 @@ export const CommentNotification = (props: HydratedCommentNotification) => { { - const profileRecord = props.subscriptionData?.identities?.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const profile = props.subscriptionData?.profile; const displayName = - profileRecord?.displayName || - props.subscriptionData?.identities?.bsky_profiles?.handle || - "Someone"; + profile?.displayName || profile?.handle || "Someone"; const pubRecord = props.normalizedPublication; - const avatarSrc = - profileRecord?.avatar?.ref && - blobRefToSrc( - profileRecord.avatar.ref, - props.subscriptionData?.identity || "", - ); + const avatarSrc = profile?.avatar ?? undefined; return ( { - const profileRecord = props.recommendData?.identities?.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const profile = props.recommendData?.profile; const displayName = - profileRecord?.displayName || - props.recommendData?.identities?.bsky_profiles?.handle || - "Someone"; + profile?.displayName || profile?.handle || "Someone"; const docRecord = props.normalizedDocument; const pubRecord = props.normalizedPublication; - const avatarSrc = - profileRecord?.avatar?.ref && - blobRefToSrc( - profileRecord.avatar.ref, - props.recommendData?.recommender_did || "", - ); if (!docRecord) return null; diff --git a/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx b/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx index 444bf5f4..0ee190dc 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx +++ b/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx @@ -1,6 +1,3 @@ -import { Avatar } from "components/Avatar"; -import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { ReplyTiny } from "components/Icons/ReplyTiny"; import { CommentInNotification, ContentLayout, @@ -8,30 +5,23 @@ import { } from "./Notification"; import { HydratedCommentNotification } from "src/notifications"; import { PubLeafletComment } from "lexicons/api"; -import { AppBskyActorProfile, AtUri } from "@atproto/api"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { ReplyTiny } from "components/Icons/ReplyTiny"; import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; export const ReplyNotification = (props: HydratedCommentNotification) => { const docRecord = props.normalizedDocument; const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const profile = props.commentData.profile; if (!docRecord) return null; const displayName = - profileRecord?.displayName || - props.commentData.bsky_profiles?.handle || - "Someone"; + profile?.displayName || profile?.handle || "Someone"; const parentRecord = props.parentData?.record as PubLeafletComment.Record; - const parentProfile = props.parentData?.bsky_profiles - ?.record as AppBskyActorProfile.Record; + const parentProfile = props.parentData?.profile; const parentDisplayName = - parentProfile?.displayName || - props.parentData?.bsky_profiles?.handle || - "Someone"; + parentProfile?.displayName || parentProfile?.handle || "Someone"; const pubRecord = props.normalizedPublication; @@ -49,13 +39,7 @@ export const ReplyNotification = (props: HydratedCommentNotification) => { {
{ const record = comment.record as PubLeafletComment.Record; - const profile = comment.bsky_profiles?.record as - | AppBskyActorProfile.Record - | undefined; + const profile = comment.profile; const displayName = - profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; - - // Get commenter DID from comment URI - const commenterDid = new AtUri(comment.uri).host; + profile?.displayName || profile?.handle || "Unknown"; const isReply = !!record.reply; @@ -126,11 +120,9 @@ const CommentItem = ({ comment }: { comment: ProfileComment }) => { const parentRecord = comment.parentComment?.record as | PubLeafletComment.Record | undefined; - const parentProfile = comment.parentComment?.bsky_profiles?.record as - | AppBskyActorProfile.Record - | undefined; + const parentProfile = comment.parentComment?.profile; const parentDisplayName = - parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; + parentProfile?.displayName || parentProfile?.handle; // Build direct link to the comment const commentLink = useMemo(() => { @@ -167,9 +159,7 @@ const CommentItem = ({ comment }: { comment: ProfileComment }) => { }, [comment.document, comment.publication, comment.uri, record.onPage]); // Get avatar source - const avatarSrc = profile?.avatar?.ref - ? blobRefToSrc(profile.avatar.ref, commenterDid) - : undefined; + const avatarSrc = profile?.avatar ?? undefined; return (
diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts b/app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts index 23e4cdb0..22b3fb30 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts +++ b/app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts @@ -3,17 +3,24 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { PubLeafletComment } from "lexicons/api"; +import { AtUri } from "@atproto/syntax"; +import { getProfiles, type Profile } from "src/identity"; export type Cursor = { indexed_at: string; uri: string; }; +export type CommentProfile = Pick< + Profile, + "did" | "handle" | "displayName" | "avatar" +>; + export type ProfileComment = { uri: string; record: Json; indexed_at: string; - bsky_profiles: { record: Json; handle: string | null } | null; + profile: CommentProfile | null; document: { uri: string; data: Json; @@ -26,10 +33,20 @@ export type ProfileComment = { parentComment: { uri: string; record: Json; - bsky_profiles: { record: Json; handle: string | null } | null; + profile: CommentProfile | null; } | null; }; +function toCommentProfile(p: Profile | null | undefined): CommentProfile | null { + if (!p) return null; + return { + did: p.did, + handle: p.handle, + displayName: p.displayName, + avatar: p.avatar, + }; +} + export async function getProfileComments( did: string, cursor?: Cursor | null, @@ -40,7 +57,6 @@ export async function getProfileComments( .from("comments_on_documents") .select( `*, - bsky_profiles(record, handle), documents(uri, data, documents_in_publications(publications(*)))`, ) .eq("profile", did) @@ -68,41 +84,47 @@ export async function getProfileComments( // 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; - } + { uri: string; record: Json } >(); if (parentUris.length > 0) { const { data: parentComments } = await supabaseServerClient .from("comments_on_documents") - .select(`uri, record, bsky_profiles(record, handle)`) + .select("uri, record") .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, - }); + parentCommentsMap.set(pc.uri, { uri: pc.uri, record: pc.record }); } } } + // Gather all author DIDs from both main and parent comments + const allDids = new Set(); + for (const c of rawComments) allDids.add(new AtUri(c.uri).host); + for (const pc of parentCommentsMap.values()) + allDids.add(new AtUri(pc.uri).host); + + const profiles = await getProfiles(Array.from(allDids)); + // 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; + const commenterDid = new AtUri(comment.uri).host; + + const parentRaw = record.reply?.parent + ? parentCommentsMap.get(record.reply.parent) + : undefined; + const parentDid = parentRaw ? new AtUri(parentRaw.uri).host : null; return { uri: comment.uri, record: comment.record, indexed_at: comment.indexed_at, - bsky_profiles: comment.bsky_profiles, + profile: toCommentProfile(profiles.get(commenterDid)), document: doc ? { uri: doc.uri, @@ -115,8 +137,14 @@ export async function getProfileComments( record: pub.record, } : null, - parentComment: record.reply?.parent - ? parentCommentsMap.get(record.reply.parent) || null + parentComment: parentRaw + ? { + uri: parentRaw.uri, + record: parentRaw.record, + profile: parentDid + ? toCommentProfile(profiles.get(parentDid)) + : null, + } : null, }; }); diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx b/app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx index 122bd4b0..146fcc31 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx +++ b/app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx @@ -1,4 +1,5 @@ -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { Suspense } from "react"; +import { idResolver } from "src/identity"; import { getProfileComments } from "./getProfileComments"; import { ProfileCommentsContent } from "./CommentsContent"; @@ -16,8 +17,15 @@ export default async function ProfileCommentsPage(props: { did = resolved; } - const { comments, nextCursor } = await getProfileComments(did); + return ( + }> + + + ); +} +async function ProfileCommentsLoader({ did }: { did: string }) { + const { comments, nextCursor } = await getProfileComments(did); return ( ); } + +function ProfileCommentsSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts index ee3e4be1..ab365435 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts +++ b/app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts @@ -8,6 +8,7 @@ import { normalizePublicationRecord, } from "src/utils/normalizeRecords"; import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; +import { idResolver } from "src/identity"; export type Cursor = { sort_date: string; @@ -20,18 +21,14 @@ export async function getProfilePosts( ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { const limit = 20; - let [{ data: rawFeed, error }, { data: profile }] = await Promise.all([ + let [{ data: rawFeed, error }, resolved] = await Promise.all([ supabaseServerClient.rpc("get_profile_posts", { p_did: did, p_cursor_sort_date: cursor?.sort_date ?? undefined, p_cursor_uri: cursor?.uri ?? undefined, p_limit: limit, }), - supabaseServerClient - .from("bsky_profiles") - .select("handle") - .eq("did", did) - .single(), + idResolver.did.resolve(did).catch(() => null), ]); if (error) { @@ -42,7 +39,8 @@ export async function getProfilePosts( let feed = deduplicateByUriOrdered(rawFeed || []); if (feed.length === 0) return { posts: [], nextCursor: null }; - let handle = profile?.handle ? `@${profile.handle}` : null; + let resolvedHandle = resolved?.alsoKnownAs?.[0]?.slice(5); + let handle = resolvedHandle ? `@${resolvedHandle}` : null; let posts: Post[] = []; for (let row of feed) { diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx b/app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx index 7f2b5676..5ade1744 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx +++ b/app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx @@ -1,4 +1,4 @@ -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/page.tsx b/app/(app)/(home-pages)/p/[didOrHandle]/page.tsx index 1310ebb3..803197e7 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/page.tsx +++ b/app/(app)/(home-pages)/p/[didOrHandle]/page.tsx @@ -1,4 +1,4 @@ -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { getProfilePosts } from "./getProfilePosts"; import { ProfilePostsContent } from "./PostsContent"; diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx b/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx index 986c0bc5..92ebcc13 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx +++ b/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx @@ -1,4 +1,4 @@ -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { getSubscriptions } from "app/(app)/(home-pages)/reader/getSubscriptions"; import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; diff --git a/app/(app)/(home-pages)/reader/enrichPost.ts b/app/(app)/(home-pages)/reader/enrichPost.ts index 9812e6f5..d6fecf3e 100644 --- a/app/(app)/(home-pages)/reader/enrichPost.ts +++ b/app/(app)/(home-pages)/reader/enrichPost.ts @@ -6,7 +6,7 @@ import { normalizeDocumentRecord, normalizePublicationRecord, } from "src/utils/normalizeRecords"; -import { idResolver } from "./idResolver"; +import { idResolver } from "src/identity"; import type { Post } from "./getReaderFeed"; type RawDocument = { diff --git a/app/(app)/(home-pages)/reader/getSubscriptions.ts b/app/(app)/(home-pages)/reader/getSubscriptions.ts index 74cecdcf..82a568cd 100644 --- a/app/(app)/(home-pages)/reader/getSubscriptions.ts +++ b/app/(app)/(home-pages)/reader/getSubscriptions.ts @@ -5,7 +5,7 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/act import { getIdentityData } from "actions/getIdentityData"; import { Json } from "supabase/database.types"; import { supabaseServerClient } from "supabase/serverClient"; -import { idResolver } from "./idResolver"; +import { idResolver } from "src/identity"; import { Cursor } from "./getReaderFeed"; import { normalizePublicationRecord, diff --git a/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts index 54ead58e..fe958851 100644 --- a/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -3,7 +3,7 @@ import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/api"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; import { normalizeDocumentRecord, diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index 4daca649..1dcfdced 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -208,10 +208,7 @@ export function PagePreview(props: { } const Interactions = (props: { pageId: string; parentPageId?: string }) => { - const { uri: document_uri, comments: allComments, mentions } = useDocument(); - let comments = allComments.filter( - (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, - ).length; + const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index cc1c6f33..05fedaa6 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -10,11 +10,7 @@ import { AppBskyFeedDefs } from "@atproto/api"; import { PageWrapper } from "components/Pages/Page"; import { Block } from "./PostContent"; import { CanvasBackgroundPattern } from "components/Canvas"; -import { - getCommentCount, - getQuoteCount, - Interactions, -} from "./Interactions/Interactions"; +import { getQuoteCount, Interactions } from "./Interactions/Interactions"; import { Separator } from "components/Layout"; import { Popover } from "components/Popover"; import { InfoSmall } from "components/Icons/InfoSmall"; @@ -71,7 +67,7 @@ export function CanvasPage({ data={document} profile={profile} preferences={preferences} - commentsCount={getCommentCount(document.comments_on_documents, pageId)} + commentsCount={document.commentsCount} quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} recommendsCount={document.recommendsCount} /> diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 30da3814..572ba68a 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -3,6 +3,7 @@ import { PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, } from "lexicons/api"; +import { Suspense } from "react"; import { QuoteHandler } from "./QuoteHandler"; import { PublicationBackgroundProvider, @@ -19,6 +20,10 @@ import { FontLoader } from "components/FontLoader"; import { mergePreferences } from "src/utils/mergePreferences"; import { PublicationNav } from "../PublicationNav"; import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { + CommentsSection, + CommentsSkeleton, +} from "./Interactions/Comments/CommentsSection"; export async function DocumentPageRenderer({ did, @@ -121,6 +126,11 @@ export async function DocumentPageRenderer({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} pollData={pollData} + commentsSlot={ + }> + + + } /> diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index a8ac5171..0d00226b 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -217,9 +217,13 @@ export function CommentBox(props: { { record: result.record, uri: result.uri, - bsky_profiles: { - record: result.profile as Json, + profile: { did: new AtUri(result.uri).host, + handle: null, + displayName: + (result.profile as { displayName?: string } | null) + ?.displayName ?? null, + avatar: null, }, }, ], diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx new file mode 100644 index 00000000..ec23b672 --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx @@ -0,0 +1,59 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/syntax"; +import { getProfiles } from "src/identity"; +import { CommentsDrawerContent, type Comment } from "./index"; + +export async function CommentsSection({ + document_uri, +}: { + document_uri: string; +}) { + const { data: rows } = await supabaseServerClient + .from("comments_on_documents") + .select("uri, record") + .eq("document", document_uri); + + const safeRows = rows ?? []; + const dids = Array.from( + new Set(safeRows.map((c) => new AtUri(c.uri).host)), + ); + const profiles = await getProfiles(dids); + + const comments: Comment[] = safeRows.map((c) => { + const did = new AtUri(c.uri).host; + const p = profiles.get(did); + return { + uri: c.uri, + record: c.record, + profile: p + ? { + did: p.did, + handle: p.handle, + displayName: p.displayName, + avatar: p.avatar, + } + : null, + }; + }); + + return ( + + ); +} + +export function CommentsSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx index 549bdc06..2a6e9325 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -1,5 +1,4 @@ "use client"; -import { CloseTiny } from "components/Icons/CloseTiny"; import { useInteractionState, setInteractionState } from "../Interactions"; import { useIdentityData } from "components/IdentityProvider"; import { CommentBox } from "./CommentBox"; @@ -9,10 +8,8 @@ import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; import { useMemo, useState } from "react"; import { CommentTiny } from "components/Icons/CommentTiny"; import { Separator } from "components/Layout"; -import { ButtonPrimary } from "components/Buttons"; -import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { Popover } from "components/Popover"; -import { AppBskyActorProfile, AtUri } from "@atproto/api"; +import { AtUri } from "@atproto/api"; import { usePathname } from "next/navigation"; import { QuoteContent } from "../Quotes"; import { timeAgo } from "src/utils/timeAgo"; @@ -20,27 +17,36 @@ import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { ProfilePopover } from "components/ProfilePopover"; import { LoginModal } from "components/LoginButton"; +export type CommentProfile = { + did: string; + handle: string | null; + displayName: string | null; + avatar: string | null; +}; + export type Comment = { record: Json; uri: string; - bsky_profiles: { record: Json; did: string } | null; + profile: CommentProfile | null; }; export function CommentsDrawerContent(props: { document_uri: string; comments: Comment[]; - pageId?: string; noCommentBox?: boolean; }) { let { identity } = useIdentityData(); - let { localComments } = useInteractionState(props.document_uri); + let { localComments, pageId } = useInteractionState(props.document_uri); let comments = useMemo(() => { + let filtered = props.comments.filter( + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, + ); return [ ...localComments.filter( - (c) => (c.record as any)?.onPage === props.pageId, + (c) => (c.record as any)?.onPage === pageId, ), - ...props.comments, + ...filtered, ]; - }, [props.comments, localComments]); + }, [props.comments, localComments, pageId]); let pathname = usePathname(); let redirectRoute = useMemo(() => { if (typeof window === "undefined") return; @@ -59,7 +65,7 @@ export function CommentsDrawerContent(props: { {!props.noCommentBox && ( <> {identity?.atp_did ? ( - + ) : (
@@ -91,12 +97,10 @@ export function CommentsDrawerContent(props: { ) .map((comment) => { let record = comment.record as PubLeafletComment.Record; - let profile = comment.bsky_profiles - ?.record as AppBskyActorProfile.Record; return ( { - const did = props.comment.bsky_profiles?.did; + const did = props.profile?.did; let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); @@ -130,7 +134,7 @@ const Comment = (props: { didOrHandle={did} trigger={
- {props.profile.displayName} + {props.profile?.displayName}
} /> @@ -270,10 +274,7 @@ const Replies = (props: { document={props.document} key={reply.uri} comment={reply} - profile={ - reply.bsky_profiles - ?.record as AppBskyActorProfile.Record - } + profile={reply.profile} record={reply.record as PubLeafletComment.Record} comments={props.comments} /> diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index fe4de395..d86b116d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -1,13 +1,10 @@ "use client"; -import { Media } from "components/Media"; import { MentionsDrawerContent } from "./Quotes"; import { InteractionState, setInteractionState, useInteractionState, } from "./Interactions"; -import { Json } from "supabase/database.types"; -import { Comment, CommentsDrawerContent } from "./Comments"; import { useSearchParams } from "next/navigation"; import { SandwichSpacer } from "components/LeafletLayout"; import { decodeQuotePosition } from "../quotePosition"; @@ -17,18 +14,13 @@ export const InteractionDrawer = (props: { showPageBackground: boolean | undefined; document_uri: string; quotesAndMentions: { uri: string; link?: string }[]; - comments: Comment[]; + commentsSlot: React.ReactNode; did: string; pageId?: string; }) => { let drawer = useDrawerOpen(props.document_uri); if (!drawer) return null; - // Filter comments and quotes based on pageId - const filteredComments = props.comments.filter( - (c) => (c.record as any)?.onPage === props.pageId, - ); - const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page const url = new URL(q.link); @@ -57,7 +49,7 @@ export const InteractionDrawer = (props: { @@ -76,11 +68,7 @@ export const InteractionDrawer = (props: {
- + {props.commentsSlot} )}
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 0425480b..892d614f 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -11,8 +11,6 @@ import { scrollIntoView } from "src/utils/scrollIntoView"; import { TagTiny } from "components/Icons/TagTiny"; import { Tag } from "components/Tags"; import { Popover } from "components/Popover"; -import { PubLeafletComment } from "lexicons/api"; -import { type CommentOnDocument } from "contexts/DocumentContext"; import { prefetchQuotesData } from "./Quotes"; import { useIdentityData } from "components/IdentityProvider"; import { ManageSubscription } from "components/Subscribe/ManageSubscribe"; @@ -368,19 +366,6 @@ export function getQuoteCountFromArray( } } -export function getCommentCount( - comments: CommentOnDocument[], - pageId?: string, -) { - if (pageId) - return comments.filter( - (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, - ).length; - else - return comments.filter( - (c) => !(c.record as PubLeafletComment.Record)?.onPage, - ).length; -} const EditButton = (props: { publication: { identity_did: string } | null; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 9467c7a7..24c447de 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -3,7 +3,6 @@ import { PubLeafletPagesLinearDocument } from "lexicons/api"; import { useLeafletContent } from "contexts/LeafletContentContext"; import { ExpandedInteractions, - getCommentCount, getQuoteCount, } from "./Interactions/Interactions"; import { PostContent } from "./PostContent"; @@ -101,9 +100,7 @@ export function LinearDocumentPage({ showComments={preferences.showComments !== false} showMentions={preferences.showMentions !== false} showRecommends={preferences.showRecommends !== false} - commentsCount={ - getCommentCount(document.comments_on_documents, pageId) || 0 - } + commentsCount={document.commentsCount} quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} recommendsCount={document.recommendsCount} /> diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 1b7852a1..72cd56b7 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -3,7 +3,6 @@ import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; import { Interactions, getQuoteCount, - getCommentCount, } from "../Interactions/Interactions"; import { PostPageData } from "../getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; @@ -97,9 +96,7 @@ export function PostHeader(props: { quotesCount={ getQuoteCount(document?.quotesAndMentions || []) || 0 } - commentsCount={ - getCommentCount(document?.comments_on_documents || []) || 0 - } + commentsCount={document?.commentsCount || 0} recommendsCount={document?.recommendsCount || 0} /> )} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx index 903f2530..9c044030 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -112,6 +112,7 @@ export function PostPages({ standardSitePostData, document_uri, pollData, + commentsSlot, }: { document_uri: string; document: PostPageData; @@ -128,6 +129,7 @@ export function PostPages({ showPrevNext?: boolean; }; pollData: PollData[]; + commentsSlot: React.ReactNode; }) { let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); @@ -188,10 +190,8 @@ export function PostPages({ p.record_uri), }, - comments: [], + commentsCount: 0, mentions: [], leafletId: null, recommendsCount: 0, diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts index c7454e31..e5d45190 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -16,7 +16,7 @@ export async function getPostPageData(did: string, rkey: string) { ` data, uri, - comments_on_documents(*, bsky_profiles(*)), + comments_on_documents(count), documents_in_publications(publications(*, documents_in_publications(documents(uri, data)), publication_subscriptions(*), @@ -150,6 +150,7 @@ export async function getPostPageData(did: string, rkey: string) { } : null; const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; + const commentsCount = document.comments_on_documents?.[0]?.count ?? 0; return { ...document, @@ -161,7 +162,7 @@ export async function getPostPageData(did: string, rkey: string) { prevNext, // Explicit relational data for DocumentContext publication, - comments: document.comments_on_documents, + commentsCount, mentions: document.document_mentions_in_bsky, leafletId: document.leaflets_in_publications[0]?.leaflet || null, // Recommends data diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts b/app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts index 1de70cbd..b140391b 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts @@ -1,6 +1,6 @@ "use server"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; export type VoterIdentity = { did: string; diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx b/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx index 350ad265..8513243a 100644 --- a/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx +++ b/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx @@ -47,7 +47,7 @@ function makeFakeDocument( theme: null, prevNext: undefined, publication: publication || null, - comments: [], + commentsCount: 0, comments_on_documents: [], mentions: [], document_mentions_in_bsky: [], @@ -110,7 +110,7 @@ export function PostPreview(props: { prevNext: undefined, quotesAndMentions: [], publication: pubInfo, - comments: [], + commentsCount: 0, mentions: [], leafletId: null, recommendsCount: 0, diff --git a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts index 95d713c4..a6638a7b 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -1,7 +1,7 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; import { jsonToLex } from "@atproto/lexicon"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; import { documentUriFilter } from "src/utils/uriHelpers"; diff --git a/app/(app)/p/[didOrHandle]/[rkey]/page.tsx b/app/(app)/p/[didOrHandle]/[rkey]/page.tsx index e1316f84..95b94628 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/page.tsx +++ b/app/(app)/p/[didOrHandle]/[rkey]/page.tsx @@ -1,6 +1,6 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { DocumentPageRenderer } from "app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; diff --git a/app/about/Examples.tsx b/app/about/Examples.tsx index a6f483b3..72e0edce 100644 --- a/app/about/Examples.tsx +++ b/app/about/Examples.tsx @@ -2,7 +2,7 @@ import { GoToArrow } from "components/Icons/GoToArrow"; import { supabaseServerClient } from "supabase/serverClient"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { PubListing } from "app/(app)/(home-pages)/p/[didOrHandle]/PubListing"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { SpeedyLink } from "components/SpeedyLink"; const pubs = [ diff --git a/app/api/inngest/functions/index_post_mention.ts b/app/api/inngest/functions/index_post_mention.ts index e34965a9..fc756b22 100644 --- a/app/api/inngest/functions/index_post_mention.ts +++ b/app/api/inngest/functions/index_post_mention.ts @@ -8,7 +8,7 @@ import { pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { documentUriFilter } from "src/utils/uriHelpers"; export const index_post_mention = inngest.createFunction( diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts index 908a7209..1a6e11c6 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -1,7 +1,7 @@ import { inngest, events } from "../client"; import { supabaseServerClient } from "supabase/serverClient"; import { AtpAgent, AtUri } from "@atproto/api"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import type { Json } from "supabase/database.types"; // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total) diff --git a/app/api/rpc/[command]/get_document_interactions.ts b/app/api/rpc/[command]/get_document_interactions.ts index 24d6fae2..a16d228d 100644 --- a/app/api/rpc/[command]/get_document_interactions.ts +++ b/app/api/rpc/[command]/get_document_interactions.ts @@ -7,6 +7,8 @@ import { normalizeDocumentRecord, normalizePublicationRecord, } from "src/utils/normalizeRecords"; +import { AtUri } from "@atproto/syntax"; +import { getProfiles } from "src/identity"; export const get_document_interactions = makeRoute({ route: "get_document_interactions", @@ -23,7 +25,7 @@ export const get_document_interactions = makeRoute({ ` data, uri, - comments_on_documents(*, bsky_profiles(*)), + comments_on_documents(*), document_mentions_in_bsky(*), documents_in_publications(publications(*)) `, @@ -81,8 +83,28 @@ export const get_document_interactions = makeRoute({ ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)), ]; + const commentDids = Array.from( + new Set(document.comments_on_documents.map((c) => new AtUri(c.uri).host)), + ); + const profiles = await getProfiles(commentDids); + const comments = document.comments_on_documents.map((c) => { + const did = new AtUri(c.uri).host; + const p = profiles.get(did); + return { + ...c, + profile: p + ? { + did: p.did, + handle: p.handle, + displayName: p.displayName, + avatar: p.avatar, + } + : null, + }; + }); + return { - comments: document.comments_on_documents, + comments, quotesAndMentions, totalMentionsCount: quotesAndMentions.length, }; diff --git a/app/api/rpc/[command]/get_profile_data.ts b/app/api/rpc/[command]/get_profile_data.ts index eb069f86..6c083314 100644 --- a/app/api/rpc/[command]/get_profile_data.ts +++ b/app/api/rpc/[command]/get_profile_data.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { Agent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; diff --git a/appview/index.ts b/appview/index.ts index 992b63b7..4059f3ea 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -1,6 +1,7 @@ import { createClient } from "@supabase/supabase-js"; import { Database, Json } from "supabase/database.types"; import { IdResolver } from "@atproto/identity"; +import Client from "ioredis"; const idResolver = new IdResolver(); import { Firehose, MemoryRunner, Event } from "@atproto/sync"; import { ids } from "lexicons/api/lexicons"; @@ -34,6 +35,22 @@ let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, ); + +const redisClient: Client | null = process.env.REDIS_URL + ? new Client(process.env.REDIS_URL) + : null; + +class RedisProfileCache { + constructor(private client: Client) {} + async clearEntry(did: string): Promise { + await this.client.del(`bsky-profile:${did}`); + } +} + +const profileCache: RedisProfileCache | null = redisClient + ? new RedisProfileCache(redisClient) + : null; + const QUOTE_PARAM = "/l-quote/"; async function main() { const runner = new MemoryRunner({}); @@ -52,7 +69,7 @@ async function main() { ids.PubLeafletPollVote, ids.PubLeafletPollDefinition, ids.PubLeafletInteractionsRecommend, - // ids.AppBskyActorProfile, + ids.AppBskyActorProfile, "app.bsky.feed.post", ids.SiteStandardDocument, ids.SiteStandardPublication, @@ -401,15 +418,15 @@ async function handleEvent(evt: Event) { .eq("uri", evt.uri.toString()); } } - // if (evt.collection === ids.AppBskyActorProfile) { - // //only listen to updates because we should fetch it for the first time when they subscribe! - // if (evt.event === "update") { - // await supabaseServerClient - // .from("bsky_profiles") - // .update({ record: evt.record as Json }) - // .eq("did", evt.did); - // } - // } + if (evt.collection === ids.AppBskyActorProfile) { + if (profileCache) { + try { + await profileCache.clearEntry(evt.did); + } catch (err) { + console.error("Failed to clear profile cache for", evt.did, err); + } + } + } if (evt.collection === "parts.page.mention.service") { if (evt.event === "create" || evt.event === "update") { let { error } = await supabase.from("mention_services").upsert({ diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx index 4f242417..3271542b 100644 --- a/contexts/DocumentContext.tsx +++ b/contexts/DocumentContext.tsx @@ -5,7 +5,6 @@ import type { PostPageData } from "app/(app)/lish/[did]/[publication]/[rkey]/get // Derive types from PostPageData type NonNullPostPageData = NonNullable; export type PublicationContext = NonNullPostPageData["publication"]; -export type CommentOnDocument = NonNullPostPageData["comments"][number]; export type DocumentMention = NonNullPostPageData["mentions"][number]; export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; @@ -18,7 +17,7 @@ export type DocumentContextValue = Pick< | "prevNext" | "quotesAndMentions" | "publication" - | "comments" + | "commentsCount" | "mentions" | "leafletId" | "recommendsCount" diff --git a/app/(app)/(home-pages)/reader/idResolver.ts b/src/identity/idResolver.ts similarity index 93% rename from app/(app)/(home-pages)/reader/idResolver.ts rename to src/identity/idResolver.ts index 37d57480..9798c2fe 100644 --- a/app/(app)/(home-pages)/reader/idResolver.ts +++ b/src/identity/idResolver.ts @@ -1,13 +1,12 @@ import { IdResolver } from "@atproto/identity"; import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; import Client from "ioredis"; -// Create Redis client for DID caching + let redisClient: Client | null = null; if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { redisClient = new Client(process.env.REDIS_URL); } -// Redis-based DID cache implementation class RedisDidCache implements DidCache { private staleTTL: number; private maxTTL: number; @@ -72,7 +71,6 @@ class RedisDidCache implements DidCache { } } -// Create IdResolver with Redis-based DID cache export const idResolver = new IdResolver({ didCache: redisClient ? new RedisDidCache(redisClient) : undefined, }); diff --git a/src/identity/index.ts b/src/identity/index.ts new file mode 100644 index 00000000..cac151ba --- /dev/null +++ b/src/identity/index.ts @@ -0,0 +1,2 @@ +export { idResolver } from "./idResolver"; +export { getProfiles, type Profile } from "./profileCache"; diff --git a/src/identity/profileCache.ts b/src/identity/profileCache.ts new file mode 100644 index 00000000..31c81c6b --- /dev/null +++ b/src/identity/profileCache.ts @@ -0,0 +1,107 @@ +import { cache } from "react"; +import Client from "ioredis"; +import { getAgent } from "app/api/bsky/agent"; + +export type Profile = { + did: string; + handle: string | null; + displayName: string | null; + avatar: string | null; + description: string | null; +}; + +const KEY_PREFIX = "bsky-profile:"; +// 30d TTL — the firehose keeps entries fresh; TTL is just GC. +const MAX_TTL = 60 * 60 * 24 * 30; +const BATCH_SIZE = 25; + +let redisClient: Client | null = null; +if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { + redisClient = new Client(process.env.REDIS_URL); +} + +type CachedEntry = { profile: Profile | null; updatedAt: number }; + +function profileKey(did: string) { + return `${KEY_PREFIX}${did}`; +} + +async function readCache(dids: string[]): Promise> { + const out = new Map(); + if (!redisClient || dids.length === 0) return out; + + const keys = dids.map(profileKey); + const raw = await redisClient.mget(...keys); + raw.forEach((value, i) => { + if (!value) return; + try { + const entry = JSON.parse(value) as CachedEntry; + const age = Date.now() - entry.updatedAt; + if (age <= MAX_TTL * 1000) { + out.set(dids[i], entry.profile); + } + } catch { + // skip malformed entry + } + }); + return out; +} + +async function writeCache(entries: Map): Promise { + if (!redisClient || entries.size === 0) return; + + const pipeline = redisClient.pipeline(); + const now = Date.now(); + for (const [did, profile] of entries) { + const entry: CachedEntry = { profile, updatedAt: now }; + pipeline.setex(profileKey(did), MAX_TTL, JSON.stringify(entry)); + } + await pipeline.exec(); +} + +async function fetchProfiles(dids: string[]): Promise> { + const out = new Map(); + for (const did of dids) out.set(did, null); + if (dids.length === 0) return out; + + const agent = await getAgent(); + for (let i = 0; i < dids.length; i += BATCH_SIZE) { + const batch = dids.slice(i, i + BATCH_SIZE); + try { + const res = await agent.app.bsky.actor.getProfiles({ actors: batch }); + for (const p of res.data.profiles) { + out.set(p.did, { + did: p.did, + handle: p.handle ?? null, + displayName: p.displayName ?? null, + avatar: p.avatar ?? null, + description: p.description ?? null, + }); + } + } catch (err) { + console.error("[profileCache] getProfiles failed:", err); + // Leave nulls in place — they'll be cached as negative results. + } + } + return out; +} + +export const getProfiles = cache( + async (dids: string[]): Promise> => { + const unique = Array.from(new Set(dids)); + const result = new Map(); + if (unique.length === 0) return result; + + const cached = await readCache(unique); + for (const [did, profile] of cached) result.set(did, profile); + + const missing = unique.filter((did) => !cached.has(did)); + if (missing.length === 0) return result; + + const fetched = await fetchProfiles(missing); + for (const [did, profile] of fetched) result.set(did, profile); + + await writeCache(fetched); + return result; + }, +); diff --git a/src/notifications.ts b/src/notifications.ts index 5d7590ea..70f5d1bc 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -3,12 +3,10 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Tables, TablesInsert } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; -import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; +import { idResolver, getProfiles, type Profile } from "src/identity"; import { normalizeDocumentRecord, normalizePublicationRecord, - type NormalizedDocument, - type NormalizedPublication, } from "src/utils/normalizeRecords"; type NotificationRow = Tables<"notifications">; @@ -17,6 +15,23 @@ export type Notification = Omit, "data"> & { data: NotificationData; }; +export type NotificationProfile = Pick< + Profile, + "did" | "handle" | "displayName" | "avatar" +>; + +function toNotificationProfile( + p: Profile | null | undefined, +): NotificationProfile | null { + if (!p) return null; + return { + did: p.did, + handle: p.handle, + displayName: p.displayName, + avatar: p.avatar, + }; +} + export type NotificationData = | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string } @@ -93,23 +108,36 @@ async function hydrateCommentNotifications(notifications: NotificationRow[]) { const { data: comments } = await supabaseServerClient .from("comments_on_documents") .select( - "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", + "*, documents(*, documents_in_publications(publications(*)))", ) .in("uri", commentUris); + const commenterDids = Array.from( + new Set((comments ?? []).map((c) => new AtUri(c.uri).host)), + ); + const profiles = await getProfiles(commenterDids); + + type CommentRow = NonNullable[number]; + const attachProfile = (c: CommentRow) => ({ + ...c, + profile: toNotificationProfile(profiles.get(new AtUri(c.uri).host)), + }); + return commentNotifications .map((notification) => { - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); - if (!commentData) return null; + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); + if (!commentRow) return null; + const commentData = attachProfile(commentRow); + const parentRow = notification.data.parent_uri + ? comments?.find((c) => c.uri === notification.data.parent_uri) + : undefined; return { id: notification.id, recipient: notification.recipient, created_at: notification.created_at, type: "comment" as const, comment_uri: notification.data.comment_uri, - parentData: notification.data.parent_uri - ? comments?.find((c) => c.uri === notification.data.parent_uri) - : undefined, + parentData: parentRow ? attachProfile(parentRow) : undefined, commentData, normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), normalizedPublication: normalizePublicationRecord( @@ -142,20 +170,35 @@ async function hydrateSubscribeNotifications(notifications: NotificationRow[]) { ); const { data: subscriptions } = await supabaseServerClient .from("publication_subscriptions") - .select("*, identities(bsky_profiles(*)), publications(*)") + .select("*, identities(atp_did), publications(*)") .in("uri", subscriptionUris); + const subscriberDids = Array.from( + new Set( + (subscriptions ?? []) + .map((s) => s.identities?.atp_did) + .filter((d): d is string => !!d), + ), + ); + const profiles = await getProfiles(subscriberDids); + return subscribeNotifications .map((notification) => { const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); if (!subscriptionData) return null; + const subscriberDid = subscriptionData.identities?.atp_did ?? null; return { id: notification.id, recipient: notification.recipient, created_at: notification.created_at, type: "subscribe" as const, subscription_uri: notification.data.subscription_uri, - subscriptionData, + subscriptionData: { + ...subscriptionData, + profile: subscriberDid + ? toNotificationProfile(profiles.get(subscriberDid)) + : null, + }, normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), }; }) @@ -436,29 +479,32 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow const { data: comments } = await supabaseServerClient .from("comments_on_documents") .select( - "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", + "*, documents(*, documents_in_publications(publications(*)))", ) .in("uri", commentUris); // Extract unique DIDs from comment URIs to resolve handles const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; - // Resolve DIDs to handles in parallel + // Resolve DIDs to handles in parallel + batch profile fetch const didToHandleMap = new Map(); - await Promise.all( - commenterDids.map(async (did) => { - try { - const resolved = await idResolver.did.resolve(did); - const handle = resolved?.alsoKnownAs?.[0] - ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix - : null; - didToHandleMap.set(did, handle); - } catch (error) { - console.error(`Failed to resolve DID ${did}:`, error); - didToHandleMap.set(did, null); - } - }), - ); + const [profiles] = await Promise.all([ + getProfiles(commenterDids), + Promise.all( + commenterDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ), + ]); // Fetch mentioned publications and documents const mentionedPublicationUris = commentMentionNotifications @@ -486,14 +532,18 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow return commentMentionNotifications .map((notification) => { - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); - if (!commentData) return null; + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); + if (!commentRow) return null; + const commenterDid = new AtUri(commentRow.uri).host; + const commentData = { + ...commentRow, + profile: toNotificationProfile(profiles.get(commenterDid)), + }; const mentionedUri = notification.data.mention_type !== "did" ? (notification.data as Extract, { mentioned_uri: string }>).mentioned_uri : undefined; - const commenterDid = new AtUri(notification.data.comment_uri).host; const commenterHandle = didToHandleMap.get(commenterDid) ?? null; const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; @@ -543,7 +593,7 @@ async function hydrateRecommendNotifications(notifications: NotificationRow[]) { const [{ data: recommends }, { data: documents }] = await Promise.all([ supabaseServerClient .from("recommends_on_documents") - .select("*, identities(bsky_profiles(*))") + .select("*") .in("uri", recommendUris), supabaseServerClient .from("documents") @@ -551,11 +601,26 @@ async function hydrateRecommendNotifications(notifications: NotificationRow[]) { .in("uri", documentUris), ]); + const recommenderDids = Array.from( + new Set( + (recommends ?? []) + .map((r) => r.recommender_did) + .filter((d): d is string => !!d), + ), + ); + const profiles = await getProfiles(recommenderDids); + return recommendNotifications .map((notification) => { - const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri); + const recommendRow = recommends?.find((r) => r.uri === notification.data.recommend_uri); const document = documents?.find((d) => d.uri === notification.data.document_uri); - if (!recommendData || !document) return null; + if (!recommendRow || !document) return null; + const recommendData = { + ...recommendRow, + profile: recommendRow.recommender_did + ? toNotificationProfile(profiles.get(recommendRow.recommender_did)) + : null, + }; return { id: notification.id, recipient: notification.recipient, -- 2.51.2 From 3df16bf161f9bb05e906e2370fa67d17b52c4f35 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 27 May 2026 16:19:40 -0400 Subject: [PATCH 02/12] fallback to did in subscribers list --- .../[publication]/dashboard/PublicationSubscribers.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx index 420865cf..0bae1cf6 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx +++ b/app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx @@ -227,17 +227,17 @@ const SubscriberListItem = (props: { return (
- {props.handle && ( + {(props.handle || props.did) && ( - {props.handle} +
{props.handle ?? props.did}
)} - {props.handle && props.email && ( + {(props.handle || props.did) && props.email && ( )} {props.email && ( -- 2.51.2 From 27886d3375654143197c6d52b68fc7df14769095 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 27 May 2026 16:37:04 -0400 Subject: [PATCH 03/12] add cache debug route --- app/api/debug/profile-cache/route.ts | 51 +++++++ app/debug/profile-cache/page.tsx | 199 +++++++++++++++++++++++++++ src/identity/profileCache.ts | 47 +++++++ 3 files changed, 297 insertions(+) create mode 100644 app/api/debug/profile-cache/route.ts create mode 100644 app/debug/profile-cache/page.tsx diff --git a/app/api/debug/profile-cache/route.ts b/app/api/debug/profile-cache/route.ts new file mode 100644 index 00000000..084d30e6 --- /dev/null +++ b/app/api/debug/profile-cache/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; +import { + debugFetchProfiles, + debugReadCache, + type DebugCacheEntry, + type Profile, +} from "src/identity/profileCache"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/debug/profile-cache?dids=did:plc:foo,did:plc:bar +// Reports what's currently in Redis for each DID and what the upstream +// Bluesky getProfiles call returns right now. Does not write to the cache. +export async function GET(req: NextRequest) { + const param = req.nextUrl.searchParams.get("dids"); + if (!param) { + return Response.json( + { error: "`dids` query param required (comma-separated)" }, + { status: 400 }, + ); + } + + const dids = Array.from( + new Set( + param + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ), + ); + if (dids.length === 0) { + return Response.json({ error: "no DIDs provided" }, { status: 400 }); + } + + const [cacheEntries, fetched] = await Promise.all([ + debugReadCache(dids), + debugFetchProfiles(dids), + ]); + + const result = dids.map((did) => { + const cache: DebugCacheEntry = cacheEntries.get(did) ?? { status: "miss" }; + const live: Profile | null = fetched.get(did) ?? null; + return { did, cache, live }; + }); + + return Response.json( + { dids, result }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/debug/profile-cache/page.tsx b/app/debug/profile-cache/page.tsx new file mode 100644 index 00000000..c3911837 --- /dev/null +++ b/app/debug/profile-cache/page.tsx @@ -0,0 +1,199 @@ +import { + debugFetchProfiles, + debugReadCache, + type DebugCacheEntry, + type Profile, +} from "src/identity/profileCache"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type SearchParams = Promise<{ dids?: string }>; + +export default async function ProfileCacheDebugPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { + const { dids: didsParam } = await searchParams; + const dids = parseDids(didsParam); + + let rows: { + did: string; + cache: DebugCacheEntry; + live: Profile | null; + }[] = []; + + if (dids.length > 0) { + const [cacheEntries, fetched] = await Promise.all([ + debugReadCache(dids), + debugFetchProfiles(dids), + ]); + rows = dids.map((did) => ({ + did, + cache: cacheEntries.get(did) ?? { status: "miss" }, + live: fetched.get(did) ?? null, + })); + } + + return ( +
+

Profile Cache Debug

+

+ Shows what's in the Redis cache and what bsky's + getProfiles returns right now. The cache is not updated. +

+ +
+ +