a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #307 from hyperlink-academy/refactor/profile-data-cache

Refactor/profile data cache

authored by

Brendan Schlagel and committed by
GitHub
(May 28, 2026, 9:56 AM EDT) 49405437 e9dd6c0b

+707 -639
+4 -15
app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx
··· 1 - import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 1 + import { PubLeafletComment } from "lexicons/api"; 2 2 import { HydratedCommentMentionNotification } from "src/notifications"; 3 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 4 3 import { MentionTiny } from "components/Icons/MentionTiny"; 5 4 import { 6 5 CommentInNotification, 7 6 ContentLayout, 8 7 Notification, 9 8 } from "./Notification"; 10 - import { AtUri } from "@atproto/api"; 11 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 12 10 13 11 export const CommentMentionNotification = ( ··· 17 15 if (!docRecord) return null; 18 16 19 17 const commentRecord = props.commentData.record as PubLeafletComment.Record; 20 - const profileRecord = props.commentData.bsky_profiles 21 - ?.record as AppBskyActorProfile.Record; 18 + const profile = props.commentData.profile; 22 19 const pubRecord = props.normalizedPublication; 23 20 24 21 const href = ··· 66 63 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 67 64 <CommentInNotification 68 65 className="" 69 - avatar={ 70 - profileRecord?.avatar?.ref && 71 - blobRefToSrc( 72 - profileRecord?.avatar?.ref, 73 - props.commentData.bsky_profiles?.did || "", 74 - ) 75 - } 66 + avatar={profile?.avatar ?? undefined} 76 67 displayName={ 77 - profileRecord?.displayName || 78 - props.commentData.bsky_profiles?.handle || 79 - "Someone" 68 + profile?.displayName || profile?.handle || "Someone" 80 69 } 81 70 index={[]} 82 71 plaintext={commentRecord.plaintext}
+4 -17
app/(app)/(home-pages)/(writer)/notifications/CommentNotication.tsx
··· 1 - import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 - import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 1 + import { PubLeafletComment } from "lexicons/api"; 3 2 import { HydratedCommentNotification } from "src/notifications"; 4 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { Avatar } from "components/Avatar"; 6 3 import { CommentTiny } from "components/Icons/CommentTiny"; 7 4 import { 8 5 CommentInNotification, 9 6 ContentLayout, 10 7 Notification, 11 8 } from "./Notification"; 12 - import { AtUri } from "@atproto/api"; 13 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 14 10 15 11 export const CommentNotification = (props: HydratedCommentNotification) => { 16 12 const docRecord = props.normalizedDocument; 17 13 const commentRecord = props.commentData.record as PubLeafletComment.Record; 18 - const profileRecord = props.commentData.bsky_profiles 19 - ?.record as AppBskyActorProfile.Record; 14 + const profile = props.commentData.profile; 20 15 21 16 if (!docRecord) return null; 22 17 23 18 const displayName = 24 - profileRecord?.displayName || 25 - props.commentData.bsky_profiles?.handle || 26 - "Someone"; 19 + profile?.displayName || profile?.handle || "Someone"; 27 20 const pubRecord = props.normalizedPublication; 28 21 29 22 const href = ··· 40 33 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 41 34 <CommentInNotification 42 35 className="" 43 - avatar={ 44 - profileRecord?.avatar?.ref && 45 - blobRefToSrc( 46 - profileRecord?.avatar?.ref, 47 - props.commentData.bsky_profiles?.did || "", 48 - ) 49 - } 36 + avatar={profile?.avatar ?? undefined} 50 37 displayName={displayName} 51 38 index={[]} 52 39 plaintext={commentRecord.plaintext}
+3 -13
app/(app)/(home-pages)/(writer)/notifications/FollowNotification.tsx
··· 1 1 import { Avatar } from "components/Avatar"; 2 2 import { Notification } from "./Notification"; 3 3 import { HydratedSubscribeNotification } from "src/notifications"; 4 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { AppBskyActorProfile } from "lexicons/api"; 6 4 7 5 export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 - const profileRecord = props.subscriptionData?.identities?.bsky_profiles 9 - ?.record as AppBskyActorProfile.Record; 6 + const profile = props.subscriptionData?.profile; 10 7 const displayName = 11 - profileRecord?.displayName || 12 - props.subscriptionData?.identities?.bsky_profiles?.handle || 13 - "Someone"; 8 + profile?.displayName || profile?.handle || "Someone"; 14 9 const pubRecord = props.normalizedPublication; 15 - const avatarSrc = 16 - profileRecord?.avatar?.ref && 17 - blobRefToSrc( 18 - profileRecord.avatar.ref, 19 - props.subscriptionData?.identity || "", 20 - ); 10 + const avatarSrc = profile?.avatar ?? undefined; 21 11 22 12 return ( 23 13 <Notification
+2 -15
app/(app)/(home-pages)/(writer)/notifications/RecommendNotification.tsx
··· 1 1 import { ContentLayout, Notification } from "./Notification"; 2 2 import { HydratedRecommendNotification } from "src/notifications"; 3 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 4 - import { AppBskyActorProfile } from "lexicons/api"; 5 - import { Avatar } from "components/Avatar"; 6 - import { AtUri } from "@atproto/api"; 7 3 import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; 8 4 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 9 5 10 6 export const RecommendNotification = ( 11 7 props: HydratedRecommendNotification, 12 8 ) => { 13 - const profileRecord = props.recommendData?.identities?.bsky_profiles 14 - ?.record as AppBskyActorProfile.Record; 9 + const profile = props.recommendData?.profile; 15 10 const displayName = 16 - profileRecord?.displayName || 17 - props.recommendData?.identities?.bsky_profiles?.handle || 18 - "Someone"; 11 + profile?.displayName || profile?.handle || "Someone"; 19 12 const docRecord = props.normalizedDocument; 20 13 const pubRecord = props.normalizedPublication; 21 - const avatarSrc = 22 - profileRecord?.avatar?.ref && 23 - blobRefToSrc( 24 - profileRecord.avatar.ref, 25 - props.recommendData?.recommender_did || "", 26 - ); 27 14 28 15 if (!docRecord) return null; 29 16
+7 -29
app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx
··· 1 - import { Avatar } from "components/Avatar"; 2 - import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 3 - import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 1 import { 5 2 CommentInNotification, 6 3 ContentLayout, ··· 8 5 } from "./Notification"; 9 6 import { HydratedCommentNotification } from "src/notifications"; 10 7 import { PubLeafletComment } from "lexicons/api"; 11 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 8 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 13 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 14 10 15 11 export const ReplyNotification = (props: HydratedCommentNotification) => { 16 12 const docRecord = props.normalizedDocument; 17 13 const commentRecord = props.commentData.record as PubLeafletComment.Record; 18 - const profileRecord = props.commentData.bsky_profiles 19 - ?.record as AppBskyActorProfile.Record; 14 + const profile = props.commentData.profile; 20 15 21 16 if (!docRecord) return null; 22 17 23 18 const displayName = 24 - profileRecord?.displayName || 25 - props.commentData.bsky_profiles?.handle || 26 - "Someone"; 19 + profile?.displayName || profile?.handle || "Someone"; 27 20 28 21 const parentRecord = props.parentData?.record as PubLeafletComment.Record; 29 - const parentProfile = props.parentData?.bsky_profiles 30 - ?.record as AppBskyActorProfile.Record; 22 + const parentProfile = props.parentData?.profile; 31 23 const parentDisplayName = 32 - parentProfile?.displayName || 33 - props.parentData?.bsky_profiles?.handle || 34 - "Someone"; 24 + parentProfile?.displayName || parentProfile?.handle || "Someone"; 35 25 36 26 const pubRecord = props.normalizedPublication; 37 27 ··· 49 39 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 50 40 <CommentInNotification 51 41 className="" 52 - avatar={ 53 - parentProfile?.avatar?.ref && 54 - blobRefToSrc( 55 - parentProfile?.avatar?.ref, 56 - props.parentData?.bsky_profiles?.did || "", 57 - ) 58 - } 42 + avatar={parentProfile?.avatar ?? undefined} 59 43 displayName={parentDisplayName} 60 44 index={[]} 61 45 plaintext={parentRecord.plaintext} ··· 64 48 <div className="h-3 -mt-[1px] ml-[10px] border-l border-border" /> 65 49 <CommentInNotification 66 50 className="" 67 - avatar={ 68 - profileRecord?.avatar?.ref && 69 - blobRefToSrc( 70 - profileRecord?.avatar?.ref, 71 - props.commentData.bsky_profiles?.did || "", 72 - ) 73 - } 51 + avatar={profile?.avatar ?? undefined} 74 52 displayName={displayName} 75 53 index={[]} 76 54 plaintext={commentRecord.plaintext}
+6 -16
app/(app)/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 3 3 import { EmptyState } from "components/EmptyState"; 4 4 import { useEffect, useRef, useMemo } from "react"; 5 5 import useSWRInfinite from "swr/infinite"; 6 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { AtUri } from "@atproto/api"; 7 7 import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 8 8 import { ReplyTiny } from "components/Icons/ReplyTiny"; 9 9 import { Avatar } from "components/Avatar"; 10 10 import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 11 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 11 import { 13 12 getProfileComments, 14 13 type ProfileComment, ··· 105 104 106 105 const CommentItem = ({ comment }: { comment: ProfileComment }) => { 107 106 const record = comment.record as PubLeafletComment.Record; 108 - const profile = comment.bsky_profiles?.record as 109 - | AppBskyActorProfile.Record 110 - | undefined; 107 + const profile = comment.profile; 111 108 const displayName = 112 - profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 113 - 114 - // Get commenter DID from comment URI 115 - const commenterDid = new AtUri(comment.uri).host; 109 + profile?.displayName || profile?.handle || "Unknown"; 116 110 117 111 const isReply = !!record.reply; 118 112 ··· 126 120 const parentRecord = comment.parentComment?.record as 127 121 | PubLeafletComment.Record 128 122 | undefined; 129 - const parentProfile = comment.parentComment?.bsky_profiles?.record as 130 - | AppBskyActorProfile.Record 131 - | undefined; 123 + const parentProfile = comment.parentComment?.profile; 132 124 const parentDisplayName = 133 - parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 125 + parentProfile?.displayName || parentProfile?.handle; 134 126 135 127 // Build direct link to the comment 136 128 const commentLink = useMemo(() => { ··· 167 159 }, [comment.document, comment.publication, comment.uri, record.onPage]); 168 160 169 161 // Get avatar source 170 - const avatarSrc = profile?.avatar?.ref 171 - ? blobRefToSrc(profile.avatar.ref, commenterDid) 172 - : undefined; 162 + const avatarSrc = profile?.avatar ?? undefined; 173 163 174 164 return ( 175 165 <div id={comment.uri} className="w-full flex flex-col text-left mb-8">
+28 -42
app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Json } from "supabase/database.types"; 5 5 import { PubLeafletComment } from "lexicons/api"; 6 + import { getProfiles, type Profile } from "src/identity"; 6 7 7 8 export type Cursor = { 8 9 indexed_at: string; ··· 13 14 uri: string; 14 15 record: Json; 15 16 indexed_at: string; 16 - bsky_profiles: { record: Json; handle: string | null } | null; 17 + profile: Profile | null; 17 18 document: { 18 19 uri: string; 19 20 data: Json; ··· 26 27 parentComment: { 27 28 uri: string; 28 29 record: Json; 29 - bsky_profiles: { record: Json; handle: string | null } | null; 30 + profile: Profile | null; 30 31 } | null; 31 32 }; 32 33 ··· 40 41 .from("comments_on_documents") 41 42 .select( 42 43 `*, 43 - bsky_profiles(record, handle), 44 44 documents(uri, data, documents_in_publications(publications(*)))`, 45 45 ) 46 46 .eq("profile", did) ··· 65 65 .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 66 .filter((uri): uri is string => !!uri); 67 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 - >(); 68 + const { data: parentComments } = parentUris.length 69 + ? await supabaseServerClient 70 + .from("comments_on_documents") 71 + .select("uri, record, profile") 72 + .in("uri", parentUris) 73 + : { data: null }; 77 74 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 - } 75 + const allDids = new Set<string>([did]); 76 + for (const pc of parentComments ?? []) { 77 + if (pc.profile) allDids.add(pc.profile); 93 78 } 79 + const profiles = await getProfiles([...allDids]); 94 80 95 - // Transform to ProfileComment format 81 + const parentMap = new Map( 82 + (parentComments ?? []).map((pc) => [ 83 + pc.uri, 84 + { 85 + uri: pc.uri, 86 + record: pc.record, 87 + profile: pc.profile ? profiles.get(pc.profile) ?? null : null, 88 + }, 89 + ]), 90 + ); 91 + 96 92 const comments: ProfileComment[] = rawComments.map((comment) => { 97 93 const record = comment.record as PubLeafletComment.Record; 98 94 const doc = comment.documents; ··· 102 98 uri: comment.uri, 103 99 record: comment.record, 104 100 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, 101 + profile: profiles.get(did) ?? null, 102 + document: doc ? { uri: doc.uri, data: doc.data } : null, 103 + publication: pub ? { uri: pub.uri, record: pub.record } : null, 118 104 parentComment: record.reply?.parent 119 - ? parentCommentsMap.get(record.reply.parent) || null 105 + ? parentMap.get(record.reply.parent) ?? null 120 106 : null, 121 107 }; 122 108 });
+10 -2
app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { Suspense } from "react"; 2 + import { idResolver } from "src/identity"; 2 3 import { getProfileComments } from "./getProfileComments"; 3 4 import { ProfileCommentsContent } from "./CommentsContent"; 4 5 ··· 16 17 did = resolved; 17 18 } 18 19 19 - const { comments, nextCursor } = await getProfileComments(did); 20 + return ( 21 + <Suspense fallback={null}> 22 + <ProfileCommentsLoader did={did} /> 23 + </Suspense> 24 + ); 25 + } 20 26 27 + async function ProfileCommentsLoader({ did }: { did: string }) { 28 + const { comments, nextCursor } = await getProfileComments(did); 21 29 return ( 22 30 <ProfileCommentsContent 23 31 did={did}
+5 -7
app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 8 8 normalizePublicationRecord, 9 9 } from "src/utils/normalizeRecords"; 10 10 import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 11 + import { idResolver } from "src/identity"; 11 12 12 13 export type Cursor = { 13 14 sort_date: string; ··· 20 21 ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 22 const limit = 20; 22 23 23 - let [{ data: rawFeed, error }, { data: profile }] = await Promise.all([ 24 + let [{ data: rawFeed, error }, resolved] = await Promise.all([ 24 25 supabaseServerClient.rpc("get_profile_posts", { 25 26 p_did: did, 26 27 p_cursor_sort_date: cursor?.sort_date ?? undefined, 27 28 p_cursor_uri: cursor?.uri ?? undefined, 28 29 p_limit: limit, 29 30 }), 30 - supabaseServerClient 31 - .from("bsky_profiles") 32 - .select("handle") 33 - .eq("did", did) 34 - .single(), 31 + idResolver.did.resolve(did).catch(() => null), 35 32 ]); 36 33 37 34 if (error) { ··· 42 39 let feed = deduplicateByUriOrdered(rawFeed || []); 43 40 if (feed.length === 0) return { posts: [], nextCursor: null }; 44 41 45 - let handle = profile?.handle ? `@${profile.handle}` : null; 42 + let resolvedHandle = resolved?.alsoKnownAs?.[0]?.slice(5); 43 + let handle = resolvedHandle ? `@${resolvedHandle}` : null; 46 44 let posts: Post[] = []; 47 45 48 46 for (let row of feed) {
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Json } from "supabase/database.types";
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { getProfilePosts } from "./getProfilePosts"; 3 3 import { ProfilePostsContent } from "./PostsContent"; 4 4
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { getSubscriptions } from "app/(app)/(home-pages)/reader/getSubscriptions"; 3 3 import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 4
+1 -1
app/(app)/(home-pages)/reader/enrichPost.ts
··· 6 6 normalizeDocumentRecord, 7 7 normalizePublicationRecord, 8 8 } from "src/utils/normalizeRecords"; 9 - import { idResolver } from "./idResolver"; 9 + import { idResolver } from "src/identity"; 10 10 import type { Post } from "./getReaderFeed"; 11 11 12 12 type RawDocument = {
+1 -1
app/(app)/(home-pages)/reader/getSubscriptions.ts
··· 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 6 import { Json } from "supabase/database.types"; 7 7 import { supabaseServerClient } from "supabase/serverClient"; 8 - import { idResolver } from "./idResolver"; 8 + import { idResolver } from "src/identity"; 9 9 import { Cursor } from "./getReaderFeed"; 10 10 import { 11 11 normalizePublicationRecord,
+1 -3
app/(app)/(home-pages)/reader/idResolver.ts src/identity/idResolver.ts
··· 1 1 import { IdResolver } from "@atproto/identity"; 2 2 import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 3 import Client from "ioredis"; 4 - // Create Redis client for DID caching 4 + 5 5 let redisClient: Client | null = null; 6 6 if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 7 7 redisClient = new Client(process.env.REDIS_URL); 8 8 } 9 9 10 - // Redis-based DID cache implementation 11 10 class RedisDidCache implements DidCache { 12 11 private staleTTL: number; 13 12 private maxTTL: number; ··· 72 71 } 73 72 } 74 73 75 - // Create IdResolver with Redis-based DID cache 76 74 export const idResolver = new IdResolver({ 77 75 didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 76 });
+1 -1
app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 3 3 import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { AtUri } from "@atproto/api"; 6 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 6 + import { idResolver } from "src/identity"; 7 7 import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; 8 8 import { 9 9 normalizeDocumentRecord,
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 208 208 } 209 209 210 210 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 211 - const { uri: document_uri, comments: allComments, mentions } = useDocument(); 212 - let comments = allComments.filter( 213 - (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 214 - ).length; 211 + const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); 215 212 let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; 216 213 217 214 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
+2 -6
app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 10 10 import { PageWrapper } from "components/Pages/Page"; 11 11 import { Block } from "./PostContent"; 12 12 import { CanvasBackgroundPattern } from "components/Canvas"; 13 - import { 14 - getCommentCount, 15 - getQuoteCount, 16 - Interactions, 17 - } from "./Interactions/Interactions"; 13 + import { getQuoteCount, Interactions } from "./Interactions/Interactions"; 18 14 import { Separator } from "components/Layout"; 19 15 import { Popover } from "components/Popover"; 20 16 import { InfoSmall } from "components/Icons/InfoSmall"; ··· 71 67 data={document} 72 68 profile={profile} 73 69 preferences={preferences} 74 - commentsCount={getCommentCount(document.comments_on_documents, pageId)} 70 + commentsCount={document.commentsCount} 75 71 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 76 72 recommendsCount={document.recommendsCount} 77 73 />
+7
app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 3 3 PubLeafletPagesLinearDocument, 4 4 PubLeafletPagesCanvas, 5 5 } from "lexicons/api"; 6 + import { Suspense } from "react"; 6 7 import { QuoteHandler } from "./QuoteHandler"; 7 8 import { 8 9 PublicationBackgroundProvider, ··· 19 20 import { mergePreferences } from "src/utils/mergePreferences"; 20 21 import { PublicationNav } from "../PublicationNav"; 21 22 import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 23 + import { CommentsSection } from "./Interactions/Comments/CommentsSection"; 22 24 23 25 export async function DocumentPageRenderer({ 24 26 did, ··· 121 123 did={did} 122 124 prerenderedCodeBlocks={prerenderedCodeBlocks} 123 125 pollData={pollData} 126 + commentsSlot={ 127 + <Suspense fallback={null}> 128 + <CommentsSection document_uri={document.uri} /> 129 + </Suspense> 130 + } 124 131 /> 125 132 </LeafletLayout> 126 133
+7 -2
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 217 217 { 218 218 record: result.record, 219 219 uri: result.uri, 220 - bsky_profiles: { 221 - record: result.profile as Json, 220 + profile: { 222 221 did: new AtUri(result.uri).host, 222 + handle: null, 223 + displayName: 224 + (result.profile as { displayName?: string } | null) 225 + ?.displayName ?? null, 226 + avatar: null, 227 + description: null, 223 228 }, 224 229 }, 225 230 ],
+28
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getProfiles } from "src/identity"; 3 + import { CommentsDrawerContent, type Comment } from "./index"; 4 + import { AtUri } from "@atproto/syntax"; 5 + 6 + export async function CommentsSection({ 7 + document_uri, 8 + }: { 9 + document_uri: string; 10 + }) { 11 + const { data: rows } = await supabaseServerClient 12 + .from("comments_on_documents") 13 + .select("uri, record ") 14 + .eq("document", document_uri); 15 + 16 + const dids = (rows ?? []).map((c) => new AtUri(c.uri).host); 17 + const profiles = await getProfiles([...new Set(dids)]); 18 + 19 + const comments: Comment[] = (rows ?? []).map((c) => ({ 20 + uri: c.uri, 21 + record: c.record, 22 + profile: profiles.get(new AtUri(c.uri).host) ?? null, 23 + })); 24 + 25 + return ( 26 + <CommentsDrawerContent document_uri={document_uri} comments={comments} /> 27 + ); 28 + }
+17 -22
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 1 1 "use client"; 2 - import { CloseTiny } from "components/Icons/CloseTiny"; 3 2 import { useInteractionState, setInteractionState } from "../Interactions"; 4 3 import { useIdentityData } from "components/IdentityProvider"; 5 4 import { CommentBox } from "./CommentBox"; ··· 9 8 import { useMemo, useState } from "react"; 10 9 import { CommentTiny } from "components/Icons/CommentTiny"; 11 10 import { Separator } from "components/Layout"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 11 import { Popover } from "components/Popover"; 15 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 + import { AtUri } from "@atproto/api"; 16 13 import { usePathname } from "next/navigation"; 17 14 import { QuoteContent } from "../Quotes"; 18 15 import { timeAgo } from "src/utils/timeAgo"; 19 16 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 17 import { ProfilePopover } from "components/ProfilePopover"; 21 18 import { LoginModal } from "components/LoginButton"; 19 + import { type Profile } from "src/identity"; 22 20 23 21 export type Comment = { 24 22 record: Json; 25 23 uri: string; 26 - bsky_profiles: { record: Json; did: string } | null; 24 + profile: Profile | null; 27 25 }; 28 26 export function CommentsDrawerContent(props: { 29 27 document_uri: string; 30 28 comments: Comment[]; 31 - pageId?: string; 32 29 noCommentBox?: boolean; 33 30 }) { 34 31 let { identity } = useIdentityData(); 35 - let { localComments } = useInteractionState(props.document_uri); 32 + let { localComments, pageId } = useInteractionState(props.document_uri); 36 33 let comments = useMemo(() => { 34 + let filtered = props.comments.filter( 35 + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 36 + ); 37 37 return [ 38 38 ...localComments.filter( 39 - (c) => (c.record as any)?.onPage === props.pageId, 39 + (c) => (c.record as any)?.onPage === pageId, 40 40 ), 41 - ...props.comments, 41 + ...filtered, 42 42 ]; 43 - }, [props.comments, localComments]); 43 + }, [props.comments, localComments, pageId]); 44 44 let pathname = usePathname(); 45 45 let redirectRoute = useMemo(() => { 46 46 if (typeof window === "undefined") return; ··· 59 59 {!props.noCommentBox && ( 60 60 <> 61 61 {identity?.atp_did ? ( 62 - <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 62 + <CommentBox doc_uri={props.document_uri} pageId={pageId} /> 63 63 ) : ( 64 64 <div className="w-full accent-container text-tertiary text-center italic p-3 gap-2"> 65 65 <span className="text-accent-contrast font-bold"> ··· 91 91 ) 92 92 .map((comment) => { 93 93 let record = comment.record as PubLeafletComment.Record; 94 - let profile = comment.bsky_profiles 95 - ?.record as AppBskyActorProfile.Record; 96 94 return ( 97 95 <Comment 98 - pageId={props.pageId} 99 - profile={profile} 96 + pageId={pageId} 97 + profile={comment.profile} 100 98 document={props.document_uri} 101 99 comment={comment} 102 100 record={record} ··· 114 112 document: string; 115 113 comment: Comment; 116 114 comments: Comment[]; 117 - profile: AppBskyActorProfile.Record; 115 + profile: Profile | null; 118 116 record: PubLeafletComment.Record; 119 117 pageId?: string; 120 118 }) => { 121 - const did = props.comment.bsky_profiles?.did; 119 + const did = props.profile?.did; 122 120 123 121 let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 124 122 ··· 130 128 didOrHandle={did} 131 129 trigger={ 132 130 <div className="text-sm text-secondary font-bold hover:underline"> 133 - {props.profile.displayName} 131 + {props.profile?.displayName} 134 132 </div> 135 133 } 136 134 /> ··· 270 268 document={props.document} 271 269 key={reply.uri} 272 270 comment={reply} 273 - profile={ 274 - reply.bsky_profiles 275 - ?.record as AppBskyActorProfile.Record 276 - } 271 + profile={reply.profile} 277 272 record={reply.record as PubLeafletComment.Record} 278 273 comments={props.comments} 279 274 />
+3 -15
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 - import { Media } from "components/Media"; 3 2 import { MentionsDrawerContent } from "./Quotes"; 4 3 import { 5 4 InteractionState, 6 5 setInteractionState, 7 6 useInteractionState, 8 7 } from "./Interactions"; 9 - import { Json } from "supabase/database.types"; 10 - import { Comment, CommentsDrawerContent } from "./Comments"; 11 8 import { useSearchParams } from "next/navigation"; 12 9 import { SandwichSpacer } from "components/LeafletLayout"; 13 10 import { decodeQuotePosition } from "../quotePosition"; ··· 17 14 showPageBackground: boolean | undefined; 18 15 document_uri: string; 19 16 quotesAndMentions: { uri: string; link?: string }[]; 20 - comments: Comment[]; 17 + commentsSlot: React.ReactNode; 21 18 did: string; 22 19 pageId?: string; 23 20 }) => { 24 21 let drawer = useDrawerOpen(props.document_uri); 25 22 if (!drawer) return null; 26 23 27 - // Filter comments and quotes based on pageId 28 - const filteredComments = props.comments.filter( 29 - (c) => (c.record as any)?.onPage === props.pageId, 30 - ); 31 - 32 24 const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { 33 25 if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page 34 26 const url = new URL(q.link); ··· 57 49 <CloseTiny /> 58 50 </button> 59 51 <MentionsDrawerContent 60 - {...props} 52 + did={props.did} 61 53 quotesAndMentions={filteredQuotesAndMentions} 62 54 /> 63 55 </> ··· 76 68 <CloseTiny /> 77 69 </button> 78 70 </div> 79 - <CommentsDrawerContent 80 - document_uri={props.document_uri} 81 - comments={filteredComments} 82 - pageId={props.pageId} 83 - /> 71 + {props.commentsSlot} 84 72 </> 85 73 )} 86 74 </div>
-15
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 11 11 import { TagTiny } from "components/Icons/TagTiny"; 12 12 import { Tag } from "components/Tags"; 13 13 import { Popover } from "components/Popover"; 14 - import { PubLeafletComment } from "lexicons/api"; 15 - import { type CommentOnDocument } from "contexts/DocumentContext"; 16 14 import { prefetchQuotesData } from "./Quotes"; 17 15 import { useIdentityData } from "components/IdentityProvider"; 18 16 import { ManageSubscription } from "components/Subscribe/ManageSubscribe"; ··· 368 366 } 369 367 } 370 368 371 - export function getCommentCount( 372 - comments: CommentOnDocument[], 373 - pageId?: string, 374 - ) { 375 - if (pageId) 376 - return comments.filter( 377 - (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 378 - ).length; 379 - else 380 - return comments.filter( 381 - (c) => !(c.record as PubLeafletComment.Record)?.onPage, 382 - ).length; 383 - } 384 369 385 370 const EditButton = (props: { 386 371 publication: { identity_did: string } | null;
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 3 3 import { useLeafletContent } from "contexts/LeafletContentContext"; 4 4 import { 5 5 ExpandedInteractions, 6 - getCommentCount, 7 6 getQuoteCount, 8 7 } from "./Interactions/Interactions"; 9 8 import { PostContent } from "./PostContent"; ··· 101 100 showComments={preferences.showComments !== false} 102 101 showMentions={preferences.showMentions !== false} 103 102 showRecommends={preferences.showRecommends !== false} 104 - commentsCount={ 105 - getCommentCount(document.comments_on_documents, pageId) || 0 106 - } 103 + commentsCount={document.commentsCount} 107 104 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 108 105 recommendsCount={document.recommendsCount} 109 106 />
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 3 3 import { 4 4 Interactions, 5 5 getQuoteCount, 6 - getCommentCount, 7 6 } from "../Interactions/Interactions"; 8 7 import { PostPageData } from "../getPostPageData"; 9 8 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; ··· 97 96 quotesCount={ 98 97 getQuoteCount(document?.quotesAndMentions || []) || 0 99 98 } 100 - commentsCount={ 101 - getCommentCount(document?.comments_on_documents || []) || 0 102 - } 99 + commentsCount={document?.commentsCount || 0} 103 100 recommendsCount={document?.recommendsCount || 0} 104 101 /> 105 102 )}
+6 -8
app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 112 112 standardSitePostData, 113 113 document_uri, 114 114 pollData, 115 + commentsSlot, 115 116 }: { 116 117 document_uri: string; 117 118 document: PostPageData; ··· 128 129 showPrevNext?: boolean; 129 130 }; 130 131 pollData: PollData[]; 132 + commentsSlot: React.ReactNode; 131 133 }) { 132 134 let drawer = useDrawerOpen(document_uri); 133 135 useInitializeOpenPages(); ··· 188 190 <InteractionDrawer 189 191 showPageBackground={pubRecord?.theme?.showPageBackground} 190 192 document_uri={document.uri} 191 - comments={ 192 - preferences.showComments === false 193 - ? [] 194 - : document.comments_on_documents 193 + commentsSlot={ 194 + preferences.showComments === false ? null : commentsSlot 195 195 } 196 196 quotesAndMentions={ 197 197 preferences.showMentions === false ? [] : quotesAndMentions ··· 303 303 showPageBackground={pubRecord?.theme?.showPageBackground} 304 304 pageId={page.id} 305 305 document_uri={document.uri} 306 - comments={ 307 - preferences.showComments === false 308 - ? [] 309 - : document.comments_on_documents 306 + commentsSlot={ 307 + preferences.showComments === false ? null : commentsSlot 310 308 } 311 309 quotesAndMentions={ 312 310 preferences.showMentions === false ? [] : quotesAndMentions
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
··· 152 152 newsletterMode: false, 153 153 pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), 154 154 }, 155 - comments: [], 155 + commentsCount: 0, 156 156 mentions: [], 157 157 leafletId: null, 158 158 recommendsCount: 0,
+3 -2
app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 16 16 ` 17 17 data, 18 18 uri, 19 - comments_on_documents(*, bsky_profiles(*)), 19 + comments_on_documents(count), 20 20 documents_in_publications(publications(*, 21 21 documents_in_publications(documents(uri, data)), 22 22 publication_subscriptions(*), ··· 150 150 } 151 151 : null; 152 152 const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 153 + const commentsCount = document.comments_on_documents?.[0]?.count ?? 0; 153 154 154 155 return { 155 156 ...document, ··· 161 162 prevNext, 162 163 // Explicit relational data for DocumentContext 163 164 publication, 164 - comments: document.comments_on_documents, 165 + commentsCount, 165 166 mentions: document.document_mentions_in_bsky, 166 167 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 167 168 // Recommends data
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts
··· 1 1 "use server"; 2 2 3 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 3 + import { idResolver } from "src/identity"; 4 4 5 5 export type VoterIdentity = { 6 6 did: string;
+148 -204
app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 1 1 "use client"; 2 - import { usePublicationData } from "./PublicationSWRProvider"; 3 2 import { ButtonPrimary } from "components/Buttons"; 4 - import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 3 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Menu"; 7 4 import { Separator } from "components/Layout"; 8 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 5 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { useDashboardState } from "components/PageLayouts/dashboardState"; 6 + import { 7 + useDashboardState, 8 + useSetDashboardState, 9 + } from "components/PageLayouts/dashboardState"; 11 10 import { AtmosphereAccount } from "components/Icons/AtmosphereAccount"; 12 11 import { EmailTiny } from "components/Icons/EmailTiny"; 13 - 14 - type subscriber = { email: string | undefined; did: string | undefined }; 12 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 13 + import { Popover } from "components/Popover"; 14 + import { Checkbox } from "components/Checkbox"; 15 15 16 - type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 16 + export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 17 17 18 - type MergedSubscriber = { 18 + export type MergedSubscriber = { 19 19 key: string; 20 20 did: string | undefined; 21 21 handle: string | undefined; ··· 24 24 status: SubscriberStatus; 25 25 }; 26 26 27 - export function useMergedSubscribers(): MergedSubscriber[] | null { 28 - let { data: publication } = usePublicationData(); 29 - let { subscriberStatus } = useDashboardState(); 30 - 31 - if (!publication) return null; 32 - // ATProto subscribers have no email lifecycle state — they're just present 33 - // or absent, so they only count under the "subscribed" status filter. 34 - let atprotoSubs = subscriberStatus.subscribed 35 - ? publication.publication?.publication_subscriptions || [] 36 - : []; 37 - let newsletterEnabled = 38 - !!publication.publication?.publication_newsletter_settings?.enabled; 39 - let emailSubs = newsletterEnabled 40 - ? (publication.publication?.publication_email_subscribers || []).filter( 41 - (s) => { 42 - if (s.state === "confirmed") return subscriberStatus.subscribed; 43 - if (s.state === "pending") return subscriberStatus.unconfirmed; 44 - if (s.state === "unsubscribed") return subscriberStatus.unsubscribed; 45 - return false; 46 - }, 47 - ) 48 - : []; 49 - 50 - let byDid = new Map<string, MergedSubscriber>(); 51 - let emailOnly: MergedSubscriber[] = []; 52 - for (let s of atprotoSubs) { 53 - let did = s.identities?.bsky_profiles?.did; 54 - if (!did) continue; 55 - byDid.set(did, { 56 - key: `did:${did}`, 57 - did, 58 - handle: s.identities?.bsky_profiles?.handle ?? undefined, 59 - email: undefined, 60 - created_at: s.created_at, 61 - status: "subscribed", 62 - }); 63 - } 64 - for (let s of emailSubs) { 65 - let status: SubscriberStatus = 66 - s.state === "pending" 67 - ? "unconfirmed" 68 - : s.state === "unsubscribed" 69 - ? "unsubscribed" 70 - : "subscribed"; 71 - let linkedDid = s.identities?.atp_did ?? undefined; 72 - let existing = linkedDid ? byDid.get(linkedDid) : undefined; 73 - if (existing && status === "subscribed") { 74 - existing.email = s.email; 75 - continue; 76 - } 77 - emailOnly.push({ 78 - key: `email:${s.id}`, 79 - did: linkedDid, 80 - handle: s.identities?.bsky_profiles?.handle ?? undefined, 81 - email: s.email, 82 - created_at: s.created_at, 83 - status, 84 - }); 85 - } 86 - return [...byDid.values(), ...emailOnly]; 87 - } 88 - 89 27 export function PublicationSubscribers(props: { 90 - showPageBackground?: boolean; 28 + subscribers: MergedSubscriber[]; 29 + publicationShareUrl: string; 30 + publicationUri: string; 31 + showPageBackground: boolean; 91 32 }) { 92 33 let smoker = useSmoker(); 93 - let { data: publication } = usePublicationData(); 94 34 let { subscriberStatus } = useDashboardState(); 95 - let subscribers = useMergedSubscribers(); 96 - 97 - if (!publication || !subscribers) return <div>null</div>; 98 - 99 - // useEffect(() => { 100 - // const allSubscribersSelected = subscribers.every((subscriber) => 101 - // checkedSubscribers.some( 102 - // (checked) => 103 - // checked.email === "dummyemail@email.com" && 104 - // checked.did === subscriber.identities?.bsky_profiles?.did, 105 - // ), 106 - // ); 107 - 108 - // if (allSubscribersSelected && subscribers.length > 0) { 109 - // setCheckAll(true); 110 - // } else { 111 - // setCheckAll(false); 112 - // } 113 - // }, [checkedSubscribers]); 35 + let filtered = props.subscribers.filter((s) => subscriberStatus[s.status]); 114 36 115 37 let activeStatuses = ( 116 38 Object.keys(subscriberStatus) as SubscriberStatus[] ··· 118 40 let isDefaultStatusFilter = 119 41 activeStatuses.length === 1 && activeStatuses[0] === "subscribed"; 120 42 121 - if (subscribers.length === 0) { 122 - if (!isDefaultStatusFilter) { 123 - let label = 124 - activeStatuses.length === 0 125 - ? "any status" 126 - : activeStatuses 127 - .map((s) => (s === "unconfirmed" ? "unconfirmed" : s)) 128 - .join(", "); 129 - return ( 130 - <div 131 - className={`italic text-tertiary 132 - flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 133 - style={ 134 - props.showPageBackground 135 - ? { 136 - backgroundColor: 137 - "rgba(var(--bg-page), var(--bg-page-alpha)) ", 138 - } 139 - : { backgroundColor: "transparent" } 140 - } 141 - > 142 - <p className="font-bold">No subscribers match your filters!</p> 143 - </div> 144 - ); 145 - } 146 - return ( 147 - <div 148 - className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 149 - style={ 150 - props.showPageBackground 151 - ? { 152 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ", 153 - } 154 - : { backgroundColor: "transparent" } 155 - } 156 - > 157 - <p className="font-bold"> No subscribers yet </p> 158 - <p>Start sharing your publication!</p> 159 - <ButtonPrimary 160 - className="mx-auto mt-2" 161 - onClick={(e) => { 162 - e.preventDefault(); 163 - let rect = (e.currentTarget as Element)?.getBoundingClientRect(); 164 - navigator.clipboard.writeText( 165 - getPublicationURL(publication.publication!), 166 - ); 167 - smoker({ 168 - position: { 169 - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 170 - y: rect ? rect.top + 26 : 0, 171 - }, 172 - text: "Copied Publication URL!", 173 - }); 174 - }} 175 - > 176 - Copy Share Link 177 - </ButtonPrimary> 178 - </div> 179 - ); 180 - } 43 + let bgStyle = props.showPageBackground 44 + ? { backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) " } 45 + : { backgroundColor: "transparent" }; 46 + let bgBorder = props.showPageBackground 47 + ? "border-border-light p-2" 48 + : "border-transparent"; 181 49 182 50 return ( 183 - <div 184 - className={`rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 185 - style={ 186 - props.showPageBackground 187 - ? { 188 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ", 189 - } 190 - : { backgroundColor: "transparent" } 51 + <DashboardPageLayout 52 + scrollKey={`dashboard-${props.publicationUri}-Subs`} 53 + pageTitle="Subscribers" 54 + mobileActions={<SubscriberStatusFilter />} 55 + publication={props.publicationUri} 56 + showHeader={true} 57 + controls={ 58 + <div className="flex items-center justify-between gap-3 text-sm"> 59 + <div className="font-bold text-secondary px-1"> 60 + {filtered.length} Subscriber{filtered.length !== 1 && "s"} 61 + </div> 62 + <SubscriberStatusFilter /> 63 + </div> 191 64 } 192 65 > 193 - <div className="subscriberListContent flex gap-2 flex-col "> 194 - {subscribers 195 - .sort((a, b) => b.created_at.localeCompare(a.created_at)) 196 - .map((subscriber) => ( 66 + {filtered.length === 0 ? ( 67 + <div 68 + className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${bgBorder}`} 69 + style={bgStyle} 70 + > 71 + {isDefaultStatusFilter ? ( 197 72 <> 198 - <SubscriberListItem 199 - key={subscriber.key} 200 - handle={subscriber.handle} 201 - did={subscriber.did} 202 - email={subscriber.email} 203 - createdAt={subscriber.created_at} 204 - status={subscriber.status} 205 - /> 206 - <hr className="border-border-light last:hidden" /> 73 + <p className="font-bold"> No subscribers yet </p> 74 + <p>Start sharing your publication!</p> 75 + <ButtonPrimary 76 + className="mx-auto mt-2" 77 + onClick={(e) => { 78 + e.preventDefault(); 79 + let rect = ( 80 + e.currentTarget as Element 81 + )?.getBoundingClientRect(); 82 + navigator.clipboard.writeText(props.publicationShareUrl); 83 + smoker({ 84 + position: { 85 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 86 + y: rect ? rect.top + 26 : 0, 87 + }, 88 + text: "Copied Publication URL!", 89 + }); 90 + }} 91 + > 92 + Copy Share Link 93 + </ButtonPrimary> 207 94 </> 208 - ))} 209 - </div> 210 - </div> 95 + ) : ( 96 + <p className="font-bold">No subscribers match your filters!</p> 97 + )} 98 + </div> 99 + ) : ( 100 + <div className={`rounded-md ${bgBorder}`} style={bgStyle}> 101 + <div className="subscriberListContent flex gap-2 flex-col "> 102 + {filtered 103 + .sort((a, b) => b.created_at.localeCompare(a.created_at)) 104 + .map((subscriber) => ( 105 + <div key={subscriber.key}> 106 + <SubscriberListItem 107 + handle={subscriber.handle} 108 + did={subscriber.did} 109 + email={subscriber.email} 110 + createdAt={subscriber.created_at} 111 + status={subscriber.status} 112 + /> 113 + <hr className="border-border-light mt-2 last:hidden" /> 114 + </div> 115 + ))} 116 + </div> 117 + </div> 118 + )} 119 + </DashboardPageLayout> 211 120 ); 212 121 } 213 122 ··· 227 136 return ( 228 137 <div className="flex flex-row justify-between gap-2 w-full"> 229 138 <div className="flex flex-col gap-0.5 grow min-w-0 w-full"> 230 - {props.handle && ( 139 + {(props.handle || props.did) && ( 231 140 <a 232 141 target="_blank" 233 142 href={`https://bsky.app/profile/${props.did}`} 234 143 className={`${contactClassName} ${props.status === "subscribed" ? subscribedClassName : props.status === "unconfirmed" ? unconfirmedClassName : mutedClassName}`} 235 144 > 236 145 <AtmosphereAccount className="text-tertiary shrink-0" /> 237 - {props.handle} 146 + <div className="truncate min-w-0">{props.handle ?? props.did}</div> 238 147 </a> 239 148 )} 240 - {props.handle && props.email && ( 149 + {(props.handle || props.did) && props.email && ( 241 150 <Separator classname="sm:block hidden" /> 242 151 )} 243 152 {props.email && ( ··· 263 172 ); 264 173 }; 265 174 266 - const SubscriberOptions = (props: { 267 - checkedSubscribers: subscriber[]; 268 - allSelected: boolean; 269 - }) => { 270 - return ( 271 - <Menu 272 - asChild 273 - className="" 274 - trigger={ 275 - <ButtonPrimary compact className="-mt-[1px]"> 276 - {props.allSelected ? "All" : props.checkedSubscribers.length} Selected{" "} 277 - <MoreOptionsVerticalTiny /> 278 - </ButtonPrimary> 279 - } 280 - > 281 - <MenuItem className="justify-center" onSelect={() => {}}> 282 - Export {props.allSelected ? "All" : "Selected"} 283 - </MenuItem> 284 - <MenuItem className="justify-center" onSelect={() => {}}> 285 - Remove {props.allSelected ? "All" : "Selected"} 286 - </MenuItem> 287 - </Menu> 288 - ); 289 - }; 290 - 291 175 function SubscriberDate(props: { createdAt: string }) { 292 176 const formattedDate = useLocalizedDate(props.createdAt, { 293 177 year: "2-digit", ··· 300 184 </div> 301 185 ); 302 186 } 187 + 188 + const SubscriberStatusFilter = () => { 189 + let { subscriberStatus } = useDashboardState(); 190 + let setState = useSetDashboardState(); 191 + let count = Object.values(subscriberStatus).filter(Boolean).length; 192 + 193 + return ( 194 + <Popover 195 + className="text-sm px-2! py-1!" 196 + trigger={ 197 + <div className="text-sm text-tertiary"> 198 + Filters {count > 0 && `(${count})`} 199 + </div> 200 + } 201 + > 202 + <Checkbox 203 + small 204 + checked={subscriberStatus.subscribed} 205 + onChange={(e) => 206 + setState({ 207 + subscriberStatus: { 208 + ...subscriberStatus, 209 + subscribed: !!e.target.checked, 210 + }, 211 + }) 212 + } 213 + > 214 + Subscribed 215 + </Checkbox> 216 + <Checkbox 217 + small 218 + checked={subscriberStatus.unconfirmed} 219 + onChange={(e) => 220 + setState({ 221 + subscriberStatus: { 222 + ...subscriberStatus, 223 + unconfirmed: !!e.target.checked, 224 + }, 225 + }) 226 + } 227 + > 228 + Unconfirmed 229 + </Checkbox> 230 + <Checkbox 231 + small 232 + checked={subscriberStatus.unsubscribed} 233 + onChange={(e) => 234 + setState({ 235 + subscriberStatus: { 236 + ...subscriberStatus, 237 + unsubscribed: !!e.target.checked, 238 + }, 239 + }) 240 + } 241 + > 242 + Unsubscribed 243 + </Checkbox> 244 + </Popover> 245 + ); 246 + };
+90 -96
app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx
··· 1 - "use client"; 2 - 3 - import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getProfiles } from "src/identity"; 3 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 4 + import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 6 import { 5 7 PublicationSubscribers, 6 - useMergedSubscribers, 8 + type MergedSubscriber, 9 + type SubscriberStatus, 7 10 } from "../PublicationSubscribers"; 8 - import { 9 - usePublicationData, 10 - useNormalizedPublicationRecord, 11 - } from "../PublicationSWRProvider"; 12 - import { Popover } from "components/Popover"; 13 - import { Checkbox } from "components/Checkbox"; 14 - import { 15 - useDashboardState, 16 - useSetDashboardState, 17 - } from "components/PageLayouts/dashboardState"; 11 + 12 + export default async function SubsPage(props: { 13 + params: Promise<{ did: string; publication: string }>; 14 + }) { 15 + const params = await props.params; 16 + const did = decodeURIComponent(params.did); 17 + const publication = decodeURIComponent(params.publication); 18 + 19 + const { result } = await get_publication_data.handler( 20 + { did, publication_name: publication }, 21 + { supabase: supabaseServerClient }, 22 + ); 23 + const pub = result.publication; 24 + if (!pub) { 25 + return ( 26 + <PublicationSubscribers 27 + subscribers={[]} 28 + publicationShareUrl="" 29 + publicationUri="" 30 + showPageBackground={false} 31 + /> 32 + ); 33 + } 18 34 19 - export default function SubsPage() { 20 - let { data } = usePublicationData(); 21 - let record = useNormalizedPublicationRecord(); 22 - let pubUri = data?.publication?.uri || ""; 35 + const record = normalizePublicationRecord(pub.record); 23 36 const showPageBackground = !!record?.theme?.showPageBackground; 24 - let subscribers = useMergedSubscribers(); 25 - let count = subscribers?.length ?? 0; 37 + const atprotoSubs = pub.publication_subscriptions || []; 38 + const newsletterEnabled = !!pub.publication_newsletter_settings?.enabled; 39 + const emailSubs = newsletterEnabled 40 + ? pub.publication_email_subscribers || [] 41 + : []; 26 42 27 - return ( 28 - <DashboardPageLayout 29 - scrollKey={`dashboard-${pubUri}-Subs`} 30 - pageTitle="Subscribers" 31 - mobileActions={<SubscriberStatusFilter />} 32 - publication={pubUri} 33 - showHeader={true} 34 - controls={ 35 - <div className="flex items-center justify-between gap-3 text-sm"> 36 - <div className="font-bold text-secondary px-1"> 37 - {count} Subscriber{count !== 1 && "s"} 38 - </div> 39 - <SubscriberStatusFilter /> 40 - </div> 41 - } 42 - > 43 - <PublicationSubscribers showPageBackground={showPageBackground} /> 44 - </DashboardPageLayout> 45 - ); 46 - } 43 + const dids = new Set<string>(); 44 + for (const s of atprotoSubs) { 45 + if (s.identities?.atp_did) dids.add(s.identities.atp_did); 46 + } 47 + for (const s of emailSubs) { 48 + if (s.identities?.atp_did) dids.add(s.identities.atp_did); 49 + } 50 + 51 + const profiles = await getProfiles(Array.from(dids)); 47 52 48 - const SubscriberStatusFilter = () => { 49 - let { subscriberStatus } = useDashboardState(); 50 - let setState = useSetDashboardState(); 51 - let count = Object.values(subscriberStatus).filter(Boolean).length; 53 + const byDid = new Map<string, MergedSubscriber>(); 54 + const emailOnly: MergedSubscriber[] = []; 55 + 56 + for (const s of atprotoSubs) { 57 + const d = s.identities?.atp_did ?? undefined; 58 + if (!d) continue; 59 + byDid.set(d, { 60 + key: `did:${d}`, 61 + did: d, 62 + handle: profiles.get(d)?.handle ?? undefined, 63 + email: undefined, 64 + created_at: s.created_at, 65 + status: "subscribed", 66 + }); 67 + } 68 + 69 + for (const s of emailSubs) { 70 + const status: SubscriberStatus = 71 + s.state === "pending" 72 + ? "unconfirmed" 73 + : s.state === "unsubscribed" 74 + ? "unsubscribed" 75 + : "subscribed"; 76 + const linkedDid = s.identities?.atp_did ?? undefined; 77 + const existing = linkedDid ? byDid.get(linkedDid) : undefined; 78 + if (existing && status === "subscribed") { 79 + existing.email = s.email; 80 + continue; 81 + } 82 + emailOnly.push({ 83 + key: `email:${s.id}`, 84 + did: linkedDid, 85 + handle: linkedDid ? (profiles.get(linkedDid)?.handle ?? undefined) : undefined, 86 + email: s.email, 87 + created_at: s.created_at, 88 + status, 89 + }); 90 + } 52 91 53 92 return ( 54 - <Popover 55 - className="text-sm px-2! py-1!" 56 - trigger={ 57 - <div className="text-sm text-tertiary"> 58 - Filters {count > 0 && `(${count})`} 59 - </div> 60 - } 61 - > 62 - <Checkbox 63 - small 64 - checked={subscriberStatus.subscribed} 65 - onChange={(e) => 66 - setState({ 67 - subscriberStatus: { 68 - ...subscriberStatus, 69 - subscribed: !!e.target.checked, 70 - }, 71 - }) 72 - } 73 - > 74 - Subscribed 75 - </Checkbox> 76 - <Checkbox 77 - small 78 - checked={subscriberStatus.unconfirmed} 79 - onChange={(e) => 80 - setState({ 81 - subscriberStatus: { 82 - ...subscriberStatus, 83 - unconfirmed: !!e.target.checked, 84 - }, 85 - }) 86 - } 87 - > 88 - Unconfirmed 89 - </Checkbox> 90 - <Checkbox 91 - small 92 - checked={subscriberStatus.unsubscribed} 93 - onChange={(e) => 94 - setState({ 95 - subscriberStatus: { 96 - ...subscriberStatus, 97 - unsubscribed: !!e.target.checked, 98 - }, 99 - }) 100 - } 101 - > 102 - Unsubscribed 103 - </Checkbox> 104 - </Popover> 93 + <PublicationSubscribers 94 + subscribers={[...byDid.values(), ...emailOnly]} 95 + publicationShareUrl={getPublicationURL(pub)} 96 + publicationUri={pub.uri} 97 + showPageBackground={showPageBackground} 98 + /> 105 99 ); 106 - }; 100 + }
+2 -2
app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 47 47 theme: null, 48 48 prevNext: undefined, 49 49 publication: publication || null, 50 - comments: [], 50 + commentsCount: 0, 51 51 comments_on_documents: [], 52 52 mentions: [], 53 53 document_mentions_in_bsky: [], ··· 110 110 prevNext: undefined, 111 111 quotesAndMentions: [], 112 112 publication: pubInfo, 113 - comments: [], 113 + commentsCount: 0, 114 114 mentions: [], 115 115 leafletId: null, 116 116 recommendsCount: 0,
+5 -7
app/(app)/merge-accounts/page.tsx
··· 11 11 PENDING_MERGE_TOKEN_COOKIE, 12 12 resolveAuthToken, 13 13 } from "src/auth"; 14 + import { getProfiles } from "src/identity"; 14 15 15 16 type SearchParams = { [key: string]: string | string[] | undefined }; 16 17 ··· 59 60 ); 60 61 } 61 62 62 - const [{ data: bsky }, { count: docCount }] = await Promise.all([ 63 - supabaseServerClient 64 - .from("bsky_profiles") 65 - .select("handle") 66 - .eq("did", target.identity.atp_did!) 67 - .maybeSingle(), 63 + const [profiles, { count: docCount }] = await Promise.all([ 64 + getProfiles([target.identity.atp_did!]), 68 65 supabaseServerClient 69 66 .from("permission_token_on_homepage") 70 67 .select("token", { count: "exact", head: true }) 71 68 .eq("identity", source.identity.id) 72 69 .not("archived", "is", true), 73 70 ]); 74 - const targetHandle = bsky?.handle ?? target.identity.atp_did!; 71 + const targetHandle = 72 + profiles.get(target.identity.atp_did!)?.handle ?? target.identity.atp_did!; 75 73 const sourceEmail = source.identity.email!; 76 74 const targetEmail = target.identity.email; 77 75 const documents = docCount ?? 0;
+1 -1
app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { jsonToLex } from "@atproto/lexicon"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 6 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 7 import { documentUriFilter } from "src/utils/uriHelpers";
+1 -1
app/(app)/p/[didOrHandle]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { Metadata } from "next"; 3 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 3 + import { idResolver } from "src/identity"; 4 4 import { DocumentPageRenderer } from "app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 5 5 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 6 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
+1 -1
app/about/Examples.tsx
··· 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 4 import { PubListing } from "app/(app)/(home-pages)/p/[didOrHandle]/PubListing"; 5 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 5 + import { idResolver } from "src/identity"; 6 6 import { SpeedyLink } from "components/SpeedyLink"; 7 7 8 8 const pubs = [
+1 -1
app/api/inngest/functions/index_post_mention.ts
··· 8 8 pingIdentityToUpdateNotification, 9 9 } from "src/notifications"; 10 10 import { v7 } from "uuid"; 11 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 11 + import { idResolver } from "src/identity"; 12 12 import { documentUriFilter } from "src/utils/uriHelpers"; 13 13 14 14 export const index_post_mention = inngest.createFunction(
+4 -7
app/api/inngest/functions/send_post_broadcast.ts
··· 15 15 resolveReplyToEmail, 16 16 } from "src/utils/newsletterSender"; 17 17 import { PubLeafletPagesLinearDocument } from "lexicons/api"; 18 + import { getProfiles } from "src/identity"; 18 19 import type { Json } from "supabase/database.types"; 19 20 20 21 const BATCH_SIZE = 500; ··· 48 49 const authorDid = new AtUri(document_uri).host; 49 50 50 51 const loaded = await step.run("load-pub-and-doc", async () => { 51 - const [pubRes, docRes, profileRes] = await Promise.all([ 52 + const [pubRes, docRes, profiles] = await Promise.all([ 52 53 supabaseServerClient 53 54 .from("publications") 54 55 .select( ··· 61 62 .select("data") 62 63 .eq("uri", document_uri) 63 64 .maybeSingle(), 64 - supabaseServerClient 65 - .from("bsky_profiles") 66 - .select("handle") 67 - .eq("did", authorDid) 68 - .maybeSingle(), 65 + getProfiles([authorDid]), 69 66 ]); 70 67 return { 71 68 pub: pubRes.data, 72 69 doc: docRes.data, 73 - profile: profileRes.data, 70 + profile: profiles.get(authorDid) ?? null, 74 71 }; 75 72 }); 76 73
+1 -1
app/api/inngest/functions/sync_document_metadata.ts
··· 1 1 import { inngest, events } from "../client"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import type { Json } from "supabase/database.types"; 6 6 7 7 // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total)
+24 -2
app/api/rpc/[command]/get_document_interactions.ts
··· 7 7 normalizeDocumentRecord, 8 8 normalizePublicationRecord, 9 9 } from "src/utils/normalizeRecords"; 10 + import { AtUri } from "@atproto/syntax"; 11 + import { getProfiles } from "src/identity"; 10 12 11 13 export const get_document_interactions = makeRoute({ 12 14 route: "get_document_interactions", ··· 23 25 ` 24 26 data, 25 27 uri, 26 - comments_on_documents(*, bsky_profiles(*)), 28 + comments_on_documents(*), 27 29 document_mentions_in_bsky(*), 28 30 documents_in_publications(publications(*)) 29 31 `, ··· 81 83 ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)), 82 84 ]; 83 85 86 + const commentDids = Array.from( 87 + new Set(document.comments_on_documents.map((c) => new AtUri(c.uri).host)), 88 + ); 89 + const profiles = await getProfiles(commentDids); 90 + const comments = document.comments_on_documents.map((c) => { 91 + const did = new AtUri(c.uri).host; 92 + const p = profiles.get(did); 93 + return { 94 + ...c, 95 + profile: p 96 + ? { 97 + did: p.did, 98 + handle: p.handle, 99 + displayName: p.displayName, 100 + avatar: p.avatar, 101 + } 102 + : null, 103 + }; 104 + }); 105 + 84 106 return { 85 - comments: document.comments_on_documents, 107 + comments, 86 108 quotesAndMentions, 87 109 totalMentionsCount: quotesAndMentions.length, 88 110 };
+1 -1
app/api/rpc/[command]/get_profile_data.ts
··· 1 1 import { z } from "zod"; 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 import { Agent } from "@atproto/api"; 7 7 import { getIdentityData } from "actions/getIdentityData";
+3 -3
app/api/rpc/[command]/get_publication_data.ts
··· 32 32 publication_name, 33 33 ).toString(); 34 34 } 35 - let { data: publication, error } = await supabase 35 + let { data: publication } = await supabase 36 36 .from("publications") 37 37 .select( 38 38 `*, ··· 43 43 recommends_on_documents(count), 44 44 publication_post_sends(status, subscriber_count) 45 45 )), 46 - publication_subscriptions(*, identities(bsky_profiles(*))), 47 - publication_email_subscribers(*, identities(atp_did, bsky_profiles(*))), 46 + publication_subscriptions(*, identities(atp_did)), 47 + publication_email_subscribers(*, identities(atp_did)), 48 48 publication_domains(*), 49 49 publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at), 50 50 leaflets_in_publications(*,
+2 -2
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 47 47 const [{ data: atprotoSubs }, { data: emailSubs }] = await Promise.all([ 48 48 supabase 49 49 .from("publication_subscriptions") 50 - .select("created_at, identities(bsky_profiles(did))") 50 + .select("created_at, identities(atp_did)") 51 51 .eq("publication", publication_uri), 52 52 newsletterEnabled 53 53 ? supabase ··· 63 63 // matching the UI merge in PublicationSubscribers.tsx. 64 64 const subscribers = new Map<string, string>(); 65 65 for (const s of atprotoSubs || []) { 66 - const did = s.identities?.bsky_profiles?.did; 66 + const did = s.identities?.atp_did; 67 67 if (!did) continue; 68 68 subscribers.set(`did:${did}`, s.created_at); 69 69 }
+4 -15
app/api/rpc/[command]/get_standard_site_posts.ts
··· 8 8 type NormalizedDocument, 9 9 type NormalizedPublication, 10 10 } from "src/utils/normalizeRecords"; 11 + import { getProfiles } from "src/identity"; 11 12 12 13 export type StandardSitePostData = { 13 14 uri: string; ··· 66 67 ), 67 68 ); 68 69 69 - const { data: profiles } = dids.length 70 - ? await supabase 71 - .from("bsky_profiles") 72 - .select("did, handle, record") 73 - .in("did", dids) 74 - : { data: [] as { did: string; handle: string | null; record: unknown }[] }; 75 - 76 - const profileByDid = new Map( 77 - (profiles || []).map((p) => [p.did, p] as const), 78 - ); 70 + const profiles = await getProfiles(dids); 79 71 80 72 const posts: StandardSitePostData[] = (documents || []) 81 73 .map((d): StandardSitePostData | null => { ··· 93 85 } catch { 94 86 did = null; 95 87 } 96 - const profile = did ? profileByDid.get(did) : undefined; 97 - const profileRecord = (profile?.record ?? null) as 98 - | { displayName?: string } 99 - | null; 88 + const profile = did ? profiles.get(did) : null; 100 89 const author = did 101 90 ? { 102 91 did, 103 92 handle: profile?.handle ?? null, 104 - displayName: profileRecord?.displayName ?? null, 93 + displayName: profile?.displayName ?? null, 105 94 } 106 95 : null; 107 96
+35 -11
appview/index.ts
··· 1 1 import { createClient } from "@supabase/supabase-js"; 2 2 import { Database, Json } from "supabase/database.types"; 3 3 import { IdResolver } from "@atproto/identity"; 4 + import Client from "ioredis"; 4 5 const idResolver = new IdResolver(); 5 6 import { Firehose, MemoryRunner, Event } from "@atproto/sync"; 6 7 import { ids } from "lexicons/api/lexicons"; ··· 34 35 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 35 36 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 36 37 ); 38 + 39 + const redisClient: Client | null = process.env.REDIS_URL 40 + ? new Client(process.env.REDIS_URL) 41 + : null; 42 + 43 + class RedisProfileCache { 44 + constructor(private client: Client) {} 45 + async clearEntry(did: string): Promise<void> { 46 + await this.client.del(`bsky-profile:${did}`); 47 + } 48 + } 49 + 50 + const profileCache: RedisProfileCache | null = redisClient 51 + ? new RedisProfileCache(redisClient) 52 + : null; 53 + 37 54 const QUOTE_PARAM = "/l-quote/"; 38 55 async function main() { 39 56 const runner = new MemoryRunner({}); ··· 41 58 service: "wss://relay1.us-west.bsky.network", 42 59 subscriptionReconnectDelay: 3000, 43 60 excludeAccount: true, 44 - excludeIdentity: true, 61 + excludeIdentity: false, 45 62 runner, 46 63 idResolver, 47 64 filterCollections: [ ··· 52 69 ids.PubLeafletPollVote, 53 70 ids.PubLeafletPollDefinition, 54 71 ids.PubLeafletInteractionsRecommend, 55 - // ids.AppBskyActorProfile, 72 + ids.AppBskyActorProfile, 56 73 "app.bsky.feed.post", 57 74 ids.SiteStandardDocument, 58 75 ids.SiteStandardPublication, ··· 91 108 .from("bsky_profiles") 92 109 .update({ handle: evt.handle }) 93 110 .eq("did", evt.did); 111 + if (profileCache) { 112 + try { 113 + await profileCache.clearEntry(evt.did); 114 + } catch (err) { 115 + console.error("Failed to clear profile cache for", evt.did, err); 116 + } 117 + } 94 118 } 95 119 if ( 96 120 evt.event == "account" || ··· 401 425 .eq("uri", evt.uri.toString()); 402 426 } 403 427 } 404 - // if (evt.collection === ids.AppBskyActorProfile) { 405 - // //only listen to updates because we should fetch it for the first time when they subscribe! 406 - // if (evt.event === "update") { 407 - // await supabaseServerClient 408 - // .from("bsky_profiles") 409 - // .update({ record: evt.record as Json }) 410 - // .eq("did", evt.did); 411 - // } 412 - // } 428 + if (evt.collection === ids.AppBskyActorProfile) { 429 + if (profileCache) { 430 + try { 431 + await profileCache.clearEntry(evt.did); 432 + } catch (err) { 433 + console.error("Failed to clear profile cache for", evt.did, err); 434 + } 435 + } 436 + } 413 437 if (evt.collection === "parts.page.mention.service") { 414 438 if (evt.event === "create" || evt.event === "update") { 415 439 let { error } = await supabase.from("mention_services").upsert({
+1 -2
contexts/DocumentContext.tsx
··· 5 5 // Derive types from PostPageData 6 6 type NonNullPostPageData = NonNullable<PostPageData>; 7 7 export type PublicationContext = NonNullPostPageData["publication"]; 8 - export type CommentOnDocument = NonNullPostPageData["comments"][number]; 9 8 export type DocumentMention = NonNullPostPageData["mentions"][number]; 10 9 export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; 11 10 ··· 18 17 | "prevNext" 19 18 | "quotesAndMentions" 20 19 | "publication" 21 - | "comments" 20 + | "commentsCount" 22 21 | "mentions" 23 22 | "leafletId" 24 23 | "recommendsCount"
+2
src/identity/index.ts
··· 1 + export { idResolver } from "./idResolver"; 2 + export { getProfiles, type Profile } from "./profileCache";
+129
src/identity/profileCache.ts
··· 1 + import { cache } from "react"; 2 + import Client from "ioredis"; 3 + import { getAgent } from "app/api/bsky/agent"; 4 + 5 + export type Profile = { 6 + did: string; 7 + handle: string | null; 8 + displayName: string | null; 9 + avatar: string | null; 10 + description: string | null; 11 + }; 12 + 13 + const KEY_PREFIX = "bsky-profile:"; 14 + // 30d TTL — the firehose keeps entries fresh; TTL is just GC. 15 + const MAX_TTL = 60 * 60 * 24 * 30; 16 + const BATCH_SIZE = 25; 17 + 18 + let redisClient: Client | null = null; 19 + if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 20 + redisClient = new Client(process.env.REDIS_URL); 21 + } 22 + 23 + type CachedEntry = { profile: Profile | null; updatedAt: number }; 24 + 25 + function profileKey(did: string) { 26 + return `${KEY_PREFIX}${did}`; 27 + } 28 + 29 + async function readCache(dids: string[]): Promise<Map<string, Profile | null>> { 30 + const out = new Map<string, Profile | null>(); 31 + if (!redisClient || dids.length === 0) return out; 32 + 33 + const keys = dids.map(profileKey); 34 + const raw = await redisClient.mget(...keys); 35 + raw.forEach((value, i) => { 36 + if (!value) return; 37 + try { 38 + const entry = JSON.parse(value) as CachedEntry; 39 + const age = Date.now() - entry.updatedAt; 40 + if (age <= MAX_TTL * 1000) { 41 + out.set(dids[i], entry.profile); 42 + } 43 + } catch { 44 + // skip malformed entry 45 + } 46 + }); 47 + return out; 48 + } 49 + 50 + async function writeCache(entries: Map<string, Profile | null>): Promise<void> { 51 + if (!redisClient || entries.size === 0) return; 52 + 53 + const pipeline = redisClient.pipeline(); 54 + const now = Date.now(); 55 + for (const [did, profile] of entries) { 56 + const entry: CachedEntry = { profile, updatedAt: now }; 57 + pipeline.setex(profileKey(did), MAX_TTL, JSON.stringify(entry)); 58 + } 59 + await pipeline.exec(); 60 + } 61 + 62 + type FetchResult = { 63 + results: Map<string, Profile | null>; 64 + toCache: Map<string, Profile | null>; 65 + }; 66 + 67 + async function fetchProfiles(dids: string[]): Promise<FetchResult> { 68 + const results = new Map<string, Profile | null>(); 69 + const toCache = new Map<string, Profile | null>(); 70 + for (const did of dids) results.set(did, null); 71 + if (dids.length === 0) return { results, toCache }; 72 + 73 + const agent = await getAgent(); 74 + const batches: string[][] = []; 75 + for (let i = 0; i < dids.length; i += BATCH_SIZE) { 76 + batches.push(dids.slice(i, i + BATCH_SIZE)); 77 + } 78 + 79 + await Promise.all( 80 + batches.map(async (batch) => { 81 + try { 82 + const res = await agent.app.bsky.actor.getProfiles({ actors: batch }); 83 + const returned = new Set<string>(); 84 + for (const p of res.data.profiles) { 85 + const profile: Profile = { 86 + did: p.did, 87 + handle: p.handle ?? null, 88 + displayName: p.displayName ?? null, 89 + avatar: p.avatar ?? null, 90 + description: p.description ?? null, 91 + }; 92 + results.set(p.did, profile); 93 + toCache.set(p.did, profile); 94 + returned.add(p.did); 95 + } 96 + // DIDs the API confirmed don't exist — safe to negative-cache. 97 + for (const did of batch) { 98 + if (!returned.has(did)) toCache.set(did, null); 99 + } 100 + } catch (err) { 101 + // Transient failure — leave results as null but don't write to 102 + // cache, so the next request retries instead of getting a stale miss. 103 + console.error("[profileCache] getProfiles failed:", err); 104 + } 105 + }), 106 + ); 107 + 108 + return { results, toCache }; 109 + } 110 + 111 + export const getProfiles = cache( 112 + async (dids: string[]): Promise<Map<string, Profile | null>> => { 113 + const unique = Array.from(new Set(dids)); 114 + const result = new Map<string, Profile | null>(); 115 + if (unique.length === 0) return result; 116 + 117 + const cached = await readCache(unique); 118 + for (const [did, profile] of cached) result.set(did, profile); 119 + 120 + const missing = unique.filter((did) => !cached.has(did)); 121 + if (missing.length === 0) return result; 122 + 123 + const { results: fetched, toCache } = await fetchProfiles(missing); 124 + for (const [did, profile] of fetched) result.set(did, profile); 125 + 126 + await writeCache(toCache); 127 + return result; 128 + }, 129 + );
+98 -33
src/notifications.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 5 import { AtUri } from "@atproto/syntax"; 6 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 6 + import { idResolver, getProfiles, type Profile } from "src/identity"; 7 7 import { 8 8 normalizeDocumentRecord, 9 9 normalizePublicationRecord, 10 - type NormalizedDocument, 11 - type NormalizedPublication, 12 10 } from "src/utils/normalizeRecords"; 13 11 14 12 type NotificationRow = Tables<"notifications">; ··· 16 14 export type Notification = Omit<TablesInsert<"notifications">, "data"> & { 17 15 data: NotificationData; 18 16 }; 17 + 18 + export type NotificationProfile = Pick< 19 + Profile, 20 + "did" | "handle" | "displayName" | "avatar" 21 + >; 22 + 23 + function toNotificationProfile( 24 + p: Profile | null | undefined, 25 + ): NotificationProfile | null { 26 + if (!p) return null; 27 + return { 28 + did: p.did, 29 + handle: p.handle, 30 + displayName: p.displayName, 31 + avatar: p.avatar, 32 + }; 33 + } 19 34 20 35 export type NotificationData = 21 36 | { type: "comment"; comment_uri: string; parent_uri?: string } ··· 93 108 const { data: comments } = await supabaseServerClient 94 109 .from("comments_on_documents") 95 110 .select( 96 - "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 111 + "*, documents(*, documents_in_publications(publications(*)))", 97 112 ) 98 113 .in("uri", commentUris); 99 114 115 + const commenterDids = Array.from( 116 + new Set((comments ?? []).map((c) => new AtUri(c.uri).host)), 117 + ); 118 + const profiles = await getProfiles(commenterDids); 119 + 120 + type CommentRow = NonNullable<typeof comments>[number]; 121 + const attachProfile = (c: CommentRow) => ({ 122 + ...c, 123 + profile: toNotificationProfile(profiles.get(new AtUri(c.uri).host)), 124 + }); 125 + 100 126 return commentNotifications 101 127 .map((notification) => { 102 - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 103 - if (!commentData) return null; 128 + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); 129 + if (!commentRow) return null; 130 + const commentData = attachProfile(commentRow); 131 + const parentRow = notification.data.parent_uri 132 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 133 + : undefined; 104 134 return { 105 135 id: notification.id, 106 136 recipient: notification.recipient, 107 137 created_at: notification.created_at, 108 138 type: "comment" as const, 109 139 comment_uri: notification.data.comment_uri, 110 - parentData: notification.data.parent_uri 111 - ? comments?.find((c) => c.uri === notification.data.parent_uri) 112 - : undefined, 140 + parentData: parentRow ? attachProfile(parentRow) : undefined, 113 141 commentData, 114 142 normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 115 143 normalizedPublication: normalizePublicationRecord( ··· 142 170 ); 143 171 const { data: subscriptions } = await supabaseServerClient 144 172 .from("publication_subscriptions") 145 - .select("*, identities(bsky_profiles(*)), publications(*)") 173 + .select("*, identities(atp_did), publications(*)") 146 174 .in("uri", subscriptionUris); 147 175 176 + const subscriberDids = Array.from( 177 + new Set( 178 + (subscriptions ?? []) 179 + .map((s) => s.identities?.atp_did) 180 + .filter((d): d is string => !!d), 181 + ), 182 + ); 183 + const profiles = await getProfiles(subscriberDids); 184 + 148 185 return subscribeNotifications 149 186 .map((notification) => { 150 187 const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 151 188 if (!subscriptionData) return null; 189 + const subscriberDid = subscriptionData.identities?.atp_did ?? null; 152 190 return { 153 191 id: notification.id, 154 192 recipient: notification.recipient, 155 193 created_at: notification.created_at, 156 194 type: "subscribe" as const, 157 195 subscription_uri: notification.data.subscription_uri, 158 - subscriptionData, 196 + subscriptionData: { 197 + ...subscriptionData, 198 + profile: subscriberDid 199 + ? toNotificationProfile(profiles.get(subscriberDid)) 200 + : null, 201 + }, 159 202 normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), 160 203 }; 161 204 }) ··· 436 479 const { data: comments } = await supabaseServerClient 437 480 .from("comments_on_documents") 438 481 .select( 439 - "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 482 + "*, documents(*, documents_in_publications(publications(*)))", 440 483 ) 441 484 .in("uri", commentUris); 442 485 443 486 // Extract unique DIDs from comment URIs to resolve handles 444 487 const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 445 488 446 - // Resolve DIDs to handles in parallel 489 + // Resolve DIDs to handles in parallel + batch profile fetch 447 490 const didToHandleMap = new Map<string, string | null>(); 448 - await Promise.all( 449 - commenterDids.map(async (did) => { 450 - try { 451 - const resolved = await idResolver.did.resolve(did); 452 - const handle = resolved?.alsoKnownAs?.[0] 453 - ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 454 - : null; 455 - didToHandleMap.set(did, handle); 456 - } catch (error) { 457 - console.error(`Failed to resolve DID ${did}:`, error); 458 - didToHandleMap.set(did, null); 459 - } 460 - }), 461 - ); 491 + const [profiles] = await Promise.all([ 492 + getProfiles(commenterDids), 493 + Promise.all( 494 + commenterDids.map(async (did) => { 495 + try { 496 + const resolved = await idResolver.did.resolve(did); 497 + const handle = resolved?.alsoKnownAs?.[0] 498 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 499 + : null; 500 + didToHandleMap.set(did, handle); 501 + } catch (error) { 502 + console.error(`Failed to resolve DID ${did}:`, error); 503 + didToHandleMap.set(did, null); 504 + } 505 + }), 506 + ), 507 + ]); 462 508 463 509 // Fetch mentioned publications and documents 464 510 const mentionedPublicationUris = commentMentionNotifications ··· 486 532 487 533 return commentMentionNotifications 488 534 .map((notification) => { 489 - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 490 - if (!commentData) return null; 535 + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); 536 + if (!commentRow) return null; 537 + const commenterDid = new AtUri(commentRow.uri).host; 538 + const commentData = { 539 + ...commentRow, 540 + profile: toNotificationProfile(profiles.get(commenterDid)), 541 + }; 491 542 492 543 const mentionedUri = notification.data.mention_type !== "did" 493 544 ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 494 545 : undefined; 495 546 496 - const commenterDid = new AtUri(notification.data.comment_uri).host; 497 547 const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 498 548 499 549 const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; ··· 543 593 const [{ data: recommends }, { data: documents }] = await Promise.all([ 544 594 supabaseServerClient 545 595 .from("recommends_on_documents") 546 - .select("*, identities(bsky_profiles(*))") 596 + .select("*") 547 597 .in("uri", recommendUris), 548 598 supabaseServerClient 549 599 .from("documents") ··· 551 601 .in("uri", documentUris), 552 602 ]); 553 603 604 + const recommenderDids = Array.from( 605 + new Set( 606 + (recommends ?? []) 607 + .map((r) => r.recommender_did) 608 + .filter((d): d is string => !!d), 609 + ), 610 + ); 611 + const profiles = await getProfiles(recommenderDids); 612 + 554 613 return recommendNotifications 555 614 .map((notification) => { 556 - const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri); 615 + const recommendRow = recommends?.find((r) => r.uri === notification.data.recommend_uri); 557 616 const document = documents?.find((d) => d.uri === notification.data.document_uri); 558 - if (!recommendData || !document) return null; 617 + if (!recommendRow || !document) return null; 618 + const recommendData = { 619 + ...recommendRow, 620 + profile: recommendRow.recommender_did 621 + ? toNotificationProfile(profiles.get(recommendRow.recommender_did)) 622 + : null, 623 + }; 559 624 return { 560 625 id: notification.id, 561 626 recipient: notification.recipient,