a tool for shared writing and social publishing

get bluesky quotes and mentions, fetch data on client

+210 -47
+2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 53 53 fullPageScroll: boolean; 54 54 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 55 }) { 56 + if (!document) return null; 57 + 56 58 let hasPageBackground = !!pubRecord.theme?.showPageBackground; 57 59 let isSubpage = !!pageId; 58 60 let drawer = useDrawerOpen(document_uri);
+5 -4
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 10 10 11 11 export const InteractionDrawer = (props: { 12 12 document_uri: string; 13 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 13 + quotesAndMentions: { uri: string; link?: string }[]; 14 14 comments: Comment[]; 15 15 did: string; 16 16 pageId?: string; ··· 23 23 (c) => (c.record as any)?.onPage === props.pageId, 24 24 ); 25 25 26 - const filteredQuotes = props.quotes.filter((q) => { 26 + const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { 27 + if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page 27 28 const url = new URL(q.link); 28 29 const quoteParam = url.pathname.split("/l-quote/")[1]; 29 - if (!quoteParam) return null; 30 + if (!quoteParam) return !props.pageId; 30 31 const quotePosition = decodeQuotePosition(quoteParam); 31 32 return quotePosition?.pageId === props.pageId; 32 33 }); ··· 40 41 className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 41 42 > 42 43 {drawer.drawer === "quotes" ? ( 43 - <Quotes {...props} quotes={filteredQuotes} /> 44 + <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> 44 45 ) : ( 45 46 <Comments 46 47 document_uri={props.document_uri}
+16 -7
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 154 154 155 155 export function getQuoteCount(document: PostPageData, pageId?: string) { 156 156 if (!document) return; 157 + return getQuoteCountFromArray(document.quotesAndMentions, pageId); 158 + } 157 159 158 - if (pageId) 159 - return document.document_mentions_in_bsky.filter((q) => 160 - q.link.includes(pageId), 161 - ).length; 162 - else 163 - return document.document_mentions_in_bsky.filter((q) => { 160 + export function getQuoteCountFromArray( 161 + quotesAndMentions: { uri: string; link?: string }[], 162 + pageId?: string, 163 + ) { 164 + if (pageId) { 165 + return quotesAndMentions.filter((q) => { 166 + if (!q.link) return false; 167 + return q.link.includes(pageId); 168 + }).length; 169 + } else { 170 + return quotesAndMentions.filter((q) => { 171 + if (!q.link) return true; // Direct mentions go to main page 164 172 const url = new URL(q.link); 165 173 const quoteParam = url.pathname.split("/l-quote/")[1]; 166 - if (!quoteParam) return null; 174 + if (!quoteParam) return true; 167 175 const quotePosition = decodeQuotePosition(quoteParam); 168 176 return !quotePosition?.pageId; 169 177 }).length; 178 + } 170 179 } 171 180 172 181 export function getCommentCount(document: PostPageData, pageId?: string) {
+56 -7
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 5 5 import { setInteractionState } from "./Interactions"; 6 6 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 7 import { AtUri } from "@atproto/api"; 8 - import { Json } from "supabase/database.types"; 9 8 import { PostPageContext } from "../PostPageContext"; 10 9 import { 11 10 PubLeafletBlocksText, ··· 21 20 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 22 21 import { flushSync } from "react-dom"; 23 22 import { openPage } from "../PostPages"; 23 + import useSWR from "swr"; 24 + import { hydrateBlueskyPosts } from "./getBlueskyMentions"; 25 + import { DotLoader } from "components/utils/DotLoader"; 24 26 25 27 export const Quotes = (props: { 26 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 28 + quotesAndMentions: { uri: string; link?: string }[]; 27 29 did: string; 28 30 }) => { 29 31 let data = useContext(PostPageContext); ··· 31 33 if (!document_uri) 32 34 throw new Error("document_uri not available in PostPageContext"); 33 35 36 + // Fetch Bluesky post data for all URIs 37 + const uris = props.quotesAndMentions.map((q) => q.uri); 38 + const { data: bskyPosts, isLoading } = useSWR( 39 + uris.length > 0 ? JSON.stringify(uris) : null, 40 + () => hydrateBlueskyPosts(uris), 41 + ); 42 + 43 + // Separate quotes with links (quoted content) from direct mentions 44 + const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); 45 + const directMentions = props.quotesAndMentions.filter((q) => !q.link); 46 + 47 + // Create a map of URIs to post views for easy lookup 48 + const postViewMap = new Map<string, PostView>(); 49 + bskyPosts?.forEach((pv) => { 50 + postViewMap.set(pv.uri, pv); 51 + }); 52 + 34 53 return ( 35 54 <div className="flex flex-col gap-2"> 36 55 <div className="w-full flex justify-between text-secondary font-bold"> ··· 44 63 <CloseTiny /> 45 64 </button> 46 65 </div> 47 - {props.quotes.length === 0 ? ( 66 + {props.quotesAndMentions.length === 0 ? ( 48 67 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 49 68 <div className="font-bold">no quotes yet!</div> 50 69 <div>highlight any part of this post to quote it</div> 51 70 </div> 71 + ) : isLoading ? ( 72 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 73 + <span>loading</span> 74 + <DotLoader /> 75 + </div> 52 76 ) : ( 53 77 <div className="quotes flex flex-col gap-8"> 54 - {props.quotes.map((q, index) => { 55 - let pv = q.bsky_posts?.post_view as unknown as PostView; 78 + {/* Quotes with links (quoted content) */} 79 + {quotesWithLinks.map((q, index) => { 80 + const pv = postViewMap.get(q.uri); 81 + if (!pv || !q.link) return null; 56 82 const url = new URL(q.link); 57 83 const quoteParam = url.pathname.split("/l-quote/")[1]; 58 84 if (!quoteParam) return null; 59 85 const quotePosition = decodeQuotePosition(quoteParam); 60 86 if (!quotePosition) return null; 61 87 return ( 62 - <div key={index} className="flex flex-col "> 88 + <div key={`quote-${index}`} className="flex flex-col "> 63 89 <QuoteContent 64 90 index={index} 65 91 did={props.did} ··· 77 103 </div> 78 104 ); 79 105 })} 106 + 107 + {/* Direct post mentions (without quoted content) */} 108 + {directMentions.length > 0 && ( 109 + <div className="flex flex-col gap-4"> 110 + <h3>Post Mentions</h3> 111 + <div className="flex flex-col gap-8"> 112 + {directMentions.map((q, index) => { 113 + const pv = postViewMap.get(q.uri); 114 + if (!pv) return null; 115 + return ( 116 + <BskyPost 117 + key={`mention-${index}`} 118 + rkey={new AtUri(pv.uri).rkey} 119 + content={pv.record.text as string} 120 + user={pv.author.displayName || pv.author.handle} 121 + profile={pv.author} 122 + handle={pv.author.handle} 123 + /> 124 + ); 125 + })} 126 + </div> 127 + </div> 128 + )} 80 129 </div> 81 130 )} 82 131 </div> ··· 154 203 ); 155 204 }; 156 205 157 - const BskyPost = (props: { 206 + export const BskyPost = (props: { 158 207 rkey: string; 159 208 content: string; 160 209 user: string;
+80
app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts
··· 1 + "use server"; 2 + 3 + import { AtUri, Agent, lexToJson } from "@atproto/api"; 4 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 5 + 6 + type ConstellationResponse = { 7 + records: { did: string; collection: string; rkey: string }[]; 8 + }; 9 + 10 + const headers = { 11 + "Content-type": "application/json", 12 + "user-agent": "leaflet.pub", 13 + }; 14 + 15 + // Fetch constellation backlinks without hydrating with Bluesky post data 16 + export async function getConstellationBacklinks( 17 + url: string, 18 + ): Promise<{ uri: string }[]> { 19 + let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`; 20 + let externalEmbeds = new URL( 21 + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`, 22 + ); 23 + let linkFacets = new URL( 24 + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`, 25 + ); 26 + 27 + let [links, embeds] = (await Promise.all([ 28 + fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) => 29 + req.json(), 30 + ), 31 + fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then((req) => 32 + req.json(), 33 + ), 34 + ])) as ConstellationResponse[]; 35 + 36 + let uris = [...links.records, ...embeds.records].map((i) => 37 + AtUri.make(i.did, i.collection, i.rkey).toString(), 38 + ); 39 + 40 + return uris.map((uri) => ({ uri })); 41 + } 42 + 43 + // Hydrate Bluesky URIs with post data 44 + export async function hydrateBlueskyPosts(uris: string[]): Promise<PostView[]> { 45 + if (uris.length === 0) return []; 46 + 47 + let agent = new Agent({ 48 + service: "https://public.api.bsky.app", 49 + fetch: (...args) => 50 + fetch(args[0], { 51 + ...args[1], 52 + next: { revalidate: 3600 }, 53 + }), 54 + }); 55 + 56 + // Process URIs in batches of 25 57 + let allPostRequests = []; 58 + for (let i = 0; i < uris.length; i += 25) { 59 + let batch = uris.slice(i, i + 25); 60 + let batchPosts = agent.getPosts( 61 + { 62 + uris: batch, 63 + }, 64 + { headers: {} }, 65 + ); 66 + allPostRequests.push(batchPosts); 67 + } 68 + let allPosts = (await Promise.all(allPostRequests)).flatMap( 69 + (r) => r.data.posts, 70 + ); 71 + 72 + return lexToJson(allPosts) as PostView[]; 73 + } 74 + 75 + // Legacy function - kept for backwards compatibility if needed 76 + export async function getMentions(url: string) { 77 + let backlinks = await getConstellationBacklinks(url); 78 + let uris = backlinks.map((b) => b.uri); 79 + return hydrateBlueskyPosts(uris); 80 + }
+7 -16
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 5 5 PubLeafletPublication, 6 6 } from "lexicons/api"; 7 7 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 8 - import { Interactions } from "../Interactions/Interactions"; 8 + import { 9 + Interactions, 10 + getQuoteCount, 11 + getCommentCount, 12 + } from "../Interactions/Interactions"; 9 13 import { PostPageData } from "../getPostPageData"; 10 14 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 15 import { useIdentityData } from "components/IdentityProvider"; 12 16 import { EditTiny } from "components/Icons/EditTiny"; 13 17 import { SpeedyLink } from "components/SpeedyLink"; 14 - import { decodeQuotePosition } from "../quotePosition"; 15 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 19 17 20 export function PostHeader(props: { ··· 95 98 <Interactions 96 99 showComments={props.preferences.showComments} 97 100 compact 98 - quotesCount={ 99 - document.document_mentions_in_bsky.filter((q) => { 100 - const url = new URL(q.link); 101 - const quoteParam = url.pathname.split("/l-quote/")[1]; 102 - if (!quoteParam) return null; 103 - const quotePosition = decodeQuotePosition(quoteParam); 104 - return !quotePosition?.pageId; 105 - }).length 106 - } 107 - commentsCount={ 108 - document.comments_on_documents.filter( 109 - (c) => !(c.record as PubLeafletComment.Record)?.onPage, 110 - ).length 111 - } 101 + quotesCount={getQuoteCount(document) || 0} 102 + commentsCount={getCommentCount(document) || 0} 112 103 /> 113 104 </div> 114 105 </div>
+3 -2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 129 129 130 130 let hasPageBackground = !!pubRecord.theme?.showPageBackground; 131 131 let record = document.data as PubLeafletDocument.Record; 132 + let quotesAndMentions = document.quotesAndMentions; 132 133 133 134 let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 134 135 return ( ··· 156 157 ? [] 157 158 : document.comments_on_documents 158 159 } 159 - quotes={document.document_mentions_in_bsky} 160 + quotesAndMentions={quotesAndMentions} 160 161 did={did} 161 162 /> 162 163 )} ··· 232 233 ? [] 233 234 : document.comments_on_documents 234 235 } 235 - quotes={document.document_mentions_in_bsky} 236 + quotesAndMentions={quotesAndMentions} 236 237 did={did} 237 238 /> 238 239 )}
+31 -3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { getConstellationBacklinks } from "./Interactions/getBlueskyMentions"; 4 + import { PubLeafletPublication } from "lexicons/api"; 2 5 3 - export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>; 4 6 export async function getPostPageData(uri: string) { 5 7 let { data: document } = await supabaseServerClient 6 8 .from("documents") ··· 10 12 uri, 11 13 comments_on_documents(*, bsky_profiles(*)), 12 14 documents_in_publications(publications(*, publication_subscriptions(*))), 13 - document_mentions_in_bsky(*, bsky_posts(*)), 15 + document_mentions_in_bsky(*), 14 16 leaflets_in_publications(*) 15 17 `, 16 18 ) 17 19 .eq("uri", uri) 18 20 .single(); 19 - return document; 21 + 22 + if (!document) return null; 23 + 24 + // Fetch constellation backlinks for mentions 25 + const pubRecord = document.documents_in_publications[0]?.publications 26 + ?.record as PubLeafletPublication.Record; 27 + const rkey = new AtUri(uri).rkey; 28 + const postUrl = `https://${pubRecord?.base_path}/${rkey}`; 29 + const constellationBacklinks = await getConstellationBacklinks(postUrl); 30 + 31 + // Combine database mentions and constellation backlinks 32 + const quotesAndMentions: { uri: string; link?: string }[] = [ 33 + // Database mentions (quotes with link to quoted content) 34 + ...document.document_mentions_in_bsky.map((m) => ({ 35 + uri: m.uri, 36 + link: m.link, 37 + })), 38 + // Constellation backlinks (direct post mentions without quote context) 39 + ...constellationBacklinks, 40 + ]; 41 + 42 + return { 43 + ...document, 44 + quotesAndMentions, 45 + }; 20 46 } 47 + 48 + export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>;
+10 -8
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 67 67 fetch: (...args) => 68 68 fetch(args[0], { 69 69 ...args[1], 70 - cache: "no-store", 71 70 next: { revalidate: 3600 }, 72 71 }), 73 72 }); ··· 132 131 // Extract poll blocks and fetch vote data 133 132 let pollBlocks = record.pages.flatMap((p) => { 134 133 let page = p as PubLeafletPagesLinearDocument.Main; 135 - return page.blocks?.filter( 136 - (b) => b.block.$type === ids.PubLeafletBlocksPoll, 137 - ) || []; 134 + return ( 135 + page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || 136 + [] 137 + ); 138 138 }); 139 - let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri)); 139 + let pollData = await fetchPollData( 140 + pollBlocks.map((b) => (b.block as any).pollRef.uri), 141 + ); 142 + 143 + let pubRecord = document.documents_in_publications[0]?.publications 144 + .record as PubLeafletPublication.Record; 140 145 141 146 let firstPage = record.pages[0]; 142 147 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 143 148 if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 144 149 blocks = firstPage.blocks || []; 145 150 } 146 - 147 - let pubRecord = document.documents_in_publications[0]?.publications 148 - .record as PubLeafletPublication.Record; 149 151 150 152 let prerenderedCodeBlocks = await extractCodeBlocks(blocks); 151 153