a tool for shared writing and social publishing

normalize data from publication and document tables

+1272 -754
+26 -6
actions/publishToPublication.ts
··· 43 43 import { Lock } from "src/utils/lock"; 44 44 import type { PubLeafletPublication } from "lexicons/api"; 45 45 import { 46 + normalizeDocumentRecord, 47 + type NormalizedDocument, 48 + } from "src/utils/normalizeRecords"; 49 + import { 46 50 ColorToRGB, 47 51 ColorToRGBA, 48 52 } from "components/ThemeManager/colorToLexicons"; ··· 147 151 credentialSession.did!, 148 152 ); 149 153 150 - let existingRecord = 151 - (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 154 + let existingRecord: Partial<PubLeafletDocument.Record> = {}; 155 + const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); 156 + if (normalizedDoc) { 157 + // When reading existing data, use normalized format to extract fields 158 + // The theme is preserved in NormalizedDocument for backward compatibility 159 + existingRecord = { 160 + publishedAt: normalizedDoc.publishedAt, 161 + title: normalizedDoc.title, 162 + description: normalizedDoc.description, 163 + tags: normalizedDoc.tags, 164 + coverImage: normalizedDoc.coverImage, 165 + theme: normalizedDoc.theme, 166 + }; 167 + } 152 168 153 169 // Extract theme for standalone documents (not for publications) 154 170 let theme: PubLeafletPublication.Theme | undefined; ··· 887 903 .single(); 888 904 889 905 if (document) { 890 - const docRecord = 891 - document.data as PubLeafletDocument.Record; 892 - if (docRecord.author !== authorDid) { 893 - mentionedDocuments.set(docRecord.author, feature.atURI); 906 + const normalizedMentionedDoc = normalizeDocumentRecord( 907 + document.data, 908 + ); 909 + // Get the author from the document URI (the DID is the host part) 910 + const mentionedUri = new AtUri(feature.atURI); 911 + const docAuthor = mentionedUri.host; 912 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 913 + mentionedDocuments.set(docAuthor, feature.atURI); 894 914 } 895 915 } 896 916 }
+3 -5
app/(home-pages)/discover/PubListing.tsx
··· 6 6 import { Separator } from "components/Layout"; 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 - import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 10 9 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 10 import { timeAgo } from "src/utils/timeAgo"; 12 - import { Json } from "supabase/database.types"; 13 11 14 12 export const PubListing = ( 15 13 props: PublicationSubscription & { 16 14 resizeHeight?: boolean; 17 15 }, 18 16 ) => { 19 - let record = props.record as PubLeafletPublication.Record; 20 - let theme = usePubTheme(record.theme); 17 + let record = props.record; 18 + let theme = usePubTheme(record?.theme); 21 19 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 22 20 ? blobRefToSrc( 23 21 record?.theme?.backgroundImage?.image?.ref, ··· 31 29 return ( 32 30 <BaseThemeProvider {...theme} local> 33 31 <a 34 - href={`https://${record.base_path}`} 32 + href={record.url} 35 33 className={`no-underline! flex flex-row gap-2 36 34 bg-bg-leaflet 37 35 border border-border-light rounded-lg
+17 -7
app/(home-pages)/discover/getPublications.ts
··· 1 1 "use server"; 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 + import { 5 + normalizePublicationRow, 6 + hasValidPublication, 7 + } from "src/utils/normalizeRecords"; 4 8 5 9 export type Cursor = { 6 10 indexed_at?: string; ··· 98 102 // Get the page 99 103 const page = allPubs.slice(startIndex, startIndex + limit); 100 104 101 - // Create next cursor 105 + // Normalize publication records 106 + const normalizedPage = page 107 + .map(normalizePublicationRow) 108 + .filter(hasValidPublication); 109 + 110 + // Create next cursor based on last item in normalizedPage 111 + const lastItem = normalizedPage[normalizedPage.length - 1]; 102 112 const nextCursor = 103 - page.length === limit && startIndex + limit < allPubs.length 113 + normalizedPage.length > 0 && startIndex + limit < allPubs.length 104 114 ? order === "recentlyUpdated" 105 115 ? { 106 - indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 - uri: page[page.length - 1].uri, 116 + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, 117 + uri: lastItem.uri, 108 118 } 109 119 : { 110 - count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 - uri: page[page.length - 1].uri, 120 + count: lastItem.publication_subscriptions[0]?.count || 0, 121 + uri: lastItem.uri, 112 122 } 113 123 : null; 114 124 115 125 return { 116 - publications: page, 126 + publications: normalizedPage, 117 127 nextCursor, 118 128 }; 119 129 }
+11 -18
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 1 - import { 2 - AppBskyActorProfile, 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 1 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 7 2 import { HydratedCommentMentionNotification } from "src/notifications"; 8 3 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 4 import { MentionTiny } from "components/Icons/MentionTiny"; ··· 17 12 export const CommentMentionNotification = ( 18 13 props: HydratedCommentMentionNotification, 19 14 ) => { 20 - const docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 15 + const docRecord = props.normalizedDocument; 16 + if (!docRecord) return null; 17 + 22 18 const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 19 const profileRecord = props.commentData.bsky_profiles 24 20 ?.record as AppBskyActorProfile.Record; 25 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 - ?.publications?.record as PubLeafletPublication.Record | undefined; 21 + const pubRecord = props.normalizedPublication; 27 22 const docUri = new AtUri(props.commentData.documents?.uri!); 28 23 const rkey = docUri.rkey; 29 24 const did = docUri.host; 30 25 31 26 const href = pubRecord 32 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 27 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 33 28 : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 29 35 30 const commenter = props.commenterHandle ··· 37 32 : "Someone"; 38 33 39 34 let actionText: React.ReactNode; 40 - let mentionedDocRecord = props.mentionedDocument 41 - ?.data as PubLeafletDocument.Record; 35 + const mentionedDocRecord = props.normalizedMentionedDocument; 42 36 43 37 if (props.mention_type === "did") { 44 38 actionText = <>{commenter} mentioned you in a comment</>; ··· 46 40 props.mention_type === "publication" && 47 41 props.mentionedPublication 48 42 ) { 49 - const mentionedPubRecord = props.mentionedPublication 50 - .record as PubLeafletPublication.Record; 43 + const mentionedPubRecord = props.normalizedMentionedPublication; 51 44 actionText = ( 52 45 <> 53 46 {commenter} mentioned your publication{" "} 54 - <span className="italic">{mentionedPubRecord.name}</span> in a comment 47 + <span className="italic">{mentionedPubRecord?.name}</span> in a comment 55 48 </> 56 49 ); 57 - } else if (props.mention_type === "document" && props.mentionedDocument) { 50 + } else if (props.mention_type === "document" && mentionedDocRecord) { 58 51 actionText = ( 59 52 <> 60 53 {commenter} mentioned your post{" "} ··· 72 65 icon={<MentionTiny />} 73 66 actionText={actionText} 74 67 content={ 75 - <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 68 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 76 69 <CommentInNotification 77 70 className="" 78 71 avatar={
+13 -17
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 1 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 - import { 3 - AppBskyActorProfile, 4 - PubLeafletComment, 5 - PubLeafletDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 8 3 import { HydratedCommentNotification } from "src/notifications"; 9 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 5 import { Avatar } from "components/Avatar"; ··· 17 12 import { AtUri } from "@atproto/api"; 18 13 19 14 export const CommentNotification = (props: HydratedCommentNotification) => { 20 - let docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 22 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 23 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 24 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 25 22 const displayName = 26 - profileRecord.displayName || 23 + profileRecord?.displayName || 27 24 props.commentData.bsky_profiles?.handle || 28 25 "Someone"; 29 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 - ?.publications?.record as PubLeafletPublication.Record | undefined; 31 - let docUri = new AtUri(props.commentData.documents?.uri!); 32 - let rkey = docUri.rkey; 33 - let did = docUri.host; 26 + const pubRecord = props.normalizedPublication; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 34 30 35 31 const href = pubRecord 36 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 32 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 37 33 : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 34 39 35 return (
+3 -4
app/(home-pages)/notifications/FollowNotification.tsx
··· 2 2 import { Notification } from "./Notification"; 3 3 import { HydratedSubscribeNotification } from "src/notifications"; 4 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 5 + import { AppBskyActorProfile } from "lexicons/api"; 6 6 7 7 export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 8 const profileRecord = props.subscriptionData?.identities?.bsky_profiles ··· 11 11 profileRecord?.displayName || 12 12 props.subscriptionData?.identities?.bsky_profiles?.handle || 13 13 "Someone"; 14 - const pubRecord = props.subscriptionData?.publications 15 - ?.record as PubLeafletPublication.Record; 14 + const pubRecord = props.normalizedPublication; 16 15 const avatarSrc = 17 16 profileRecord?.avatar?.ref && 18 17 blobRefToSrc( ··· 23 22 return ( 24 23 <Notification 25 24 timestamp={props.created_at} 26 - href={`https://${pubRecord?.base_path}`} 25 + href={pubRecord ? pubRecord.url : "#"} 27 26 icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 28 27 actionText={ 29 28 <>
+11 -12
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedMentionNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { Agent, AtUri } from "@atproto/api"; 4 + import { AtUri } from "@atproto/api"; 6 5 7 6 export const MentionNotification = (props: HydratedMentionNotification) => { 8 - const docRecord = props.document.data as PubLeafletDocument.Record; 9 - const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 - ?.record as PubLeafletPublication.Record | undefined; 7 + const docRecord = props.normalizedDocument; 8 + const pubRecord = props.normalizedPublication; 9 + 10 + if (!docRecord) return null; 11 + 11 12 const docUri = new AtUri(props.document.uri); 12 13 const rkey = docUri.rkey; 13 14 const did = docUri.host; 14 15 15 16 const href = pubRecord 16 - ? `https://${pubRecord.base_path}/${rkey}` 17 + ? `${pubRecord.url}/${rkey}` 17 18 : `/p/${did}/${rkey}`; 18 19 19 20 let actionText: React.ReactNode; 20 21 let mentionedItemName: string | undefined; 21 - let mentionedDocRecord = props.mentionedDocument 22 - ?.data as PubLeafletDocument.Record; 22 + const mentionedDocRecord = props.normalizedMentionedDocument; 23 23 24 24 const mentioner = props.documentCreatorHandle 25 25 ? `@${props.documentCreatorHandle}` ··· 31 31 props.mention_type === "publication" && 32 32 props.mentionedPublication 33 33 ) { 34 - const mentionedPubRecord = props.mentionedPublication 35 - .record as PubLeafletPublication.Record; 36 - mentionedItemName = mentionedPubRecord.name; 34 + const mentionedPubRecord = props.normalizedMentionedPublication; 35 + mentionedItemName = mentionedPubRecord?.name; 37 36 actionText = ( 38 37 <> 39 38 {mentioner} mentioned your publication{" "} 40 39 <span className="italic">{mentionedItemName}</span> 41 40 </> 42 41 ); 43 - } else if (props.mention_type === "document" && props.mentionedDocument) { 42 + } else if (props.mention_type === "document" && mentionedDocRecord) { 44 43 mentionedItemName = mentionedDocRecord.title; 45 44 actionText = ( 46 45 <>
+5 -4
app/(home-pages)/notifications/Notification.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 3 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 4 - import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { PubLeafletRichtextFacet } from "lexicons/api"; 5 5 import { timeAgo } from "src/utils/timeAgo"; 6 6 import { useReplicache, useEntity } from "src/replicache"; 7 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 7 8 8 9 export const Notification = (props: { 9 10 icon: React.ReactNode; ··· 58 59 59 60 export const ContentLayout = (props: { 60 61 children: React.ReactNode; 61 - postTitle: string; 62 - pubRecord?: PubLeafletPublication.Record; 62 + postTitle: string | undefined; 63 + pubRecord?: NormalizedPublication | null; 63 64 }) => { 64 65 let { rootEntity } = useReplicache(); 65 66 let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data ··· 77 78 <> 78 79 <hr className="mt-1 mb-1 border-border-light" /> 79 80 <a 80 - href={`https://${props.pubRecord.base_path}`} 81 + href={props.pubRecord.url} 81 82 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!" 82 83 > 83 84 {props.pubRecord.name}
+6 -5
app/(home-pages)/notifications/QuoteNotification.tsx
··· 1 1 import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedQuoteNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 4 import { AtUri } from "@atproto/api"; 6 5 import { Avatar } from "components/Avatar"; 7 6 ··· 9 8 const postView = props.bskyPost.post_view as any; 10 9 const author = postView.author; 11 10 const displayName = author.displayName || author.handle || "Someone"; 12 - const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 14 - ?.record as PubLeafletPublication.Record | undefined; 11 + const docRecord = props.normalizedDocument; 12 + const pubRecord = props.normalizedPublication; 13 + 14 + if (!docRecord) return null; 15 + 15 16 const docUri = new AtUri(props.document.uri); 16 17 const rkey = docUri.rkey; 17 18 const did = docUri.host; 18 19 const postText = postView.record?.text || ""; 19 20 20 21 const href = pubRecord 21 - ? `https://${pubRecord.base_path}/${rkey}` 22 + ? `${pubRecord.url}/${rkey}` 22 23 : `/p/${did}/${rkey}`; 23 24 24 25 return (
+16 -19
app/(home-pages)/notifications/ReplyNotification.tsx
··· 7 7 Notification, 8 8 } from "./Notification"; 9 9 import { HydratedCommentNotification } from "src/notifications"; 10 - import { 11 - PubLeafletComment, 12 - PubLeafletDocument, 13 - PubLeafletPublication, 14 - } from "lexicons/api"; 10 + import { PubLeafletComment } from "lexicons/api"; 15 11 import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 13 18 14 export const ReplyNotification = (props: HydratedCommentNotification) => { 19 - let docRecord = props.commentData.documents 20 - ?.data as PubLeafletDocument.Record; 21 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 22 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 23 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 24 22 const displayName = 25 - profileRecord.displayName || 23 + profileRecord?.displayName || 26 24 props.commentData.bsky_profiles?.handle || 27 25 "Someone"; 28 26 29 - let parentRecord = props.parentData?.record as PubLeafletComment.Record; 30 - let parentProfile = props.parentData?.bsky_profiles 27 + const parentRecord = props.parentData?.record as PubLeafletComment.Record; 28 + const parentProfile = props.parentData?.bsky_profiles 31 29 ?.record as AppBskyActorProfile.Record; 32 30 const parentDisplayName = 33 - parentProfile.displayName || 31 + parentProfile?.displayName || 34 32 props.parentData?.bsky_profiles?.handle || 35 33 "Someone"; 36 34 37 - let docUri = new AtUri(props.commentData.documents?.uri!); 38 - let rkey = docUri.rkey; 39 - let did = docUri.host; 40 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 - ?.publications?.record as PubLeafletPublication.Record | undefined; 35 + const docUri = new AtUri(props.commentData.documents?.uri!); 36 + const rkey = docUri.rkey; 37 + const did = docUri.host; 38 + const pubRecord = props.normalizedPublication; 42 39 43 40 const href = pubRecord 44 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 41 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 45 42 : `/p/${did}/${rkey}?interactionDrawer=comments`; 46 43 47 44 return (
+5 -13
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 - import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 3 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 4 import type { ProfileData } from "./layout"; 6 5 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 6 import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 7 import { PubIcon } from "components/ActionBar/Publications"; 9 - import { Json } from "supabase/database.types"; 8 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 10 9 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 11 10 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 11 import { SpeedyLink } from "components/SpeedyLink"; ··· 15 14 16 15 export const ProfileHeader = (props: { 17 16 profile: ProfileViewDetailed; 18 - publications: { record: Json; uri: string }[]; 17 + publications: { record: NormalizedPublication; uri: string }[]; 19 18 popover?: boolean; 20 19 }) => { 21 20 let profileRecord = props.profile; ··· 89 88 className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 90 89 > 91 90 {props.publications.map((p) => ( 92 - <PublicationCard 93 - key={p.uri} 94 - record={p.record as PubLeafletPublication.Record} 95 - uri={p.uri} 96 - /> 91 + <PublicationCard key={p.uri} record={p.record} uri={p.uri} /> 97 92 ))} 98 93 </div> 99 94 </div> ··· 114 109 </div> 115 110 ); 116 111 }; 117 - const PublicationCard = (props: { 118 - record: PubLeafletPublication.Record; 119 - uri: string; 120 - }) => { 112 + const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { 121 113 const { record, uri } = props; 122 114 const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 123 115 124 116 return ( 125 117 <a 126 - href={`https://${record.base_path}`} 118 + href={record.url} 127 119 className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 128 120 style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 129 121 >
+10 -2
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 5 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + import { 7 + normalizeDocumentRecord, 8 + normalizePublicationRecord, 9 + } from "src/utils/normalizeRecords"; 6 10 7 11 export type Cursor = { 8 12 indexed_at: string; ··· 58 62 let posts: Post[] = []; 59 63 60 64 for (let doc of docs || []) { 65 + // Normalize records - filter out unrecognized formats 66 + const normalizedData = normalizeDocumentRecord(doc.data); 67 + if (!normalizedData) continue; 68 + 61 69 let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 70 let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 71 64 72 let post: Post = { 65 73 author: handle, 66 74 documents: { 67 - data: doc.data, 75 + data: normalizedData, 68 76 uri: doc.uri, 69 77 indexed_at: doc.indexed_at, 70 78 comments_on_documents: doc.comments_on_documents, ··· 75 83 if (pub) { 76 84 post.publication = { 77 85 href: getPublicationURL(pub), 78 - pubRecord: pub.record, 86 + pubRecord: normalizePublicationRecord(pub.record), 79 87 uri: pub.uri, 80 88 }; 81 89 }
+44 -38
app/(home-pages)/reader/getReaderFeed.ts
··· 7 7 import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 8 import Client from "ioredis"; 9 9 import { AtUri } from "@atproto/api"; 10 - import { Json } from "supabase/database.types"; 11 10 import { idResolver } from "./idResolver"; 11 + import { 12 + normalizeDocumentRecord, 13 + normalizePublicationRecord, 14 + type NormalizedDocument, 15 + type NormalizedPublication, 16 + } from "src/utils/normalizeRecords"; 12 17 13 18 export type Cursor = { 14 19 timestamp: string; ··· 42 47 } 43 48 let { data: feed, error } = await query; 44 49 45 - let posts = await Promise.all( 46 - feed?.map(async (post) => { 47 - let pub = post.documents_in_publications[0].publications!; 48 - let uri = new AtUri(post.uri); 49 - let handle = await idResolver.did.resolve(uri.host); 50 - let p: Post = { 51 - publication: { 52 - href: getPublicationURL(pub), 53 - pubRecord: pub?.record || null, 54 - uri: pub?.uri || "", 55 - }, 56 - author: handle?.alsoKnownAs?.[0] 57 - ? `@${handle.alsoKnownAs[0].slice(5)}` 58 - : null, 59 - documents: { 60 - comments_on_documents: post.comments_on_documents, 61 - document_mentions_in_bsky: post.document_mentions_in_bsky, 62 - data: post.data, 63 - uri: post.uri, 64 - indexed_at: post.indexed_at, 65 - }, 66 - }; 67 - return p; 68 - }) || [], 69 - ); 50 + let posts = ( 51 + await Promise.all( 52 + feed?.map(async (post) => { 53 + let pub = post.documents_in_publications[0].publications!; 54 + let uri = new AtUri(post.uri); 55 + let handle = await idResolver.did.resolve(uri.host); 56 + 57 + // Normalize records - filter out unrecognized formats 58 + const normalizedData = normalizeDocumentRecord(post.data); 59 + if (!normalizedData) return null; 60 + 61 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 62 + 63 + let p: Post = { 64 + publication: { 65 + href: getPublicationURL(pub), 66 + pubRecord: normalizedPubRecord, 67 + uri: pub?.uri || "", 68 + }, 69 + author: handle?.alsoKnownAs?.[0] 70 + ? `@${handle.alsoKnownAs[0].slice(5)}` 71 + : null, 72 + documents: { 73 + comments_on_documents: post.comments_on_documents, 74 + document_mentions_in_bsky: post.document_mentions_in_bsky, 75 + data: normalizedData, 76 + uri: post.uri, 77 + indexed_at: post.indexed_at, 78 + }, 79 + }; 80 + return p; 81 + }) || [], 82 + ) 83 + ).filter((post): post is Post => post !== null); 70 84 const nextCursor = 71 85 posts.length > 0 72 86 ? { ··· 85 99 author: string | null; 86 100 publication?: { 87 101 href: string; 88 - pubRecord: Json; 102 + pubRecord: NormalizedPublication | null; 89 103 uri: string; 90 104 }; 91 105 documents: { 92 - data: Json; 106 + data: NormalizedDocument | null; 93 107 uri: string; 94 108 indexed_at: string; 95 - comments_on_documents: 96 - | { 97 - count: number; 98 - }[] 99 - | undefined; 100 - document_mentions_in_bsky: 101 - | { 102 - count: number; 103 - }[] 104 - | undefined; 109 + comments_on_documents: { count: number }[] | undefined; 110 + document_mentions_in_bsky: { count: number }[] | undefined; 105 111 }; 106 112 };
+23 -12
app/(home-pages)/reader/getSubscriptions.ts
··· 7 7 import { supabaseServerClient } from "supabase/serverClient"; 8 8 import { idResolver } from "./idResolver"; 9 9 import { Cursor } from "./getReaderFeed"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 10 14 11 15 export async function getSubscriptions( 12 16 did?: string | null, ··· 43 47 } 44 48 let { data: pubs, error } = await query; 45 49 46 - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 47 - pubs?.map(async (pub) => { 48 - let id = await idResolver.did.resolve(pub.publications?.identity_did!); 49 - return { 50 - ...pub.publications!, 51 - authorProfile: id?.alsoKnownAs?.[0] 52 - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 53 - : undefined, 54 - }; 55 - }) || [], 56 - ); 50 + const hydratedSubscriptions = ( 51 + await Promise.all( 52 + pubs?.map(async (pub) => { 53 + const normalizedRecord = normalizePublicationRecord( 54 + pub.publications?.record 55 + ); 56 + if (!normalizedRecord) return null; 57 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 58 + return { 59 + ...pub.publications!, 60 + record: normalizedRecord, 61 + authorProfile: id?.alsoKnownAs?.[0] 62 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 63 + : undefined, 64 + } as PublicationSubscription; 65 + }) || [] 66 + ) 67 + ).filter((sub): sub is PublicationSubscription => sub !== null); 57 68 58 69 const nextCursor = 59 70 pubs && pubs.length > 0 ··· 71 82 72 83 export type PublicationSubscription = { 73 84 authorProfile?: { handle: string }; 74 - record: Json; 85 + record: NormalizedPublication; 75 86 uri: string; 76 87 documents_in_publications: { 77 88 documents: { data?: Json; indexed_at: string } | null;
+14 -3
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 3 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { AtUri } from "@atproto/api"; 6 - import { Json } from "supabase/database.types"; 7 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 7 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 8 + import { 9 + normalizeDocumentRecord, 10 + normalizePublicationRecord, 11 + } from "src/utils/normalizeRecords"; 9 12 10 13 export async function getDocumentsByTag( 11 14 tag: string, ··· 37 40 return null; 38 41 } 39 42 43 + // Normalize the document data - skip unrecognized formats 44 + const normalizedData = normalizeDocumentRecord(doc.data); 45 + if (!normalizedData) { 46 + return null; 47 + } 48 + 49 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 50 + 40 51 const uri = new AtUri(doc.uri); 41 52 const handle = await idResolver.did.resolve(uri.host); 42 53 43 54 const post: Post = { 44 55 publication: { 45 56 href: getPublicationURL(pub), 46 - pubRecord: pub?.record || null, 57 + pubRecord: normalizedPubRecord, 47 58 uri: pub?.uri || "", 48 59 }, 49 60 author: handle?.alsoKnownAs?.[0] ··· 52 63 documents: { 53 64 comments_on_documents: doc.comments_on_documents, 54 65 document_mentions_in_bsky: doc.document_mentions_in_bsky, 55 - data: doc.data, 66 + data: normalizedData, 56 67 uri: doc.uri, 57 68 indexed_at: doc.indexed_at, 58 69 },
+2 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 22 22 import { SpeedyLink } from "components/SpeedyLink"; 23 23 import { useToaster } from "components/Toast"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 - import { PubLeafletPublication } from "lexicons/api"; 25 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 26 26 import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 27 import { useState, useMemo } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; ··· 370 370 </PubOption> 371 371 <hr className="border-border-light border-dashed " /> 372 372 {props.publications.map((p) => { 373 - let pubRecord = p.record as PubLeafletPublication.Record; 373 + let pubRecord = normalizePublicationRecord(p.record); 374 374 return ( 375 375 <PubOption 376 376 key={p.uri}
+1 -4
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 { PubLeafletDocument } from "lexicons/api"; 18 17 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 18 import { AtUri } from "@atproto/syntax"; 20 19 import { useIsMobile } from "src/hooks/isMobile"; ··· 88 87 isPub?: boolean; 89 88 }) => { 90 89 let { permission_token } = useReplicache(); 91 - let { data: pub } = useLeafletPublicationData(); 92 - 93 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 90 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 94 91 95 92 let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 93 let postLink = !docURI
+7 -7
app/[leaflet_id]/publish/PublishPost.tsx
··· 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 9 10 - import { PubLeafletPublication } from "lexicons/api"; 10 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; ··· 31 31 profile: ProfileViewDetailed; 32 32 description: string; 33 33 publication_uri?: string; 34 - record?: PubLeafletPublication.Record; 34 + record?: NormalizedPublication | null; 35 35 posts_in_pub?: number; 36 36 entitiesToDelete?: string[]; 37 37 hasDraft: boolean; ··· 127 127 } 128 128 129 129 // Generate post URL based on whether it's in a publication or standalone 130 - let post_url = props.record?.base_path 131 - ? `https://${props.record.base_path}/${result.rkey}` 130 + let post_url = props.record?.url 131 + ? `${props.record.url}/${result.rkey}` 132 132 : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 133 133 134 134 let [text, facets] = editorStateRef.current ··· 228 228 title: string; 229 229 profile: ProfileViewDetailed; 230 230 description: string; 231 - record?: PubLeafletPublication.Record; 231 + record?: NormalizedPublication | null; 232 232 }) => { 233 233 return ( 234 234 <div className="flex flex-col gap-2"> ··· 295 295 <div className="text-tertiary">{props.description}</div> 296 296 <hr className="border-border mt-2 mb-1" /> 297 297 <p className="text-xs text-tertiary"> 298 - {props.record?.base_path} 298 + {props.record?.url?.replace(/^https?:\/\//, "")} 299 299 </p> 300 300 </div> 301 301 </div> ··· 312 312 313 313 const PublishingTo = (props: { 314 314 publication_uri?: string; 315 - record?: PubLeafletPublication.Record; 315 + record?: NormalizedPublication | null; 316 316 }) => { 317 317 if (props.publication_uri && props.record) { 318 318 return (
+2 -2
app/[leaflet_id]/publish/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { PublishPost } from "./PublishPost"; 3 - import { PubLeafletPublication } from "lexicons/api"; 3 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 6 6 import { AtpAgent } from "@atproto/api"; ··· 118 118 title={title} 119 119 description={description} 120 120 publication_uri={publication?.uri} 121 - record={publication?.record as PubLeafletPublication.Record | undefined} 121 + record={normalizePublicationRecord(publication?.record)} 122 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 123 123 entitiesToDelete={entitiesToDelete} 124 124 hasDraft={hasDraft}
+10 -8
app/api/pub_icon/route.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { IdResolver } from "@atproto/identity"; 3 3 import { NextRequest, NextResponse } from "next/server"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 4 import { supabaseServerClient } from "supabase/serverClient"; 5 + import { 6 + normalizePublicationRecord, 7 + type NormalizedPublication, 8 + } from "src/utils/normalizeRecords"; 6 9 import sharp from "sharp"; 7 10 8 11 const idResolver = new IdResolver(); ··· 29 32 return new NextResponse(null, { status: 400 }); 30 33 } 31 34 32 - let publicationRecord: PubLeafletPublication.Record | null = null; 35 + let normalizedPub: NormalizedPublication | null = null; 33 36 let publicationUri: string; 34 37 35 38 // Check if it's a document or publication ··· 46 49 } 47 50 48 51 publicationUri = docInPub.publication; 49 - publicationRecord = docInPub.publications 50 - .record as PubLeafletPublication.Record; 52 + normalizedPub = normalizePublicationRecord(docInPub.publications.record); 51 53 } else if (uri.collection === "pub.leaflet.publication") { 52 54 // Query the publications table directly 53 55 const { data: publication } = await supabaseServerClient ··· 61 63 } 62 64 63 65 publicationUri = publication.uri; 64 - publicationRecord = publication.record as PubLeafletPublication.Record; 66 + normalizedPub = normalizePublicationRecord(publication.record); 65 67 } else { 66 68 // Not a supported collection 67 69 return new NextResponse(null, { status: 404 }); 68 70 } 69 71 70 72 // Check if the publication has an icon 71 - if (!publicationRecord?.icon) { 73 + if (!normalizedPub?.icon) { 72 74 // Generate a placeholder with the first letter of the publication name 73 - const firstLetter = (publicationRecord?.name || "?") 75 + const firstLetter = (normalizedPub?.name || "?") 74 76 .slice(0, 1) 75 77 .toUpperCase(); 76 78 ··· 94 96 const pubUri = new AtUri(publicationUri); 95 97 96 98 // Get the CID from the icon blob 97 - const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 99 + const cid = (normalizedPub.icon.ref as unknown as { $link: string })[ 98 100 "$link" 99 101 ]; 100 102
+10 -1
app/api/rpc/[command]/get_profile_data.ts
··· 6 6 import { Agent } from "@atproto/api"; 7 7 import { getIdentityData } from "actions/getIdentityData"; 8 8 import { createOauthClient } from "src/atproto-oauth"; 9 + import { 10 + normalizePublicationRow, 11 + hasValidPublication, 12 + } from "src/utils/normalizeRecords"; 9 13 10 14 export type GetProfileDataReturnType = Awaited< 11 15 ReturnType<(typeof get_profile_data)["handler"]> ··· 59 63 publicationsReq, 60 64 ]); 61 65 66 + // Normalize publication records before returning 67 + const normalizedPublications = (publications || []) 68 + .map(normalizePublicationRow) 69 + .filter(hasValidPublication); 70 + 62 71 return { 63 72 result: { 64 73 profile, 65 - publications: publications || [], 74 + publications: normalizedPublications, 66 75 }, 67 76 }; 68 77 },
+38 -1
app/api/rpc/[command]/get_publication_data.ts
··· 3 3 import type { Env } from "./route"; 4 4 import { AtUri } from "@atproto/syntax"; 5 5 import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 6 7 7 8 export type GetPublicationDataReturnType = Awaited< 8 9 ReturnType<(typeof get_publication_data)["handler"]> ··· 58 59 { supabase }, 59 60 ); 60 61 61 - return { result: { publication, leaflet_data: leaflet_data.result } }; 62 + // Pre-normalize documents from documents_in_publications 63 + const documents = (publication?.documents_in_publications || []) 64 + .map((dip) => { 65 + if (!dip.documents) return null; 66 + const normalized = normalizeDocumentRecord(dip.documents.data); 67 + if (!normalized) return null; 68 + return { 69 + uri: dip.documents.uri, 70 + record: normalized, 71 + indexed_at: dip.documents.indexed_at, 72 + data: dip.documents.data, 73 + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 74 + mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 75 + }; 76 + }) 77 + .filter((d): d is NonNullable<typeof d> => d !== null); 78 + 79 + // Pre-filter drafts (leaflets without published documents, not archived) 80 + const drafts = (publication?.leaflets_in_publications || []) 81 + .filter((l) => !l.documents) 82 + .filter((l) => !(l as { archived?: boolean }).archived) 83 + .map((l) => ({ 84 + leaflet: l.leaflet, 85 + title: l.title, 86 + permission_tokens: l.permission_tokens, 87 + // Keep the full leaflet data for LeafletList compatibility 88 + _raw: l, 89 + })); 90 + 91 + return { 92 + result: { 93 + publication, 94 + documents, 95 + drafts, 96 + leaflet_data: leaflet_data.result, 97 + }, 98 + }; 62 99 }, 63 100 });
+6 -10
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { CSSProperties, useContext, useRef } from "react"; 5 + import { CSSProperties, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 7 import { PostContent, Block } from "../PostContent"; 8 8 import { ··· 15 15 } from "lexicons/api"; 16 16 import { AppBskyFeedDefs } from "@atproto/api"; 17 17 import { TextBlock } from "./TextBlock"; 18 - import { PostPageContext } from "../PostPageContext"; 18 + import { useDocument } from "contexts/DocumentContext"; 19 19 import { openPage, useOpenPages } from "../PostPages"; 20 20 import { 21 21 openInteractionDrawer, ··· 155 155 }) { 156 156 let previewRef = useRef<HTMLDivElement | null>(null); 157 157 let { rootEntity } = useReplicache(); 158 - let data = useContext(PostPageContext); 159 - let theme = data?.theme; 158 + const { theme } = useDocument(); 160 159 let pageWidth = `var(--page-width-unitless)`; 161 160 let cardBorderHidden = !theme?.showPageBackground; 162 161 return ( ··· 195 194 } 196 195 197 196 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 - const data = useContext(PostPageContext); 199 - const document_uri = data?.uri; 200 - if (!document_uri) 201 - throw new Error("document_uri not available in PostPageContext"); 202 - let comments = data.comments_on_documents.filter( 197 + const { uri: document_uri, comments: allComments, mentions } = useDocument(); 198 + let comments = allComments.filter( 203 199 (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 200 ).length; 205 - let quotes = data.document_mentions_in_bsky.filter((q) => 201 + let quotes = mentions.filter((q) => 206 202 q.link.includes(props.pageId), 207 203 ).length; 208 204
+2 -2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 69 69 data={document} 70 70 profile={profile} 71 71 preferences={preferences} 72 - commentsCount={getCommentCount(document, pageId)} 73 - quotesCount={getQuoteCount(document, pageId)} 72 + commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 74 /> 75 75 <CanvasContent 76 76 blocks={blocks}
+34 -36
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 3 3 import { ids } from "lexicons/api/lexicons"; 4 4 import { 5 5 PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 6 PubLeafletPagesLinearDocument, 8 7 PubLeafletPagesCanvas, 9 - PubLeafletPublication, 10 8 } from "lexicons/api"; 11 9 import { QuoteHandler } from "./QuoteHandler"; 12 10 import { ··· 14 12 PublicationThemeProvider, 15 13 } from "components/ThemeManager/PublicationThemeProvider"; 16 14 import { getPostPageData } from "./getPostPageData"; 17 - import { PostPageContextProvider } from "./PostPageContext"; 18 15 import { PostPages } from "./PostPages"; 19 16 import { extractCodeBlocks } from "./extractCodeBlocks"; 20 17 import { LeafletLayout } from "components/LeafletLayout"; 21 18 import { fetchPollData } from "./fetchPollData"; 19 + import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; 20 + import { DocumentProvider } from "contexts/DocumentContext"; 21 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 22 22 23 23 export async function DocumentPageRenderer({ 24 24 did, ··· 41 41 agent.getProfile({ actor: did }), 42 42 ]); 43 43 44 - if (!document?.data) 44 + const record = document?.normalizedDocument; 45 + const pages = record ? getDocumentPages(record) : undefined; 46 + 47 + if (!document?.data || !record || !pages) 45 48 return ( 46 49 <div className="bg-bg-leaflet h-full p-3 text-center relative"> 47 50 <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> ··· 55 58 </div> 56 59 </div> 57 60 ); 58 - 59 - let record = document.data as PubLeafletDocument.Record; 60 61 let bskyPosts = 61 - record.pages.flatMap((p) => { 62 + pages.flatMap((p) => { 62 63 let page = p as PubLeafletPagesLinearDocument.Main; 63 64 return page.blocks?.filter( 64 65 (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, ··· 91 92 : []; 92 93 93 94 // Extract poll blocks and fetch vote data 94 - let pollBlocks = record.pages.flatMap((p) => { 95 + let pollBlocks = pages.flatMap((p) => { 95 96 let page = p as PubLeafletPagesLinearDocument.Main; 96 97 return ( 97 98 page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || ··· 102 103 pollBlocks.map((b) => (b.block as any).pollRef.uri), 103 104 ); 104 105 105 - // Get theme from publication or document (for standalone docs) 106 - let pubRecord = document.documents_in_publications[0]?.publications 107 - ?.record as PubLeafletPublication.Record | undefined; 108 - let theme = pubRecord?.theme || record.theme || null; 109 - let pub_creator = 110 - document.documents_in_publications[0]?.publications?.identity_did || did; 106 + const pubRecord = document.normalizedPublication; 107 + let pub_creator = document.publication?.identity_did || did; 111 108 let isStandalone = !pubRecord; 112 109 113 - let firstPage = record.pages[0]; 114 - 110 + let firstPage = pages[0]; 115 111 let firstPageBlocks = 116 112 ( 117 113 firstPage as ··· 121 117 let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 122 118 123 119 return ( 124 - <PostPageContextProvider value={document}> 125 - <PublicationThemeProvider theme={theme} pub_creator={pub_creator} isStandalone={isStandalone}> 126 - <PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}> 127 - <LeafletLayout> 128 - <PostPages 129 - document_uri={document.uri} 130 - preferences={pubRecord?.preferences || {}} 131 - pubRecord={pubRecord} 132 - profile={JSON.parse(JSON.stringify(profile.data))} 133 - document={document} 134 - bskyPostData={bskyPostData} 135 - did={did} 136 - prerenderedCodeBlocks={prerenderedCodeBlocks} 137 - pollData={pollData} 138 - /> 139 - </LeafletLayout> 120 + <DocumentProvider value={document}> 121 + <LeafletContentProvider value={{ pages }}> 122 + <PublicationThemeProvider theme={document.theme} pub_creator={pub_creator} isStandalone={isStandalone}> 123 + <PublicationBackgroundProvider theme={document.theme} pub_creator={pub_creator}> 124 + <LeafletLayout> 125 + <PostPages 126 + document_uri={document.uri} 127 + preferences={pubRecord?.preferences || {}} 128 + pubRecord={pubRecord} 129 + profile={JSON.parse(JSON.stringify(profile.data))} 130 + document={document} 131 + bskyPostData={bskyPostData} 132 + did={did} 133 + prerenderedCodeBlocks={prerenderedCodeBlocks} 134 + pollData={pollData} 135 + /> 136 + </LeafletLayout> 140 137 141 - <QuoteHandler /> 142 - </PublicationBackgroundProvider> 143 - </PublicationThemeProvider> 144 - </PostPageContextProvider> 138 + <QuoteHandler /> 139 + </PublicationBackgroundProvider> 140 + </PublicationThemeProvider> 141 + </LeafletContentProvider> 142 + </DocumentProvider> 145 143 ); 146 144 }
+27 -38
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 8 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 - import { useContext } from "react"; 10 - import { PostPageContext } from "../PostPageContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 11 10 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 11 import { TagTiny } from "components/Icons/TagTiny"; 13 12 import { Tag } from "components/Tags"; 14 13 import { Popover } from "components/Popover"; 15 - import { PostPageData } from "../getPostPageData"; 16 - import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 + import { PubLeafletComment } from "lexicons/api"; 15 + import { type CommentOnDocument } from "contexts/DocumentContext"; 17 16 import { prefetchQuotesData } from "./Quotes"; 18 17 import { useIdentityData } from "components/IdentityProvider"; 19 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 111 110 showMentions?: boolean; 112 111 pageId?: string; 113 112 }) => { 114 - const data = useContext(PostPageContext); 115 - const document_uri = data?.uri; 113 + const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 116 114 let { identity } = useIdentityData(); 117 - if (!document_uri) 118 - throw new Error("document_uri not available in PostPageContext"); 119 115 120 116 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 121 117 122 118 const handleQuotePrefetch = () => { 123 - if (data?.quotesAndMentions) { 124 - prefetchQuotesData(data.quotesAndMentions); 119 + if (quotesAndMentions) { 120 + prefetchQuotesData(quotesAndMentions); 125 121 } 126 122 }; 127 123 128 - const tags = (data?.data as any)?.tags as string[] | undefined; 124 + const tags = normalizedDocument.tags; 129 125 const tagCount = tags?.length || 0; 130 126 131 127 return ( ··· 172 168 showMentions?: boolean; 173 169 pageId?: string; 174 170 }) => { 175 - const data = useContext(PostPageContext); 171 + const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 176 172 let { identity } = useIdentityData(); 177 173 178 - const document_uri = data?.uri; 179 - if (!document_uri) 180 - throw new Error("document_uri not available in PostPageContext"); 181 - 182 174 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 183 175 184 176 const handleQuotePrefetch = () => { 185 - if (data?.quotesAndMentions) { 186 - prefetchQuotesData(data.quotesAndMentions); 177 + if (quotesAndMentions) { 178 + prefetchQuotesData(quotesAndMentions); 187 179 } 188 180 }; 189 - let publication = data?.documents_in_publications[0]?.publications; 190 181 191 - const tags = (data?.data as any)?.tags as string[] | undefined; 182 + const tags = normalizedDocument.tags; 192 183 const tagCount = tags?.length || 0; 193 184 194 185 let noInteractions = !props.showComments && !props.showMentions; ··· 202 193 203 194 let isAuthor = 204 195 identity && 205 - identity.atp_did === 206 - data.documents_in_publications[0]?.publications?.identity_did && 207 - data.leaflets_in_publications[0]; 196 + identity.atp_did === publication?.identity_did && 197 + leafletId; 208 198 209 199 return ( 210 200 <div ··· 299 289 </> 300 290 )} 301 291 302 - <EditButton document={data} /> 292 + <EditButton publication={publication} leafletId={leafletId} /> 303 293 {subscribed && publication && ( 304 294 <ManageSubscription 305 295 base_url={getPublicationURL(publication)} ··· 340 330 </div> 341 331 ); 342 332 }; 343 - export function getQuoteCount(document: PostPageData, pageId?: string) { 344 - if (!document) return; 345 - return getQuoteCountFromArray(document.quotesAndMentions, pageId); 333 + export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 334 + return getQuoteCountFromArray(quotesAndMentions, pageId); 346 335 } 347 336 348 337 export function getQuoteCountFromArray( ··· 366 355 } 367 356 } 368 357 369 - export function getCommentCount(document: PostPageData, pageId?: string) { 370 - if (!document) return; 358 + export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 371 359 if (pageId) 372 - return document.comments_on_documents.filter( 360 + return comments.filter( 373 361 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 374 362 ).length; 375 363 else 376 - return document.comments_on_documents.filter( 364 + return comments.filter( 377 365 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 378 366 ).length; 379 367 } 380 368 381 - const EditButton = (props: { document: PostPageData }) => { 369 + const EditButton = (props: { 370 + publication: { identity_did: string } | null; 371 + leafletId: string | null; 372 + }) => { 382 373 let { identity } = useIdentityData(); 383 - if (!props.document) return; 384 374 if ( 385 375 identity && 386 - identity.atp_did === 387 - props.document.documents_in_publications[0]?.publications?.identity_did && 388 - props.document.leaflets_in_publications[0] 376 + identity.atp_did === props.publication?.identity_did && 377 + props.leafletId 389 378 ) 390 379 return ( 391 380 <a 392 - href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 381 + href={`https://leaflet.pub/${props.leafletId}`} 393 382 className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 394 383 > 395 384 <EditTiny /> Edit Post 396 385 </a> 397 386 ); 398 - return; 387 + return null; 399 388 };
+7 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 1 1 "use client"; 2 2 import { CloseTiny } from "components/Icons/CloseTiny"; 3 - import { useContext } from "react"; 4 3 import { useIsMobile } from "src/hooks/isMobile"; 5 4 import { setInteractionState } from "./Interactions"; 6 5 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 6 import { AtUri, AppBskyFeedPost } from "@atproto/api"; 8 - import { PostPageContext } from "../PostPageContext"; 9 7 import { 10 8 PubLeafletBlocksText, 11 9 PubLeafletBlocksUnorderedList, 12 10 PubLeafletBlocksHeader, 13 - PubLeafletDocument, 14 11 PubLeafletPagesLinearDocument, 15 12 PubLeafletBlocksCode, 16 13 } from "lexicons/api"; 14 + import { useDocument } from "contexts/DocumentContext"; 15 + import { useLeafletContent } from "contexts/LeafletContentContext"; 17 16 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 18 17 import { useActiveHighlightState } from "../useHighlight"; 19 18 import { PostContent } from "../PostContent"; ··· 66 65 quotesAndMentions: { uri: string; link?: string }[]; 67 66 did: string; 68 67 }) => { 69 - let data = useContext(PostPageContext); 70 - const document_uri = data?.uri; 71 - if (!document_uri) 72 - throw new Error("document_uri not available in PostPageContext"); 68 + const { uri: document_uri } = useDocument(); 73 69 74 70 // Fetch Bluesky post data for all URIs 75 71 const uris = props.quotesAndMentions.map((q) => q.uri); ··· 182 178 did: string; 183 179 }) => { 184 180 let isMobile = useIsMobile(); 185 - const data = useContext(PostPageContext); 186 - const document_uri = data?.uri; 181 + const { uri: document_uri } = useDocument(); 182 + const { pages } = useLeafletContent(); 187 183 188 - let record = data?.data as PubLeafletDocument.Record; 189 184 let page: PubLeafletPagesLinearDocument.Main | undefined = ( 190 185 props.position.pageId 191 - ? record.pages.find( 186 + ? pages.find( 192 187 (p) => 193 188 (p as PubLeafletPagesLinearDocument.Main).id === 194 189 props.position.pageId, 195 190 ) 196 - : record.pages[0] 191 + : pages[0] 197 192 ) as PubLeafletPagesLinearDocument.Main; 198 193 // Extract blocks within the quote range 199 194 const content = extractQuotedBlocks(page.blocks || [], props.position, []);
+6 -13
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 3 + import { useLeafletContent } from "contexts/LeafletContentContext"; 8 4 import { PostPageData } from "./getPostPageData"; 9 5 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 6 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; ··· 49 45 hasPageBackground, 50 46 } = props; 51 47 let drawer = useDrawerOpen(document_uri); 48 + const { pages } = useLeafletContent(); 52 49 53 50 if (!document) return null; 54 51 55 - let record = document.data as PubLeafletDocument.Record; 56 - 57 52 const isSubpage = !!pageId; 58 - 59 - console.log("prev/next?: " + preferences.showPrevNext); 60 53 61 54 return ( 62 55 <> ··· 78 71 )} 79 72 <PostContent 80 73 pollData={pollData} 81 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 74 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 82 75 pageId={pageId} 83 76 bskyPostData={bskyPostData} 84 77 blocks={blocks} ··· 92 85 pageId={pageId} 93 86 showComments={preferences.showComments} 94 87 showMentions={preferences.showMentions} 95 - commentsCount={getCommentCount(document, pageId) || 0} 96 - quotesCount={getQuoteCount(document, pageId) || 0} 88 + commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 89 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 97 90 /> 98 91 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 99 92 </PageWrapper>
+5 -10
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 7 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 8 3 import { 9 4 Interactions, ··· 28 23 let { identity } = useIdentityData(); 29 24 let document = props.data; 30 25 31 - let record = document?.data as PubLeafletDocument.Record; 26 + const record = document?.normalizedDocument; 32 27 let profile = props.profile; 33 28 let pub = props.data?.documents_in_publications[0]?.publications; 34 29 35 30 const formattedDate = useLocalizedDate( 36 - record.publishedAt || new Date().toISOString(), 31 + record?.publishedAt || new Date().toISOString(), 37 32 { 38 33 year: "numeric", 39 34 month: "long", ··· 41 36 }, 42 37 ); 43 38 44 - if (!document?.data) return; 39 + if (!document?.data || !record) return null; 45 40 return ( 46 41 <PostHeaderLayout 47 42 pubLink={ ··· 92 87 <Interactions 93 88 showComments={props.preferences.showComments} 94 89 showMentions={props.preferences.showMentions} 95 - quotesCount={getQuoteCount(document) || 0} 96 - commentsCount={getCommentCount(document) || 0} 90 + quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 + commentsCount={getCommentCount(document?.comments_on_documents || []) || 0} 97 92 /> 98 93 </> 99 94 }
-19
app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx
··· 1 - "use client"; 2 - import { createContext } from "react"; 3 - import { PostPageData } from "./getPostPageData"; 4 - 5 - export const PostPageContext = createContext<PostPageData>(null); 6 - 7 - export const PostPageContextProvider = ({ 8 - children, 9 - value, 10 - }: { 11 - children: React.ReactNode; 12 - value: PostPageData; 13 - }) => { 14 - return ( 15 - <PostPageContext.Provider value={value}> 16 - {children} 17 - </PostPageContext.Provider> 18 - ); 19 - };
+12 -9
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 1 1 "use client"; 2 2 import { 3 - PubLeafletDocument, 4 3 PubLeafletPagesLinearDocument, 5 4 PubLeafletPagesCanvas, 6 5 PubLeafletPublication, 7 6 } from "lexicons/api"; 7 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 8 + import { useLeafletContent } from "contexts/LeafletContentContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 8 10 import { PostPageData } from "./getPostPageData"; 9 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 12 import { AppBskyFeedDefs } from "@atproto/api"; ··· 152 154 showMentions?: boolean; 153 155 showPrevNext?: boolean; 154 156 }; 155 - pubRecord?: PubLeafletPublication.Record; 157 + pubRecord?: NormalizedPublication | null; 156 158 theme?: PubLeafletPublication.Theme | null; 157 159 prerenderedCodeBlocks?: Map<string, string>; 158 160 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 206 208 document_uri: string; 207 209 document: PostPageData; 208 210 profile: ProfileViewDetailed; 209 - pubRecord?: PubLeafletPublication.Record; 211 + pubRecord?: NormalizedPublication | null; 210 212 did: string; 211 213 prerenderedCodeBlocks?: Map<string, string>; 212 214 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 220 222 let drawer = useDrawerOpen(document_uri); 221 223 useInitializeOpenPages(); 222 224 let openPageIds = useOpenPages(); 223 - if (!document) return null; 225 + const { pages } = useLeafletContent(); 226 + const { quotesAndMentions } = useDocument(); 227 + const record = document?.normalizedDocument; 228 + if (!document || !record) return null; 224 229 225 - let record = document.data as PubLeafletDocument.Record; 226 230 let theme = pubRecord?.theme || record.theme || null; 227 231 // For publication posts, respect the publication's showPageBackground setting 228 232 // For standalone documents, default to showing page background 229 233 let isInPublication = !!pubRecord; 230 234 let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; 231 - let quotesAndMentions = document.quotesAndMentions; 232 235 233 - let firstPage = record.pages[0] as 236 + let firstPage = pages[0] as 234 237 | PubLeafletPagesLinearDocument.Main 235 238 | PubLeafletPagesCanvas.Main; 236 239 ··· 250 253 pollData, 251 254 document_uri, 252 255 hasPageBackground, 253 - allPages: record.pages as ( 256 + allPages: pages as ( 254 257 | PubLeafletPagesLinearDocument.Main 255 258 | PubLeafletPagesCanvas.Main 256 259 )[], ··· 329 332 } 330 333 331 334 // Handle document pages 332 - let page = record.pages.find( 335 + let page = pages.find( 333 336 (p) => 334 337 ( 335 338 p as
+7 -13
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 1 "use client"; 2 - import { PubLeafletDocument } from "lexicons/api"; 3 - import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 3 import { AtUri } from "@atproto/api"; 6 - import { useParams } from "next/navigation"; 7 - import { getPostPageData } from "./getPostPageData"; 8 - import { PostPageContext } from "./PostPageContext"; 9 - import { useContext } from "react"; 4 + import { useDocument } from "contexts/DocumentContext"; 10 5 import { SpeedyLink } from "components/SpeedyLink"; 11 6 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 7 13 8 export const PostPrevNextButtons = (props: { 14 9 showPrevNext: boolean | undefined; 15 10 }) => { 16 - let postData = useContext(PostPageContext); 17 - let pub = postData?.documents_in_publications[0]?.publications; 11 + const { prevNext, publication } = useDocument(); 18 12 19 - if (!props.showPrevNext || !pub || !postData) return; 13 + if (!props.showPrevNext || !publication) return null; 20 14 21 15 function getPostLink(uri: string) { 22 - return pub && uri 23 - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 16 + return publication && uri 17 + ? `${getPublicationURL(publication)}/${new AtUri(uri).rkey}` 24 18 : "leaflet.pub/not-found"; 25 19 } 26 - let prevPost = postData?.prevNext?.prev; 27 - let nextPost = postData?.prevNext?.next; 20 + let prevPost = prevNext?.prev; 21 + let nextPost = prevNext?.next; 28 22 29 23 return ( 30 24 <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
+4 -10
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 3 3 import { CopyTiny } from "components/Icons/CopyTiny"; 4 4 import { Separator } from "components/Layout"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { useEffect, useMemo, useState, useContext } from "react"; 6 + import { useEffect, useMemo, useState } from "react"; 7 7 import { 8 8 encodeQuotePosition, 9 9 decodeQuotePosition, ··· 12 12 import { useIdentityData } from "components/IdentityProvider"; 13 13 import { CommentTiny } from "components/Icons/CommentTiny"; 14 14 import { setInteractionState } from "./Interactions/Interactions"; 15 - import { PostPageContext } from "./PostPageContext"; 16 - import { PubLeafletPublication } from "lexicons/api"; 15 + import { useDocument } from "contexts/DocumentContext"; 17 16 import { flushSync } from "react-dom"; 18 17 import { scrollIntoView } from "src/utils/scrollIntoView"; 19 18 ··· 148 147 export const QuoteOptionButtons = (props: { position: string }) => { 149 148 let smoker = useSmoker(); 150 149 let { identity } = useIdentityData(); 151 - const data = useContext(PostPageContext); 152 - const document_uri = data?.uri; 153 - if (!document_uri) 154 - throw new Error("document_uri not available in PostPageContext"); 150 + const { uri: document_uri, publication } = useDocument(); 155 151 let [url, position] = useMemo(() => { 156 152 let currentUrl = new URL(window.location.href); 157 153 let pos = decodeQuotePosition(props.position); ··· 169 165 currentUrl.hash = `#${fragmentId}`; 170 166 return [currentUrl.toString(), pos]; 171 167 }, [props.position]); 172 - let pubRecord = data.documents_in_publications[0]?.publications?.record as 173 - | PubLeafletPublication.Record 174 - | undefined; 168 + let pubRecord = publication?.record; 175 169 176 170 return ( 177 171 <>
+49 -21
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + normalizeDocumentRecord, 5 + normalizePublicationRecord, 6 + type NormalizedDocument, 7 + type NormalizedPublication, 8 + } from "src/utils/normalizeRecords"; 9 + import { PubLeafletPublication } from "lexicons/api"; 4 10 5 11 export async function getPostPageData(uri: string) { 6 12 let { data: document } = await supabaseServerClient ··· 23 29 24 30 if (!document) return null; 25 31 32 + // Normalize the document record - this is the primary way consumers should access document data 33 + const normalizedDocument = normalizeDocumentRecord(document.data); 34 + if (!normalizedDocument) return null; 35 + 36 + // Normalize the publication record - this is the primary way consumers should access publication data 37 + const normalizedPublication = normalizePublicationRecord( 38 + document.documents_in_publications[0]?.publications?.record 39 + ); 40 + 26 41 // Fetch constellation backlinks for mentions 27 - const pubRecord = document.documents_in_publications[0]?.publications 28 - ?.record as PubLeafletPublication.Record; 29 42 let aturi = new AtUri(uri); 30 - const postUrl = pubRecord 31 - ? `https://${pubRecord?.base_path}/${aturi.rkey}` 43 + const postUrl = normalizedPublication 44 + ? `${normalizedPublication.url}/${aturi.rkey}` 32 45 : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 33 46 const constellationBacklinks = await getConstellationBacklinks(postUrl); 34 47 ··· 48 61 ...uniqueBacklinks, 49 62 ]; 50 63 51 - let theme = 52 - ( 53 - document?.documents_in_publications[0]?.publications 54 - ?.record as PubLeafletPublication.Record 55 - )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 64 + let theme = normalizedPublication?.theme || normalizedDocument?.theme; 56 65 57 66 // Calculate prev/next documents from the fetched publication documents 58 67 let prevNext: ··· 62 71 } 63 72 | undefined; 64 73 65 - const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 - ?.publishedAt; 74 + const currentPublishedAt = normalizedDocument.publishedAt; 67 75 const allDocs = 68 76 document.documents_in_publications[0]?.publications 69 77 ?.documents_in_publications; ··· 71 79 if (currentPublishedAt && allDocs) { 72 80 // Filter and sort documents by publishedAt 73 81 const sortedDocs = allDocs 74 - .map((dip) => ({ 75 - uri: dip?.documents?.uri, 76 - title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 - .publishedAt, 79 - })) 80 - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 82 + .map((dip) => { 83 + const normalizedData = normalizeDocumentRecord(dip?.documents?.data); 84 + return { 85 + uri: dip?.documents?.uri, 86 + title: normalizedData?.title, 87 + publishedAt: normalizedData?.publishedAt, 88 + }; 89 + }) 90 + .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data 81 91 .sort( 82 92 (a, b) => 83 93 new Date(a.publishedAt!).getTime() - ··· 93 103 currentIndex > 0 94 104 ? { 95 105 uri: sortedDocs[currentIndex - 1].uri || "", 96 - title: sortedDocs[currentIndex - 1].title, 106 + title: sortedDocs[currentIndex - 1].title || "", 97 107 } 98 108 : undefined, 99 109 next: 100 110 currentIndex < sortedDocs.length - 1 101 111 ? { 102 112 uri: sortedDocs[currentIndex + 1].uri || "", 103 - title: sortedDocs[currentIndex + 1].title, 113 + title: sortedDocs[currentIndex + 1].title || "", 104 114 } 105 115 : undefined, 106 116 }; 107 117 } 108 118 } 109 119 120 + // Build explicit publication context for consumers 121 + const rawPub = document.documents_in_publications[0]?.publications; 122 + const publication = rawPub ? { 123 + uri: rawPub.uri, 124 + name: rawPub.name, 125 + identity_did: rawPub.identity_did, 126 + record: rawPub.record as PubLeafletPublication.Record | null, 127 + publication_subscriptions: rawPub.publication_subscriptions || [], 128 + } : null; 129 + 110 130 return { 111 131 ...document, 132 + // Pre-normalized data - consumers should use these instead of normalizing themselves 133 + normalizedDocument, 134 + normalizedPublication, 112 135 quotesAndMentions, 113 136 theme, 114 137 prevNext, 138 + // Explicit relational data for DocumentContext 139 + publication, 140 + comments: document.comments_on_documents, 141 + mentions: document.document_mentions_in_bsky, 142 + leafletId: document.leaflets_in_publications[0]?.leaflet || null, 115 143 }; 116 144 } 117 145
+3 -3
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { AtUri } from "@atproto/syntax"; 4 4 import { ids } from "lexicons/api/lexicons"; 5 - import { PubLeafletDocument } from "lexicons/api"; 6 5 import { jsonToLex } from "@atproto/lexicon"; 7 6 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 7 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 8 8 9 9 export const revalidate = 60; 10 10 ··· 22 22 .single(); 23 23 24 24 if (document) { 25 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 26 - if (docRecord.coverImage) { 25 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 26 + if (docRecord?.coverImage) { 27 27 try { 28 28 // Get CID from the blob ref (handle both serialized and hydrated forms) 29 29 let cid =
+3 -2
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { ids } from "lexicons/api/lexicons"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 4 import { Metadata } from "next"; 6 5 import { DocumentPageRenderer } from "./DocumentPageRenderer"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 7 8 8 export async function generateMetadata(props: { 9 9 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 21 21 ]); 22 22 if (!document) return { title: "404" }; 23 23 24 - let docRecord = document.data as PubLeafletDocument.Record; 24 + const docRecord = normalizeDocumentRecord(document.data); 25 + if (!docRecord) return { title: "404" }; 25 26 26 27 return { 27 28 icons: {
-3
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 2 2 "use client"; 3 3 4 4 import { useParams } from "next/navigation"; 5 - import { useContext } from "react"; 6 - import { PostPageContext } from "./PostPageContext"; 7 5 import { create } from "zustand"; 8 6 import { decodeQuotePosition, QuotePosition } from "./quotePosition"; 9 7 ··· 12 10 })); 13 11 14 12 export const useHighlight = (pos: number[], pageId?: string) => { 15 - let doc = useContext(PostPageContext); 16 13 let { quote } = useParams(); 17 14 let activeHighlight = useActiveHighlightState( 18 15 (state) => state.activeHighlight,
+32 -28
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 2 2 3 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 4 import React from "react"; 5 - import { usePublicationData } from "./PublicationSWRProvider"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + } from "./PublicationSWRProvider"; 6 9 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 10 8 11 export function DraftList(props: { ··· 10 13 showPageBackground: boolean; 11 14 }) { 12 15 let { data: pub_data } = usePublicationData(); 16 + // Normalize the publication record - skip rendering if unrecognized format 17 + const normalizedPubRecord = useNormalizedPublicationRecord(); 13 18 if (!pub_data?.publication) return null; 14 - let { leaflets_in_publications, ...publication } = pub_data.publication; 19 + const { drafts, leaflet_data } = pub_data; 20 + const { leaflets_in_publications, ...publication } = pub_data.publication; 21 + 22 + if (!normalizedPubRecord) return null; 23 + 15 24 return ( 16 25 <div className="flex flex-col gap-4"> 17 26 <NewDraftSecondaryButton ··· 23 32 searchValue={props.searchValue} 24 33 showPreview={false} 25 34 defaultDisplay="list" 26 - leaflets={leaflets_in_publications 27 - .filter((l) => !l.documents) 28 - .filter((l) => !l.archived) 29 - .map((l) => { 30 - return { 31 - archived: l.archived, 32 - added_at: "", 33 - token: { 34 - ...l.permission_tokens!, 35 - leaflets_in_publications: [ 36 - { 37 - ...l, 38 - publications: { 39 - ...publication, 40 - }, 41 - }, 42 - ], 43 - }, 44 - }; 45 - })} 46 - initialFacts={pub_data.leaflet_data.facts || {}} 35 + leaflets={drafts 36 + .filter((d) => d.permission_tokens) 37 + .map((d) => ({ 38 + archived: (d._raw as { archived?: boolean }).archived, 39 + added_at: "", 40 + token: { 41 + ...d.permission_tokens!, 42 + leaflets_in_publications: [ 43 + { 44 + ...d._raw, 45 + publications: publication, 46 + }, 47 + ], 48 + }, 49 + }))} 50 + initialFacts={leaflet_data.facts || {}} 47 51 titles={{ 48 - ...leaflets_in_publications.reduce( 49 - (acc, leaflet) => { 50 - if (leaflet.permission_tokens) 51 - acc[leaflet.permission_tokens.root_entity] = 52 - leaflet.title || "Untitled"; 52 + ...drafts.reduce( 53 + (acc, draft) => { 54 + if (draft.permission_tokens) 55 + acc[draft.permission_tokens.root_entity] = 56 + draft.title || "Untitled"; 53 57 return acc; 54 58 }, 55 59 {} as { [l: string]: string },
+2 -4
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 5 5 import { Actions } from "./Actions"; 6 6 import React, { useState } from "react"; 7 7 import { PublishedPostsList } from "./PublishedPostsLists"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { PublicationSubscribers } from "./PublicationSubscribers"; 10 - import { AtUri } from "@atproto/syntax"; 11 9 import { 12 - HomeDashboardControls, 13 10 DashboardLayout, 14 11 PublicationDashboardControls, 15 12 } from "components/PageLayouts/DashboardLayout"; 16 13 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 14 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 17 15 18 16 export default function PublicationDashboard({ 19 17 publication, 20 18 record, 21 19 }: { 22 - record: PubLeafletPublication.Record; 20 + record: NormalizedPublication; 23 21 publication: Exclude< 24 22 GetPublicationDataReturnType["result"]["publication"], 25 23 null
+22 -3
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext, useEffect } from "react"; 5 + import { createContext, useContext, useEffect, useMemo } from "react"; 6 6 import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 7 - import { produce, Draft } from "immer"; 7 + import { produce, Draft as ImmerDraft } from "immer"; 8 + import { 9 + normalizePublicationRecord, 10 + type NormalizedPublication, 11 + } from "src/utils/normalizeRecords"; 8 12 13 + // Derive all types from the RPC return type 9 14 export type PublicationData = GetPublicationDataReturnType["result"]; 15 + export type PublishedDocument = NonNullable<PublicationData>["documents"][number]; 16 + export type PublicationDraft = NonNullable<PublicationData>["drafts"][number]; 10 17 11 18 const PublicationContext = createContext({ name: "", did: "" }); 12 19 export function PublicationSWRDataProvider(props: { ··· 49 56 return { data, mutate }; 50 57 } 51 58 59 + /** 60 + * Returns the normalized publication record from the publication data. 61 + * Use this instead of manually calling normalizePublicationRecord on data.publication.record 62 + */ 63 + export function useNormalizedPublicationRecord(): NormalizedPublication | null { 64 + const { data } = usePublicationData(); 65 + return useMemo( 66 + () => normalizePublicationRecord(data?.publication?.record), 67 + [data?.publication?.record] 68 + ); 69 + } 70 + 52 71 export function mutatePublicationData( 53 72 mutate: KeyedMutator<PublicationData>, 54 - recipe: (draft: Draft<NonNullable<PublicationData>>) => void, 73 + recipe: (draft: ImmerDraft<NonNullable<PublicationData>>) => void, 55 74 ) { 56 75 mutate( 57 76 (data) => {
+123 -125
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 3 import { EditTiny } from "components/Icons/EditTiny"; 5 4 6 - import { usePublicationData } from "./PublicationSWRProvider"; 7 - import { Fragment, useState } from "react"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + type PublishedDocument, 9 + } from "./PublicationSWRProvider"; 10 + import { Fragment } from "react"; 8 11 import { useParams } from "next/navigation"; 9 12 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Menu"; 11 - import { deletePost } from "./deletePost"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 - import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 13 import { SpeedyLink } from "components/SpeedyLink"; 18 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 - import { CommentTiny } from "components/Icons/CommentTiny"; 20 14 import { InteractionPreview } from "components/InteractionsPreview"; 21 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 16 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; ··· 27 21 showPageBackground: boolean; 28 22 }) { 29 23 let { data } = usePublicationData(); 30 - let params = useParams(); 31 - let { publication } = data!; 32 - let pubRecord = publication?.record as PubLeafletPublication.Record; 24 + let { publication, documents } = data || {}; 25 + const pubRecord = useNormalizedPublicationRecord(); 33 26 34 27 if (!publication) return null; 35 - if (publication.documents_in_publications.length === 0) 28 + if (!documents || documents.length === 0) 36 29 return ( 37 30 <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 38 31 Nothing's been published yet... 39 32 </div> 40 33 ); 34 + 35 + // Sort by publishedAt (most recent first) 36 + const sortedDocuments = [...documents].sort((a, b) => { 37 + const aDate = a.record.publishedAt 38 + ? new Date(a.record.publishedAt) 39 + : new Date(0); 40 + const bDate = b.record.publishedAt 41 + ? new Date(b.record.publishedAt) 42 + : new Date(0); 43 + return bDate.getTime() - aDate.getTime(); 44 + }); 45 + 41 46 return ( 42 47 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 43 - {publication.documents_in_publications 44 - .sort((a, b) => { 45 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 46 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 47 - const aDate = aRecord.publishedAt 48 - ? new Date(aRecord.publishedAt) 49 - : new Date(0); 50 - const bDate = bRecord.publishedAt 51 - ? new Date(bRecord.publishedAt) 52 - : new Date(0); 53 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 54 - }) 55 - .map((doc) => { 56 - if (!doc.documents) return null; 57 - let leaflet = publication.leaflets_in_publications.find( 58 - (l) => doc.documents && l.doc === doc.documents.uri, 59 - ); 60 - let uri = new AtUri(doc.documents.uri); 61 - let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 - let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 - let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 - let tags = (postRecord?.tags as string[] | undefined) || []; 65 - 66 - let postLink = data?.publication 67 - ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` 68 - : ""; 48 + {sortedDocuments.map((doc) => ( 49 + <PublishedPostItem 50 + key={doc.uri} 51 + doc={doc} 52 + publication={publication} 53 + pubRecord={pubRecord} 54 + showPageBackground={props.showPageBackground} 55 + /> 56 + ))} 57 + </div> 58 + ); 59 + } 69 60 70 - return ( 71 - <Fragment key={doc.documents?.uri}> 72 - <div className="flex gap-2 w-full "> 73 - <div 74 - className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${props.showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 75 - style={{ 76 - backgroundColor: props.showPageBackground 77 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 78 - : "transparent", 79 - }} 80 - > 81 - <div className="flex justify-between gap-2"> 82 - <a 83 - className="hover:no-underline!" 84 - target="_blank" 85 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 - > 87 - <h3 className="text-primary grow leading-snug"> 88 - {postRecord.title} 89 - </h3> 90 - </a> 91 - <div className="flex justify-start align-top flex-row gap-1"> 92 - {leaflet && leaflet.permission_tokens && ( 93 - <> 94 - <SpeedyLink 95 - className="pt-[6px]" 96 - href={`/${leaflet.leaflet}`} 97 - > 98 - <EditTiny /> 99 - </SpeedyLink> 61 + function PublishedPostItem(props: { 62 + doc: PublishedDocument; 63 + publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 64 + pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 + showPageBackground: boolean; 66 + }) { 67 + const { doc, publication, pubRecord, showPageBackground } = props; 68 + const uri = new AtUri(doc.uri); 69 + const leaflet = publication.leaflets_in_publications.find( 70 + (l) => l.doc === doc.uri, 71 + ); 100 72 101 - <StaticLeafletDataContext 102 - value={{ 103 - ...leaflet.permission_tokens, 104 - leaflets_in_publications: [ 105 - { 106 - ...leaflet, 107 - publications: publication, 108 - documents: doc.documents 109 - ? { 110 - uri: doc.documents.uri, 111 - indexed_at: doc.documents.indexed_at, 112 - data: doc.documents.data, 113 - } 114 - : null, 115 - }, 116 - ], 117 - leaflets_to_documents: [], 118 - blocked_by_admin: null, 119 - custom_domain_routes: [], 120 - }} 121 - > 122 - <LeafletOptions loggedIn={true} /> 123 - </StaticLeafletDataContext> 124 - </> 125 - )} 126 - </div> 127 - </div> 73 + return ( 74 + <Fragment> 75 + <div className="flex gap-2 w-full "> 76 + <div 77 + className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 78 + style={{ 79 + backgroundColor: showPageBackground 80 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 81 + : "transparent", 82 + }} 83 + > 84 + <div className="flex justify-between gap-2"> 85 + <a 86 + className="hover:no-underline!" 87 + target="_blank" 88 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 89 + > 90 + <h3 className="text-primary grow leading-snug"> 91 + {doc.record.title} 92 + </h3> 93 + </a> 94 + <div className="flex justify-start align-top flex-row gap-1"> 95 + {leaflet && leaflet.permission_tokens && ( 96 + <> 97 + <SpeedyLink 98 + className="pt-[6px]" 99 + href={`/${leaflet.leaflet}`} 100 + > 101 + <EditTiny /> 102 + </SpeedyLink> 128 103 129 - {postRecord.description ? ( 130 - <p className="italic text-secondary"> 131 - {postRecord.description} 132 - </p> 133 - ) : null} 134 - <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 - {postRecord.publishedAt ? ( 136 - <PublishedDate dateString={postRecord.publishedAt} /> 137 - ) : null} 138 - <InteractionPreview 139 - quotesCount={quotes} 140 - commentsCount={comments} 141 - tags={tags} 142 - showComments={pubRecord?.preferences?.showComments} 143 - showMentions={pubRecord?.preferences?.showMentions} 144 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 145 - /> 146 - </div> 147 - </div> 148 - </div> 149 - {!props.showPageBackground && ( 150 - <hr className="last:hidden border-border-light" /> 104 + <StaticLeafletDataContext 105 + value={{ 106 + ...leaflet.permission_tokens, 107 + leaflets_in_publications: [ 108 + { 109 + ...leaflet, 110 + publications: publication, 111 + documents: { 112 + uri: doc.uri, 113 + indexed_at: doc.indexed_at, 114 + data: doc.data, 115 + }, 116 + }, 117 + ], 118 + leaflets_to_documents: [], 119 + blocked_by_admin: null, 120 + custom_domain_routes: [], 121 + }} 122 + > 123 + <LeafletOptions loggedIn={true} /> 124 + </StaticLeafletDataContext> 125 + </> 151 126 )} 152 - </Fragment> 153 - ); 154 - })} 155 - </div> 127 + </div> 128 + </div> 129 + 130 + {doc.record.description ? ( 131 + <p className="italic text-secondary"> 132 + {doc.record.description} 133 + </p> 134 + ) : null} 135 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 136 + {doc.record.publishedAt ? ( 137 + <PublishedDate dateString={doc.record.publishedAt} /> 138 + ) : null} 139 + <InteractionPreview 140 + quotesCount={doc.mentionsCount} 141 + commentsCount={doc.commentsCount} 142 + tags={doc.record.tags || []} 143 + showComments={pubRecord?.preferences?.showComments} 144 + showMentions={pubRecord?.preferences?.showMentions} 145 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 146 + /> 147 + </div> 148 + </div> 149 + </div> 150 + {!showPageBackground && ( 151 + <hr className="last:hidden border-border-light" /> 152 + )} 153 + </Fragment> 156 154 ); 157 155 } 158 156
+3 -5
app/lish/[did]/[publication]/dashboard/page.tsx
··· 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 4 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 5 import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 6 - import { PubLeafletPublication } from "lexicons/api"; 7 6 import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 8 7 import { AtUri } from "@atproto/syntax"; 9 8 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 10 9 import PublicationDashboard from "./PublicationDashboard"; 11 - import Link from "next/link"; 10 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 12 11 13 12 export async function generateMetadata(props: { 14 13 params: Promise<{ publication: string; did: string }>; ··· 24 23 { supabase: supabaseServerClient }, 25 24 ); 26 25 let { publication } = publication_data; 27 - let record = 28 - (publication?.record as PubLeafletPublication.Record) || undefined; 26 + const record = normalizePublicationRecord(publication?.record); 29 27 if (!publication) return { title: "404 Publication" }; 30 28 return { title: record?.name || "Untitled Publication" }; 31 29 } ··· 56 54 { supabase: supabaseServerClient }, 57 55 ); 58 56 let { publication, leaflet_data } = publication_data; 59 - let record = publication?.record as PubLeafletPublication.Record | null; 57 + const record = normalizePublicationRecord(publication?.record); 60 58 61 59 if (!publication || identity.atp_did !== publication.identity_did || !record) 62 60 return <PubNotFound />;
+6 -4
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 - import { usePublicationData } from "../PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "../PublicationSWRProvider"; 3 5 import { PubSettingsHeader } from "./PublicationSettings"; 4 6 import { useState } from "react"; 5 7 import { Toggle } from "components/Toggle"; ··· 15 17 let { data } = usePublicationData(); 16 18 17 19 let { publication: pubData } = data || {}; 18 - let record = pubData?.record as PubLeafletPublication.Record; 20 + const record = useNormalizedPublicationRecord(); 19 21 20 22 let [showComments, setShowComments] = useState( 21 23 record?.preferences?.showComments === undefined ··· 37 39 return ( 38 40 <form 39 41 onSubmit={async (e) => { 40 - if (!pubData) return; 42 + if (!pubData || !record) return; 41 43 e.preventDefault(); 42 44 props.setLoading(true); 43 45 let data = await updatePublication({
+24 -20
app/lish/[did]/[publication]/generateFeed.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { Feed } from "feed"; 3 - import { 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 3 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 8 4 import { createElement } from "react"; 9 5 import { StaticPostContent } from "./[rkey]/StaticPostContent"; 10 6 import { supabaseServerClient } from "supabase/serverClient"; 11 7 import { NextResponse } from "next/server"; 8 + import { 9 + normalizePublicationRecord, 10 + normalizeDocumentRecord, 11 + hasLeafletContent, 12 + } from "src/utils/normalizeRecords"; 12 13 13 14 export async function generateFeed( 14 15 did: string, ··· 37 38 .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 38 39 .single(); 39 40 40 - let pubRecord = publication?.record as PubLeafletPublication.Record; 41 + const pubRecord = normalizePublicationRecord(publication?.record); 41 42 if (!publication || !pubRecord) 42 43 return new NextResponse(null, { status: 404 }); 43 44 44 45 const feed = new Feed({ 45 46 title: pubRecord.name, 46 47 description: pubRecord.description, 47 - id: `https://${pubRecord.base_path}`, 48 - link: `https://${pubRecord.base_path}`, 48 + id: pubRecord.url, 49 + link: pubRecord.url, 49 50 language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 50 51 copyright: "", 51 52 feedLinks: { 52 - rss: `https://${pubRecord.base_path}/rss`, 53 - atom: `https://${pubRecord.base_path}/atom`, 54 - json: `https://${pubRecord.base_path}/json`, 53 + rss: `${pubRecord.url}/rss`, 54 + atom: `${pubRecord.url}/atom`, 55 + json: `${pubRecord.url}/json`, 55 56 }, 56 57 }); 57 58 58 59 await Promise.all( 59 60 publication.documents_in_publications.map(async (doc) => { 60 61 if (!doc.documents) return; 61 - let record = doc.documents?.data as PubLeafletDocument.Record; 62 - let uri = new AtUri(doc.documents?.uri); 63 - let rkey = uri.rkey; 62 + const record = normalizeDocumentRecord(doc.documents?.data); 63 + const uri = new AtUri(doc.documents?.uri); 64 + const rkey = uri.rkey; 64 65 if (!record) return; 65 - let firstPage = record.pages[0]; 66 + 66 67 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 67 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 68 - blocks = firstPage.blocks || []; 68 + if (hasLeafletContent(record) && record.content.pages[0]) { 69 + const firstPage = record.content.pages[0]; 70 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 71 + blocks = firstPage.blocks || []; 72 + } 69 73 } 70 - let stream = await renderToReadableStream( 74 + const stream = await renderToReadableStream( 71 75 createElement(StaticPostContent, { blocks, did: uri.host }), 72 76 ); 73 77 const reader = stream.getReader(); ··· 85 89 title: record.title, 86 90 description: record.description, 87 91 date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 88 - id: `https://${pubRecord.base_path}/${rkey}`, 89 - link: `https://${pubRecord.base_path}/${rkey}`, 92 + id: `${pubRecord.url}/${rkey}`, 93 + link: `${pubRecord.url}/${rkey}`, 90 94 content: chunks.join(""), 91 95 }); 92 96 }),
+2 -2
app/lish/[did]/[publication]/icon/route.ts
··· 1 1 import { NextRequest } from "next/server"; 2 2 import { IdResolver } from "@atproto/identity"; 3 3 import { AtUri } from "@atproto/syntax"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 4 import { supabaseServerClient } from "supabase/serverClient"; 6 5 import sharp from "sharp"; 7 6 import { redirect } from "next/navigation"; 7 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 8 8 9 9 let idResolver = new IdResolver(); 10 10 ··· 38 38 .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 39 39 .single(); 40 40 41 - let record = publication?.record as PubLeafletPublication.Record | null; 41 + const record = normalizePublicationRecord(publication?.record); 42 42 if (!record?.icon) return redirect("/icon.png"); 43 43 44 44 let identity = await idResolver.did.resolve(did);
+6 -6
app/lish/[did]/[publication]/layout.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 1 import { supabaseServerClient } from "supabase/serverClient"; 3 2 import { Metadata } from "next"; 4 3 import { AtUri } from "@atproto/syntax"; 4 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 5 5 6 6 export default async function PublicationLayout(props: { 7 7 children: React.ReactNode; ··· 41 41 .single(); 42 42 if (!publication) return { title: "Publication 404" }; 43 43 44 - let pubRecord = publication?.record as PubLeafletPublication.Record; 44 + const pubRecord = normalizePublicationRecord(publication?.record); 45 45 46 46 return { 47 47 title: pubRecord?.name || "Untitled Publication", ··· 60 60 url: publication.uri, 61 61 }, 62 62 }, 63 - alternates: pubRecord?.base_path 63 + alternates: pubRecord?.url 64 64 ? { 65 65 types: { 66 - "application/rss+xml": `https://${pubRecord?.base_path}/rss`, 67 - "application/atom+xml": `https://${pubRecord?.base_path}/atom`, 68 - "application/json": `https://${pubRecord?.base_path}/json`, 66 + "application/rss+xml": `${pubRecord.url}/rss`, 67 + "application/atom+xml": `${pubRecord.url}/atom`, 68 + "application/json": `${pubRecord.url}/json`, 69 69 }, 70 70 } 71 71 : undefined,
+12 -12
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 - import Link from "next/link"; 5 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 4 import { BskyAgent } from "@atproto/api"; 7 5 import { SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 12 10 } from "components/ThemeManager/PublicationThemeProvider"; 13 11 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 14 12 import { SpeedyLink } from "components/SpeedyLink"; 15 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 - import { CommentTiny } from "components/Icons/CommentTiny"; 17 13 import { InteractionPreview } from "components/InteractionsPreview"; 18 14 import { LocalizedDate } from "./LocalizedDate"; 19 15 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 16 import { PublicationAuthor } from "./PublicationAuthor"; 21 17 import { Separator } from "components/Layout"; 18 + import { 19 + normalizePublicationRecord, 20 + normalizeDocumentRecord, 21 + } from "src/utils/normalizeRecords"; 22 22 23 23 export default async function Publication(props: { 24 24 params: Promise<{ publication: string; did: string }>; ··· 55 55 agent.getProfile({ actor: did }), 56 56 ]); 57 57 58 - let record = publication?.record as PubLeafletPublication.Record | null; 58 + const record = normalizePublicationRecord(publication?.record); 59 59 60 60 let showPageBackground = record?.theme?.showPageBackground; 61 61 ··· 112 112 {publication.documents_in_publications 113 113 .filter((d) => !!d?.documents) 114 114 .sort((a, b) => { 115 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 116 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 117 - const aDate = aRecord.publishedAt 115 + const aRecord = normalizeDocumentRecord(a.documents?.data); 116 + const bRecord = normalizeDocumentRecord(b.documents?.data); 117 + const aDate = aRecord?.publishedAt 118 118 ? new Date(aRecord.publishedAt) 119 119 : new Date(0); 120 - const bDate = bRecord.publishedAt 120 + const bDate = bRecord?.publishedAt 121 121 ? new Date(bRecord.publishedAt) 122 122 : new Date(0); 123 123 return bDate.getTime() - aDate.getTime(); // Sort by most recent first 124 124 }) 125 125 .map((doc) => { 126 126 if (!doc.documents) return null; 127 + const doc_record = normalizeDocumentRecord(doc.documents.data); 128 + if (!doc_record) return null; 127 129 let uri = new AtUri(doc.documents.uri); 128 - let doc_record = doc.documents 129 - .data as PubLeafletDocument.Record; 130 130 let quotes = 131 131 doc.documents.document_mentions_in_bsky[0].count || 0; 132 132 let comments = 133 133 record?.preferences?.showComments === false 134 134 ? 0 135 135 : doc.documents.comments_on_documents[0].count || 0; 136 - let tags = (doc_record?.tags as string[] | undefined) || []; 136 + let tags = doc_record.tags || []; 137 137 138 138 return ( 139 139 <React.Fragment key={doc.documents?.uri}>
+10 -7
app/lish/createPub/UpdatePubForm.tsx
··· 7 7 updatePublication, 8 8 updatePublicationBasePath, 9 9 } from "./updatePublication"; 10 - import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 11 - import { PubLeafletPublication } from "lexicons/api"; 10 + import { 11 + usePublicationData, 12 + useNormalizedPublicationRecord, 13 + } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 12 14 import useSWR, { mutate } from "swr"; 13 15 import { AddTiny } from "components/Icons/AddTiny"; 14 16 import { DotLoader } from "components/utils/DotLoader"; ··· 30 32 }) => { 31 33 let { data } = usePublicationData(); 32 34 let { publication: pubData } = data || {}; 33 - let record = pubData?.record as PubLeafletPublication.Record; 35 + let record = useNormalizedPublicationRecord(); 34 36 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 35 37 36 38 let [nameValue, setNameValue] = useState(record?.name || ""); ··· 60 62 let [iconPreview, setIconPreview] = useState<string | null>(null); 61 63 let fileInputRef = useRef<HTMLInputElement>(null); 62 64 useEffect(() => { 63 - if (!pubData || !pubData.record) return; 65 + if (!pubData || !pubData.record || !record) return; 64 66 setNameValue(record.name); 65 67 setDescriptionValue(record.description || ""); 66 68 if (record.icon) 67 69 setIconPreview( 68 70 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, 69 71 ); 70 - }, [pubData]); 72 + }, [pubData, record]); 71 73 let toast = useToaster(); 72 74 73 75 return ( ··· 202 204 export function CustomDomainForm() { 203 205 let { data } = usePublicationData(); 204 206 let { publication: pubData } = data || {}; 207 + let record = useNormalizedPublicationRecord(); 205 208 if (!pubData) return null; 206 - let record = pubData?.record as PubLeafletPublication.Record; 209 + if (!record) return null; 207 210 let [state, setState] = useState< 208 211 | { type: "default" } 209 212 | { type: "addDomain" } ··· 243 246 <Domain 244 247 domain={d.domain} 245 248 publication_uri={pubData.uri} 246 - base_path={record.base_path || ""} 249 + base_path={record.url.replace(/^https?:\/\//, "")} 247 250 setDomain={(v) => { 248 251 setState({ 249 252 type: "domainSettings",
+34 -9
app/lish/createPub/getPublicationURL.ts
··· 2 2 import { PubLeafletPublication } from "lexicons/api"; 3 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 4 import { Json } from "supabase/database.types"; 5 + import { 6 + normalizePublicationRecord, 7 + isLeafletPublication, 8 + type NormalizedPublication, 9 + } from "src/utils/normalizeRecords"; 5 10 6 - export function getPublicationURL(pub: { uri: string; record: Json }) { 7 - let record = pub.record as PubLeafletPublication.Record; 8 - if (isProductionDomain() && record?.base_path) 9 - return `https://${record.base_path}`; 10 - else return getBasePublicationURL(pub); 11 + type PublicationInput = 12 + | { uri: string; record: Json | NormalizedPublication | null } 13 + | { uri: string; record: unknown }; 14 + 15 + /** 16 + * Gets the public URL for a publication. 17 + * Works with both pub.leaflet.publication and site.standard.publication records. 18 + */ 19 + export function getPublicationURL(pub: PublicationInput): string { 20 + const normalized = normalizePublicationRecord(pub.record); 21 + 22 + // If we have a normalized record with a URL (site.standard format), use it 23 + if (normalized?.url && isProductionDomain()) { 24 + return normalized.url; 25 + } 26 + 27 + // Fall back to checking raw record for legacy base_path 28 + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { 29 + return `https://${pub.record.base_path}`; 30 + } 31 + 32 + return getBasePublicationURL(pub); 11 33 } 12 34 13 - export function getBasePublicationURL(pub: { uri: string; record: Json }) { 14 - let record = pub.record as PubLeafletPublication.Record; 15 - let aturi = new AtUri(pub.uri); 16 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 35 + export function getBasePublicationURL(pub: PublicationInput): string { 36 + const normalized = normalizePublicationRecord(pub.record); 37 + const aturi = new AtUri(pub.uri); 38 + 39 + // Use normalized name if available, fall back to rkey 40 + const name = normalized?.name || aturi.rkey; 41 + return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 17 42 }
+46 -4
app/lish/createPub/updatePublication.ts
··· 11 11 import { Json } from "supabase/database.types"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { $Typed } from "@atproto/api"; 14 + import { 15 + normalizePublicationRecord, 16 + type NormalizedPublication, 17 + } from "src/utils/normalizeRecords"; 14 18 15 19 type UpdatePublicationResult = 16 20 | { success: true; publication: any } ··· 143 147 } 144 148 let aturi = new AtUri(existingPub.uri); 145 149 150 + // Normalize the existing record to read its properties, then build a new pub.leaflet record 151 + const normalizedPub = normalizePublicationRecord(existingPub.record); 152 + // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 153 + const existingBasePath = normalizedPub?.url 154 + ? normalizedPub.url.replace(/^https?:\/\//, "") 155 + : undefined; 156 + 146 157 let record: PubLeafletPublication.Record = { 147 - ...(existingPub.record as PubLeafletPublication.Record), 158 + $type: "pub.leaflet.publication", 159 + name: normalizedPub?.name || "", 160 + description: normalizedPub?.description, 161 + icon: normalizedPub?.icon, 162 + theme: normalizedPub?.theme, 163 + preferences: normalizedPub?.preferences 164 + ? { 165 + $type: "pub.leaflet.publication#preferences" as const, 166 + showInDiscover: normalizedPub.preferences.showInDiscover, 167 + showComments: normalizedPub.preferences.showComments, 168 + showMentions: normalizedPub.preferences.showMentions, 169 + showPrevNext: normalizedPub.preferences.showPrevNext, 170 + } 171 + : undefined, 148 172 base_path, 149 173 }; 150 174 ··· 219 243 } 220 244 let aturi = new AtUri(existingPub.uri); 221 245 222 - let oldRecord = existingPub.record as PubLeafletPublication.Record; 246 + // Normalize the existing record to read its properties 247 + const normalizedPub = normalizePublicationRecord(existingPub.record); 248 + // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 249 + const existingBasePath = normalizedPub?.url 250 + ? normalizedPub.url.replace(/^https?:\/\//, "") 251 + : undefined; 252 + 223 253 let record: PubLeafletPublication.Record = { 224 - ...oldRecord, 225 254 $type: "pub.leaflet.publication", 255 + name: normalizedPub?.name || "", 256 + description: normalizedPub?.description, 257 + icon: normalizedPub?.icon, 258 + base_path: existingBasePath, 259 + preferences: normalizedPub?.preferences 260 + ? { 261 + $type: "pub.leaflet.publication#preferences" as const, 262 + showInDiscover: normalizedPub.preferences.showInDiscover, 263 + showComments: normalizedPub.preferences.showComments, 264 + showMentions: normalizedPub.preferences.showMentions, 265 + showPrevNext: normalizedPub.preferences.showPrevNext, 266 + } 267 + : undefined, 226 268 theme: { 227 269 backgroundImage: theme.backgroundImage 228 270 ? { ··· 238 280 } 239 281 : theme.backgroundImage === null 240 282 ? undefined 241 - : oldRecord.theme?.backgroundImage, 283 + : normalizedPub?.theme?.backgroundImage, 242 284 backgroundColor: theme.backgroundColor 243 285 ? { 244 286 ...theme.backgroundColor,
+7 -4
app/lish/feeds/[...path]/route.ts
··· 2 2 import { DidResolver } from "@atproto/identity"; 3 3 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - import { PubLeafletDocument } from "lexicons/api"; 5 + import { 6 + normalizeDocumentRecord, 7 + type NormalizedDocument, 8 + } from "src/utils/normalizeRecords"; 6 9 7 10 const serviceDid = "did:web:leaflet.pub:lish:feeds"; 8 11 export async function GET( ··· 34 37 let posts = pub.publications?.documents_in_publications || []; 35 38 return posts.flatMap((p) => { 36 39 if (!p.documents?.data) return []; 37 - let record = p.documents.data as PubLeafletDocument.Record; 38 - if (!record.postRef) return []; 39 - return { post: record.postRef.uri }; 40 + const normalizedDoc = normalizeDocumentRecord(p.documents.data); 41 + if (!normalizedDoc?.bskyPostRef) return []; 42 + return { post: normalizedDoc.bskyPostRef.uri }; 40 43 }); 41 44 }), 42 45 ],
+20 -22
app/lish/uri/[uri]/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 2 import { AtUri } from "@atproto/api"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { PubLeafletPublication } from "lexicons/api"; 4 + import { 5 + normalizePublicationRecord, 6 + type NormalizedPublication, 7 + } from "src/utils/normalizeRecords"; 5 8 6 9 /** 7 10 * Redirect route for AT URIs (publications and documents) ··· 28 31 return new NextResponse("Publication not found", { status: 404 }); 29 32 } 30 33 31 - const record = publication.record as PubLeafletPublication.Record; 32 - const basePath = record.base_path; 33 - 34 - if (!basePath) { 35 - return new NextResponse("Publication has no base_path", { 34 + const normalizedPub = normalizePublicationRecord(publication.record); 35 + if (!normalizedPub?.url) { 36 + return new NextResponse("Publication has no url", { 36 37 status: 404, 37 38 }); 38 39 } 39 40 40 - // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 41 - return NextResponse.redirect(basePath, 307); 41 + // Redirect to the publication's hosted domain (temporary redirect since url can change) 42 + return NextResponse.redirect(normalizedPub.url, 307); 42 43 } else if (uri.collection === "pub.leaflet.document") { 43 44 // Document link - need to find the publication it belongs to 44 45 const { data: docInPub } = await supabaseServerClient ··· 49 50 50 51 if (docInPub?.publication && docInPub.publications) { 51 52 // Document is in a publication - redirect to domain/rkey 52 - const record = docInPub.publications 53 - .record as PubLeafletPublication.Record; 54 - const basePath = record.base_path; 53 + const normalizedPub = normalizePublicationRecord( 54 + docInPub.publications.record, 55 + ); 55 56 56 - if (!basePath) { 57 - return new NextResponse("Publication has no base_path", { 57 + if (!normalizedPub?.url) { 58 + return new NextResponse("Publication has no url", { 58 59 status: 404, 59 60 }); 60 61 } 61 62 62 - // Ensure basePath ends without trailing slash 63 - const cleanBasePath = basePath.endsWith("/") 64 - ? basePath.slice(0, -1) 65 - : basePath; 63 + // Ensure url ends without trailing slash 64 + const cleanUrl = normalizedPub.url.endsWith("/") 65 + ? normalizedPub.url.slice(0, -1) 66 + : normalizedPub.url; 66 67 67 - // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 68 - return NextResponse.redirect( 69 - `https://${cleanBasePath}/${uri.rkey}`, 70 - 307, 71 - ); 68 + // Redirect to the document on the publication's domain (temporary redirect since url can change) 69 + return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307); 72 70 } 73 71 74 72 // If not in a publication, check if it's a standalone document
+3 -3
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { AtUri } from "@atproto/syntax"; 4 4 import { ids } from "lexicons/api/lexicons"; 5 - import { PubLeafletDocument } from "lexicons/api"; 6 5 import { jsonToLex } from "@atproto/lexicon"; 7 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 7 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 8 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 9 9 10 10 export const revalidate = 60; 11 11 ··· 35 35 .single(); 36 36 37 37 if (document) { 38 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 39 - if (docRecord.coverImage) { 38 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 39 + if (docRecord?.coverImage) { 40 40 try { 41 41 // Get CID from the blob ref (handle both serialized and hydrated forms) 42 42 let cid =
+3 -2
app/p/[didOrHandle]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { ids } from "lexicons/api/lexicons"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 4 import { Metadata } from "next"; 6 5 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 6 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 7 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 8 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 9 9 10 10 export async function generateMetadata(props: { 11 11 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 32 32 33 33 if (!document) return { title: "404" }; 34 34 35 - let docRecord = document.data as PubLeafletDocument.Record; 35 + const docRecord = normalizeDocumentRecord(document.data); 36 + if (!docRecord) return { title: "404" }; 36 37 37 38 // For documents in publications, include publication name 38 39 let publicationName =
+18 -16
appview/index.ts
··· 231 231 .eq("uri", evt.uri.toString()); 232 232 } 233 233 } 234 + // site.standard.document records go into the main "documents" table 235 + // The normalization layer handles reading both pub.leaflet and site.standard formats 234 236 if (evt.collection === ids.SiteStandardDocument) { 235 237 if (evt.event === "create" || evt.event === "update") { 236 238 let record = SiteStandardDocument.validateRecord(evt.record); ··· 238 240 console.log(record.error); 239 241 return; 240 242 } 241 - await supabase 242 - .from("identities") 243 - .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 244 - let docResult = await supabase.from("site_standard_documents").upsert({ 243 + let docResult = await supabase.from("documents").upsert({ 245 244 uri: evt.uri.toString(), 246 245 data: record.value as Json, 247 - identity_did: evt.did, 248 246 }); 249 247 if (docResult.error) console.log(docResult.error); 248 + 249 + // site.standard.document uses "site" field to reference the publication 250 250 if (record.value.site) { 251 251 let siteURI = new AtUri(record.value.site); 252 252 ··· 255 255 return; 256 256 } 257 257 let docInPublicationResult = await supabase 258 - .from("site_standard_documents_in_publications") 258 + .from("documents_in_publications") 259 259 .upsert({ 260 260 publication: record.value.site, 261 261 document: evt.uri.toString(), 262 262 }); 263 263 await supabase 264 - .from("site_standard_documents_in_publications") 264 + .from("documents_in_publications") 265 265 .delete() 266 266 .neq("publication", record.value.site) 267 267 .eq("document", evt.uri.toString()); ··· 271 271 } 272 272 } 273 273 if (evt.event === "delete") { 274 - await supabase 275 - .from("site_standard_documents") 276 - .delete() 277 - .eq("uri", evt.uri.toString()); 274 + await supabase.from("documents").delete().eq("uri", evt.uri.toString()); 278 275 } 279 276 } 277 + 278 + // site.standard.publication records go into the main "publications" table 280 279 if (evt.collection === ids.SiteStandardPublication) { 281 280 if (evt.event === "create" || evt.event === "update") { 282 281 let record = SiteStandardPublication.validateRecord(evt.record); ··· 284 283 await supabase 285 284 .from("identities") 286 285 .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 287 - await supabase.from("site_standard_publications").upsert({ 286 + await supabase.from("publications").upsert({ 288 287 uri: evt.uri.toString(), 289 288 identity_did: evt.did, 290 - data: record.value as Json, 289 + name: record.value.name, 290 + record: record.value as Json, 291 291 }); 292 292 } 293 293 if (evt.event === "delete") { 294 294 await supabase 295 - .from("site_standard_publications") 295 + .from("publications") 296 296 .delete() 297 297 .eq("uri", evt.uri.toString()); 298 298 } 299 299 } 300 + 301 + // site.standard.graph.subscription records go into the main "publication_subscriptions" table 300 302 if (evt.collection === ids.SiteStandardGraphSubscription) { 301 303 if (evt.event === "create" || evt.event === "update") { 302 304 let record = SiteStandardGraphSubscription.validateRecord(evt.record); ··· 304 306 await supabase 305 307 .from("identities") 306 308 .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 307 - await supabase.from("site_standard_subscriptions").upsert({ 309 + await supabase.from("publication_subscriptions").upsert({ 308 310 uri: evt.uri.toString(), 309 311 identity: evt.did, 310 312 publication: record.value.publication, ··· 313 315 } 314 316 if (evt.event === "delete") { 315 317 await supabase 316 - .from("site_standard_subscriptions") 318 + .from("publication_subscriptions") 317 319 .delete() 318 320 .eq("uri", evt.uri.toString()); 319 321 }
+7 -4
components/ActionBar/Publications.tsx
··· 5 5 import { theme } from "tailwind.config"; 6 6 import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 7 7 import { Json } from "supabase/database.types"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { AtUri } from "@atproto/syntax"; 10 9 import { ActionButton } from "./ActionButton"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 11 14 import { SpeedyLink } from "components/SpeedyLink"; 12 15 import { PublishSmall } from "components/Icons/PublishSmall"; 13 16 import { Popover } from "components/Popover"; ··· 85 88 record: Json; 86 89 current?: boolean; 87 90 }) => { 88 - let record = props.record as PubLeafletPublication.Record | null; 91 + let record = normalizePublicationRecord(props.record); 89 92 if (!record) return; 90 93 91 94 return ( ··· 181 184 }; 182 185 183 186 export const PubIcon = (props: { 184 - record: PubLeafletPublication.Record; 187 + record: NormalizedPublication | null; 185 188 uri: string; 186 189 small?: boolean; 187 190 large?: boolean; 188 191 className?: string; 189 192 }) => { 190 - if (!props.record) return; 193 + if (!props.record) return null; 191 194 192 195 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 193 196
+7 -7
components/Blocks/PublicationPollBlock.tsx
··· 11 11 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 12 import { 13 13 PubLeafletBlocksPoll, 14 - PubLeafletDocument, 15 14 PubLeafletPagesLinearDocument, 16 15 } from "lexicons/api"; 16 + import { getDocumentPages } from "src/utils/normalizeRecords"; 17 17 import { ids } from "lexicons/api/lexicons"; 18 18 19 19 /** ··· 22 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 23 */ 24 24 export const PublicationPollBlock = (props: BlockProps) => { 25 - let { data: publicationData } = useLeafletPublicationData(); 25 + let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); 26 26 let isSelected = useUIState((s) => 27 27 s.selectedBlocks.find((b) => b.value === props.entityID), 28 28 ); 29 29 // Check if this poll has been published in a publication document 30 30 const isPublished = useMemo(() => { 31 - if (!publicationData?.documents?.data) return false; 31 + if (!normalizedDocument) return false; 32 32 33 - const docRecord = publicationData.documents 34 - .data as PubLeafletDocument.Record; 33 + const pages = getDocumentPages(normalizedDocument); 34 + if (!pages) return false; 35 35 36 36 // Search through all pages and blocks to find if this poll entity has been published 37 - for (const page of docRecord.pages || []) { 37 + for (const page of pages) { 38 38 if (page.$type === "pub.leaflet.pages.linearDocument") { 39 39 const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 40 for (const blockWrapper of linearPage.blocks || []) { ··· 50 50 } 51 51 } 52 52 return false; 53 - }, [publicationData, props.entityID]); 53 + }, [normalizedDocument, props.entityID]); 54 54 55 55 return ( 56 56 <BlockLayout
+4 -8
components/Canvas.tsx
··· 21 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 - import { 25 - PubLeafletPublication, 26 - PubLeafletPublicationRecord, 27 - } from "lexicons/api"; 28 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 25 30 26 export function Canvas(props: { ··· 165 161 } 166 162 167 163 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 - let { data: pub } = useLeafletPublicationData(); 164 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 169 165 if (!pub || !pub.publications) return null; 170 166 171 - let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 - let showComments = pubRecord.preferences?.showComments; 173 - let showMentions = pubRecord.preferences?.showMentions; 167 + if (!normalizedPublication) return null; 168 + let showComments = normalizedPublication.preferences?.showComments; 169 + let showMentions = normalizedPublication.preferences?.showMentions; 174 170 175 171 return ( 176 172 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
+20 -1
components/PageSWRDataProvider.tsx
··· 6 6 import { callRPC } from "app/api/rpc/client"; 7 7 import { getPollData } from "actions/pollActions"; 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 - import { createContext, useContext } from "react"; 9 + import { createContext, useContext, useMemo } from "react"; 10 10 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 11 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 12 import { AtUri } from "@atproto/syntax"; 13 + import { 14 + normalizeDocumentRecord, 15 + normalizePublicationRecord, 16 + type NormalizedDocument, 17 + type NormalizedPublication, 18 + } from "src/utils/normalizeRecords"; 13 19 14 20 export const StaticLeafletDataContext = createContext< 15 21 null | GetLeafletDataReturnType["result"]["data"] ··· 73 79 // First check for leaflets in publications 74 80 let pubData = getPublicationMetadataFromLeafletData(data); 75 81 82 + // Normalize records so consumers don't have to 83 + const normalizedPublication = useMemo( 84 + () => normalizePublicationRecord(pubData?.publications?.record), 85 + [pubData?.publications?.record] 86 + ); 87 + const normalizedDocument = useMemo( 88 + () => normalizeDocumentRecord(pubData?.documents?.data), 89 + [pubData?.documents?.data] 90 + ); 91 + 76 92 return { 77 93 data: pubData || null, 94 + // Pre-normalized data - consumers should use these instead of normalizing themselves 95 + normalizedPublication, 96 + normalizedDocument, 78 97 mutate, 79 98 }; 80 99 }
+9 -16
components/Pages/PublicationMetadata.tsx
··· 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 8 import { 10 9 getBasePublicationURL, 11 10 getPublicationURL, ··· 22 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 22 export const PublicationMetadata = () => { 24 23 let { rep } = useReplicache(); 25 - let { data: pub } = useLeafletPublicationData(); 24 + let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 26 25 let { identity } = useIdentityData(); 27 26 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 28 27 let description = useSubscribe(rep, (tx) => 29 28 tx.get<string>("publication_description"), 30 29 ); 31 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 - let pubRecord = pub?.publications?.record as 33 - | PubLeafletPublication.Record 34 - | undefined; 35 - let publishedAt = record?.publishedAt; 30 + let publishedAt = normalizedDocument?.publishedAt; 36 31 37 32 if (!pub) return null; 38 33 ··· 121 116 <Separator classname="h-4!" /> 122 117 </> 123 118 )} 124 - {pubRecord?.preferences?.showMentions && ( 119 + {normalizedPublication?.preferences?.showMentions && ( 125 120 <div className="flex gap-1 items-center"> 126 121 <QuoteTiny />— 127 122 </div> 128 123 )} 129 - {pubRecord?.preferences?.showComments && ( 124 + {normalizedPublication?.preferences?.showComments && ( 130 125 <div className="flex gap-1 items-center"> 131 126 <CommentTiny />— 132 127 </div> ··· 210 205 }; 211 206 212 207 export const PublicationMetadataPreview = () => { 213 - let { data: pub } = useLeafletPublicationData(); 214 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 215 - let publishedAt = record?.publishedAt; 208 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 209 + let publishedAt = normalizedDocument?.publishedAt; 216 210 217 211 if (!pub) return null; 218 212 ··· 237 231 }; 238 232 239 233 const AddTags = () => { 240 - let { data: pub } = useLeafletPublicationData(); 234 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 241 235 let { rep } = useReplicache(); 242 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 243 236 244 237 // Get tags from Replicache local state or published document 245 238 let replicacheTags = useSubscribe(rep, (tx) => ··· 250 243 let tags: string[] = []; 251 244 if (Array.isArray(replicacheTags)) { 252 245 tags = replicacheTags; 253 - } else if (record?.tags && Array.isArray(record.tags)) { 254 - tags = record.tags as string[]; 246 + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 247 + tags = normalizedDocument.tags as string[]; 255 248 } 256 249 257 250 // Update tags in replicache local state
+12 -4
components/PostListing.tsx
··· 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 9 import { useSmoker } from "components/Toast"; 10 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import type { 12 + NormalizedDocument, 13 + NormalizedPublication, 14 + } from "src/utils/normalizeRecords"; 12 15 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 16 14 17 import Link from "next/link"; ··· 17 20 18 21 export const PostListing = (props: Post) => { 19 22 let pubRecord = props.publication?.pubRecord as 20 - | PubLeafletPublication.Record 23 + | NormalizedPublication 21 24 | undefined; 22 25 23 - let postRecord = props.documents.data as PubLeafletDocument.Record; 26 + let postRecord = props.documents.data as NormalizedDocument | null; 27 + 28 + // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) 29 + if (!postRecord) { 30 + return null; 31 + } 24 32 let postUri = new AtUri(props.documents.uri); 25 33 let uri = props.publication ? props.publication?.uri : props.documents.uri; 26 34 ··· 110 118 111 119 const PubInfo = (props: { 112 120 href: string; 113 - pubRecord: PubLeafletPublication.Record; 121 + pubRecord: NormalizedPublication; 114 122 uri: string; 115 123 }) => { 116 124 return (
+8 -8
components/ThemeManager/PubThemeSetter.tsx
··· 1 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 2 5 import { useState } from "react"; 3 6 import { pickers, SectionArrow } from "./ThemeSetter"; 4 7 import { Color } from "react-aria-components"; 5 - import { 6 - PubLeafletPublication, 7 - PubLeafletThemeBackgroundImage, 8 - } from "lexicons/api"; 8 + import { PubLeafletThemeBackgroundImage } from "lexicons/api"; 9 9 import { AtUri } from "@atproto/syntax"; 10 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 11 import { BaseThemeProvider } from "./ThemeProvider"; ··· 35 35 let [openPicker, setOpenPicker] = useState<pickers>("null"); 36 36 let { data, mutate } = usePublicationData(); 37 37 let { publication: pub } = data || {}; 38 - let record = pub?.record as PubLeafletPublication.Record | undefined; 38 + let record = useNormalizedPublicationRecord(); 39 39 let [showPageBackground, setShowPageBackground] = useState( 40 40 !!record?.theme?.showPageBackground, 41 41 ); ··· 246 246 }) => { 247 247 let { data } = usePublicationData(); 248 248 let { publication } = data || {}; 249 - let record = publication?.record as PubLeafletPublication.Record | null; 249 + let record = useNormalizedPublicationRecord(); 250 250 251 251 return ( 252 252 <div ··· 314 314 }) => { 315 315 let { data } = usePublicationData(); 316 316 let { publication } = data || {}; 317 - let record = publication?.record as PubLeafletPublication.Record | null; 317 + let record = useNormalizedPublicationRecord(); 318 318 return ( 319 319 <div 320 320 style={{
+7 -3
components/ThemeManager/PublicationThemeProvider.tsx
··· 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 9 + import { 10 + usePublicationData, 11 + useNormalizedPublicationRecord, 12 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 13 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 14 12 15 const PubThemeDefaults = { ··· 53 56 }) { 54 57 let { data } = usePublicationData(); 55 58 let { publication: pub } = data || {}; 59 + const normalizedPub = useNormalizedPublicationRecord(); 56 60 return ( 57 61 <PublicationThemeProvider 58 62 pub_creator={pub?.identity_did || ""} 59 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 63 + theme={normalizedPub?.theme} 60 64 > 61 65 <PublicationBackgroundProvider 62 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 66 + theme={normalizedPub?.theme} 63 67 pub_creator={pub?.identity_did || ""} 64 68 > 65 69 {props.children}
+4 -7
components/ThemeManager/ThemeProvider.tsx
··· 21 21 PublicationBackgroundProvider, 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 - import { PubLeafletPublication } from "lexicons/api"; 25 24 import { getColorDifference } from "./themeUtils"; 26 25 27 26 // define a function to set an Aria Color to a CSS Variable in RGB ··· 40 39 children: React.ReactNode; 41 40 className?: string; 42 41 }) { 43 - let { data: pub } = useLeafletPublicationData(); 42 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 44 43 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 45 44 return ( 46 45 <PublicationThemeProvider 47 46 {...props} 48 - theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 47 + theme={normalizedPublication?.theme} 49 48 pub_creator={pub.publications?.identity_did} 50 49 /> 51 50 ); ··· 328 327 entityID: string; 329 328 children: React.ReactNode; 330 329 }) => { 331 - let { data: pub } = useLeafletPublicationData(); 330 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 332 331 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 333 332 let backgroundImageRepeat = useEntity( 334 333 props.entityID, ··· 338 337 return ( 339 338 <PublicationBackgroundProvider 340 339 pub_creator={pub?.publications.identity_did || ""} 341 - theme={ 342 - (pub.publications?.record as PubLeafletPublication.Record)?.theme 343 - } 340 + theme={normalizedPublication?.theme} 344 341 > 345 342 {props.children} 346 343 </PublicationBackgroundProvider>
+50
contexts/DocumentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 4 + 5 + // Derive types from PostPageData 6 + type NonNullPostPageData = NonNullable<PostPageData>; 7 + export type PublicationContext = NonNullPostPageData["publication"]; 8 + export type CommentOnDocument = NonNullPostPageData["comments"][number]; 9 + export type DocumentMention = NonNullPostPageData["mentions"][number]; 10 + export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; 11 + 12 + export type DocumentContextValue = Pick< 13 + NonNullPostPageData, 14 + | "uri" 15 + | "normalizedDocument" 16 + | "normalizedPublication" 17 + | "theme" 18 + | "prevNext" 19 + | "quotesAndMentions" 20 + | "publication" 21 + | "comments" 22 + | "mentions" 23 + | "leafletId" 24 + >; 25 + 26 + const DocumentContext = createContext<DocumentContextValue | null>(null); 27 + 28 + export function useDocument() { 29 + const ctx = useContext(DocumentContext); 30 + if (!ctx) throw new Error("useDocument must be used within DocumentProvider"); 31 + return ctx; 32 + } 33 + 34 + export function useDocumentOptional() { 35 + return useContext(DocumentContext); 36 + } 37 + 38 + export function DocumentProvider({ 39 + children, 40 + value, 41 + }: { 42 + children: React.ReactNode; 43 + value: DocumentContextValue; 44 + }) { 45 + return ( 46 + <DocumentContext.Provider value={value}> 47 + {children} 48 + </DocumentContext.Provider> 49 + ); 50 + }
+35
contexts/LeafletContentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PubLeafletContent } from "lexicons/api"; 4 + 5 + export type Page = PubLeafletContent.Main["pages"][number]; 6 + 7 + export type LeafletContentContextValue = { 8 + pages: Page[]; 9 + }; 10 + 11 + const LeafletContentContext = createContext<LeafletContentContextValue | null>(null); 12 + 13 + export function useLeafletContent() { 14 + const ctx = useContext(LeafletContentContext); 15 + if (!ctx) throw new Error("useLeafletContent must be used within LeafletContentProvider"); 16 + return ctx; 17 + } 18 + 19 + export function useLeafletContentOptional() { 20 + return useContext(LeafletContentContext); 21 + } 22 + 23 + export function LeafletContentProvider({ 24 + children, 25 + value, 26 + }: { 27 + children: React.ReactNode; 28 + value: LeafletContentContextValue; 29 + }) { 30 + return ( 31 + <LeafletContentContext.Provider value={value}> 32 + {children} 33 + </LeafletContentContext.Provider> 34 + ); 35 + }
+43 -1
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, leaflets_to_documents, permission_token_rights, leaflets_in_publications } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, leaflets_to_documents, permission_token_rights, leaflets_in_publications } from "./schema"; 3 3 4 4 export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { ··· 17 17 fields: [identities.home_page], 18 18 references: [permission_tokens.id] 19 19 }), 20 + site_standard_publications: many(site_standard_publications), 21 + site_standard_documents: many(site_standard_documents), 20 22 custom_domains_identity: many(custom_domains, { 21 23 relationName: "custom_domains_identity_identities_email" 22 24 }), ··· 33 35 permission_token_on_homepages: many(permission_token_on_homepage), 34 36 publication_domains: many(publication_domains), 35 37 publication_subscriptions: many(publication_subscriptions), 38 + site_standard_subscriptions: many(site_standard_subscriptions), 36 39 })); 37 40 38 41 export const publicationsRelations = relations(publications, ({one, many}) => ({ ··· 149 152 }), 150 153 })); 151 154 155 + export const site_standard_publicationsRelations = relations(site_standard_publications, ({one, many}) => ({ 156 + identity: one(identities, { 157 + fields: [site_standard_publications.identity_did], 158 + references: [identities.atp_did] 159 + }), 160 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 161 + site_standard_subscriptions: many(site_standard_subscriptions), 162 + })); 163 + 152 164 export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 153 165 custom_domain: one(custom_domains, { 154 166 fields: [custom_domain_routes.domain], ··· 179 191 relationName: "custom_domains_identity_id_identities_id" 180 192 }), 181 193 publication_domains: many(publication_domains), 194 + })); 195 + 196 + export const site_standard_documentsRelations = relations(site_standard_documents, ({one, many}) => ({ 197 + identity: one(identities, { 198 + fields: [site_standard_documents.identity_did], 199 + references: [identities.atp_did] 200 + }), 201 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 182 202 })); 183 203 184 204 export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ ··· 227 247 }), 228 248 })); 229 249 250 + export const site_standard_documents_in_publicationsRelations = relations(site_standard_documents_in_publications, ({one}) => ({ 251 + site_standard_document: one(site_standard_documents, { 252 + fields: [site_standard_documents_in_publications.document], 253 + references: [site_standard_documents.uri] 254 + }), 255 + site_standard_publication: one(site_standard_publications, { 256 + fields: [site_standard_documents_in_publications.publication], 257 + references: [site_standard_publications.uri] 258 + }), 259 + })); 260 + 230 261 export const documents_in_publicationsRelations = relations(documents_in_publications, ({one}) => ({ 231 262 document: one(documents, { 232 263 fields: [documents_in_publications.document], ··· 287 318 publication: one(publications, { 288 319 fields: [publication_subscriptions.publication], 289 320 references: [publications.uri] 321 + }), 322 + })); 323 + 324 + export const site_standard_subscriptionsRelations = relations(site_standard_subscriptions, ({one}) => ({ 325 + identity: one(identities, { 326 + fields: [site_standard_subscriptions.identity], 327 + references: [identities.atp_did] 328 + }), 329 + site_standard_publication: one(site_standard_publications, { 330 + fields: [site_standard_subscriptions.publication], 331 + references: [site_standard_publications.uri] 290 332 }), 291 333 })); 292 334
+39
drizzle/schema.ts
··· 173 173 } 174 174 }); 175 175 176 + export const site_standard_publications = pgTable("site_standard_publications", { 177 + uri: text("uri").primaryKey().notNull(), 178 + data: jsonb("data").notNull(), 179 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 180 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 181 + }); 182 + 176 183 export const custom_domain_routes = pgTable("custom_domain_routes", { 177 184 id: uuid("id").defaultRandom().primaryKey().notNull(), 178 185 domain: text("domain").notNull().references(() => custom_domains.domain), ··· 186 193 edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 187 194 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 188 195 } 196 + }); 197 + 198 + export const site_standard_documents = pgTable("site_standard_documents", { 199 + uri: text("uri").primaryKey().notNull(), 200 + data: jsonb("data").notNull(), 201 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 202 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 189 203 }); 190 204 191 205 export const custom_domains = pgTable("custom_domains", { ··· 260 274 } 261 275 }); 262 276 277 + export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { 278 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 279 + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), 280 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 281 + }, 282 + (table) => { 283 + return { 284 + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), 285 + } 286 + }); 287 + 263 288 export const documents_in_publications = pgTable("documents_in_publications", { 264 289 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 265 290 document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), ··· 321 346 publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 322 347 publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 323 348 publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 349 + } 350 + }); 351 + 352 + export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { 353 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 354 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 355 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 356 + record: jsonb("record").notNull(), 357 + uri: text("uri").notNull(), 358 + }, 359 + (table) => { 360 + return { 361 + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), 362 + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), 324 363 } 325 364 }); 326 365
+7 -4
feeds/index.ts
··· 3 3 import { DidResolver } from "@atproto/identity"; 4 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { PubLeafletDocument } from "lexicons/api"; 6 + import { 7 + normalizeDocumentRecord, 8 + type NormalizedDocument, 9 + } from "src/utils/normalizeRecords"; 7 10 import { inngest } from "app/api/inngest/client"; 8 11 import { AtUri } from "@atproto/api"; 9 12 ··· 133 136 cursor: newCursor || cursor, 134 137 feed: posts.flatMap((p) => { 135 138 if (!p.data) return []; 136 - let record = p.data as PubLeafletDocument.Record; 137 - if (!record.postRef) return []; 138 - return { post: record.postRef.uri }; 139 + const normalizedDoc = normalizeDocumentRecord(p.data); 140 + if (!normalizedDoc?.bskyPostRef) return []; 141 + return { post: normalizedDoc.bskyPostRef.uri }; 139 142 }), 140 143 }); 141 144 });
+37 -4
src/notifications.ts
··· 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 5 import { AtUri } from "@atproto/syntax"; 6 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + import { 8 + normalizeDocumentRecord, 9 + normalizePublicationRecord, 10 + type NormalizedDocument, 11 + type NormalizedPublication, 12 + } from "src/utils/normalizeRecords"; 7 13 8 14 type NotificationRow = Tables<"notifications">; 9 15 ··· 99 105 ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 106 : undefined, 101 107 commentData, 108 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data), 109 + normalizedPublication: normalizePublicationRecord( 110 + commentData.documents?.documents_in_publications[0]?.publications?.record, 111 + ), 102 112 }; 103 113 }) 104 114 .filter((n) => n !== null); ··· 140 150 type: "subscribe" as const, 141 151 subscription_uri: notification.data.subscription_uri, 142 152 subscriptionData, 153 + normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), 143 154 }; 144 155 }) 145 156 .filter((n) => n !== null); ··· 187 198 document_uri: notification.data.document_uri, 188 199 bskyPost, 189 200 document, 201 + normalizedDocument: normalizeDocumentRecord(document.data), 202 + normalizedPublication: normalizePublicationRecord( 203 + document.documents_in_publications[0]?.publications?.record, 204 + ), 190 205 }; 191 206 }) 192 207 .filter((n) => n !== null); ··· 269 284 const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 285 const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 286 287 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 288 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 289 + 272 290 return { 273 291 id: notification.id, 274 292 recipient: notification.recipient, ··· 279 297 mentioned_uri: mentionedUri, 280 298 document, 281 299 documentCreatorHandle, 282 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 300 + mentionedPublication, 301 + mentionedDocument: mentionedDoc, 302 + normalizedDocument: normalizeDocumentRecord(document.data), 303 + normalizedPublication: normalizePublicationRecord( 304 + document.documents_in_publications[0]?.publications?.record, 305 + ), 306 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 307 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data), 284 308 }; 285 309 }) 286 310 .filter((n) => n !== null); ··· 365 389 const commenterDid = new AtUri(notification.data.comment_uri).host; 366 390 const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 391 392 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 393 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 394 + 368 395 return { 369 396 id: notification.id, 370 397 recipient: notification.recipient, ··· 375 402 mentioned_uri: mentionedUri, 376 403 commentData, 377 404 commenterHandle, 378 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 405 + mentionedPublication, 406 + mentionedDocument: mentionedDoc, 407 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data), 408 + normalizedPublication: normalizePublicationRecord( 409 + commentData.documents?.documents_in_publications[0]?.publications?.record, 410 + ), 411 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 412 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data), 380 413 }; 381 414 }) 382 415 .filter((n) => n !== null);
+30 -20
src/utils/getPublicationMetadataFromLeafletData.ts
··· 1 1 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 2 2 import { Json } from "supabase/database.types"; 3 3 4 + /** 5 + * Return type for publication metadata extraction. 6 + * Note: `publications.record` and `documents.data` are raw JSON from the database. 7 + * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` 8 + * from `src/utils/normalizeRecords` to get properly typed data. 9 + */ 10 + export type PublicationMetadata = { 11 + description: string; 12 + title: string; 13 + leaflet: string; 14 + doc: string | null; 15 + publications: { 16 + identity_did: string; 17 + name: string; 18 + indexed_at: string; 19 + /** Raw record - use normalizePublicationRecord() to get typed data */ 20 + record: Json | null; 21 + uri: string; 22 + } | null; 23 + documents: { 24 + /** Raw data - use normalizeDocumentRecord() to get typed data */ 25 + data: Json; 26 + indexed_at: string; 27 + uri: string; 28 + } | null; 29 + } | null; 30 + 4 31 export function getPublicationMetadataFromLeafletData( 5 32 data?: GetLeafletDataReturnType["result"]["data"], 6 - ) { 33 + ): PublicationMetadata { 7 34 if (!data) return null; 8 35 9 36 let pubData: 10 - | { 11 - description: string; 12 - title: string; 13 - leaflet: string; 14 - doc: string | null; 15 - publications: { 16 - identity_did: string; 17 - name: string; 18 - indexed_at: string; 19 - record: Json | null; 20 - uri: string; 21 - } | null; 22 - documents: { 23 - data: Json; 24 - indexed_at: string; 25 - uri: string; 26 - } | null; 27 - } 37 + | NonNullable<PublicationMetadata> 28 38 | undefined 29 39 | null = 30 40 data?.leaflets_in_publications?.[0] || ··· 46 56 doc: standaloneDoc.document, 47 57 }; 48 58 } 49 - return pubData; 59 + return pubData || null; 50 60 }
+129
src/utils/normalizeRecords.ts
··· 1 + /** 2 + * Utilities for normalizing pub.leaflet and site.standard records from database queries. 3 + * 4 + * These helpers apply the normalization functions from lexicons/src/normalize.ts 5 + * to database query results, providing properly typed normalized records. 6 + */ 7 + 8 + import { 9 + normalizeDocument, 10 + normalizePublication, 11 + type NormalizedDocument, 12 + type NormalizedPublication, 13 + } from "lexicons/src/normalize"; 14 + import type { Json } from "supabase/database.types"; 15 + 16 + /** 17 + * Normalizes a document record from a database query result. 18 + * Returns the normalized document or null if the record is invalid/unrecognized. 19 + * 20 + * @example 21 + * const doc = normalizeDocumentRecord(dbResult.data); 22 + * if (doc) { 23 + * // doc is NormalizedDocument with proper typing 24 + * console.log(doc.title, doc.site, doc.publishedAt); 25 + * } 26 + */ 27 + export function normalizeDocumentRecord( 28 + data: Json | unknown 29 + ): NormalizedDocument | null { 30 + return normalizeDocument(data); 31 + } 32 + 33 + /** 34 + * Normalizes a publication record from a database query result. 35 + * Returns the normalized publication or null if the record is invalid/unrecognized. 36 + * 37 + * @example 38 + * const pub = normalizePublicationRecord(dbResult.record); 39 + * if (pub) { 40 + * // pub is NormalizedPublication with proper typing 41 + * console.log(pub.name, pub.url); 42 + * } 43 + */ 44 + export function normalizePublicationRecord( 45 + record: Json | unknown 46 + ): NormalizedPublication | null { 47 + return normalizePublication(record); 48 + } 49 + 50 + /** 51 + * Type helper for a document row from the database with normalized data. 52 + * Use this when you need the full row but with typed data. 53 + */ 54 + export type DocumentRowWithNormalizedData< 55 + T extends { data: Json | unknown } 56 + > = Omit<T, "data"> & { 57 + data: NormalizedDocument | null; 58 + }; 59 + 60 + /** 61 + * Type helper for a publication row from the database with normalized record. 62 + * Use this when you need the full row but with typed record. 63 + */ 64 + export type PublicationRowWithNormalizedRecord< 65 + T extends { record: Json | unknown } 66 + > = Omit<T, "record"> & { 67 + record: NormalizedPublication | null; 68 + }; 69 + 70 + /** 71 + * Normalizes a document row in place, returning a properly typed row. 72 + */ 73 + export function normalizeDocumentRow<T extends { data: Json | unknown }>( 74 + row: T 75 + ): DocumentRowWithNormalizedData<T> { 76 + return { 77 + ...row, 78 + data: normalizeDocumentRecord(row.data), 79 + }; 80 + } 81 + 82 + /** 83 + * Normalizes a publication row in place, returning a properly typed row. 84 + */ 85 + export function normalizePublicationRow<T extends { record: Json | unknown }>( 86 + row: T 87 + ): PublicationRowWithNormalizedRecord<T> { 88 + return { 89 + ...row, 90 + record: normalizePublicationRecord(row.record), 91 + }; 92 + } 93 + 94 + /** 95 + * Type guard for filtering normalized document rows with non-null data. 96 + * Use with .filter() after .map(normalizeDocumentRow) to narrow the type. 97 + */ 98 + export function hasValidDocument<T extends { data: NormalizedDocument | null }>( 99 + row: T 100 + ): row is T & { data: NormalizedDocument } { 101 + return row.data !== null; 102 + } 103 + 104 + /** 105 + * Type guard for filtering normalized publication rows with non-null record. 106 + * Use with .filter() after .map(normalizePublicationRow) to narrow the type. 107 + */ 108 + export function hasValidPublication< 109 + T extends { record: NormalizedPublication | null } 110 + >(row: T): row is T & { record: NormalizedPublication } { 111 + return row.record !== null; 112 + } 113 + 114 + // Re-export the core types and functions for convenience 115 + export { 116 + normalizeDocument, 117 + normalizePublication, 118 + type NormalizedDocument, 119 + type NormalizedPublication, 120 + } from "lexicons/src/normalize"; 121 + 122 + export { 123 + isLeafletDocument, 124 + isStandardDocument, 125 + isLeafletPublication, 126 + isStandardPublication, 127 + hasLeafletContent, 128 + getDocumentPages, 129 + } from "lexicons/src/normalize";
+9
supabase/database.types.ts
··· 580 580 } 581 581 leaflets_in_publications: { 582 582 Row: { 583 + archived: boolean | null 583 584 cover_image: string | null 584 585 description: string 585 586 doc: string | null ··· 589 590 title: string 590 591 } 591 592 Insert: { 593 + archived?: boolean | null 592 594 cover_image?: string | null 593 595 description?: string 594 596 doc?: string | null ··· 598 600 title?: string 599 601 } 600 602 Update: { 603 + archived?: boolean | null 601 604 cover_image?: string | null 602 605 description?: string 603 606 doc?: string | null ··· 632 635 } 633 636 leaflets_to_documents: { 634 637 Row: { 638 + archived: boolean | null 635 639 cover_image: string | null 636 640 created_at: string 637 641 description: string ··· 641 645 title: string 642 646 } 643 647 Insert: { 648 + archived?: boolean | null 644 649 cover_image?: string | null 645 650 created_at?: string 646 651 description?: string ··· 650 655 title?: string 651 656 } 652 657 Update: { 658 + archived?: boolean | null 653 659 cover_image?: string | null 654 660 created_at?: string 655 661 description?: string ··· 739 745 } 740 746 permission_token_on_homepage: { 741 747 Row: { 748 + archived: boolean | null 742 749 created_at: string 743 750 identity: string 744 751 token: string 745 752 } 746 753 Insert: { 754 + archived?: boolean | null 747 755 created_at?: string 748 756 identity: string 749 757 token: string 750 758 } 751 759 Update: { 760 + archived?: boolean | null 752 761 created_at?: string 753 762 identity?: string 754 763 token?: string