diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 4f7e8b38..0a4f281b 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -42,6 +42,10 @@ import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; import { Lock } from "src/utils/lock"; import type { PubLeafletPublication } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; import { ColorToRGB, ColorToRGBA, @@ -147,8 +151,20 @@ export async function publishToPublication({ credentialSession.did!, ); - let existingRecord = - (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; + let existingRecord: Partial = {}; + const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); + if (normalizedDoc) { + // When reading existing data, use normalized format to extract fields + // The theme is preserved in NormalizedDocument for backward compatibility + existingRecord = { + publishedAt: normalizedDoc.publishedAt, + title: normalizedDoc.title, + description: normalizedDoc.description, + tags: normalizedDoc.tags, + coverImage: normalizedDoc.coverImage, + theme: normalizedDoc.theme, + }; + } // Extract theme for standalone documents (not for publications) let theme: PubLeafletPublication.Theme | undefined; @@ -887,10 +903,14 @@ async function createMentionNotifications( .single(); if (document) { - const docRecord = - document.data as PubLeafletDocument.Record; - if (docRecord.author !== authorDid) { - mentionedDocuments.set(docRecord.author, feature.atURI); + const normalizedMentionedDoc = normalizeDocumentRecord( + document.data, + ); + // Get the author from the document URI (the DID is the host part) + const mentionedUri = new AtUri(feature.atURI); + const docAuthor = mentionedUri.host; + if (normalizedMentionedDoc && docAuthor !== authorDid) { + mentionedDocuments.set(docAuthor, feature.atURI); } } } diff --git a/app/(home-pages)/discover/PubListing.tsx b/app/(home-pages)/discover/PubListing.tsx index 54dab387..eff5df20 100644 --- a/app/(home-pages)/discover/PubListing.tsx +++ b/app/(home-pages)/discover/PubListing.tsx @@ -6,18 +6,16 @@ import { PubIcon } from "components/ActionBar/Publications"; import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { timeAgo } from "src/utils/timeAgo"; -import { Json } from "supabase/database.types"; export const PubListing = ( props: PublicationSubscription & { resizeHeight?: boolean; }, ) => { - let record = props.record as PubLeafletPublication.Record; - let theme = usePubTheme(record.theme); + let record = props.record; + let theme = usePubTheme(record?.theme); let backgroundImage = record?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( record?.theme?.backgroundImage?.image?.ref, @@ -31,7 +29,7 @@ export const PubListing = ( return ( 0 && startIndex + limit < allPubs.length ? order === "recentlyUpdated" ? { - indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, - uri: page[page.length - 1].uri, + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, + uri: lastItem.uri, } : { - count: page[page.length - 1].publication_subscriptions[0]?.count || 0, - uri: page[page.length - 1].uri, + count: lastItem.publication_subscriptions[0]?.count || 0, + uri: lastItem.uri, } : null; return { - publications: page, + publications: normalizedPage, nextCursor, }; } diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx index 0e9520cb..10917d61 100644 --- a/app/(home-pages)/notifications/CommentMentionNotification.tsx +++ b/app/(home-pages)/notifications/CommentMentionNotification.tsx @@ -1,9 +1,4 @@ -import { - AppBskyActorProfile, - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; import { HydratedCommentMentionNotification } from "src/notifications"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { MentionTiny } from "components/Icons/MentionTiny"; @@ -17,19 +12,19 @@ import { AtUri } from "@atproto/api"; export const CommentMentionNotification = ( props: HydratedCommentMentionNotification, ) => { - const docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; + const docRecord = props.normalizedDocument; + if (!docRecord) return null; + const commentRecord = props.commentData.record as PubLeafletComment.Record; const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; + const pubRecord = props.normalizedPublication; const docUri = new AtUri(props.commentData.documents?.uri!); const rkey = docUri.rkey; const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; const commenter = props.commenterHandle @@ -37,8 +32,7 @@ export const CommentMentionNotification = ( : "Someone"; let actionText: React.ReactNode; - let mentionedDocRecord = props.mentionedDocument - ?.data as PubLeafletDocument.Record; + const mentionedDocRecord = props.normalizedMentionedDocument; if (props.mention_type === "did") { actionText = <>{commenter} mentioned you in a comment; @@ -46,15 +40,14 @@ export const CommentMentionNotification = ( props.mention_type === "publication" && props.mentionedPublication ) { - const mentionedPubRecord = props.mentionedPublication - .record as PubLeafletPublication.Record; + const mentionedPubRecord = props.normalizedMentionedPublication; actionText = ( <> {commenter} mentioned your publication{" "} - {mentionedPubRecord.name} in a comment + {mentionedPubRecord?.name} in a comment ); - } else if (props.mention_type === "document" && props.mentionedDocument) { + } else if (props.mention_type === "document" && mentionedDocRecord) { actionText = ( <> {commenter} mentioned your post{" "} @@ -72,7 +65,7 @@ export const CommentMentionNotification = ( icon={} actionText={actionText} content={ - + { - let docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; - let commentRecord = props.commentData.record as PubLeafletComment.Record; - let profileRecord = props.commentData.bsky_profiles + const docRecord = props.normalizedDocument; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; + + if (!docRecord) return null; + const displayName = - profileRecord.displayName || + profileRecord?.displayName || props.commentData.bsky_profiles?.handle || "Someone"; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; - let docUri = new AtUri(props.commentData.documents?.uri!); - let rkey = docUri.rkey; - let did = docUri.host; + const pubRecord = props.normalizedPublication; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; return ( diff --git a/app/(home-pages)/notifications/FollowNotification.tsx b/app/(home-pages)/notifications/FollowNotification.tsx index dd8b2b74..dbdd5efd 100644 --- a/app/(home-pages)/notifications/FollowNotification.tsx +++ b/app/(home-pages)/notifications/FollowNotification.tsx @@ -2,7 +2,7 @@ import { Avatar } from "components/Avatar"; import { Notification } from "./Notification"; import { HydratedSubscribeNotification } from "src/notifications"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; +import { AppBskyActorProfile } from "lexicons/api"; export const FollowNotification = (props: HydratedSubscribeNotification) => { const profileRecord = props.subscriptionData?.identities?.bsky_profiles @@ -11,8 +11,7 @@ export const FollowNotification = (props: HydratedSubscribeNotification) => { profileRecord?.displayName || props.subscriptionData?.identities?.bsky_profiles?.handle || "Someone"; - const pubRecord = props.subscriptionData?.publications - ?.record as PubLeafletPublication.Record; + const pubRecord = props.normalizedPublication; const avatarSrc = profileRecord?.avatar?.ref && blobRefToSrc( @@ -23,7 +22,7 @@ export const FollowNotification = (props: HydratedSubscribeNotification) => { return ( } actionText={ <> diff --git a/app/(home-pages)/notifications/MentionNotification.tsx b/app/(home-pages)/notifications/MentionNotification.tsx index 917b2cdf..21c1bab3 100644 --- a/app/(home-pages)/notifications/MentionNotification.tsx +++ b/app/(home-pages)/notifications/MentionNotification.tsx @@ -1,25 +1,25 @@ import { MentionTiny } from "components/Icons/MentionTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedMentionNotification } from "src/notifications"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import { Agent, AtUri } from "@atproto/api"; +import { AtUri } from "@atproto/api"; export const MentionNotification = (props: HydratedMentionNotification) => { - const docRecord = props.document.data as PubLeafletDocument.Record; - const pubRecord = props.document.documents_in_publications?.[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + const docUri = new AtUri(props.document.uri); const rkey = docUri.rkey; const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}` + ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; let actionText: React.ReactNode; let mentionedItemName: string | undefined; - let mentionedDocRecord = props.mentionedDocument - ?.data as PubLeafletDocument.Record; + const mentionedDocRecord = props.normalizedMentionedDocument; const mentioner = props.documentCreatorHandle ? `@${props.documentCreatorHandle}` @@ -31,16 +31,15 @@ export const MentionNotification = (props: HydratedMentionNotification) => { props.mention_type === "publication" && props.mentionedPublication ) { - const mentionedPubRecord = props.mentionedPublication - .record as PubLeafletPublication.Record; - mentionedItemName = mentionedPubRecord.name; + const mentionedPubRecord = props.normalizedMentionedPublication; + mentionedItemName = mentionedPubRecord?.name; actionText = ( <> {mentioner} mentioned your publication{" "} {mentionedItemName} ); - } else if (props.mention_type === "document" && props.mentionedDocument) { + } else if (props.mention_type === "document" && mentionedDocRecord) { mentionedItemName = mentionedDocRecord.title; actionText = ( <> diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx index f70f7af2..205d15a8 100644 --- a/app/(home-pages)/notifications/Notification.tsx +++ b/app/(home-pages)/notifications/Notification.tsx @@ -1,9 +1,10 @@ "use client"; import { Avatar } from "components/Avatar"; import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; +import { PubLeafletRichtextFacet } from "lexicons/api"; import { timeAgo } from "src/utils/timeAgo"; import { useReplicache, useEntity } from "src/replicache"; +import type { NormalizedPublication } from "src/utils/normalizeRecords"; export const Notification = (props: { icon: React.ReactNode; @@ -58,8 +59,8 @@ export const Notification = (props: { export const ContentLayout = (props: { children: React.ReactNode; - postTitle: string; - pubRecord?: PubLeafletPublication.Record; + postTitle: string | undefined; + pubRecord?: NormalizedPublication | null; }) => { let { rootEntity } = useReplicache(); let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data @@ -77,7 +78,7 @@ export const ContentLayout = (props: { <>
{props.pubRecord.name} diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx index 94c283b6..d6849205 100644 --- a/app/(home-pages)/notifications/QuoteNotification.tsx +++ b/app/(home-pages)/notifications/QuoteNotification.tsx @@ -1,7 +1,6 @@ import { QuoteTiny } from "components/Icons/QuoteTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedQuoteNotification } from "src/notifications"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { AtUri } from "@atproto/api"; import { Avatar } from "components/Avatar"; @@ -9,16 +8,18 @@ export const QuoteNotification = (props: HydratedQuoteNotification) => { const postView = props.bskyPost.post_view as any; const author = postView.author; const displayName = author.displayName || author.handle || "Someone"; - const docRecord = props.document.data as PubLeafletDocument.Record; - const pubRecord = props.document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + const docUri = new AtUri(props.document.uri); const rkey = docUri.rkey; const did = docUri.host; const postText = postView.record?.text || ""; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}` + ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; return ( diff --git a/app/(home-pages)/notifications/ReplyNotification.tsx b/app/(home-pages)/notifications/ReplyNotification.tsx index 699b7174..00e377b6 100644 --- a/app/(home-pages)/notifications/ReplyNotification.tsx +++ b/app/(home-pages)/notifications/ReplyNotification.tsx @@ -7,41 +7,38 @@ import { Notification, } from "./Notification"; import { HydratedCommentNotification } from "src/notifications"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletComment } from "lexicons/api"; import { AppBskyActorProfile, AtUri } from "@atproto/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; export const ReplyNotification = (props: HydratedCommentNotification) => { - let docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; - let commentRecord = props.commentData.record as PubLeafletComment.Record; - let profileRecord = props.commentData.bsky_profiles + const docRecord = props.normalizedDocument; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; + + if (!docRecord) return null; + const displayName = - profileRecord.displayName || + profileRecord?.displayName || props.commentData.bsky_profiles?.handle || "Someone"; - let parentRecord = props.parentData?.record as PubLeafletComment.Record; - let parentProfile = props.parentData?.bsky_profiles + const parentRecord = props.parentData?.record as PubLeafletComment.Record; + const parentProfile = props.parentData?.bsky_profiles ?.record as AppBskyActorProfile.Record; const parentDisplayName = - parentProfile.displayName || + parentProfile?.displayName || props.parentData?.bsky_profiles?.handle || "Someone"; - let docUri = new AtUri(props.commentData.documents?.uri!); - let rkey = docUri.rkey; - let did = docUri.host; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; + const pubRecord = props.normalizedPublication; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; return ( diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index 459ef99c..8104a4c9 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -1,12 +1,11 @@ "use client"; import { Avatar } from "components/Avatar"; -import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import type { ProfileData } from "./layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { colorToString } from "components/ThemeManager/useColorAttribute"; import { PubIcon } from "components/ActionBar/Publications"; -import { Json } from "supabase/database.types"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { SpeedyLink } from "components/SpeedyLink"; @@ -15,7 +14,7 @@ import * as linkify from "linkifyjs"; export const ProfileHeader = (props: { profile: ProfileViewDetailed; - publications: { record: Json; uri: string }[]; + publications: { record: NormalizedPublication; uri: string }[]; popover?: boolean; }) => { let profileRecord = props.profile; @@ -89,11 +88,7 @@ export const ProfileHeader = (props: { 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]"}`} > {props.publications.map((p) => ( - + ))} @@ -114,16 +109,13 @@ const ProfileLinks = (props: { handle: string }) => { ); }; -const PublicationCard = (props: { - record: PubLeafletPublication.Record; - uri: string; -}) => { +const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { const { record, uri } = props; const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); return ( diff --git a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts index cc9e762d..0e1591d1 100644 --- a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts +++ b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts @@ -3,6 +3,10 @@ import { supabaseServerClient } from "supabase/serverClient"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; export type Cursor = { indexed_at: string; @@ -58,13 +62,17 @@ export async function getProfilePosts( let posts: Post[] = []; for (let doc of docs || []) { + // Normalize records - filter out unrecognized formats + const normalizedData = normalizeDocumentRecord(doc.data); + if (!normalizedData) continue; + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; let post: Post = { author: handle, documents: { - data: doc.data, + data: normalizedData, uri: doc.uri, indexed_at: doc.indexed_at, comments_on_documents: doc.comments_on_documents, @@ -75,7 +83,7 @@ export async function getProfilePosts( if (pub) { post.publication = { href: getPublicationURL(pub), - pubRecord: pub.record, + pubRecord: normalizePublicationRecord(pub.record), uri: pub.uri, }; } diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts index c084649d..39def812 100644 --- a/app/(home-pages)/reader/getReaderFeed.ts +++ b/app/(home-pages)/reader/getReaderFeed.ts @@ -7,8 +7,13 @@ import { IdResolver } from "@atproto/identity"; import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; import Client from "ioredis"; import { AtUri } from "@atproto/api"; -import { Json } from "supabase/database.types"; import { idResolver } from "./idResolver"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; export type Cursor = { timestamp: string; @@ -42,31 +47,40 @@ export async function getReaderFeed( } let { data: feed, error } = await query; - let posts = await Promise.all( - feed?.map(async (post) => { - let pub = post.documents_in_publications[0].publications!; - let uri = new AtUri(post.uri); - let handle = await idResolver.did.resolve(uri.host); - let p: Post = { - publication: { - href: getPublicationURL(pub), - pubRecord: pub?.record || null, - uri: pub?.uri || "", - }, - author: handle?.alsoKnownAs?.[0] - ? `@${handle.alsoKnownAs[0].slice(5)}` - : null, - documents: { - comments_on_documents: post.comments_on_documents, - document_mentions_in_bsky: post.document_mentions_in_bsky, - data: post.data, - uri: post.uri, - indexed_at: post.indexed_at, - }, - }; - return p; - }) || [], - ); + let posts = ( + await Promise.all( + feed?.map(async (post) => { + let pub = post.documents_in_publications[0].publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + + // Normalize records - filter out unrecognized formats + const normalizedData = normalizeDocumentRecord(post.data); + if (!normalizedData) return null; + + const normalizedPubRecord = normalizePublicationRecord(pub?.record); + + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: normalizedPubRecord, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + data: normalizedData, + uri: post.uri, + indexed_at: post.indexed_at, + }, + }; + return p; + }) || [], + ) + ).filter((post): post is Post => post !== null); const nextCursor = posts.length > 0 ? { @@ -85,22 +99,14 @@ export type Post = { author: string | null; publication?: { href: string; - pubRecord: Json; + pubRecord: NormalizedPublication | null; uri: string; }; documents: { - data: Json; + data: NormalizedDocument | null; uri: string; indexed_at: string; - comments_on_documents: - | { - count: number; - }[] - | undefined; - document_mentions_in_bsky: - | { - count: number; - }[] - | undefined; + comments_on_documents: { count: number }[] | undefined; + document_mentions_in_bsky: { count: number }[] | undefined; }; }; diff --git a/app/(home-pages)/reader/getSubscriptions.ts b/app/(home-pages)/reader/getSubscriptions.ts index bff1cd6d..3d2d7765 100644 --- a/app/(home-pages)/reader/getSubscriptions.ts +++ b/app/(home-pages)/reader/getSubscriptions.ts @@ -7,6 +7,10 @@ import { Json } from "supabase/database.types"; import { supabaseServerClient } from "supabase/serverClient"; import { idResolver } from "./idResolver"; import { Cursor } from "./getReaderFeed"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; export async function getSubscriptions( did?: string | null, @@ -43,17 +47,24 @@ export async function getSubscriptions( } let { data: pubs, error } = await query; - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( - pubs?.map(async (pub) => { - let id = await idResolver.did.resolve(pub.publications?.identity_did!); - return { - ...pub.publications!, - authorProfile: id?.alsoKnownAs?.[0] - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } - : undefined, - }; - }) || [], - ); + const hydratedSubscriptions = ( + await Promise.all( + pubs?.map(async (pub) => { + const normalizedRecord = normalizePublicationRecord( + pub.publications?.record + ); + if (!normalizedRecord) return null; + let id = await idResolver.did.resolve(pub.publications?.identity_did!); + return { + ...pub.publications!, + record: normalizedRecord, + authorProfile: id?.alsoKnownAs?.[0] + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } + : undefined, + } as PublicationSubscription; + }) || [] + ) + ).filter((sub): sub is PublicationSubscription => sub !== null); const nextCursor = pubs && pubs.length > 0 @@ -71,7 +82,7 @@ export async function getSubscriptions( export type PublicationSubscription = { authorProfile?: { handle: string }; - record: Json; + record: NormalizedPublication; uri: string; documents_in_publications: { documents: { data?: Json; indexed_at: string } | null; diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts index 56ac8663..b2b1eaa3 100644 --- a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -3,9 +3,12 @@ import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/api"; -import { Json } from "supabase/database.types"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; export async function getDocumentsByTag( tag: string, @@ -37,13 +40,21 @@ export async function getDocumentsByTag( return null; } + // Normalize the document data - skip unrecognized formats + const normalizedData = normalizeDocumentRecord(doc.data); + if (!normalizedData) { + return null; + } + + const normalizedPubRecord = normalizePublicationRecord(pub?.record); + const uri = new AtUri(doc.uri); const handle = await idResolver.did.resolve(uri.host); const post: Post = { publication: { href: getPublicationURL(pub), - pubRecord: pub?.record || null, + pubRecord: normalizedPubRecord, uri: pub?.uri || "", }, author: handle?.alsoKnownAs?.[0] @@ -52,7 +63,7 @@ export async function getDocumentsByTag( documents: { comments_on_documents: doc.comments_on_documents, document_mentions_in_bsky: doc.document_mentions_in_bsky, - data: doc.data, + data: normalizedData, uri: doc.uri, indexed_at: doc.indexed_at, }, diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index f0140132..0f63a818 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -22,7 +22,7 @@ import { Popover } from "components/Popover"; import { SpeedyLink } from "components/SpeedyLink"; import { useToaster } from "components/Toast"; import { DotLoader } from "components/utils/DotLoader"; -import { PubLeafletPublication } from "lexicons/api"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState, useMemo } from "react"; import { useIsMobile } from "src/hooks/isMobile"; @@ -370,7 +370,7 @@ const PubSelector = (props: {
{props.publications.map((p) => { - let pubRecord = p.record as PubLeafletPublication.Record; + let pubRecord = normalizePublicationRecord(p.record); return ( { let { permission_token } = useReplicache(); - let { data: pub } = useLeafletPublicationData(); - - let record = pub?.documents?.data as PubLeafletDocument.Record | null; + let { data: pub, normalizedDocument } = useLeafletPublicationData(); let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; let postLink = !docURI diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 4b890427..2599b673 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -7,7 +7,7 @@ import { Radio } from "components/Checkbox"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { PubLeafletPublication } from "lexicons/api"; +import type { NormalizedPublication } from "src/utils/normalizeRecords"; import { publishPostToBsky } from "./publishBskyPost"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AtUri } from "@atproto/syntax"; @@ -31,7 +31,7 @@ type Props = { profile: ProfileViewDetailed; description: string; publication_uri?: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; posts_in_pub?: number; entitiesToDelete?: string[]; hasDraft: boolean; @@ -127,8 +127,8 @@ const PublishPostForm = ( } // Generate post URL based on whether it's in a publication or standalone - let post_url = props.record?.base_path - ? `https://${props.record.base_path}/${result.rkey}` + let post_url = props.record?.url + ? `${props.record.url}/${result.rkey}` : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; let [text, facets] = editorStateRef.current @@ -228,7 +228,7 @@ const ShareOptions = (props: { title: string; profile: ProfileViewDetailed; description: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; }) => { return (
@@ -295,7 +295,7 @@ const ShareOptions = (props: {
{props.description}

- {props.record?.base_path} + {props.record?.url?.replace(/^https?:\/\//, "")}

@@ -312,7 +312,7 @@ const ShareOptions = (props: { const PublishingTo = (props: { publication_uri?: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; }) => { if (props.publication_uri && props.record) { return ( diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 42690741..fa0f2f6f 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -1,6 +1,6 @@ import { supabaseServerClient } from "supabase/serverClient"; import { PublishPost } from "./PublishPost"; -import { PubLeafletPublication } from "lexicons/api"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { getIdentityData } from "actions/getIdentityData"; import { AtpAgent } from "@atproto/api"; @@ -118,7 +118,7 @@ export default async function PublishLeafletPage(props: Props) { title={title} description={description} publication_uri={publication?.uri} - record={publication?.record as PubLeafletPublication.Record | undefined} + record={normalizePublicationRecord(publication?.record)} posts_in_pub={publication?.documents_in_publications[0]?.count} entitiesToDelete={entitiesToDelete} hasDraft={hasDraft} diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts index 16ecfb7d..26c698a9 100644 --- a/app/api/pub_icon/route.ts +++ b/app/api/pub_icon/route.ts @@ -1,8 +1,11 @@ import { AtUri } from "@atproto/syntax"; import { IdResolver } from "@atproto/identity"; import { NextRequest, NextResponse } from "next/server"; -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; import sharp from "sharp"; const idResolver = new IdResolver(); @@ -29,7 +32,7 @@ export async function GET(req: NextRequest) { return new NextResponse(null, { status: 400 }); } - let publicationRecord: PubLeafletPublication.Record | null = null; + let normalizedPub: NormalizedPublication | null = null; let publicationUri: string; // Check if it's a document or publication @@ -46,8 +49,7 @@ export async function GET(req: NextRequest) { } publicationUri = docInPub.publication; - publicationRecord = docInPub.publications - .record as PubLeafletPublication.Record; + normalizedPub = normalizePublicationRecord(docInPub.publications.record); } else if (uri.collection === "pub.leaflet.publication") { // Query the publications table directly const { data: publication } = await supabaseServerClient @@ -61,16 +63,16 @@ export async function GET(req: NextRequest) { } publicationUri = publication.uri; - publicationRecord = publication.record as PubLeafletPublication.Record; + normalizedPub = normalizePublicationRecord(publication.record); } else { // Not a supported collection return new NextResponse(null, { status: 404 }); } // Check if the publication has an icon - if (!publicationRecord?.icon) { + if (!normalizedPub?.icon) { // Generate a placeholder with the first letter of the publication name - const firstLetter = (publicationRecord?.name || "?") + const firstLetter = (normalizedPub?.name || "?") .slice(0, 1) .toUpperCase(); @@ -94,7 +96,7 @@ export async function GET(req: NextRequest) { const pubUri = new AtUri(publicationUri); // Get the CID from the icon blob - const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ + const cid = (normalizedPub.icon.ref as unknown as { $link: string })[ "$link" ]; diff --git a/app/api/rpc/[command]/get_profile_data.ts b/app/api/rpc/[command]/get_profile_data.ts index 2c0e4f8e..16e81fa3 100644 --- a/app/api/rpc/[command]/get_profile_data.ts +++ b/app/api/rpc/[command]/get_profile_data.ts @@ -6,6 +6,10 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Agent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; import { createOauthClient } from "src/atproto-oauth"; +import { + normalizePublicationRow, + hasValidPublication, +} from "src/utils/normalizeRecords"; export type GetProfileDataReturnType = Awaited< ReturnType<(typeof get_profile_data)["handler"]> @@ -59,10 +63,15 @@ export const get_profile_data = makeRoute({ publicationsReq, ]); + // Normalize publication records before returning + const normalizedPublications = (publications || []) + .map(normalizePublicationRow) + .filter(hasValidPublication); + return { result: { profile, - publications: publications || [], + publications: normalizedPublications, }, }; }, diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 8af7dc5b..6a58f951 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -3,6 +3,7 @@ import { makeRoute } from "../lib"; import type { Env } from "./route"; import { AtUri } from "@atproto/syntax"; import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export type GetPublicationDataReturnType = Awaited< ReturnType<(typeof get_publication_data)["handler"]> @@ -58,6 +59,42 @@ export const get_publication_data = makeRoute({ { supabase }, ); - return { result: { publication, leaflet_data: leaflet_data.result } }; + // Pre-normalize documents from documents_in_publications + const documents = (publication?.documents_in_publications || []) + .map((dip) => { + if (!dip.documents) return null; + const normalized = normalizeDocumentRecord(dip.documents.data); + if (!normalized) return null; + return { + uri: dip.documents.uri, + record: normalized, + indexed_at: dip.documents.indexed_at, + data: dip.documents.data, + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, + mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, + }; + }) + .filter((d): d is NonNullable => d !== null); + + // Pre-filter drafts (leaflets without published documents, not archived) + const drafts = (publication?.leaflets_in_publications || []) + .filter((l) => !l.documents) + .filter((l) => !(l as { archived?: boolean }).archived) + .map((l) => ({ + leaflet: l.leaflet, + title: l.title, + permission_tokens: l.permission_tokens, + // Keep the full leaflet data for LeafletList compatibility + _raw: l, + })); + + return { + result: { + publication, + documents, + drafts, + leaflet_data: leaflet_data.result, + }, + }; }, }); diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index 39664a84..938ec530 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -2,7 +2,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; -import { CSSProperties, useContext, useRef } from "react"; +import { CSSProperties, useRef } from "react"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { PostContent, Block } from "../PostContent"; import { @@ -15,7 +15,7 @@ import { } from "lexicons/api"; import { AppBskyFeedDefs } from "@atproto/api"; import { TextBlock } from "./TextBlock"; -import { PostPageContext } from "../PostPageContext"; +import { useDocument } from "contexts/DocumentContext"; import { openPage, useOpenPages } from "../PostPages"; import { openInteractionDrawer, @@ -155,8 +155,7 @@ export function PagePreview(props: { }) { let previewRef = useRef(null); let { rootEntity } = useReplicache(); - let data = useContext(PostPageContext); - let theme = data?.theme; + const { theme } = useDocument(); let pageWidth = `var(--page-width-unitless)`; let cardBorderHidden = !theme?.showPageBackground; return ( @@ -195,14 +194,11 @@ export function PagePreview(props: { } const Interactions = (props: { pageId: string; parentPageId?: string }) => { - const data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); - let comments = data.comments_on_documents.filter( + const { uri: document_uri, comments: allComments, mentions } = useDocument(); + let comments = allComments.filter( (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, ).length; - let quotes = data.document_mentions_in_bsky.filter((q) => + let quotes = mentions.filter((q) => q.link.includes(props.pageId), ).length; diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 1ea95dcc..23964296 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -69,8 +69,8 @@ export function CanvasPage({ data={document} profile={profile} preferences={preferences} - commentsCount={getCommentCount(document, pageId)} - quotesCount={getQuoteCount(document, pageId)} + commentsCount={getCommentCount(document.comments_on_documents, pageId)} + quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} />
@@ -55,10 +58,8 @@ export async function DocumentPageRenderer({
); - - let record = document.data as PubLeafletDocument.Record; let bskyPosts = - record.pages.flatMap((p) => { + pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; return page.blocks?.filter( (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, @@ -91,7 +92,7 @@ export async function DocumentPageRenderer({ : []; // Extract poll blocks and fetch vote data - let pollBlocks = record.pages.flatMap((p) => { + let pollBlocks = pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; return ( page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || @@ -102,16 +103,11 @@ export async function DocumentPageRenderer({ pollBlocks.map((b) => (b.block as any).pollRef.uri), ); - // Get theme from publication or document (for standalone docs) - let pubRecord = document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; - let theme = pubRecord?.theme || record.theme || null; - let pub_creator = - document.documents_in_publications[0]?.publications?.identity_did || did; + const pubRecord = document.normalizedPublication; + let pub_creator = document.publication?.identity_did || did; let isStandalone = !pubRecord; - let firstPage = record.pages[0]; - + let firstPage = pages[0]; let firstPageBlocks = ( firstPage as @@ -121,26 +117,28 @@ export async function DocumentPageRenderer({ let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); return ( - - - - - - + + + + + + + - - - - + + + + + ); } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index a612f7e7..58ecd19c 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -6,14 +6,13 @@ import type { Json } from "supabase/database.types"; import { create } from "zustand"; import type { Comment } from "./Comments"; import { decodeQuotePosition, QuotePosition } from "../quotePosition"; -import { useContext } from "react"; -import { PostPageContext } from "../PostPageContext"; +import { useDocument } from "contexts/DocumentContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; import { TagTiny } from "components/Icons/TagTiny"; import { Tag } from "components/Tags"; import { Popover } from "components/Popover"; -import { PostPageData } from "../getPostPageData"; -import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; +import { PubLeafletComment } from "lexicons/api"; +import { type CommentOnDocument } from "contexts/DocumentContext"; import { prefetchQuotesData } from "./Quotes"; import { useIdentityData } from "components/IdentityProvider"; import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; @@ -111,21 +110,18 @@ export const Interactions = (props: { showMentions?: boolean; pageId?: string; }) => { - const data = useContext(PostPageContext); - const document_uri = data?.uri; + const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); let { identity } = useIdentityData(); - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); const handleQuotePrefetch = () => { - if (data?.quotesAndMentions) { - prefetchQuotesData(data.quotesAndMentions); + if (quotesAndMentions) { + prefetchQuotesData(quotesAndMentions); } }; - const tags = (data?.data as any)?.tags as string[] | undefined; + const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; return ( @@ -172,23 +168,18 @@ export const ExpandedInteractions = (props: { showMentions?: boolean; pageId?: string; }) => { - const data = useContext(PostPageContext); + const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); let { identity } = useIdentityData(); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); const handleQuotePrefetch = () => { - if (data?.quotesAndMentions) { - prefetchQuotesData(data.quotesAndMentions); + if (quotesAndMentions) { + prefetchQuotesData(quotesAndMentions); } }; - let publication = data?.documents_in_publications[0]?.publications; - const tags = (data?.data as any)?.tags as string[] | undefined; + const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; let noInteractions = !props.showComments && !props.showMentions; @@ -202,9 +193,8 @@ export const ExpandedInteractions = (props: { let isAuthor = identity && - identity.atp_did === - data.documents_in_publications[0]?.publications?.identity_did && - data.leaflets_in_publications[0]; + identity.atp_did === publication?.identity_did && + leafletId; return (
)} - + {subscribed && publication && ( {
); }; -export function getQuoteCount(document: PostPageData, pageId?: string) { - if (!document) return; - return getQuoteCountFromArray(document.quotesAndMentions, pageId); +export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { + return getQuoteCountFromArray(quotesAndMentions, pageId); } export function getQuoteCountFromArray( @@ -366,34 +355,34 @@ export function getQuoteCountFromArray( } } -export function getCommentCount(document: PostPageData, pageId?: string) { - if (!document) return; +export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { if (pageId) - return document.comments_on_documents.filter( + return comments.filter( (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ).length; else - return document.comments_on_documents.filter( + return comments.filter( (c) => !(c.record as PubLeafletComment.Record)?.onPage, ).length; } -const EditButton = (props: { document: PostPageData }) => { +const EditButton = (props: { + publication: { identity_did: string } | null; + leafletId: string | null; +}) => { let { identity } = useIdentityData(); - if (!props.document) return; if ( identity && - identity.atp_did === - props.document.documents_in_publications[0]?.publications?.identity_did && - props.document.leaflets_in_publications[0] + identity.atp_did === props.publication?.identity_did && + props.leafletId ) return (
Edit Post ); - return; + return null; }; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 9f945a84..9f54f84d 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -1,19 +1,18 @@ "use client"; import { CloseTiny } from "components/Icons/CloseTiny"; -import { useContext } from "react"; import { useIsMobile } from "src/hooks/isMobile"; import { setInteractionState } from "./Interactions"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; import { AtUri, AppBskyFeedPost } from "@atproto/api"; -import { PostPageContext } from "../PostPageContext"; import { PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksHeader, - PubLeafletDocument, PubLeafletPagesLinearDocument, PubLeafletBlocksCode, } from "lexicons/api"; +import { useDocument } from "contexts/DocumentContext"; +import { useLeafletContent } from "contexts/LeafletContentContext"; import { decodeQuotePosition, QuotePosition } from "../quotePosition"; import { useActiveHighlightState } from "../useHighlight"; import { PostContent } from "../PostContent"; @@ -66,10 +65,7 @@ export const Quotes = (props: { quotesAndMentions: { uri: string; link?: string }[]; did: string; }) => { - let data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); + const { uri: document_uri } = useDocument(); // Fetch Bluesky post data for all URIs const uris = props.quotesAndMentions.map((q) => q.uri); @@ -182,18 +178,17 @@ export const QuoteContent = (props: { did: string; }) => { let isMobile = useIsMobile(); - const data = useContext(PostPageContext); - const document_uri = data?.uri; + const { uri: document_uri } = useDocument(); + const { pages } = useLeafletContent(); - let record = data?.data as PubLeafletDocument.Record; let page: PubLeafletPagesLinearDocument.Main | undefined = ( props.position.pageId - ? record.pages.find( + ? pages.find( (p) => (p as PubLeafletPagesLinearDocument.Main).id === props.position.pageId, ) - : record.pages[0] + : pages[0] ) as PubLeafletPagesLinearDocument.Main; // Extract blocks within the quote range const content = extractQuotedBlocks(page.blocks || [], props.position, []); diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index dfd3e6b5..23737847 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -1,10 +1,6 @@ "use client"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; +import { useLeafletContent } from "contexts/LeafletContentContext"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; @@ -49,15 +45,12 @@ export function LinearDocumentPage({ hasPageBackground, } = props; let drawer = useDrawerOpen(document_uri); + const { pages } = useLeafletContent(); if (!document) return null; - let record = document.data as PubLeafletDocument.Record; - const isSubpage = !!pageId; - console.log("prev/next?: " + preferences.showPrevNext); - return ( <> {!hasPageBackground &&
} diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 364dfac5..688de287 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -1,9 +1,4 @@ "use client"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { Interactions, @@ -28,12 +23,12 @@ export function PostHeader(props: { let { identity } = useIdentityData(); let document = props.data; - let record = document?.data as PubLeafletDocument.Record; + const record = document?.normalizedDocument; let profile = props.profile; let pub = props.data?.documents_in_publications[0]?.publications; const formattedDate = useLocalizedDate( - record.publishedAt || new Date().toISOString(), + record?.publishedAt || new Date().toISOString(), { year: "numeric", month: "long", @@ -41,7 +36,7 @@ export function PostHeader(props: { }, ); - if (!document?.data) return; + if (!document?.data || !record) return null; return ( } diff --git a/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx b/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx deleted file mode 100644 index 0ccfade2..00000000 --- a/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; -import { createContext } from "react"; -import { PostPageData } from "./getPostPageData"; - -export const PostPageContext = createContext(null); - -export const PostPageContextProvider = ({ - children, - value, -}: { - children: React.ReactNode; - value: PostPageData; -}) => { - return ( - - {children} - - ); -}; diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index d60b23c9..c2305d27 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -1,10 +1,12 @@ "use client"; import { - PubLeafletDocument, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletPublication, } from "lexicons/api"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; +import { useLeafletContent } from "contexts/LeafletContentContext"; +import { useDocument } from "contexts/DocumentContext"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AppBskyFeedDefs } from "@atproto/api"; @@ -152,7 +154,7 @@ export type SharedPageProps = { showMentions?: boolean; showPrevNext?: boolean; }; - pubRecord?: PubLeafletPublication.Record; + pubRecord?: NormalizedPublication | null; theme?: PubLeafletPublication.Theme | null; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -206,7 +208,7 @@ export function PostPages({ document_uri: string; document: PostPageData; profile: ProfileViewDetailed; - pubRecord?: PubLeafletPublication.Record; + pubRecord?: NormalizedPublication | null; did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -220,17 +222,18 @@ export function PostPages({ let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); let openPageIds = useOpenPages(); - if (!document) return null; + const { pages } = useLeafletContent(); + const { quotesAndMentions } = useDocument(); + const record = document?.normalizedDocument; + if (!document || !record) return null; - let record = document.data as PubLeafletDocument.Record; let theme = pubRecord?.theme || record.theme || null; // For publication posts, respect the publication's showPageBackground setting // For standalone documents, default to showing page background let isInPublication = !!pubRecord; let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; - let quotesAndMentions = document.quotesAndMentions; - let firstPage = record.pages[0] as + let firstPage = pages[0] as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; @@ -250,7 +253,7 @@ export function PostPages({ pollData, document_uri, hasPageBackground, - allPages: record.pages as ( + allPages: pages as ( | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main )[], @@ -329,7 +332,7 @@ export function PostPages({ } // Handle document pages - let page = record.pages.find( + let page = pages.find( (p) => ( p as diff --git a/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx b/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx index 48a1a99c..35126aec 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx @@ -1,30 +1,24 @@ "use client"; -import { PubLeafletDocument } from "lexicons/api"; -import { usePublicationData } from "../dashboard/PublicationSWRProvider"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { AtUri } from "@atproto/api"; -import { useParams } from "next/navigation"; -import { getPostPageData } from "./getPostPageData"; -import { PostPageContext } from "./PostPageContext"; -import { useContext } from "react"; +import { useDocument } from "contexts/DocumentContext"; import { SpeedyLink } from "components/SpeedyLink"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; export const PostPrevNextButtons = (props: { showPrevNext: boolean | undefined; }) => { - let postData = useContext(PostPageContext); - let pub = postData?.documents_in_publications[0]?.publications; + const { prevNext, publication } = useDocument(); - if (!props.showPrevNext || !pub || !postData) return; + if (!props.showPrevNext || !publication) return null; function getPostLink(uri: string) { - return pub && uri - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` + return publication && uri + ? `${getPublicationURL(publication)}/${new AtUri(uri).rkey}` : "leaflet.pub/not-found"; } - let prevPost = postData?.prevNext?.prev; - let nextPost = postData?.prevNext?.next; + let prevPost = prevNext?.prev; + let nextPost = prevNext?.next; return (
diff --git a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx index beb835e3..7918ab2d 100644 --- a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx +++ b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx @@ -3,7 +3,7 @@ import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { CopyTiny } from "components/Icons/CopyTiny"; import { Separator } from "components/Layout"; import { useSmoker } from "components/Toast"; -import { useEffect, useMemo, useState, useContext } from "react"; +import { useEffect, useMemo, useState } from "react"; import { encodeQuotePosition, decodeQuotePosition, @@ -12,8 +12,7 @@ import { import { useIdentityData } from "components/IdentityProvider"; import { CommentTiny } from "components/Icons/CommentTiny"; import { setInteractionState } from "./Interactions/Interactions"; -import { PostPageContext } from "./PostPageContext"; -import { PubLeafletPublication } from "lexicons/api"; +import { useDocument } from "contexts/DocumentContext"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; @@ -148,10 +147,7 @@ export function QuoteHandler() { export const QuoteOptionButtons = (props: { position: string }) => { let smoker = useSmoker(); let { identity } = useIdentityData(); - const data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); + const { uri: document_uri, publication } = useDocument(); let [url, position] = useMemo(() => { let currentUrl = new URL(window.location.href); let pos = decodeQuotePosition(props.position); @@ -169,9 +165,7 @@ export const QuoteOptionButtons = (props: { position: string }) => { currentUrl.hash = `#${fragmentId}`; return [currentUrl.toString(), pos]; }, [props.position]); - let pubRecord = data.documents_in_publications[0]?.publications?.record as - | PubLeafletPublication.Record - | undefined; + let pubRecord = publication?.record; return ( <> diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index aa0cc515..d40622cd 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -1,6 +1,12 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { PubLeafletPublication } from "lexicons/api"; export async function getPostPageData(uri: string) { let { data: document } = await supabaseServerClient @@ -23,12 +29,19 @@ export async function getPostPageData(uri: string) { if (!document) return null; + // Normalize the document record - this is the primary way consumers should access document data + const normalizedDocument = normalizeDocumentRecord(document.data); + if (!normalizedDocument) return null; + + // Normalize the publication record - this is the primary way consumers should access publication data + const normalizedPublication = normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record + ); + // Fetch constellation backlinks for mentions - const pubRecord = document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record; let aturi = new AtUri(uri); - const postUrl = pubRecord - ? `https://${pubRecord?.base_path}/${aturi.rkey}` + const postUrl = normalizedPublication + ? `${normalizedPublication.url}/${aturi.rkey}` : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; const constellationBacklinks = await getConstellationBacklinks(postUrl); @@ -48,11 +61,7 @@ export async function getPostPageData(uri: string) { ...uniqueBacklinks, ]; - let theme = - ( - document?.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record - )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; + let theme = normalizedPublication?.theme || normalizedDocument?.theme; // Calculate prev/next documents from the fetched publication documents let prevNext: @@ -62,8 +71,7 @@ export async function getPostPageData(uri: string) { } | undefined; - const currentPublishedAt = (document.data as PubLeafletDocument.Record) - ?.publishedAt; + const currentPublishedAt = normalizedDocument.publishedAt; const allDocs = document.documents_in_publications[0]?.publications ?.documents_in_publications; @@ -71,13 +79,15 @@ export async function getPostPageData(uri: string) { if (currentPublishedAt && allDocs) { // Filter and sort documents by publishedAt const sortedDocs = allDocs - .map((dip) => ({ - uri: dip?.documents?.uri, - title: (dip?.documents?.data as PubLeafletDocument.Record).title, - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) - .publishedAt, - })) - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt + .map((dip) => { + const normalizedData = normalizeDocumentRecord(dip?.documents?.data); + return { + uri: dip?.documents?.uri, + title: normalizedData?.title, + publishedAt: normalizedData?.publishedAt, + }; + }) + .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data .sort( (a, b) => new Date(a.publishedAt!).getTime() - @@ -93,25 +103,43 @@ export async function getPostPageData(uri: string) { currentIndex > 0 ? { uri: sortedDocs[currentIndex - 1].uri || "", - title: sortedDocs[currentIndex - 1].title, + title: sortedDocs[currentIndex - 1].title || "", } : undefined, next: currentIndex < sortedDocs.length - 1 ? { uri: sortedDocs[currentIndex + 1].uri || "", - title: sortedDocs[currentIndex + 1].title, + title: sortedDocs[currentIndex + 1].title || "", } : undefined, }; } } + // Build explicit publication context for consumers + const rawPub = document.documents_in_publications[0]?.publications; + const publication = rawPub ? { + uri: rawPub.uri, + name: rawPub.name, + identity_did: rawPub.identity_did, + record: rawPub.record as PubLeafletPublication.Record | null, + publication_subscriptions: rawPub.publication_subscriptions || [], + } : null; + return { ...document, + // Pre-normalized data - consumers should use these instead of normalizing themselves + normalizedDocument, + normalizedPublication, quotesAndMentions, theme, prevNext, + // Explicit relational data for DocumentContext + publication, + comments: document.comments_on_documents, + mentions: document.document_mentions_in_bsky, + leafletId: document.leaflets_in_publications[0]?.leaflet || null, }; } diff --git a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts index d58a9993..01d53045 100644 --- a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -2,9 +2,9 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { jsonToLex } from "@atproto/lexicon"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export const revalidate = 60; @@ -22,8 +22,8 @@ export default async function OpenGraphImage(props: { .single(); if (document) { - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; - if (docRecord.coverImage) { + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); + if (docRecord?.coverImage) { try { // Get CID from the blob ref (handle both serialized and hydrated forms) let cid = diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index bf1ea7e8..197006cb 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,9 +1,9 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { Metadata } from "next"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -21,7 +21,8 @@ export async function generateMetadata(props: { ]); if (!document) return { title: "404" }; - let docRecord = document.data as PubLeafletDocument.Record; + const docRecord = normalizeDocumentRecord(document.data); + if (!docRecord) return { title: "404" }; return { icons: { diff --git a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx index ee045aa6..7ccee1ea 100644 --- a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -2,8 +2,6 @@ "use client"; import { useParams } from "next/navigation"; -import { useContext } from "react"; -import { PostPageContext } from "./PostPageContext"; import { create } from "zustand"; import { decodeQuotePosition, QuotePosition } from "./quotePosition"; @@ -12,7 +10,6 @@ export const useActiveHighlightState = create(() => ({ })); export const useHighlight = (pos: number[], pageId?: string) => { - let doc = useContext(PostPageContext); let { quote } = useParams(); let activeHighlight = useActiveHighlightState( (state) => state.activeHighlight, diff --git a/app/lish/[did]/[publication]/dashboard/DraftList.tsx b/app/lish/[did]/[publication]/dashboard/DraftList.tsx index 998efcbd..5ef24543 100644 --- a/app/lish/[did]/[publication]/dashboard/DraftList.tsx +++ b/app/lish/[did]/[publication]/dashboard/DraftList.tsx @@ -2,7 +2,10 @@ import { NewDraftSecondaryButton } from "./NewDraftButton"; import React from "react"; -import { usePublicationData } from "./PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "./PublicationSWRProvider"; import { LeafletList } from "app/(home-pages)/home/HomeLayout"; export function DraftList(props: { @@ -10,8 +13,14 @@ export function DraftList(props: { showPageBackground: boolean; }) { let { data: pub_data } = usePublicationData(); + // Normalize the publication record - skip rendering if unrecognized format + const normalizedPubRecord = useNormalizedPublicationRecord(); if (!pub_data?.publication) return null; - let { leaflets_in_publications, ...publication } = pub_data.publication; + const { drafts, leaflet_data } = pub_data; + const { leaflets_in_publications, ...publication } = pub_data.publication; + + if (!normalizedPubRecord) return null; + return (
!l.documents) - .filter((l) => !l.archived) - .map((l) => { - return { - archived: l.archived, - added_at: "", - token: { - ...l.permission_tokens!, - leaflets_in_publications: [ - { - ...l, - publications: { - ...publication, - }, - }, - ], - }, - }; - })} - initialFacts={pub_data.leaflet_data.facts || {}} + leaflets={drafts + .filter((d) => d.permission_tokens) + .map((d) => ({ + archived: (d._raw as { archived?: boolean }).archived, + added_at: "", + token: { + ...d.permission_tokens!, + leaflets_in_publications: [ + { + ...d._raw, + publications: publication, + }, + ], + }, + }))} + initialFacts={leaflet_data.facts || {}} titles={{ - ...leaflets_in_publications.reduce( - (acc, leaflet) => { - if (leaflet.permission_tokens) - acc[leaflet.permission_tokens.root_entity] = - leaflet.title || "Untitled"; + ...drafts.reduce( + (acc, draft) => { + if (draft.permission_tokens) + acc[draft.permission_tokens.root_entity] = + draft.title || "Untitled"; return acc; }, {} as { [l: string]: string }, diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index 28aecd87..184f4e25 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -5,21 +5,19 @@ import { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publicat import { Actions } from "./Actions"; import React, { useState } from "react"; import { PublishedPostsList } from "./PublishedPostsLists"; -import { PubLeafletPublication } from "lexicons/api"; import { PublicationSubscribers } from "./PublicationSubscribers"; -import { AtUri } from "@atproto/syntax"; import { - HomeDashboardControls, DashboardLayout, PublicationDashboardControls, } from "components/PageLayouts/DashboardLayout"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; export default function PublicationDashboard({ publication, record, }: { - record: PubLeafletPublication.Record; + record: NormalizedPublication; publication: Exclude< GetPublicationDataReturnType["result"]["publication"], null diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx index 96f51b9c..4ba0fd87 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx @@ -2,11 +2,18 @@ import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; import { callRPC } from "app/api/rpc/client"; -import { createContext, useContext, useEffect } from "react"; +import { createContext, useContext, useEffect, useMemo } from "react"; import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; -import { produce, Draft } from "immer"; +import { produce, Draft as ImmerDraft } from "immer"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +// Derive all types from the RPC return type export type PublicationData = GetPublicationDataReturnType["result"]; +export type PublishedDocument = NonNullable["documents"][number]; +export type PublicationDraft = NonNullable["drafts"][number]; const PublicationContext = createContext({ name: "", did: "" }); export function PublicationSWRDataProvider(props: { @@ -49,9 +56,21 @@ export function usePublicationData() { return { data, mutate }; } +/** + * Returns the normalized publication record from the publication data. + * Use this instead of manually calling normalizePublicationRecord on data.publication.record + */ +export function useNormalizedPublicationRecord(): NormalizedPublication | null { + const { data } = usePublicationData(); + return useMemo( + () => normalizePublicationRecord(data?.publication?.record), + [data?.publication?.record] + ); +} + export function mutatePublicationData( mutate: KeyedMutator, - recipe: (draft: Draft>) => void, + recipe: (draft: ImmerDraft>) => void, ) { mutate( (data) => { diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index d8982373..ba41c8e4 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -1,22 +1,16 @@ "use client"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { EditTiny } from "components/Icons/EditTiny"; -import { usePublicationData } from "./PublicationSWRProvider"; -import { Fragment, useState } from "react"; +import { + usePublicationData, + useNormalizedPublicationRecord, + type PublishedDocument, +} from "./PublicationSWRProvider"; +import { Fragment } from "react"; import { useParams } from "next/navigation"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { Menu, MenuItem } from "components/Menu"; -import { deletePost } from "./deletePost"; -import { ButtonPrimary } from "components/Buttons"; -import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; -import { DeleteSmall } from "components/Icons/DeleteSmall"; -import { ShareSmall } from "components/Icons/ShareSmall"; -import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; import { SpeedyLink } from "components/SpeedyLink"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { CommentTiny } from "components/Icons/CommentTiny"; import { InteractionPreview } from "components/InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; @@ -27,132 +21,136 @@ export function PublishedPostsList(props: { showPageBackground: boolean; }) { let { data } = usePublicationData(); - let params = useParams(); - let { publication } = data!; - let pubRecord = publication?.record as PubLeafletPublication.Record; + let { publication, documents } = data || {}; + const pubRecord = useNormalizedPublicationRecord(); if (!publication) return null; - if (publication.documents_in_publications.length === 0) + if (!documents || documents.length === 0) return (
Nothing's been published yet...
); + + // Sort by publishedAt (most recent first) + const sortedDocuments = [...documents].sort((a, b) => { + const aDate = a.record.publishedAt + ? new Date(a.record.publishedAt) + : new Date(0); + const bDate = b.record.publishedAt + ? new Date(b.record.publishedAt) + : new Date(0); + return bDate.getTime() - aDate.getTime(); + }); + return (
- {publication.documents_in_publications - .sort((a, b) => { - let aRecord = a.documents?.data! as PubLeafletDocument.Record; - let bRecord = b.documents?.data! as PubLeafletDocument.Record; - const aDate = aRecord.publishedAt - ? new Date(aRecord.publishedAt) - : new Date(0); - const bDate = bRecord.publishedAt - ? new Date(bRecord.publishedAt) - : new Date(0); - return bDate.getTime() - aDate.getTime(); // Sort by most recent first - }) - .map((doc) => { - if (!doc.documents) return null; - let leaflet = publication.leaflets_in_publications.find( - (l) => doc.documents && l.doc === doc.documents.uri, - ); - let uri = new AtUri(doc.documents.uri); - let postRecord = doc.documents.data as PubLeafletDocument.Record; - let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; - let comments = doc.documents.comments_on_documents[0]?.count || 0; - let tags = (postRecord?.tags as string[] | undefined) || []; - - let postLink = data?.publication - ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` - : ""; + {sortedDocuments.map((doc) => ( + + ))} +
+ ); +} - return ( - -
-
-
- -

- {postRecord.title} -

-
-
- {leaflet && leaflet.permission_tokens && ( - <> - - - +function PublishedPostItem(props: { + doc: PublishedDocument; + publication: NonNullable["data"]>["publication"]>; + pubRecord: ReturnType; + showPageBackground: boolean; +}) { + const { doc, publication, pubRecord, showPageBackground } = props; + const uri = new AtUri(doc.uri); + const leaflet = publication.leaflets_in_publications.find( + (l) => l.doc === doc.uri, + ); - - - - - )} -
-
+ return ( + +
+
+
+ +

+ {doc.record.title} +

+
+
+ {leaflet && leaflet.permission_tokens && ( + <> + + + - {postRecord.description ? ( -

- {postRecord.description} -

- ) : null} -
- {postRecord.publishedAt ? ( - - ) : null} - -
-
-
- {!props.showPageBackground && ( -
+ + + + )} - - ); - })} -
+
+
+ + {doc.record.description ? ( +

+ {doc.record.description} +

+ ) : null} +
+ {doc.record.publishedAt ? ( + + ) : null} + +
+
+
+ {!showPageBackground && ( +
+ )} + ); } diff --git a/app/lish/[did]/[publication]/dashboard/page.tsx b/app/lish/[did]/[publication]/dashboard/page.tsx index d124fe8e..6dfd25bb 100644 --- a/app/lish/[did]/[publication]/dashboard/page.tsx +++ b/app/lish/[did]/[publication]/dashboard/page.tsx @@ -3,12 +3,11 @@ import { Metadata } from "next"; import { getIdentityData } from "actions/getIdentityData"; import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; -import { PubLeafletPublication } from "lexicons/api"; import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; import { AtUri } from "@atproto/syntax"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import PublicationDashboard from "./PublicationDashboard"; -import Link from "next/link"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string }>; @@ -24,8 +23,7 @@ export async function generateMetadata(props: { { supabase: supabaseServerClient }, ); let { publication } = publication_data; - let record = - (publication?.record as PubLeafletPublication.Record) || undefined; + const record = normalizePublicationRecord(publication?.record); if (!publication) return { title: "404 Publication" }; return { title: record?.name || "Untitled Publication" }; } @@ -56,7 +54,7 @@ export default async function Publication(props: { { supabase: supabaseServerClient }, ); let { publication, leaflet_data } = publication_data; - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); if (!publication || identity.atp_did !== publication.identity_did || !record) return ; diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx index 6309ca9b..0d564dd5 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx @@ -1,5 +1,7 @@ -import { PubLeafletPublication } from "lexicons/api"; -import { usePublicationData } from "../PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../PublicationSWRProvider"; import { PubSettingsHeader } from "./PublicationSettings"; import { useState } from "react"; import { Toggle } from "components/Toggle"; @@ -15,7 +17,7 @@ export const PostOptions = (props: { let { data } = usePublicationData(); let { publication: pubData } = data || {}; - let record = pubData?.record as PubLeafletPublication.Record; + const record = useNormalizedPublicationRecord(); let [showComments, setShowComments] = useState( record?.preferences?.showComments === undefined @@ -37,7 +39,7 @@ export const PostOptions = (props: { return (
{ - if (!pubData) return; + if (!pubData || !record) return; e.preventDefault(); props.setLoading(true); let data = await updatePublication({ diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts index 42096c05..337f50d6 100644 --- a/app/lish/[did]/[publication]/generateFeed.ts +++ b/app/lish/[did]/[publication]/generateFeed.ts @@ -1,14 +1,15 @@ import { AtUri } from "@atproto/syntax"; import { Feed } from "feed"; -import { - PubLeafletDocument, - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; import { createElement } from "react"; import { StaticPostContent } from "./[rkey]/StaticPostContent"; import { supabaseServerClient } from "supabase/serverClient"; import { NextResponse } from "next/server"; +import { + normalizePublicationRecord, + normalizeDocumentRecord, + hasLeafletContent, +} from "src/utils/normalizeRecords"; export async function generateFeed( did: string, @@ -37,37 +38,40 @@ export async function generateFeed( .or(`name.eq."${publication_name}", uri.eq."${uri}"`) .single(); - let pubRecord = publication?.record as PubLeafletPublication.Record; + const pubRecord = normalizePublicationRecord(publication?.record); if (!publication || !pubRecord) return new NextResponse(null, { status: 404 }); const feed = new Feed({ title: pubRecord.name, description: pubRecord.description, - id: `https://${pubRecord.base_path}`, - link: `https://${pubRecord.base_path}`, + id: pubRecord.url, + link: pubRecord.url, language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes copyright: "", feedLinks: { - rss: `https://${pubRecord.base_path}/rss`, - atom: `https://${pubRecord.base_path}/atom`, - json: `https://${pubRecord.base_path}/json`, + rss: `${pubRecord.url}/rss`, + atom: `${pubRecord.url}/atom`, + json: `${pubRecord.url}/json`, }, }); await Promise.all( publication.documents_in_publications.map(async (doc) => { if (!doc.documents) return; - let record = doc.documents?.data as PubLeafletDocument.Record; - let uri = new AtUri(doc.documents?.uri); - let rkey = uri.rkey; + const record = normalizeDocumentRecord(doc.documents?.data); + const uri = new AtUri(doc.documents?.uri); + const rkey = uri.rkey; if (!record) return; - let firstPage = record.pages[0]; + let blocks: PubLeafletPagesLinearDocument.Block[] = []; - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { - blocks = firstPage.blocks || []; + if (hasLeafletContent(record) && record.content.pages[0]) { + const firstPage = record.content.pages[0]; + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { + blocks = firstPage.blocks || []; + } } - let stream = await renderToReadableStream( + const stream = await renderToReadableStream( createElement(StaticPostContent, { blocks, did: uri.host }), ); const reader = stream.getReader(); @@ -85,8 +89,8 @@ export async function generateFeed( title: record.title, description: record.description, date: record.publishedAt ? new Date(record.publishedAt) : new Date(), - id: `https://${pubRecord.base_path}/${rkey}`, - link: `https://${pubRecord.base_path}/${rkey}`, + id: `${pubRecord.url}/${rkey}`, + link: `${pubRecord.url}/${rkey}`, content: chunks.join(""), }); }), diff --git a/app/lish/[did]/[publication]/icon/route.ts b/app/lish/[did]/[publication]/icon/route.ts index 80d8921e..47c2cccc 100644 --- a/app/lish/[did]/[publication]/icon/route.ts +++ b/app/lish/[did]/[publication]/icon/route.ts @@ -1,10 +1,10 @@ import { NextRequest } from "next/server"; import { IdResolver } from "@atproto/identity"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; import sharp from "sharp"; import { redirect } from "next/navigation"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; let idResolver = new IdResolver(); @@ -38,7 +38,7 @@ export async function GET( .or(`name.eq."${params.publication}", uri.eq."${uri}"`) .single(); - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); if (!record?.icon) return redirect("/icon.png"); let identity = await idResolver.did.resolve(did); diff --git a/app/lish/[did]/[publication]/layout.tsx b/app/lish/[did]/[publication]/layout.tsx index 1518b718..2e4aaa2e 100644 --- a/app/lish/[did]/[publication]/layout.tsx +++ b/app/lish/[did]/[publication]/layout.tsx @@ -1,7 +1,7 @@ -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { AtUri } from "@atproto/syntax"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; export default async function PublicationLayout(props: { children: React.ReactNode; @@ -41,7 +41,7 @@ export async function generateMetadata(props: { .single(); if (!publication) return { title: "Publication 404" }; - let pubRecord = publication?.record as PubLeafletPublication.Record; + const pubRecord = normalizePublicationRecord(publication?.record); return { title: pubRecord?.name || "Untitled Publication", @@ -60,12 +60,12 @@ export async function generateMetadata(props: { url: publication.uri, }, }, - alternates: pubRecord?.base_path + alternates: pubRecord?.url ? { types: { - "application/rss+xml": `https://${pubRecord?.base_path}/rss`, - "application/atom+xml": `https://${pubRecord?.base_path}/atom`, - "application/json": `https://${pubRecord?.base_path}/json`, + "application/rss+xml": `${pubRecord.url}/rss`, + "application/atom+xml": `${pubRecord.url}/atom`, + "application/json": `${pubRecord.url}/json`, }, } : undefined, diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 3266835d..b61eb4c6 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -1,7 +1,5 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import Link from "next/link"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { BskyAgent } from "@atproto/api"; import { SubscribeWithBluesky } from "app/lish/Subscribe"; @@ -12,13 +10,15 @@ import { } from "components/ThemeManager/PublicationThemeProvider"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { SpeedyLink } from "components/SpeedyLink"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { CommentTiny } from "components/Icons/CommentTiny"; import { InteractionPreview } from "components/InteractionsPreview"; import { LocalizedDate } from "./LocalizedDate"; import { PublicationHomeLayout } from "./PublicationHomeLayout"; import { PublicationAuthor } from "./PublicationAuthor"; import { Separator } from "components/Layout"; +import { + normalizePublicationRecord, + normalizeDocumentRecord, +} from "src/utils/normalizeRecords"; export default async function Publication(props: { params: Promise<{ publication: string; did: string }>; @@ -55,7 +55,7 @@ export default async function Publication(props: { agent.getProfile({ actor: did }), ]); - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); let showPageBackground = record?.theme?.showPageBackground; @@ -112,28 +112,28 @@ export default async function Publication(props: { {publication.documents_in_publications .filter((d) => !!d?.documents) .sort((a, b) => { - let aRecord = a.documents?.data! as PubLeafletDocument.Record; - let bRecord = b.documents?.data! as PubLeafletDocument.Record; - const aDate = aRecord.publishedAt + const aRecord = normalizeDocumentRecord(a.documents?.data); + const bRecord = normalizeDocumentRecord(b.documents?.data); + const aDate = aRecord?.publishedAt ? new Date(aRecord.publishedAt) : new Date(0); - const bDate = bRecord.publishedAt + const bDate = bRecord?.publishedAt ? new Date(bRecord.publishedAt) : new Date(0); return bDate.getTime() - aDate.getTime(); // Sort by most recent first }) .map((doc) => { if (!doc.documents) return null; + const doc_record = normalizeDocumentRecord(doc.documents.data); + if (!doc_record) return null; let uri = new AtUri(doc.documents.uri); - let doc_record = doc.documents - .data as PubLeafletDocument.Record; let quotes = doc.documents.document_mentions_in_bsky[0].count || 0; let comments = record?.preferences?.showComments === false ? 0 : doc.documents.comments_on_documents[0].count || 0; - let tags = (doc_record?.tags as string[] | undefined) || []; + let tags = doc_record.tags || []; return ( diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index 90843f90..3672c5f8 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -7,8 +7,10 @@ import { updatePublication, updatePublicationBasePath, } from "./updatePublication"; -import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; -import { PubLeafletPublication } from "lexicons/api"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../[did]/[publication]/dashboard/PublicationSWRProvider"; import useSWR, { mutate } from "swr"; import { AddTiny } from "components/Icons/AddTiny"; import { DotLoader } from "components/utils/DotLoader"; @@ -30,7 +32,7 @@ export const EditPubForm = (props: { }) => { let { data } = usePublicationData(); let { publication: pubData } = data || {}; - let record = pubData?.record as PubLeafletPublication.Record; + let record = useNormalizedPublicationRecord(); let [formState, setFormState] = useState<"normal" | "loading">("normal"); let [nameValue, setNameValue] = useState(record?.name || ""); @@ -60,14 +62,14 @@ export const EditPubForm = (props: { let [iconPreview, setIconPreview] = useState(null); let fileInputRef = useRef(null); useEffect(() => { - if (!pubData || !pubData.record) return; + if (!pubData || !pubData.record || !record) return; setNameValue(record.name); setDescriptionValue(record.description || ""); if (record.icon) setIconPreview( `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, ); - }, [pubData]); + }, [pubData, record]); let toast = useToaster(); return ( @@ -202,8 +204,9 @@ export const EditPubForm = (props: { export function CustomDomainForm() { let { data } = usePublicationData(); let { publication: pubData } = data || {}; + let record = useNormalizedPublicationRecord(); if (!pubData) return null; - let record = pubData?.record as PubLeafletPublication.Record; + if (!record) return null; let [state, setState] = useState< | { type: "default" } | { type: "addDomain" } @@ -243,7 +246,7 @@ export function CustomDomainForm() { { setState({ type: "domainSettings", diff --git a/app/lish/createPub/getPublicationURL.ts b/app/lish/createPub/getPublicationURL.ts index 357af329..da8a77b6 100644 --- a/app/lish/createPub/getPublicationURL.ts +++ b/app/lish/createPub/getPublicationURL.ts @@ -2,16 +2,41 @@ import { AtUri } from "@atproto/syntax"; import { PubLeafletPublication } from "lexicons/api"; import { isProductionDomain } from "src/utils/isProductionDeployment"; import { Json } from "supabase/database.types"; +import { + normalizePublicationRecord, + isLeafletPublication, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; -export function getPublicationURL(pub: { uri: string; record: Json }) { - let record = pub.record as PubLeafletPublication.Record; - if (isProductionDomain() && record?.base_path) - return `https://${record.base_path}`; - else return getBasePublicationURL(pub); +type PublicationInput = + | { uri: string; record: Json | NormalizedPublication | null } + | { uri: string; record: unknown }; + +/** + * Gets the public URL for a publication. + * Works with both pub.leaflet.publication and site.standard.publication records. + */ +export function getPublicationURL(pub: PublicationInput): string { + const normalized = normalizePublicationRecord(pub.record); + + // If we have a normalized record with a URL (site.standard format), use it + if (normalized?.url && isProductionDomain()) { + return normalized.url; + } + + // Fall back to checking raw record for legacy base_path + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { + return `https://${pub.record.base_path}`; + } + + return getBasePublicationURL(pub); } -export function getBasePublicationURL(pub: { uri: string; record: Json }) { - let record = pub.record as PubLeafletPublication.Record; - let aturi = new AtUri(pub.uri); - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; +export function getBasePublicationURL(pub: PublicationInput): string { + const normalized = normalizePublicationRecord(pub.record); + const aturi = new AtUri(pub.uri); + + // Use normalized name if available, fall back to rkey + const name = normalized?.name || aturi.rkey; + return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; } diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 6881ac03..11752c54 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -11,6 +11,10 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; import { $Typed } from "@atproto/api"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; type UpdatePublicationResult = | { success: true; publication: any } @@ -143,8 +147,28 @@ export async function updatePublicationBasePath({ } let aturi = new AtUri(existingPub.uri); + // Normalize the existing record to read its properties, then build a new pub.leaflet record + const normalizedPub = normalizePublicationRecord(existingPub.record); + // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) + const existingBasePath = normalizedPub?.url + ? normalizedPub.url.replace(/^https?:\/\//, "") + : undefined; + let record: PubLeafletPublication.Record = { - ...(existingPub.record as PubLeafletPublication.Record), + $type: "pub.leaflet.publication", + name: normalizedPub?.name || "", + description: normalizedPub?.description, + icon: normalizedPub?.icon, + theme: normalizedPub?.theme, + preferences: normalizedPub?.preferences + ? { + $type: "pub.leaflet.publication#preferences" as const, + showInDiscover: normalizedPub.preferences.showInDiscover, + showComments: normalizedPub.preferences.showComments, + showMentions: normalizedPub.preferences.showMentions, + showPrevNext: normalizedPub.preferences.showPrevNext, + } + : undefined, base_path, }; @@ -219,10 +243,28 @@ export async function updatePublicationTheme({ } let aturi = new AtUri(existingPub.uri); - let oldRecord = existingPub.record as PubLeafletPublication.Record; + // Normalize the existing record to read its properties + const normalizedPub = normalizePublicationRecord(existingPub.record); + // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) + const existingBasePath = normalizedPub?.url + ? normalizedPub.url.replace(/^https?:\/\//, "") + : undefined; + let record: PubLeafletPublication.Record = { - ...oldRecord, $type: "pub.leaflet.publication", + name: normalizedPub?.name || "", + description: normalizedPub?.description, + icon: normalizedPub?.icon, + base_path: existingBasePath, + preferences: normalizedPub?.preferences + ? { + $type: "pub.leaflet.publication#preferences" as const, + showInDiscover: normalizedPub.preferences.showInDiscover, + showComments: normalizedPub.preferences.showComments, + showMentions: normalizedPub.preferences.showMentions, + showPrevNext: normalizedPub.preferences.showPrevNext, + } + : undefined, theme: { backgroundImage: theme.backgroundImage ? { @@ -238,7 +280,7 @@ export async function updatePublicationTheme({ } : theme.backgroundImage === null ? undefined - : oldRecord.theme?.backgroundImage, + : normalizedPub?.theme?.backgroundImage, backgroundColor: theme.backgroundColor ? { ...theme.backgroundColor, diff --git a/app/lish/feeds/[...path]/route.ts b/app/lish/feeds/[...path]/route.ts index ff98264c..953df731 100644 --- a/app/lish/feeds/[...path]/route.ts +++ b/app/lish/feeds/[...path]/route.ts @@ -2,7 +2,10 @@ import { NextResponse } from "next/server"; import { DidResolver } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletDocument } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; const serviceDid = "did:web:leaflet.pub:lish:feeds"; export async function GET( @@ -34,9 +37,9 @@ export async function GET( let posts = pub.publications?.documents_in_publications || []; return posts.flatMap((p) => { if (!p.documents?.data) return []; - let record = p.documents.data as PubLeafletDocument.Record; - if (!record.postRef) return []; - return { post: record.postRef.uri }; + const normalizedDoc = normalizeDocumentRecord(p.documents.data); + if (!normalizedDoc?.bskyPostRef) return []; + return { post: normalizedDoc.bskyPostRef.uri }; }); }), ], diff --git a/app/lish/uri/[uri]/route.ts b/app/lish/uri/[uri]/route.ts index f4e0d0ed..d15d4f0c 100644 --- a/app/lish/uri/[uri]/route.ts +++ b/app/lish/uri/[uri]/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { AtUri } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletPublication } from "lexicons/api"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; /** * Redirect route for AT URIs (publications and documents) @@ -28,17 +31,15 @@ export async function GET( return new NextResponse("Publication not found", { status: 404 }); } - const record = publication.record as PubLeafletPublication.Record; - const basePath = record.base_path; - - if (!basePath) { - return new NextResponse("Publication has no base_path", { + const normalizedPub = normalizePublicationRecord(publication.record); + if (!normalizedPub?.url) { + return new NextResponse("Publication has no url", { status: 404, }); } - // Redirect to the publication's hosted domain (temporary redirect since base_path can change) - return NextResponse.redirect(basePath, 307); + // Redirect to the publication's hosted domain (temporary redirect since url can change) + return NextResponse.redirect(normalizedPub.url, 307); } else if (uri.collection === "pub.leaflet.document") { // Document link - need to find the publication it belongs to const { data: docInPub } = await supabaseServerClient @@ -49,26 +50,23 @@ export async function GET( if (docInPub?.publication && docInPub.publications) { // Document is in a publication - redirect to domain/rkey - const record = docInPub.publications - .record as PubLeafletPublication.Record; - const basePath = record.base_path; + const normalizedPub = normalizePublicationRecord( + docInPub.publications.record, + ); - if (!basePath) { - return new NextResponse("Publication has no base_path", { + if (!normalizedPub?.url) { + return new NextResponse("Publication has no url", { status: 404, }); } - // Ensure basePath ends without trailing slash - const cleanBasePath = basePath.endsWith("/") - ? basePath.slice(0, -1) - : basePath; + // Ensure url ends without trailing slash + const cleanUrl = normalizedPub.url.endsWith("/") + ? normalizedPub.url.slice(0, -1) + : normalizedPub.url; - // Redirect to the document on the publication's domain (temporary redirect since base_path can change) - return NextResponse.redirect( - `https://${cleanBasePath}/${uri.rkey}`, - 307, - ); + // Redirect to the document on the publication's domain (temporary redirect since url can change) + return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307); } // If not in a publication, check if it's a standalone document diff --git a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts index dbf93f01..ba24a0ae 100644 --- a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -2,10 +2,10 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { jsonToLex } from "@atproto/lexicon"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export const revalidate = 60; @@ -35,8 +35,8 @@ export default async function OpenGraphImage(props: { .single(); if (document) { - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; - if (docRecord.coverImage) { + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); + if (docRecord?.coverImage) { try { // Get CID from the blob ref (handle both serialized and hydrated forms) let cid = diff --git a/app/p/[didOrHandle]/[rkey]/page.tsx b/app/p/[didOrHandle]/[rkey]/page.tsx index ed34577c..2ba3143e 100644 --- a/app/p/[didOrHandle]/[rkey]/page.tsx +++ b/app/p/[didOrHandle]/[rkey]/page.tsx @@ -1,11 +1,11 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { Metadata } from "next"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export async function generateMetadata(props: { params: Promise<{ didOrHandle: string; rkey: string }>; @@ -32,7 +32,8 @@ export async function generateMetadata(props: { if (!document) return { title: "404" }; - let docRecord = document.data as PubLeafletDocument.Record; + const docRecord = normalizeDocumentRecord(document.data); + if (!docRecord) return { title: "404" }; // For documents in publications, include publication name let publicationName = diff --git a/appview/index.ts b/appview/index.ts index 31b0f0c2..c229e0e1 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -231,6 +231,8 @@ async function handleEvent(evt: Event) { .eq("uri", evt.uri.toString()); } } + // site.standard.document records go into the main "documents" table + // The normalization layer handles reading both pub.leaflet and site.standard formats if (evt.collection === ids.SiteStandardDocument) { if (evt.event === "create" || evt.event === "update") { let record = SiteStandardDocument.validateRecord(evt.record); @@ -238,15 +240,13 @@ async function handleEvent(evt: Event) { console.log(record.error); return; } - await supabase - .from("identities") - .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); - let docResult = await supabase.from("site_standard_documents").upsert({ + let docResult = await supabase.from("documents").upsert({ uri: evt.uri.toString(), data: record.value as Json, - identity_did: evt.did, }); if (docResult.error) console.log(docResult.error); + + // site.standard.document uses "site" field to reference the publication if (record.value.site) { let siteURI = new AtUri(record.value.site); @@ -255,13 +255,13 @@ async function handleEvent(evt: Event) { return; } let docInPublicationResult = await supabase - .from("site_standard_documents_in_publications") + .from("documents_in_publications") .upsert({ publication: record.value.site, document: evt.uri.toString(), }); await supabase - .from("site_standard_documents_in_publications") + .from("documents_in_publications") .delete() .neq("publication", record.value.site) .eq("document", evt.uri.toString()); @@ -271,12 +271,11 @@ async function handleEvent(evt: Event) { } } if (evt.event === "delete") { - await supabase - .from("site_standard_documents") - .delete() - .eq("uri", evt.uri.toString()); + await supabase.from("documents").delete().eq("uri", evt.uri.toString()); } } + + // site.standard.publication records go into the main "publications" table if (evt.collection === ids.SiteStandardPublication) { if (evt.event === "create" || evt.event === "update") { let record = SiteStandardPublication.validateRecord(evt.record); @@ -284,19 +283,22 @@ async function handleEvent(evt: Event) { await supabase .from("identities") .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); - await supabase.from("site_standard_publications").upsert({ + await supabase.from("publications").upsert({ uri: evt.uri.toString(), identity_did: evt.did, - data: record.value as Json, + name: record.value.name, + record: record.value as Json, }); } if (evt.event === "delete") { await supabase - .from("site_standard_publications") + .from("publications") .delete() .eq("uri", evt.uri.toString()); } } + + // site.standard.graph.subscription records go into the main "publication_subscriptions" table if (evt.collection === ids.SiteStandardGraphSubscription) { if (evt.event === "create" || evt.event === "update") { let record = SiteStandardGraphSubscription.validateRecord(evt.record); @@ -304,7 +306,7 @@ async function handleEvent(evt: Event) { await supabase .from("identities") .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); - await supabase.from("site_standard_subscriptions").upsert({ + await supabase.from("publication_subscriptions").upsert({ uri: evt.uri.toString(), identity: evt.did, publication: record.value.publication, @@ -313,7 +315,7 @@ async function handleEvent(evt: Event) { } if (evt.event === "delete") { await supabase - .from("site_standard_subscriptions") + .from("publication_subscriptions") .delete() .eq("uri", evt.uri.toString()); } diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx index 964d3b5e..305db983 100644 --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -5,9 +5,12 @@ import { useIdentityData } from "components/IdentityProvider"; import { theme } from "tailwind.config"; import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; import { Json } from "supabase/database.types"; -import { PubLeafletPublication } from "lexicons/api"; import { AtUri } from "@atproto/syntax"; import { ActionButton } from "./ActionButton"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; import { SpeedyLink } from "components/SpeedyLink"; import { PublishSmall } from "components/Icons/PublishSmall"; import { Popover } from "components/Popover"; @@ -85,7 +88,7 @@ export const PublicationOption = (props: { record: Json; current?: boolean; }) => { - let record = props.record as PubLeafletPublication.Record | null; + let record = normalizePublicationRecord(props.record); if (!record) return; return ( @@ -181,13 +184,13 @@ export const PubListEmptyContent = (props: { compact?: boolean }) => { }; export const PubIcon = (props: { - record: PubLeafletPublication.Record; + record: NormalizedPublication | null; uri: string; small?: boolean; large?: boolean; className?: string; }) => { - if (!props.record) return; + if (!props.record) return null; let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx index 002095c5..f776b566 100644 --- a/components/Blocks/PublicationPollBlock.tsx +++ b/components/Blocks/PublicationPollBlock.tsx @@ -11,9 +11,9 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { PubLeafletBlocksPoll, - PubLeafletDocument, PubLeafletPagesLinearDocument, } from "lexicons/api"; +import { getDocumentPages } from "src/utils/normalizeRecords"; import { ids } from "lexicons/api/lexicons"; /** @@ -22,19 +22,19 @@ import { ids } from "lexicons/api/lexicons"; * but disables adding new options once the poll record exists (indicated by pollUri). */ export const PublicationPollBlock = (props: BlockProps) => { - let { data: publicationData } = useLeafletPublicationData(); + let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); // Check if this poll has been published in a publication document const isPublished = useMemo(() => { - if (!publicationData?.documents?.data) return false; + if (!normalizedDocument) return false; - const docRecord = publicationData.documents - .data as PubLeafletDocument.Record; + const pages = getDocumentPages(normalizedDocument); + if (!pages) return false; // Search through all pages and blocks to find if this poll entity has been published - for (const page of docRecord.pages || []) { + for (const page of pages) { if (page.$type === "pub.leaflet.pages.linearDocument") { const linearPage = page as PubLeafletPagesLinearDocument.Main; for (const blockWrapper of linearPage.blocks || []) { @@ -50,7 +50,7 @@ export const PublicationPollBlock = (props: BlockProps) => { } } return false; - }, [publicationData, props.entityID]); + }, [normalizedDocument, props.entityID]); return ( { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); if (!pub || !pub.publications) return null; - let pubRecord = pub.publications.record as PubLeafletPublication.Record; - let showComments = pubRecord.preferences?.showComments; - let showMentions = pubRecord.preferences?.showMentions; + if (!normalizedPublication) return null; + let showComments = normalizedPublication.preferences?.showComments; + let showMentions = normalizedPublication.preferences?.showMentions; return (
diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index ab866443..227c17e9 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -6,10 +6,16 @@ import useSWR from "swr"; import { callRPC } from "app/api/rpc/client"; import { getPollData } from "actions/pollActions"; import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo } from "react"; import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { AtUri } from "@atproto/syntax"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; export const StaticLeafletDataContext = createContext< null | GetLeafletDataReturnType["result"]["data"] @@ -73,8 +79,21 @@ export function useLeafletPublicationData() { // First check for leaflets in publications let pubData = getPublicationMetadataFromLeafletData(data); + // Normalize records so consumers don't have to + const normalizedPublication = useMemo( + () => normalizePublicationRecord(pubData?.publications?.record), + [pubData?.publications?.record] + ); + const normalizedDocument = useMemo( + () => normalizeDocumentRecord(pubData?.documents?.data), + [pubData?.documents?.data] + ); + return { data: pubData || null, + // Pre-normalized data - consumers should use these instead of normalizing themselves + normalizedPublication, + normalizedDocument, mutate, }; } diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index cc111575..9be922c4 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -5,7 +5,6 @@ import { useReplicache } from "src/replicache"; import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; import { Separator } from "components/Layout"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { getBasePublicationURL, getPublicationURL, @@ -22,17 +21,13 @@ import { useIdentityData } from "components/IdentityProvider"; import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; export const PublicationMetadata = () => { let { rep } = useReplicache(); - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); let { identity } = useIdentityData(); let title = useSubscribe(rep, (tx) => tx.get("publication_title")); let description = useSubscribe(rep, (tx) => tx.get("publication_description"), ); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let pubRecord = pub?.publications?.record as - | PubLeafletPublication.Record - | undefined; - let publishedAt = record?.publishedAt; + let publishedAt = normalizedDocument?.publishedAt; if (!pub) return null; @@ -121,12 +116,12 @@ export const PublicationMetadata = () => { )} - {pubRecord?.preferences?.showMentions && ( + {normalizedPublication?.preferences?.showMentions && (
—
)} - {pubRecord?.preferences?.showComments && ( + {normalizedPublication?.preferences?.showComments && (
—
@@ -210,9 +205,8 @@ export const TextField = ({ }; export const PublicationMetadataPreview = () => { - let { data: pub } = useLeafletPublicationData(); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let publishedAt = record?.publishedAt; + let { data: pub, normalizedDocument } = useLeafletPublicationData(); + let publishedAt = normalizedDocument?.publishedAt; if (!pub) return null; @@ -237,9 +231,8 @@ export const PublicationMetadataPreview = () => { }; const AddTags = () => { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedDocument } = useLeafletPublicationData(); let { rep } = useReplicache(); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; // Get tags from Replicache local state or published document let replicacheTags = useSubscribe(rep, (tx) => @@ -250,8 +243,8 @@ const AddTags = () => { let tags: string[] = []; if (Array.isArray(replicacheTags)) { tags = replicacheTags; - } else if (record?.tags && Array.isArray(record.tags)) { - tags = record.tags as string[]; + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { + tags = normalizedDocument.tags as string[]; } // Update tags in replicache local state diff --git a/components/PostListing.tsx b/components/PostListing.tsx index 4e0eff39..9b25081d 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -7,8 +7,11 @@ import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useSmoker } from "components/Toast"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import type { + NormalizedDocument, + NormalizedPublication, +} from "src/utils/normalizeRecords"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; import Link from "next/link"; @@ -17,10 +20,15 @@ import { useLocalizedDate } from "src/hooks/useLocalizedDate"; export const PostListing = (props: Post) => { let pubRecord = props.publication?.pubRecord as - | PubLeafletPublication.Record + | NormalizedPublication | undefined; - let postRecord = props.documents.data as PubLeafletDocument.Record; + let postRecord = props.documents.data as NormalizedDocument | null; + + // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) + if (!postRecord) { + return null; + } let postUri = new AtUri(props.documents.uri); let uri = props.publication ? props.publication?.uri : props.documents.uri; @@ -110,7 +118,7 @@ export const PostListing = (props: Post) => { const PubInfo = (props: { href: string; - pubRecord: PubLeafletPublication.Record; + pubRecord: NormalizedPublication; uri: string; }) => { return ( diff --git a/components/ThemeManager/PubThemeSetter.tsx b/components/ThemeManager/PubThemeSetter.tsx index 51ee0c45..e49ffbe1 100644 --- a/components/ThemeManager/PubThemeSetter.tsx +++ b/components/ThemeManager/PubThemeSetter.tsx @@ -1,11 +1,11 @@ -import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { useState } from "react"; import { pickers, SectionArrow } from "./ThemeSetter"; import { Color } from "react-aria-components"; -import { - PubLeafletPublication, - PubLeafletThemeBackgroundImage, -} from "lexicons/api"; +import { PubLeafletThemeBackgroundImage } from "lexicons/api"; import { AtUri } from "@atproto/syntax"; import { useLocalPubTheme } from "./PublicationThemeProvider"; import { BaseThemeProvider } from "./ThemeProvider"; @@ -35,7 +35,7 @@ export const PubThemeSetter = (props: { let [openPicker, setOpenPicker] = useState("null"); let { data, mutate } = usePublicationData(); let { publication: pub } = data || {}; - let record = pub?.record as PubLeafletPublication.Record | undefined; + let record = useNormalizedPublicationRecord(); let [showPageBackground, setShowPageBackground] = useState( !!record?.theme?.showPageBackground, ); @@ -246,7 +246,7 @@ const SamplePub = (props: { }) => { let { data } = usePublicationData(); let { publication } = data || {}; - let record = publication?.record as PubLeafletPublication.Record | null; + let record = useNormalizedPublicationRecord(); return (
{ let { data } = usePublicationData(); let { publication } = data || {}; - let record = publication?.record as PubLeafletPublication.Record | null; + let record = useNormalizedPublicationRecord(); return (
{props.children} diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx index a6211c17..e9534a50 100644 --- a/components/ThemeManager/ThemeProvider.tsx +++ b/components/ThemeManager/ThemeProvider.tsx @@ -21,7 +21,6 @@ import { PublicationBackgroundProvider, PublicationThemeProvider, } from "./PublicationThemeProvider"; -import { PubLeafletPublication } from "lexicons/api"; import { getColorDifference } from "./themeUtils"; // define a function to set an Aria Color to a CSS Variable in RGB @@ -40,12 +39,12 @@ export function ThemeProvider(props: { children: React.ReactNode; className?: string; }) { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); if (!pub || !pub.publications) return ; return ( ); @@ -328,7 +327,7 @@ export const ThemeBackgroundProvider = (props: { entityID: string; children: React.ReactNode; }) => { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); let backgroundImage = useEntity(props.entityID, "theme/background-image"); let backgroundImageRepeat = useEntity( props.entityID, @@ -338,9 +337,7 @@ export const ThemeBackgroundProvider = (props: { return ( {props.children} diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx new file mode 100644 index 00000000..467d9ad9 --- /dev/null +++ b/contexts/DocumentContext.tsx @@ -0,0 +1,50 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; + +// Derive types from PostPageData +type NonNullPostPageData = NonNullable; +export type PublicationContext = NonNullPostPageData["publication"]; +export type CommentOnDocument = NonNullPostPageData["comments"][number]; +export type DocumentMention = NonNullPostPageData["mentions"][number]; +export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; + +export type DocumentContextValue = Pick< + NonNullPostPageData, + | "uri" + | "normalizedDocument" + | "normalizedPublication" + | "theme" + | "prevNext" + | "quotesAndMentions" + | "publication" + | "comments" + | "mentions" + | "leafletId" +>; + +const DocumentContext = createContext(null); + +export function useDocument() { + const ctx = useContext(DocumentContext); + if (!ctx) throw new Error("useDocument must be used within DocumentProvider"); + return ctx; +} + +export function useDocumentOptional() { + return useContext(DocumentContext); +} + +export function DocumentProvider({ + children, + value, +}: { + children: React.ReactNode; + value: DocumentContextValue; +}) { + return ( + + {children} + + ); +} diff --git a/contexts/LeafletContentContext.tsx b/contexts/LeafletContentContext.tsx new file mode 100644 index 00000000..ebbe463c --- /dev/null +++ b/contexts/LeafletContentContext.tsx @@ -0,0 +1,35 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { PubLeafletContent } from "lexicons/api"; + +export type Page = PubLeafletContent.Main["pages"][number]; + +export type LeafletContentContextValue = { + pages: Page[]; +}; + +const LeafletContentContext = createContext(null); + +export function useLeafletContent() { + const ctx = useContext(LeafletContentContext); + if (!ctx) throw new Error("useLeafletContent must be used within LeafletContentProvider"); + return ctx; +} + +export function useLeafletContentOptional() { + return useContext(LeafletContentContext); +} + +export function LeafletContentProvider({ + children, + value, +}: { + children: React.ReactNode; + value: LeafletContentContextValue; +}) { + return ( + + {children} + + ); +} diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 7c933dbf..c6db789a 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -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"; +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"; export const notificationsRelations = relations(notifications, ({one}) => ({ identity: one(identities, { @@ -17,6 +17,8 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ fields: [identities.home_page], references: [permission_tokens.id] }), + site_standard_publications: many(site_standard_publications), + site_standard_documents: many(site_standard_documents), custom_domains_identity: many(custom_domains, { relationName: "custom_domains_identity_identities_email" }), @@ -33,6 +35,7 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ permission_token_on_homepages: many(permission_token_on_homepage), publication_domains: many(publication_domains), publication_subscriptions: many(publication_subscriptions), + site_standard_subscriptions: many(site_standard_subscriptions), })); export const publicationsRelations = relations(publications, ({one, many}) => ({ @@ -149,6 +152,15 @@ export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ( }), })); +export const site_standard_publicationsRelations = relations(site_standard_publications, ({one, many}) => ({ + identity: one(identities, { + fields: [site_standard_publications.identity_did], + references: [identities.atp_did] + }), + site_standard_documents_in_publications: many(site_standard_documents_in_publications), + site_standard_subscriptions: many(site_standard_subscriptions), +})); + export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ custom_domain: one(custom_domains, { fields: [custom_domain_routes.domain], @@ -181,6 +193,14 @@ export const custom_domainsRelations = relations(custom_domains, ({one, many}) = publication_domains: many(publication_domains), })); +export const site_standard_documentsRelations = relations(site_standard_documents, ({one, many}) => ({ + identity: one(identities, { + fields: [site_standard_documents.identity_did], + references: [identities.atp_did] + }), + site_standard_documents_in_publications: many(site_standard_documents_in_publications), +})); + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ entity: one(entities, { fields: [email_subscriptions_to_entity.entity], @@ -227,6 +247,17 @@ export const subscribers_to_publicationsRelations = relations(subscribers_to_pub }), })); +export const site_standard_documents_in_publicationsRelations = relations(site_standard_documents_in_publications, ({one}) => ({ + site_standard_document: one(site_standard_documents, { + fields: [site_standard_documents_in_publications.document], + references: [site_standard_documents.uri] + }), + site_standard_publication: one(site_standard_publications, { + fields: [site_standard_documents_in_publications.publication], + references: [site_standard_publications.uri] + }), +})); + export const documents_in_publicationsRelations = relations(documents_in_publications, ({one}) => ({ document: one(documents, { fields: [documents_in_publications.document], @@ -290,6 +321,17 @@ export const publication_subscriptionsRelations = relations(publication_subscrip }), })); +export const site_standard_subscriptionsRelations = relations(site_standard_subscriptions, ({one}) => ({ + identity: one(identities, { + fields: [site_standard_subscriptions.identity], + references: [identities.atp_did] + }), + site_standard_publication: one(site_standard_publications, { + fields: [site_standard_subscriptions.publication], + references: [site_standard_publications.uri] + }), +})); + export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ document: one(documents, { fields: [leaflets_to_documents.document], diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 171a551e..6e1f7f71 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -173,6 +173,13 @@ export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { } }); +export const site_standard_publications = pgTable("site_standard_publications", { + uri: text("uri").primaryKey().notNull(), + data: jsonb("data").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), +}); + export const custom_domain_routes = pgTable("custom_domain_routes", { id: uuid("id").defaultRandom().primaryKey().notNull(), domain: text("domain").notNull().references(() => custom_domains.domain), @@ -188,6 +195,13 @@ export const custom_domain_routes = pgTable("custom_domain_routes", { } }); +export const site_standard_documents = pgTable("site_standard_documents", { + uri: text("uri").primaryKey().notNull(), + data: jsonb("data").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), +}); + export const custom_domains = pgTable("custom_domains", { domain: text("domain").primaryKey().notNull(), identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), @@ -260,6 +274,17 @@ export const subscribers_to_publications = pgTable("subscribers_to_publications" } }); +export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), + } +}); + export const documents_in_publications = pgTable("documents_in_publications", { publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), @@ -324,6 +349,20 @@ export const publication_subscriptions = pgTable("publication_subscriptions", { } }); +export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + record: jsonb("record").notNull(), + uri: text("uri").notNull(), +}, +(table) => { + return { + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), + } +}); + export const leaflets_to_documents = pgTable("leaflets_to_documents", { leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), diff --git a/feeds/index.ts b/feeds/index.ts index fe4c58a2..dea8b84a 100644 --- a/feeds/index.ts +++ b/feeds/index.ts @@ -3,7 +3,10 @@ import { serve } from "@hono/node-server"; import { DidResolver } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletDocument } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; import { inngest } from "app/api/inngest/client"; import { AtUri } from "@atproto/api"; @@ -133,9 +136,9 @@ app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { cursor: newCursor || cursor, feed: posts.flatMap((p) => { if (!p.data) return []; - let record = p.data as PubLeafletDocument.Record; - if (!record.postRef) return []; - return { post: record.postRef.uri }; + const normalizedDoc = normalizeDocumentRecord(p.data); + if (!normalizedDoc?.bskyPostRef) return []; + return { post: normalizedDoc.bskyPostRef.uri }; }), }); }); diff --git a/src/notifications.ts b/src/notifications.ts index 5c4acbd7..e4dd1406 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -4,6 +4,12 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Tables, TablesInsert } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; type NotificationRow = Tables<"notifications">; @@ -99,6 +105,10 @@ async function hydrateCommentNotifications(notifications: NotificationRow[]) { ? comments?.find((c) => c.uri === notification.data.parent_uri) : undefined, commentData, + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data), + normalizedPublication: normalizePublicationRecord( + commentData.documents?.documents_in_publications[0]?.publications?.record, + ), }; }) .filter((n) => n !== null); @@ -140,6 +150,7 @@ async function hydrateSubscribeNotifications(notifications: NotificationRow[]) { type: "subscribe" as const, subscription_uri: notification.data.subscription_uri, subscriptionData, + normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), }; }) .filter((n) => n !== null); @@ -187,6 +198,10 @@ async function hydrateQuoteNotifications(notifications: NotificationRow[]) { document_uri: notification.data.document_uri, bskyPost, document, + normalizedDocument: normalizeDocumentRecord(document.data), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), }; }) .filter((n) => n !== null); @@ -269,6 +284,9 @@ async function hydrateMentionNotifications(notifications: NotificationRow[]) { const documentCreatorDid = new AtUri(notification.data.document_uri).host; const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; + return { id: notification.id, recipient: notification.recipient, @@ -279,8 +297,14 @@ async function hydrateMentionNotifications(notifications: NotificationRow[]) { mentioned_uri: mentionedUri, document, documentCreatorHandle, - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + mentionedPublication, + mentionedDocument: mentionedDoc, + normalizedDocument: normalizeDocumentRecord(document.data), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data), }; }) .filter((n) => n !== null); @@ -365,6 +389,9 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow const commenterDid = new AtUri(notification.data.comment_uri).host; const commenterHandle = didToHandleMap.get(commenterDid) ?? null; + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; + return { id: notification.id, recipient: notification.recipient, @@ -375,8 +402,14 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow mentioned_uri: mentionedUri, commentData, commenterHandle, - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + mentionedPublication, + mentionedDocument: mentionedDoc, + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data), + normalizedPublication: normalizePublicationRecord( + commentData.documents?.documents_in_publications[0]?.publications?.record, + ), + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data), }; }) .filter((n) => n !== null); diff --git a/src/utils/getPublicationMetadataFromLeafletData.ts b/src/utils/getPublicationMetadataFromLeafletData.ts index 32483bd4..e0220520 100644 --- a/src/utils/getPublicationMetadataFromLeafletData.ts +++ b/src/utils/getPublicationMetadataFromLeafletData.ts @@ -1,30 +1,40 @@ import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; import { Json } from "supabase/database.types"; +/** + * Return type for publication metadata extraction. + * Note: `publications.record` and `documents.data` are raw JSON from the database. + * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` + * from `src/utils/normalizeRecords` to get properly typed data. + */ +export type PublicationMetadata = { + description: string; + title: string; + leaflet: string; + doc: string | null; + publications: { + identity_did: string; + name: string; + indexed_at: string; + /** Raw record - use normalizePublicationRecord() to get typed data */ + record: Json | null; + uri: string; + } | null; + documents: { + /** Raw data - use normalizeDocumentRecord() to get typed data */ + data: Json; + indexed_at: string; + uri: string; + } | null; +} | null; + export function getPublicationMetadataFromLeafletData( data?: GetLeafletDataReturnType["result"]["data"], -) { +): PublicationMetadata { if (!data) return null; let pubData: - | { - description: string; - title: string; - leaflet: string; - doc: string | null; - publications: { - identity_did: string; - name: string; - indexed_at: string; - record: Json | null; - uri: string; - } | null; - documents: { - data: Json; - indexed_at: string; - uri: string; - } | null; - } + | NonNullable | undefined | null = data?.leaflets_in_publications?.[0] || @@ -46,5 +56,5 @@ export function getPublicationMetadataFromLeafletData( doc: standaloneDoc.document, }; } - return pubData; + return pubData || null; } diff --git a/src/utils/normalizeRecords.ts b/src/utils/normalizeRecords.ts new file mode 100644 index 00000000..50b419f1 --- /dev/null +++ b/src/utils/normalizeRecords.ts @@ -0,0 +1,129 @@ +/** + * Utilities for normalizing pub.leaflet and site.standard records from database queries. + * + * These helpers apply the normalization functions from lexicons/src/normalize.ts + * to database query results, providing properly typed normalized records. + */ + +import { + normalizeDocument, + normalizePublication, + type NormalizedDocument, + type NormalizedPublication, +} from "lexicons/src/normalize"; +import type { Json } from "supabase/database.types"; + +/** + * Normalizes a document record from a database query result. + * Returns the normalized document or null if the record is invalid/unrecognized. + * + * @example + * const doc = normalizeDocumentRecord(dbResult.data); + * if (doc) { + * // doc is NormalizedDocument with proper typing + * console.log(doc.title, doc.site, doc.publishedAt); + * } + */ +export function normalizeDocumentRecord( + data: Json | unknown +): NormalizedDocument | null { + return normalizeDocument(data); +} + +/** + * Normalizes a publication record from a database query result. + * Returns the normalized publication or null if the record is invalid/unrecognized. + * + * @example + * const pub = normalizePublicationRecord(dbResult.record); + * if (pub) { + * // pub is NormalizedPublication with proper typing + * console.log(pub.name, pub.url); + * } + */ +export function normalizePublicationRecord( + record: Json | unknown +): NormalizedPublication | null { + return normalizePublication(record); +} + +/** + * Type helper for a document row from the database with normalized data. + * Use this when you need the full row but with typed data. + */ +export type DocumentRowWithNormalizedData< + T extends { data: Json | unknown } +> = Omit & { + data: NormalizedDocument | null; +}; + +/** + * Type helper for a publication row from the database with normalized record. + * Use this when you need the full row but with typed record. + */ +export type PublicationRowWithNormalizedRecord< + T extends { record: Json | unknown } +> = Omit & { + record: NormalizedPublication | null; +}; + +/** + * Normalizes a document row in place, returning a properly typed row. + */ +export function normalizeDocumentRow( + row: T +): DocumentRowWithNormalizedData { + return { + ...row, + data: normalizeDocumentRecord(row.data), + }; +} + +/** + * Normalizes a publication row in place, returning a properly typed row. + */ +export function normalizePublicationRow( + row: T +): PublicationRowWithNormalizedRecord { + return { + ...row, + record: normalizePublicationRecord(row.record), + }; +} + +/** + * Type guard for filtering normalized document rows with non-null data. + * Use with .filter() after .map(normalizeDocumentRow) to narrow the type. + */ +export function hasValidDocument( + row: T +): row is T & { data: NormalizedDocument } { + return row.data !== null; +} + +/** + * Type guard for filtering normalized publication rows with non-null record. + * Use with .filter() after .map(normalizePublicationRow) to narrow the type. + */ +export function hasValidPublication< + T extends { record: NormalizedPublication | null } +>(row: T): row is T & { record: NormalizedPublication } { + return row.record !== null; +} + +// Re-export the core types and functions for convenience +export { + normalizeDocument, + normalizePublication, + type NormalizedDocument, + type NormalizedPublication, +} from "lexicons/src/normalize"; + +export { + isLeafletDocument, + isStandardDocument, + isLeafletPublication, + isStandardPublication, + hasLeafletContent, + getDocumentPages, +} from "lexicons/src/normalize"; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index c236d1ab..757213b4 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -580,6 +580,7 @@ export type Database = { } leaflets_in_publications: { Row: { + archived: boolean | null cover_image: string | null description: string doc: string | null @@ -589,6 +590,7 @@ export type Database = { title: string } Insert: { + archived?: boolean | null cover_image?: string | null description?: string doc?: string | null @@ -598,6 +600,7 @@ export type Database = { title?: string } Update: { + archived?: boolean | null cover_image?: string | null description?: string doc?: string | null @@ -632,6 +635,7 @@ export type Database = { } leaflets_to_documents: { Row: { + archived: boolean | null cover_image: string | null created_at: string description: string @@ -641,6 +645,7 @@ export type Database = { title: string } Insert: { + archived?: boolean | null cover_image?: string | null created_at?: string description?: string @@ -650,6 +655,7 @@ export type Database = { title?: string } Update: { + archived?: boolean | null cover_image?: string | null created_at?: string description?: string @@ -739,16 +745,19 @@ export type Database = { } permission_token_on_homepage: { Row: { + archived: boolean | null created_at: string identity: string token: string } Insert: { + archived?: boolean | null created_at?: string identity: string token: string } Update: { + archived?: boolean | null created_at?: string identity?: string token?: string