a tool for shared writing and social publishing

fix post links to standard site blogs

+123 -76
+2 -5
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 5 6 6 7 export const BskyPostEmbedNotification = ( 7 8 props: HydratedBskyPostEmbedNotification, ··· 11 12 12 13 if (!docRecord) return null; 13 14 14 - const docUri = new AtUri(props.document.uri); 15 - const rkey = docUri.rkey; 16 - const did = docUri.host; 17 - 18 - const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 15 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 19 16 20 17 const embedder = props.documentCreatorHandle 21 18 ? `@${props.documentCreatorHandle}`
+4 -6
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 8 8 Notification, 9 9 } from "./Notification"; 10 10 import { AtUri } from "@atproto/api"; 11 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 11 12 12 13 export const CommentMentionNotification = ( 13 14 props: HydratedCommentMentionNotification, ··· 19 20 const profileRecord = props.commentData.bsky_profiles 20 21 ?.record as AppBskyActorProfile.Record; 21 22 const pubRecord = props.normalizedPublication; 22 - const docUri = new AtUri(props.commentData.documents?.uri!); 23 - const rkey = docUri.rkey; 24 - const did = docUri.host; 25 23 26 - const href = pubRecord 27 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 28 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 24 + const href = 25 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 26 + "?interactionDrawer=comments"; 29 27 30 28 const commenter = props.commenterHandle 31 29 ? `@${props.commenterHandle}`
+4 -6
app/(home-pages)/notifications/CommentNotication.tsx
··· 10 10 Notification, 11 11 } from "./Notification"; 12 12 import { AtUri } from "@atproto/api"; 13 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 14 14 15 export const CommentNotification = (props: HydratedCommentNotification) => { 15 16 const docRecord = props.normalizedDocument; ··· 24 25 props.commentData.bsky_profiles?.handle || 25 26 "Someone"; 26 27 const pubRecord = props.normalizedPublication; 27 - const docUri = new AtUri(props.commentData.documents?.uri!); 28 - const rkey = docUri.rkey; 29 - const did = docUri.host; 30 28 31 - const href = pubRecord 32 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 33 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 29 + const href = 30 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 31 + "?interactionDrawer=comments"; 34 32 35 33 return ( 36 34 <Notification
+2 -7
app/(home-pages)/notifications/MentionNotification.tsx
··· 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedMentionNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 5 6 6 7 export const MentionNotification = (props: HydratedMentionNotification) => { 7 8 const docRecord = props.normalizedDocument; ··· 9 10 10 11 if (!docRecord) return null; 11 12 12 - const docUri = new AtUri(props.document.uri); 13 - const rkey = docUri.rkey; 14 - const did = docUri.host; 15 - 16 - const href = pubRecord 17 - ? `${pubRecord.url}/${rkey}` 18 - : `/p/${did}/${rkey}`; 13 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 19 14 20 15 let actionText: React.ReactNode; 21 16 let mentionedItemName: string | undefined;
+2 -6
app/(home-pages)/notifications/QuoteNotification.tsx
··· 3 3 import { HydratedQuoteNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 5 import { Avatar } from "components/Avatar"; 6 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 6 7 7 8 export const QuoteNotification = (props: HydratedQuoteNotification) => { 8 9 const postView = props.bskyPost.post_view as any; ··· 13 14 14 15 if (!docRecord) return null; 15 16 16 - const docUri = new AtUri(props.document.uri); 17 - const rkey = docUri.rkey; 18 - const did = docUri.host; 19 17 const postText = postView.record?.text || ""; 20 18 21 - const href = pubRecord 22 - ? `${pubRecord.url}/${rkey}` 23 - : `/p/${did}/${rkey}`; 19 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 24 20 25 21 return ( 26 22 <Notification
+2 -5
app/(home-pages)/notifications/RecommendNotification.tsx
··· 5 5 import { Avatar } from "components/Avatar"; 6 6 import { AtUri } from "@atproto/api"; 7 7 import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; 8 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 8 9 9 10 export const RecommendNotification = ( 10 11 props: HydratedRecommendNotification, ··· 26 27 27 28 if (!docRecord) return null; 28 29 29 - const docUri = new AtUri(props.document.uri); 30 - const rkey = docUri.rkey; 31 - const did = docUri.host; 32 - 33 - const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 30 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 34 31 35 32 return ( 36 33 <Notification
+4 -6
app/(home-pages)/notifications/ReplyNotification.tsx
··· 10 10 import { PubLeafletComment } from "lexicons/api"; 11 11 import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 14 14 15 export const ReplyNotification = (props: HydratedCommentNotification) => { 15 16 const docRecord = props.normalizedDocument; ··· 32 33 props.parentData?.bsky_profiles?.handle || 33 34 "Someone"; 34 35 35 - const docUri = new AtUri(props.commentData.documents?.uri!); 36 - const rkey = docUri.rkey; 37 - const did = docUri.host; 38 36 const pubRecord = props.normalizedPublication; 39 37 40 - const href = pubRecord 41 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 42 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 + const href = 39 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 40 + "?interactionDrawer=comments"; 43 41 44 42 return ( 45 43 <Notification
+9 -7
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 14 14 useLeafletPublicationData, 15 15 } from "components/PageSWRDataProvider"; 16 16 import { ShareSmall } from "components/Icons/ShareSmall"; 17 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 17 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 18 18 import { AtUri } from "@atproto/syntax"; 19 19 import { useIsMobile } from "src/hooks/isMobile"; 20 20 ··· 89 89 let { permission_token } = useReplicache(); 90 90 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 91 91 92 - let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 93 - let postLink = !docURI 94 - ? null 95 - : pub?.publications 96 - ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` 97 - : `p/${docURI.host}/${docURI.rkey}`; 92 + let postLink = 93 + pub?.documents && normalizedDocument 94 + ? getDocumentURL( 95 + normalizedDocument, 96 + pub.documents.uri, 97 + pub?.publications || null, 98 + ) 99 + : null; 98 100 let publishLink = useReadOnlyShareLink(); 99 101 let [collabLink, setCollabLink] = useState<null | string>(null); 100 102 useEffect(() => {
+7 -5
app/api/rpc/[command]/search_publication_documents.ts
··· 2 2 import { z } from "zod"; 3 3 import { makeRoute } from "../lib"; 4 4 import type { Env } from "./route"; 5 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 6 7 7 8 export type SearchPublicationDocumentsReturnType = Awaited< 8 9 ReturnType<(typeof search_publication_documents)["handler"]> ··· 37 38 } 38 39 39 40 const result = documents.map((d) => { 40 - const docUri = new AtUri(d.documents.uri); 41 - const pubUrl = getPublicationURL(d.publications); 41 + const normalizedDoc = normalizeDocumentRecord(d.documents.data, d.documents.uri); 42 42 43 43 return { 44 44 uri: d.documents.uri, 45 - title: (d.documents.data as { title?: string })?.title || "Untitled", 46 - url: `${pubUrl}/${docUri.rkey}`, 45 + title: normalizedDoc?.title || (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: normalizedDoc 47 + ? getDocumentURL(normalizedDoc, d.documents.uri, d.publications) 48 + : `${d.documents.uri}`, 47 49 }; 48 50 }); 49 51
+7 -5
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 8 8 } from "src/utils/normalizeRecords"; 9 9 import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 10 import { documentUriFilter } from "src/utils/uriHelpers"; 11 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 11 12 12 13 export async function getPostPageData(did: string, rkey: string) { 13 14 let { data: documents } = await supabaseServerClient ··· 46 47 ); 47 48 48 49 // Fetch constellation backlinks for mentions 49 - let aturi = new AtUri(document.uri); 50 - const postUrl = normalizedPublication 51 - ? `${normalizedPublication.url}/${aturi.rkey}` 52 - : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 53 - const constellationBacklinks = await getConstellationBacklinks(postUrl); 50 + const postUrl = getDocumentURL(normalizedDocument, document.uri, normalizedPublication); 51 + // Constellation needs an absolute URL 52 + const absolutePostUrl = postUrl.startsWith("/") 53 + ? `https://leaflet.pub${postUrl}` 54 + : postUrl; 55 + const constellationBacklinks = await getConstellationBacklinks(absolutePostUrl); 54 56 55 57 // Deduplicate constellation backlinks (same post could appear in both links and embeds) 56 58 const uniqueBacklinks = Array.from(
+4 -3
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 9 9 } from "./PublicationSWRProvider"; 10 10 import { Fragment } from "react"; 11 11 import { useParams } from "next/navigation"; 12 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 13 import { SpeedyLink } from "components/SpeedyLink"; 14 14 import { InteractionPreview } from "components/InteractionsPreview"; 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; ··· 71 71 const leaflet = publication.leaflets_in_publications.find( 72 72 (l) => l.doc === doc.uri, 73 73 ); 74 + const docUrl = getDocumentURL(doc.record, doc.uri, publication); 74 75 75 76 return ( 76 77 <Fragment> ··· 87 88 <a 88 89 className="hover:no-underline!" 89 90 target="_blank" 90 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 91 + href={docUrl} 91 92 > 92 93 <h3 className="text-primary grow leading-snug"> 93 94 {doc.record.title} ··· 144 145 showComments={pubRecord?.preferences?.showComments !== false} 145 146 showMentions={pubRecord?.preferences?.showMentions !== false} 146 147 showRecommends={pubRecord?.preferences?.showRecommends !== false} 147 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 148 + postUrl={docUrl} 148 149 /> 149 150 </div> 150 151 </div>
+4 -2
app/lish/[did]/[publication]/generateFeed.ts
··· 11 11 hasLeafletContent, 12 12 } from "src/utils/normalizeRecords"; 13 13 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 14 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 14 15 15 16 export async function generateFeed( 16 17 did: string, ··· 84 85 } 85 86 } 86 87 88 + const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord); 87 89 feed.addItem({ 88 90 title: record.title, 89 91 description: record.description, 90 92 date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 91 - id: `${pubRecord.url}/${rkey}`, 92 - link: `${pubRecord.url}/${rkey}`, 93 + id: docUrl, 94 + link: docUrl, 93 95 content: chunks.join(""), 94 96 }); 95 97 }),
+4 -3
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 3 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { BskyAgent } from "@atproto/api"; 5 5 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 6 6 import { SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 135 135 doc.documents.recommends_on_documents?.[0]?.count || 0; 136 136 let tags = doc_record.tags || []; 137 137 138 + const docUrl = getDocumentURL(doc_record, doc.documents.uri, publication); 138 139 return ( 139 140 <React.Fragment key={doc.documents?.uri}> 140 141 <div className="flex w-full grow flex-col "> 141 142 <SpeedyLink 142 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 143 + href={docUrl} 143 144 className="publishedPost hover:no-underline! flex flex-col" 144 145 > 145 146 <h3 className="text-primary">{doc_record.title}</h3> ··· 168 169 recommendsCount={recommends} 169 170 documentUri={doc.documents.uri} 170 171 tags={tags} 171 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 172 + postUrl={docUrl} 172 173 showComments={ 173 174 record?.preferences?.showComments !== false 174 175 }
+45
app/lish/createPub/getPublicationURL.ts
··· 5 5 import { 6 6 normalizePublicationRecord, 7 7 isLeafletPublication, 8 + hasLeafletContent, 9 + type NormalizedDocument, 8 10 type NormalizedPublication, 9 11 } from "src/utils/normalizeRecords"; 10 12 ··· 44 46 const name = aturi.rkey || normalized?.name; 45 47 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 46 48 } 49 + 50 + /** 51 + * Gets the full URL for a document. 52 + * Always appends the document's path property. 53 + * For non-leaflet documents (content.$type !== "pub.leaflet.content"), 54 + * always uses the full publication site URL, not internal /lish/ URLs. 55 + */ 56 + export function getDocumentURL( 57 + doc: NormalizedDocument, 58 + docUri: string, 59 + publication?: PublicationInput | NormalizedPublication | null, 60 + ): string { 61 + const path = doc.path || "/" + new AtUri(docUri).rkey; 62 + const aturi = new AtUri(docUri); 63 + 64 + const isNormalized = 65 + !!publication && 66 + (publication as NormalizedPublication).$type === "site.standard.publication"; 67 + const normPub = isNormalized 68 + ? (publication as NormalizedPublication) 69 + : publication 70 + ? normalizePublicationRecord((publication as PublicationInput).record) 71 + : null; 72 + const pubInput = isNormalized ? null : (publication as PublicationInput | null); 73 + 74 + // Non-leaflet documents always use the full publication site URL 75 + if (doc.content && !hasLeafletContent(doc) && normPub?.url) { 76 + return normPub.url + path; 77 + } 78 + 79 + // For leaflet documents, use getPublicationURL (may return /lish/ internal paths) 80 + if (pubInput) { 81 + return getPublicationURL(pubInput) + path; 82 + } 83 + 84 + // When we only have a normalized publication, use its URL directly 85 + if (normPub?.url) { 86 + return normPub.url + path; 87 + } 88 + 89 + // Standalone document fallback 90 + return `/p/${aturi.host}${path}`; 91 + }
+21 -7
components/PageSWRDataProvider.tsx
··· 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 9 import { createContext, useContext, useMemo } from "react"; 10 10 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { 14 14 normalizeDocumentRecord, ··· 119 119 // Compute the full post URL for sharing 120 120 let postShareLink: string | undefined; 121 121 if (publishedInPublication?.publications && publishedInPublication.documents) { 122 - // Published in a publication - use publication URL + document rkey 123 - const docUri = new AtUri(publishedInPublication.documents.uri); 124 - postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`; 122 + const normalizedDoc = normalizeDocumentRecord( 123 + publishedInPublication.documents.data, 124 + publishedInPublication.documents.uri, 125 + ); 126 + if (normalizedDoc) { 127 + postShareLink = getDocumentURL( 128 + normalizedDoc, 129 + publishedInPublication.documents.uri, 130 + publishedInPublication.publications, 131 + ); 132 + } 125 133 } else if (publishedStandalone?.document) { 126 - // Standalone published post - use /p/{did}/{rkey} format 127 - const docUri = new AtUri(publishedStandalone.document); 128 - postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 134 + const normalizedDoc = publishedStandalone.documents 135 + ? normalizeDocumentRecord(publishedStandalone.documents.data, publishedStandalone.document) 136 + : null; 137 + if (normalizedDoc) { 138 + postShareLink = getDocumentURL(normalizedDoc, publishedStandalone.document); 139 + } else { 140 + const docUri = new AtUri(publishedStandalone.document); 141 + postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 142 + } 129 143 } 130 144 131 145 return {
+2 -3
components/PostListing.tsx
··· 18 18 import { InteractionPreview } from "./InteractionsPreview"; 19 19 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 20 import { mergePreferences } from "src/utils/mergePreferences"; 21 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 21 22 22 23 export const PostListing = (props: Post) => { 23 24 let pubRecord = props.publication?.pubRecord as ··· 60 61 let tags = (postRecord?.tags as string[] | undefined) || []; 61 62 62 63 // For standalone posts, link directly to the document 63 - let postHref = props.publication 64 - ? `${props.publication.href}/${postUri.rkey}` 65 - : `/p/${postUri.host}/${postUri.rkey}`; 64 + let postHref = getDocumentURL(postRecord, props.documents.uri, pubRecord); 66 65 67 66 return ( 68 67 <BaseThemeProvider {...theme} local>