diff --git a/app/api/link_previews/route.ts b/app/api/link_previews/route.ts index 465a3b41..91b38bef 100644 --- a/app/api/link_previews/route.ts +++ b/app/api/link_previews/route.ts @@ -9,6 +9,7 @@ import { getMicroLinkOgImage, getWebpageImage, } from "src/utils/getMicroLinkOgImage"; +import { resolveStandardSitePostUrl } from "src/utils/resolveStandardSitePostUrl"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, @@ -19,15 +20,25 @@ export async function POST(req: NextRequest) { let body = (await req.json()) as LinkPreviewBody; let url = encodeURIComponent(body.url); if (body.type === "meta") { - let result = await get_link_metadata(url); - return Response.json(result); + let [iframely, leafletPost] = await Promise.all([ + get_link_metadata(url), + resolveStandardSitePostUrl(body.url, supabase).catch(() => null), + ]); + return Response.json({ + ...iframely, + leafletPost: leafletPost ? { uri: leafletPost } : null, + }); } else { let result = await get_link_image_preview(body.url); return Response.json(result); } } -export type LinkPreviewMetadataResult = ReturnType; +export type LinkPreviewMetadataResult = Promise< + Awaited> & { + leafletPost: { uri: string } | null; + } +>; export type LinkPreviewImageResult = ReturnType; async function get_link_image_preview(url: string) { diff --git a/app/api/rpc/[command]/get_standard_site_posts.ts b/app/api/rpc/[command]/get_standard_site_posts.ts new file mode 100644 index 00000000..5ff3b31f --- /dev/null +++ b/app/api/rpc/[command]/get_standard_site_posts.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +import { AtUri } from "@atproto/syntax"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; + +export type StandardSitePostData = { + uri: string; + record: NormalizedDocument; + publication: { + uri: string; + record: NormalizedPublication | null; + } | null; + author: { + did: string; + handle: string | null; + displayName: string | null; + } | null; + commentsCount: number; + mentionsCount: number; + recommendsCount: number; +}; + +export type GetStandardSitePostsReturnType = Awaited< + ReturnType<(typeof get_standard_site_posts)["handler"]> +>; + +export const get_standard_site_posts = makeRoute({ + route: "get_standard_site_posts", + input: z.object({ + uris: z.array(z.string()).max(100), + }), + handler: async ({ uris }, { supabase }: Pick) => { + if (uris.length === 0) return { result: { posts: [] } }; + + const { data: documents } = await supabase + .from("documents") + .select( + ` + uri, + data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count), + documents_in_publications(publications(uri, record)) + `, + ) + .in("uri", uris); + + const dids = Array.from( + new Set( + (documents || []) + .map((d) => { + try { + return new AtUri(d.uri).host; + } catch { + return null; + } + }) + .filter((did): did is string => !!did), + ), + ); + + const { data: profiles } = dids.length + ? await supabase + .from("bsky_profiles") + .select("did, handle, record") + .in("did", dids) + : { data: [] as { did: string; handle: string | null; record: unknown }[] }; + + const profileByDid = new Map( + (profiles || []).map((p) => [p.did, p] as const), + ); + + const posts: StandardSitePostData[] = (documents || []) + .map((d): StandardSitePostData | null => { + const normalized = normalizeDocumentRecord(d.data, d.uri); + if (!normalized) return null; + + const pubRow = d.documents_in_publications?.[0]?.publications; + const publication = pubRow + ? { uri: pubRow.uri, record: normalizePublicationRecord(pubRow.record) } + : null; + + let did: string | null; + try { + did = new AtUri(d.uri).host; + } catch { + did = null; + } + const profile = did ? profileByDid.get(did) : undefined; + const profileRecord = (profile?.record ?? null) as + | { displayName?: string } + | null; + const author = did + ? { + did, + handle: profile?.handle ?? null, + displayName: profileRecord?.displayName ?? null, + } + : null; + + return { + uri: d.uri, + record: normalized, + publication, + author, + commentsCount: d.comments_on_documents?.[0]?.count || 0, + mentionsCount: d.document_mentions_in_bsky?.[0]?.count || 0, + recommendsCount: d.recommends_on_documents?.[0]?.count || 0, + }; + }) + .filter((p): p is StandardSitePostData => p !== null); + + return { result: { posts } }; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index 91f4bb74..eccbae45 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -11,6 +11,7 @@ import { } from "./domain_routes"; import { get_leaflet_data } from "./get_leaflet_data"; import { get_publication_data } from "./get_publication_data"; +import { get_standard_site_posts } from "./get_standard_site_posts"; import { search_publication_names } from "./search_publication_names"; import { search_publication_documents } from "./search_publication_documents"; import { get_profile_data } from "./get_profile_data"; @@ -45,6 +46,7 @@ let Routes = [ get_leaflet_subdomain_status, get_leaflet_data, get_publication_data, + get_standard_site_posts, search_publication_names, search_publication_documents, get_profile_data, diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index 0d2004a8..8e23253e 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -218,6 +218,7 @@ export function PublicationPostItem(props: { href?: string; title?: string; description?: string; + author?: React.ReactNode; date?: React.ReactNode; interactions?: React.ReactNode; }) { @@ -228,6 +229,9 @@ export function PublicationPostItem(props: { ); + const hasAuthor = props.author !== undefined && props.author !== null; + const hasDate = props.date !== undefined && props.date !== null; + return ( <>
@@ -245,7 +249,11 @@ export function PublicationPostItem(props: { )}
-

{props.date}

+

+ {hasAuthor && {props.author}} + {hasAuthor && hasDate && |} + {hasDate && {props.date}} +

{props.interactions}
diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index fbe3dddd..4daca649 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -14,6 +14,7 @@ import { PubLeafletPublication, } from "lexicons/api"; import { AppBskyFeedDefs } from "@atproto/api"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { TextBlock } from "./TextBlock"; import { useDocument } from "contexts/DocumentContext"; import { openPage, useOpenPages } from "../postPageState"; @@ -35,6 +36,7 @@ export function PublishedPageLinkBlock(props: { className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; isCanvas?: boolean; pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { @@ -70,6 +72,7 @@ export function PublishedPageLinkBlock(props: { did={props.did} pageId={props.pageId} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} pages={props.pages || []} /> ) : ( @@ -197,6 +200,7 @@ export function PagePreview(props: { blocks={props.blocks} preview bskyPostData={[]} + standardSitePostData={[]} /> @@ -269,6 +273,7 @@ const CanvasLinkBlock = (props: { did: string; pageId: string; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) => { let pageWidth = `var(--page-width-unitless)`; @@ -332,6 +337,7 @@ const CanvasLinkBlock = (props: { pageId={props.pageId} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={linearBlock} did={props.did} index={[index]} diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 2b94be45..cc1c6f33 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -22,6 +22,7 @@ import { PostHeader } from "./PostHeader/PostHeader"; import { useDrawerOpen } from "./Interactions/InteractionDrawer"; import { PollData } from "./fetchPollData"; import { SharedPageProps } from "./PostPages"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { useIsMobile } from "src/hooks/isMobile"; export function CanvasPage({ @@ -41,6 +42,7 @@ export function CanvasPage({ theme, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, pageId, @@ -78,6 +80,7 @@ export function CanvasPage({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} pollData={pollData} pageId={pageId} pages={pages} @@ -91,6 +94,7 @@ function CanvasContent({ did, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pollData, pages, @@ -100,6 +104,7 @@ function CanvasContent({ prerenderedCodeBlocks?: Map; pollData: PollData[]; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { @@ -132,6 +137,7 @@ function CanvasContent({ pollData={pollData} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} pageId={pageId} pages={pages} index={index} @@ -148,6 +154,7 @@ function CanvasBlock({ did, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, pageId, pages, @@ -157,6 +164,7 @@ function CanvasBlock({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; @@ -187,6 +195,7 @@ function CanvasBlock({ pageId={pageId} pages={pages} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} block={linearBlock} did={did} index={[index]} diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 1f1c642c..69528b90 100644 --- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -2,6 +2,7 @@ import { AtpAgent } from "@atproto/api"; import { ids } from "lexicons/api/lexicons"; import { PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksOrderedList, PubLeafletBlocksUnorderedList, PubLeafletPagesLinearDocument, @@ -9,6 +10,8 @@ import { PubLeafletBlocksPoll, } from "lexicons/api"; import { type $Typed } from "lexicons/api/util"; +import { get_standard_site_posts } from "app/api/rpc/[command]/get_standard_site_posts"; +import { supabaseServerClient } from "supabase/serverClient"; import { QuoteHandler } from "./QuoteHandler"; import { PublicationBackgroundProvider, @@ -103,6 +106,25 @@ export async function DocumentPageRenderer({ ? bskyPostResponses.flatMap((response) => response.data.posts) : []; + let standardSitePostBlocks = pages.flatMap((p) => { + let page = p as PubLeafletPagesLinearDocument.Main; + return extractBlocksByType<$Typed>( + page.blocks || [], + ids.PubLeafletBlocksStandardSitePost, + ); + }); + let standardSitePostUris = Array.from( + new Set(standardSitePostBlocks.map((b) => b.block.uri)), + ); + let standardSitePostsResult = + standardSitePostUris.length > 0 + ? await get_standard_site_posts.handler( + { uris: standardSitePostUris }, + { supabase: supabaseServerClient }, + ) + : { result: { posts: [] } }; + let standardSitePosts = standardSitePostsResult.result.posts; + // Extract poll blocks and fetch vote data let pollBlocks = pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; @@ -152,9 +174,14 @@ export async function DocumentPageRenderer({ pubRecord?.preferences, )} pubRecord={pubRecord} - profile={profile ? JSON.parse(JSON.stringify(profile)) : undefined} + profile={ + profile ? JSON.parse(JSON.stringify(profile)) : undefined + } document={document} bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} + standardSitePostData={JSON.parse( + JSON.stringify(standardSitePosts), + )} did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} pollData={pollData} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index c418d27d..fbb1c6f5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -281,6 +281,7 @@ export const QuoteContent = (props: { pollData={[]} pages={[]} bskyPostData={[]} + standardSitePostData={[]} blocks={content} did={props.did} preview diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 5ac6778d..9467c7a7 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -38,6 +38,7 @@ export function LinearDocumentPage({ preferences, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, pageId, @@ -85,6 +86,7 @@ export function LinearDocumentPage({ pages={pages as PubLeafletPagesLinearDocument.Main[]} pageId={pageId} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} blocks={blocks} did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index e6164175..56fb0ae5 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -14,6 +14,7 @@ import { PubLeafletBlocksHorizontalRule, PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksIframe, PubLeafletBlocksPage, PubLeafletBlocksPoll, @@ -29,6 +30,8 @@ import { StaticMathBlock } from "./Blocks/StaticMathBlock"; import { PubCodeBlock } from "./Blocks/PubCodeBlock"; import { AppBskyFeedDefs } from "@atproto/api"; import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; +import { StandardSitePostItemView } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; import { PollData } from "./fetchPollData"; @@ -50,6 +53,7 @@ export function PostContent({ className, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pages, pollData, @@ -62,6 +66,7 @@ export function PostContent({ className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; footnoteIndexMap?: Map; @@ -77,6 +82,7 @@ export function PostContent({ pageId={pageId} pages={pages} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} block={b} did={did} key={index} @@ -106,6 +112,7 @@ export let Block = ({ nextBlock, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pages, pollData, @@ -124,6 +131,7 @@ export let Block = ({ nextBlock?: PubLeafletPagesLinearDocument.Block; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; footnoteIndexMap?: Map; isFirst?: boolean; @@ -187,6 +195,7 @@ export let Block = ({ parentPageId={pageId} did={did} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} isCanvas={isCanvas} pages={pages} className={className} @@ -206,6 +215,22 @@ export let Block = ({ /> ); } + case PubLeafletBlocksStandardSitePost.isMain(b.block): { + let uri = b.block.uri; + let post = standardSitePostData.find((p) => p.uri === uri); + if (!post) { + return ( +
+

Post not found.

+
+ ); + } + return ( +
+ +
+ ); + } case PubLeafletBlocksIframe.isMain(b.block): { return ( ; @@ -558,6 +586,7 @@ function ListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -576,6 +605,7 @@ function ListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -606,6 +636,7 @@ function ListItem(props: { pollData={props.pollData} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={{ block: props.item.content }} did={props.did} isList @@ -627,6 +658,7 @@ function OrderedListItem(props: { did: string; className?: string; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pageId?: string; startIndex?: number; @@ -641,6 +673,7 @@ function OrderedListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -660,6 +693,7 @@ function OrderedListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -689,6 +723,7 @@ function OrderedListItem(props: { pollData={props.pollData} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={{ block: props.item.content }} did={props.did} isList diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 85d8b2bb..903f2530 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -19,6 +19,7 @@ import { PageOptionButton } from "components/Pages/PageOptions"; import { CloseTiny } from "components/Icons/CloseTiny"; import { Fragment } from "react"; import { PollData } from "./fetchPollData"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; @@ -62,6 +63,7 @@ export type SharedPageProps = { theme?: PubLeafletPublication.Theme | null; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; document_uri: string; fullPageScroll: boolean; @@ -107,6 +109,7 @@ export function PostPages({ pubRecord, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, document_uri, pollData, }: { @@ -117,6 +120,7 @@ export function PostPages({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; preferences: { showComments?: boolean; showMentions?: boolean; @@ -153,6 +157,7 @@ export function PostPages({ theme, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, hasPageBackground, diff --git a/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx index d4f2637a..26bb232b 100644 --- a/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx +++ b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx @@ -139,6 +139,7 @@ export function PostPreview(props: { }} prerenderedCodeBlocks={new Map()} bskyPostData={[]} + standardSitePostData={[]} pollData={[]} document_uri={FAKE_DOC_URI} fullPageScroll={!props.showPageBackground} diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 66606905..c8b64733 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -25,6 +25,7 @@ import { elementId } from "src/utils/elementId"; import { ButtonBlock } from "./ButtonBlock"; import { PollBlock } from "./PollBlock"; import { BlueskyPostBlock } from "./BlueskyPostBlock"; +import { StandardSitePostBlock } from "./StandardSitePostBlock"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; import { MathBlock } from "./MathBlock"; @@ -362,6 +363,7 @@ const BlockTypeComponents: { button: ButtonBlock, poll: PollBlock, "bluesky-post": BlueskyPostBlock, + "standard-site-post": StandardSitePostBlock, "horizontal-rule": HorizontalRule, }; diff --git a/components/Blocks/EmbedBlock.tsx b/components/Blocks/EmbedBlock.tsx index 93eda1fa..3a35c48e 100644 --- a/components/Blocks/EmbedBlock.tsx +++ b/components/Blocks/EmbedBlock.tsx @@ -268,6 +268,22 @@ const BlockLinkInput = (props: BlockProps) => { if (res.status === 200) { let data = await (res.json() as LinkPreviewMetadataResult); + if (data.leafletPost) { + await rep.mutate.assertFact([ + { + entity, + attribute: "block/type", + data: { type: "block-type-union", value: "standard-site-post" }, + }, + { + entity, + attribute: "block/standard-site-post", + data: { type: "string", value: data.leafletPost.uri }, + }, + ]); + setLoading(false); + return; + } if (data.success && data.data.links?.player?.[0]) { let embed = data.data.links.player[0]; embedUrl = embed.href; diff --git a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx new file mode 100644 index 00000000..c30135ca --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx @@ -0,0 +1,67 @@ +"use client"; +import { PublicationPostItem } from "app/lish/[did]/[publication]/PublicationContent"; +import { LocalizedDate } from "app/lish/[did]/[publication]/LocalizedDate"; +import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; +import { getFirstParagraph } from "src/utils/getFirstParagraph"; +import { useStandardSitePost } from "components/StandardSitePostDataProvider"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; + +export function StandardSitePostItem({ uri }: { uri: string }) { + const { data, isLoading } = useStandardSitePost(uri); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + if (!data) { + return ( +

+ Post not found. +

+ ); + } + + return ; +} + +export function StandardSitePostItemView({ + post, +}: { + post: StandardSitePostData; +}) { + const docUrl = getDocumentURL( + post.record, + post.uri, + post.publication ?? undefined, + ); + const authorLabel = + post.author?.displayName || + (post.author?.handle ? `@${post.author.handle}` : undefined); + + return ( + + ) : undefined + } + /> + ); +} diff --git a/components/Blocks/StandardSitePostBlock/index.tsx b/components/Blocks/StandardSitePostBlock/index.tsx new file mode 100644 index 00000000..d4f31eed --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/index.tsx @@ -0,0 +1,26 @@ +import { BlockProps, BlockLayout } from "../Block"; +import { useEntity } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { StandardSitePostItem } from "./StandardSitePostItem"; + +export const StandardSitePostBlock = ( + props: BlockProps & { preview?: boolean }, +) => { + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let uri = useEntity(props.entityID, "block/standard-site-post")?.data.value; + + if (!uri) return null; + + return ( + + + + ); +}; diff --git a/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts b/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts new file mode 100644 index 00000000..1d88624e --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts @@ -0,0 +1,23 @@ +import { ids } from "lexicons/api/lexicons"; + +export function parseStandardSitePostInput(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + // https://leaflet.pub/p/{did}/{rkey} + let m = url.pathname.match(/^\/p\/([^/]+)\/([^/]+)\/?$/); + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; + + // https://leaflet.pub/lish/{did}/{publication}/{rkey} + m = url.pathname.match(/^\/lish\/([^/]+)\/[^/]+\/([^/]+)\/?$/); + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; + + return null; +} diff --git a/components/StandardSitePostDataProvider.tsx b/components/StandardSitePostDataProvider.tsx new file mode 100644 index 00000000..e337f72f --- /dev/null +++ b/components/StandardSitePostDataProvider.tsx @@ -0,0 +1,81 @@ +"use client"; +import { createContext, useContext } from "react"; +import useSWR from "swr"; +import { callRPC } from "app/api/rpc/client"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; + +type Resolver = (data: StandardSitePostData | null) => void; +let pendingURIs = new Set(); +let pendingResolvers = new Map(); +let flushScheduled = false; + +function scheduleFlush() { + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(async () => { + const uris = Array.from(pendingURIs); + pendingURIs = new Set(); + const resolvers = pendingResolvers; + pendingResolvers = new Map(); + flushScheduled = false; + if (uris.length === 0) return; + try { + const res = await callRPC("get_standard_site_posts", { uris }); + const byUri = new Map( + (res?.result.posts || []).map((p) => [p.uri, p] as const), + ); + for (const uri of uris) { + const post = byUri.get(uri) ?? null; + for (const r of resolvers.get(uri) || []) r(post); + } + } catch { + for (const uri of uris) { + for (const r of resolvers.get(uri) || []) r(null); + } + } + }); +} + +function batchFetchStandardSitePost( + uri: string, +): Promise { + return new Promise((resolve) => { + pendingURIs.add(uri); + const existing = pendingResolvers.get(uri) || []; + existing.push(resolve); + pendingResolvers.set(uri, existing); + scheduleFlush(); + }); +} + +export const StandardSitePostDataContext = createContext | null>(null); + +export function StandardSitePostDataProvider(props: { + posts: StandardSitePostData[]; + children: React.ReactNode; +}) { + const map: Record = {}; + for (const p of props.posts) map[p.uri] = p; + return ( + + {props.children} + + ); +} + +export function useStandardSitePost(uri: string | null | undefined) { + const ctx = useContext(StandardSitePostDataContext); + const fromContext = uri ? ctx?.[uri] : undefined; + const swr = useSWR( + !uri || fromContext ? null : `standard-site-post:${uri}`, + () => batchFetchStandardSitePost(uri!), + { revalidateOnFocus: false, revalidateIfStale: false }, + ); + return { + data: fromContext ?? swr.data, + isLoading: !fromContext && swr.isLoading, + }; +} diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index c1901c0a..7cf07858 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -39,6 +39,7 @@ import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +import * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -92,6 +93,7 @@ export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +export * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 38a10025..14c63a22 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1568,6 +1568,25 @@ export const schemaDict = { }, }, }, + PubLeafletBlocksStandardSitePost: { + lexicon: 1, + id: 'pub.leaflet.blocks.standardSitePost', + defs: { + main: { + type: 'object', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + }, + }, + }, + }, + }, PubLeafletBlocksText: { lexicon: 1, id: 'pub.leaflet.blocks.text', @@ -1931,6 +1950,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.code', 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', + 'lex:pub.leaflet.blocks.standardSitePost', 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', @@ -2033,6 +2053,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.code', 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', + 'lex:pub.leaflet.blocks.standardSitePost', 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', @@ -2866,6 +2887,7 @@ export const ids = { PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', PubLeafletBlocksPage: 'pub.leaflet.blocks.page', PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', + PubLeafletBlocksStandardSitePost: 'pub.leaflet.blocks.standardSitePost', PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', diff --git a/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts b/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts new file mode 100644 index 00000000..7c48066b --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.standardSitePost' + +export interface Main { + $type?: 'pub.leaflet.blocks.standardSitePost' + uri: string + cid?: string +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/pages/canvas.ts b/lexicons/api/types/pub/leaflet/pages/canvas.ts index fc55c202..1d34e809 100644 --- a/lexicons/api/types/pub/leaflet/pages/canvas.ts +++ b/lexicons/api/types/pub/leaflet/pages/canvas.ts @@ -21,6 +21,7 @@ import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' +import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' @@ -60,6 +61,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index 82dd1bf2..83d485a2 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -21,6 +21,7 @@ import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' +import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' @@ -60,6 +61,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/lexicons/pub/leaflet/blocks/standardSitePost.json b/lexicons/pub/leaflet/blocks/standardSitePost.json new file mode 100644 index 00000000..978e14e8 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/standardSitePost.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.standardSitePost", + "defs": { + "main": { + "type": "object", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/pages/canvas.json b/lexicons/pub/leaflet/pages/canvas.json index 2efc04dd..3fcd13cf 100644 --- a/lexicons/pub/leaflet/pages/canvas.json +++ b/lexicons/pub/leaflet/pages/canvas.json @@ -44,6 +44,7 @@ "pub.leaflet.blocks.code", "pub.leaflet.blocks.horizontalRule", "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", "pub.leaflet.blocks.button" diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index 51007d09..2c36f1b6 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -41,6 +41,7 @@ "pub.leaflet.blocks.code", "pub.leaflet.blocks.horizontalRule", "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", "pub.leaflet.blocks.button" diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index 198e1709..c352954f 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -49,6 +49,21 @@ export const PubLeafletBlocksBskyPost: LexiconDoc = { }, }; +export const PubLeafletBlocksStandardSitePost: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.standardSitePost", + defs: { + main: { + type: "object", + required: ["uri"], + properties: { + uri: { type: "string", format: "at-uri" }, + cid: { type: "string" }, + }, + }, + }, +}; + export const PubLeafletBlocksBlockQuote: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.blockquote", @@ -362,6 +377,7 @@ export const BlockLexicons = [ PubLeafletBlocksCode, PubLeafletBlocksHorizontalRule, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksPage, PubLeafletBlocksPoll, PubLeafletBlocksButton, diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index eb59fa17..fc0a1455 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -87,6 +87,10 @@ const BlockAttributes = { type: "bluesky-post", cardinality: "one", }, + "block/standard-site-post": { + type: "string", + cardinality: "one", + }, "block/math": { type: "string", cardinality: "one", @@ -370,6 +374,7 @@ export type Data = { | "button" | "poll" | "bluesky-post" + | "standard-site-post" | "math" | "code" | "blockquote" diff --git a/src/utils/addLinkBlock.ts b/src/utils/addLinkBlock.ts index 36965e61..e08367cb 100644 --- a/src/utils/addLinkBlock.ts +++ b/src/utils/addLinkBlock.ts @@ -40,6 +40,23 @@ export async function addLinkBlock( return; } let data = await (res.json() as LinkPreviewMetadataResult); + + if (data.leafletPost) { + await rep.mutate.assertFact([ + { + entity: entityID, + attribute: "block/type", + data: { type: "block-type-union", value: "standard-site-post" }, + }, + { + entity: entityID, + attribute: "block/standard-site-post", + data: { type: "string", value: data.leafletPost.uri }, + }, + ]); + return; + } + if (!data.success) { await rep?.mutate.assertFact([ { diff --git a/src/utils/factsToPagesRecord.ts b/src/utils/factsToPagesRecord.ts index 89a3d328..4e6b0567 100644 --- a/src/utils/factsToPagesRecord.ts +++ b/src/utils/factsToPagesRecord.ts @@ -6,6 +6,7 @@ import { BlobRef } from "@atproto/lexicon"; import { PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksButton, PubLeafletBlocksCode, PubLeafletBlocksHeader, @@ -335,6 +336,15 @@ export async function processBlocksToPages(opts: { }; return block; } + if (b.type === "standard-site-post") { + const [uri] = scan.eav(b.value, "block/standard-site-post"); + if (!uri) return; + const block: $Typed = { + $type: ids.PubLeafletBlocksStandardSitePost, + uri: uri.data.value, + }; + return block; + } if (b.type === "horizontal-rule") { const block: $Typed = { $type: ids.PubLeafletBlocksHorizontalRule, diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx index 182e7ac6..d494e910 100644 --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -98,6 +98,11 @@ const BlockTypeToHTML: { /> ); }, + "standard-site-post": async (b, tx) => { + let [uri] = await scanIndex(tx).eav(b.value, "block/standard-site-post"); + if (!uri) return null; + return
; + }, math: async (b, tx, a) => { let [math] = await scanIndex(tx).eav(b.value, "block/math"); const html = Katex.renderToString(math?.data.value || "", { diff --git a/src/utils/resolveStandardSitePostUrl.ts b/src/utils/resolveStandardSitePostUrl.ts new file mode 100644 index 00000000..0ebab975 --- /dev/null +++ b/src/utils/resolveStandardSitePostUrl.ts @@ -0,0 +1,71 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database, Json } from "supabase/database.types"; +import { parseStandardSitePostInput } from "components/Blocks/StandardSitePostBlock/parseStandardSitePostInput"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; + +/** + * Resolve a URL or AT URI to a standard-site-post AT URI by checking our DB. + * Handles at:// URIs, leaflet.pub patterns synchronously, and custom-domain + * URLs via DB lookup against the publications + documents tables. + */ +export async function resolveStandardSitePostUrl( + input: string, + supabase: SupabaseClient, +): Promise { + const direct = parseStandardSitePostInput(input); + if (direct) { + const { data } = await supabase + .from("documents") + .select("uri") + .eq("uri", direct) + .maybeSingle(); + return data?.uri ?? null; + } + + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + const origin = `${url.protocol}//${url.host}`; + const pathOnly = url.pathname.replace(/\/$/, "") || "/"; + + // Find publications whose URL matches this origin + const { data: publications } = await supabase + .from("publications") + .select("uri, record"); + if (!publications) return null; + + const matchedPub = publications.find((p) => { + const normalized = normalizePublicationRecord(p.record); + if (!normalized?.url) return false; + return normalized.url.replace(/\/$/, "") === origin; + }); + if (!matchedPub) return null; + + // Find documents in that publication where data.path matches pathOnly + const { data: docsInPub } = await supabase + .from("documents_in_publications") + .select("documents(uri, data)") + .eq("publication", matchedPub.uri); + + if (!docsInPub) return null; + + for (const row of docsInPub) { + const doc = row.documents; + if (!doc) continue; + const normalized = normalizeDocumentRecord(doc.data as Json, doc.uri); + if (!normalized) continue; + const docPath = normalized.path + ? normalized.path.startsWith("/") + ? normalized.path + : `/${normalized.path}` + : null; + if (docPath === pathOnly) return doc.uri; + } + return null; +}