a tool for shared writing and social publishing

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into update/delete-blocks

+5612 -1351
+5 -2
actions/getIdentityData.ts
··· 3 3 import { cookies } from "next/headers"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { cache } from "react"; 6 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 6 7 export const getIdentityData = cache(uncachedGetIdentityData); 7 8 export async function uncachedGetIdentityData() { 8 9 let cookieStore = await cookies(); ··· 44 45 if (!auth_res?.data?.identities) return null; 45 46 if (auth_res.data.identities.atp_did) { 46 47 //I should create a relationship table so I can do this in the above query 47 - let { data: publications } = await supabaseServerClient 48 + let { data: rawPublications } = await supabaseServerClient 48 49 .from("publications") 49 50 .select("*") 50 51 .eq("identity_did", auth_res.data.identities.atp_did); 52 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 53 + const publications = deduplicateByUri(rawPublications || []); 51 54 return { 52 55 ...auth_res.data.identities, 53 - publications: publications || [], 56 + publications, 54 57 }; 55 58 } 56 59
+216 -80
actions/publishToPublication.ts
··· 11 11 PubLeafletBlocksText, 12 12 PubLeafletBlocksUnorderedList, 13 13 PubLeafletDocument, 14 + SiteStandardDocument, 15 + PubLeafletContent, 14 16 PubLeafletPagesLinearDocument, 15 17 PubLeafletPagesCanvas, 16 18 PubLeafletRichtextFacet, ··· 43 45 import { Lock } from "src/utils/lock"; 44 46 import type { PubLeafletPublication } from "lexicons/api"; 45 47 import { 48 + normalizeDocumentRecord, 49 + type NormalizedDocument, 50 + } from "src/utils/normalizeRecords"; 51 + import { 46 52 ColorToRGB, 47 53 ColorToRGBA, 48 54 } from "components/ThemeManager/colorToLexicons"; ··· 52 58 pingIdentityToUpdateNotification, 53 59 } from "src/notifications"; 54 60 import { v7 } from "uuid"; 61 + import { 62 + isDocumentCollection, 63 + isPublicationCollection, 64 + getDocumentType, 65 + } from "src/utils/collectionHelpers"; 55 66 56 67 type PublishResult = 57 68 | { success: true; rkey: string; record: PubLeafletDocument.Record } ··· 66 77 tags, 67 78 cover_image, 68 79 entitiesToDelete, 80 + publishedAt, 69 81 }: { 70 82 root_entity: string; 71 83 publication_uri?: string; ··· 75 87 tags?: string[]; 76 88 cover_image?: string | null; 77 89 entitiesToDelete?: string[]; 90 + publishedAt?: string; 78 91 }): Promise<PublishResult> { 79 92 let identity = await getIdentityData(); 80 93 if (!identity || !identity.atp_did) { ··· 147 160 credentialSession.did!, 148 161 ); 149 162 150 - let existingRecord = 151 - (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 163 + let existingRecord: Partial<PubLeafletDocument.Record> = {}; 164 + const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); 165 + if (normalizedDoc) { 166 + // When reading existing data, use normalized format to extract fields 167 + // The theme is preserved in NormalizedDocument for backward compatibility 168 + existingRecord = { 169 + publishedAt: normalizedDoc.publishedAt, 170 + title: normalizedDoc.title, 171 + description: normalizedDoc.description, 172 + tags: normalizedDoc.tags, 173 + coverImage: normalizedDoc.coverImage, 174 + theme: normalizedDoc.theme, 175 + }; 176 + } 152 177 153 178 // Extract theme for standalone documents (not for publications) 154 179 let theme: PubLeafletPublication.Theme | undefined; ··· 173 198 } 174 199 } 175 200 176 - let record: PubLeafletDocument.Record = { 177 - publishedAt: new Date().toISOString(), 178 - ...existingRecord, 179 - $type: "pub.leaflet.document", 180 - author: credentialSession.did!, 181 - ...(publication_uri && { publication: publication_uri }), 182 - ...(theme && { theme }), 183 - title: title || "Untitled", 184 - description: description || "", 185 - ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 186 - ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 187 - pages: pages.map((p) => { 188 - if (p.type === "canvas") { 189 - return { 190 - $type: "pub.leaflet.pages.canvas" as const, 191 - id: p.id, 192 - blocks: p.blocks as PubLeafletPagesCanvas.Block[], 193 - }; 194 - } else { 195 - return { 196 - $type: "pub.leaflet.pages.linearDocument" as const, 197 - id: p.id, 198 - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 199 - }; 200 - } 201 - }), 202 - }; 201 + // Determine the collection to use - preserve existing schema if updating 202 + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 203 + const documentType = getDocumentType(existingCollection); 204 + 205 + // Build the pages array (used by both formats) 206 + const pagesArray = pages.map((p) => { 207 + if (p.type === "canvas") { 208 + return { 209 + $type: "pub.leaflet.pages.canvas" as const, 210 + id: p.id, 211 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 212 + }; 213 + } else { 214 + return { 215 + $type: "pub.leaflet.pages.linearDocument" as const, 216 + id: p.id, 217 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 218 + }; 219 + } 220 + }); 221 + 222 + // Determine the rkey early since we need it for the path field 223 + const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 224 + 225 + // Create record based on the document type 226 + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 227 + 228 + if (documentType === "site.standard.document") { 229 + // site.standard.document format 230 + // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 231 + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 232 + 233 + record = { 234 + $type: "site.standard.document", 235 + title: title || "Untitled", 236 + site: siteUri, 237 + path: rkey, 238 + publishedAt: 239 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 240 + ...(description && { description }), 241 + ...(tags !== undefined && { tags }), 242 + ...(coverImageBlob && { coverImage: coverImageBlob }), 243 + // Include theme for standalone documents (not for publication documents) 244 + ...(!publication_uri && theme && { theme }), 245 + content: { 246 + $type: "pub.leaflet.content" as const, 247 + pages: pagesArray, 248 + }, 249 + } satisfies SiteStandardDocument.Record; 250 + } else { 251 + // pub.leaflet.document format (legacy) 252 + record = { 253 + $type: "pub.leaflet.document", 254 + author: credentialSession.did!, 255 + ...(publication_uri && { publication: publication_uri }), 256 + ...(theme && { theme }), 257 + title: title || "Untitled", 258 + description: description || "", 259 + ...(tags !== undefined && { tags }), 260 + ...(coverImageBlob && { coverImage: coverImageBlob }), 261 + pages: pagesArray, 262 + publishedAt: 263 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 264 + } satisfies PubLeafletDocument.Record; 265 + } 203 266 204 - // Keep the same rkey if updating an existing document 205 - let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 206 267 let { data: result } = await agent.com.atproto.repo.putRecord({ 207 268 rkey, 208 269 repo: credentialSession.did!, ··· 214 275 // Optimistically create database entries 215 276 await supabaseServerClient.from("documents").upsert({ 216 277 uri: result.uri, 217 - data: record as Json, 278 + data: record as unknown as Json, 218 279 }); 219 280 220 281 if (publication_uri) { ··· 836 897 */ 837 898 async function createMentionNotifications( 838 899 documentUri: string, 839 - record: PubLeafletDocument.Record, 900 + record: PubLeafletDocument.Record | SiteStandardDocument.Record, 840 901 authorDid: string, 841 902 ) { 842 903 const mentionedDids = new Set<string>(); 843 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 844 905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 906 + const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 845 907 846 - // Extract mentions from all text blocks in all pages 847 - for (const page of record.pages) { 848 - if (page.$type === "pub.leaflet.pages.linearDocument") { 849 - const linearPage = page as PubLeafletPagesLinearDocument.Main; 850 - for (const blockWrapper of linearPage.blocks) { 851 - const block = blockWrapper.block; 852 - if (block.$type === "pub.leaflet.blocks.text") { 853 - const textBlock = block as PubLeafletBlocksText.Main; 854 - if (textBlock.facets) { 855 - for (const facet of textBlock.facets) { 856 - for (const feature of facet.features) { 857 - // Check for DID mentions 858 - if (PubLeafletRichtextFacet.isDidMention(feature)) { 859 - if (feature.did !== authorDid) { 860 - mentionedDids.add(feature.did); 861 - } 862 - } 863 - // Check for AT URI mentions (publications and documents) 864 - if (PubLeafletRichtextFacet.isAtMention(feature)) { 865 - const uri = new AtUri(feature.atURI); 908 + // Extract pages from either format 909 + let pages: PubLeafletContent.Main["pages"] | undefined; 910 + if (record.$type === "site.standard.document") { 911 + const content = record.content; 912 + if (content && PubLeafletContent.isMain(content)) { 913 + pages = content.pages; 914 + } 915 + } else { 916 + pages = record.pages; 917 + } 918 + 919 + if (!pages) return; 920 + 921 + // Helper to extract blocks from all pages (both linear and canvas) 922 + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 923 + const blocks: ( 924 + | PubLeafletPagesLinearDocument.Block["block"] 925 + | PubLeafletPagesCanvas.Block["block"] 926 + )[] = []; 927 + for (const page of pages) { 928 + if (page.$type === "pub.leaflet.pages.linearDocument") { 929 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 930 + for (const blockWrapper of linearPage.blocks) { 931 + blocks.push(blockWrapper.block); 932 + } 933 + } else if (page.$type === "pub.leaflet.pages.canvas") { 934 + const canvasPage = page as PubLeafletPagesCanvas.Main; 935 + for (const blockWrapper of canvasPage.blocks) { 936 + blocks.push(blockWrapper.block); 937 + } 938 + } 939 + } 940 + return blocks; 941 + } 942 + 943 + const allBlocks = getAllBlocks(pages); 944 + 945 + // Extract mentions from all text blocks and embedded Bluesky posts 946 + for (const block of allBlocks) { 947 + // Check for embedded Bluesky posts 948 + if (PubLeafletBlocksBskyPost.isMain(block)) { 949 + const bskyPostUri = block.postRef.uri; 950 + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 951 + const postAuthorDid = new AtUri(bskyPostUri).host; 952 + if (postAuthorDid !== authorDid) { 953 + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 954 + } 955 + } 956 + 957 + // Check for text blocks with mentions 958 + if (block.$type === "pub.leaflet.blocks.text") { 959 + const textBlock = block as PubLeafletBlocksText.Main; 960 + if (textBlock.facets) { 961 + for (const facet of textBlock.facets) { 962 + for (const feature of facet.features) { 963 + // Check for DID mentions 964 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 965 + if (feature.did !== authorDid) { 966 + mentionedDids.add(feature.did); 967 + } 968 + } 969 + // Check for AT URI mentions (publications and documents) 970 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 971 + const uri = new AtUri(feature.atURI); 866 972 867 - if (uri.collection === "pub.leaflet.publication") { 868 - // Get the publication owner's DID 869 - const { data: publication } = await supabaseServerClient 870 - .from("publications") 871 - .select("identity_did") 872 - .eq("uri", feature.atURI) 873 - .single(); 973 + if (isPublicationCollection(uri.collection)) { 974 + // Get the publication owner's DID 975 + const { data: publication } = await supabaseServerClient 976 + .from("publications") 977 + .select("identity_did") 978 + .eq("uri", feature.atURI) 979 + .single(); 874 980 875 - if (publication && publication.identity_did !== authorDid) { 876 - mentionedPublications.set( 877 - publication.identity_did, 878 - feature.atURI, 879 - ); 880 - } 881 - } else if (uri.collection === "pub.leaflet.document") { 882 - // Get the document owner's DID 883 - const { data: document } = await supabaseServerClient 884 - .from("documents") 885 - .select("uri, data") 886 - .eq("uri", feature.atURI) 887 - .single(); 981 + if (publication && publication.identity_did !== authorDid) { 982 + mentionedPublications.set( 983 + publication.identity_did, 984 + feature.atURI, 985 + ); 986 + } 987 + } else if (isDocumentCollection(uri.collection)) { 988 + // Get the document owner's DID 989 + const { data: document } = await supabaseServerClient 990 + .from("documents") 991 + .select("uri, data") 992 + .eq("uri", feature.atURI) 993 + .single(); 888 994 889 - if (document) { 890 - const docRecord = 891 - document.data as PubLeafletDocument.Record; 892 - if (docRecord.author !== authorDid) { 893 - mentionedDocuments.set(docRecord.author, feature.atURI); 894 - } 895 - } 995 + if (document) { 996 + const normalizedMentionedDoc = normalizeDocumentRecord( 997 + document.data, 998 + ); 999 + // Get the author from the document URI (the DID is the host part) 1000 + const mentionedUri = new AtUri(feature.atURI); 1001 + const docAuthor = mentionedUri.host; 1002 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 1003 + mentionedDocuments.set(docAuthor, feature.atURI); 896 1004 } 897 1005 } 898 1006 } ··· 948 1056 }; 949 1057 await supabaseServerClient.from("notifications").insert(notification); 950 1058 await pingIdentityToUpdateNotification(recipientDid); 1059 + } 1060 + 1061 + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1062 + if (embeddedBskyPosts.size > 0) { 1063 + // Check which of the Bluesky post authors have Leaflet accounts 1064 + const { data: identities } = await supabaseServerClient 1065 + .from("identities") 1066 + .select("atp_did") 1067 + .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1068 + 1069 + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1070 + 1071 + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1072 + // Only notify if the post author has a Leaflet account 1073 + if (leafletUserDids.has(postAuthorDid)) { 1074 + const notification: Notification = { 1075 + id: v7(), 1076 + recipient: postAuthorDid, 1077 + data: { 1078 + type: "bsky_post_embed", 1079 + document_uri: documentUri, 1080 + bsky_post_uri: bskyPostUri, 1081 + }, 1082 + }; 1083 + await supabaseServerClient.from("notifications").insert(notification); 1084 + await pingIdentityToUpdateNotification(postAuthorDid); 1085 + } 1086 + } 951 1087 } 952 1088 }
+3 -5
app/(home-pages)/discover/PubListing.tsx
··· 6 6 import { Separator } from "components/Layout"; 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 - import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 10 9 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 10 import { timeAgo } from "src/utils/timeAgo"; 12 - import { Json } from "supabase/database.types"; 13 11 14 12 export const PubListing = ( 15 13 props: PublicationSubscription & { 16 14 resizeHeight?: boolean; 17 15 }, 18 16 ) => { 19 - let record = props.record as PubLeafletPublication.Record; 20 - let theme = usePubTheme(record.theme); 17 + let record = props.record; 18 + let theme = usePubTheme(record?.theme); 21 19 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 22 20 ? blobRefToSrc( 23 21 record?.theme?.backgroundImage?.image?.ref, ··· 31 29 return ( 32 30 <BaseThemeProvider {...theme} local> 33 31 <a 34 - href={`https://${record.base_path}`} 32 + href={record.url} 35 33 className={`no-underline! flex flex-row gap-2 36 34 bg-bg-leaflet 37 35 border border-border-light rounded-lg
+22 -8
app/(home-pages)/discover/getPublications.ts
··· 1 1 "use server"; 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 + import { 5 + normalizePublicationRow, 6 + hasValidPublication, 7 + } from "src/utils/normalizeRecords"; 8 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 4 9 5 10 export type Cursor = { 6 11 indexed_at?: string; ··· 38 43 return { publications: [], nextCursor: null }; 39 44 } 40 45 46 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 47 + const dedupedPublications = deduplicateByUri(publications || []); 48 + 41 49 // Filter out publications without documents 42 - const allPubs = (publications || []).filter( 50 + const allPubs = dedupedPublications.filter( 43 51 (pub) => pub.documents_in_publications.length > 0, 44 52 ); 45 53 ··· 98 106 // Get the page 99 107 const page = allPubs.slice(startIndex, startIndex + limit); 100 108 101 - // Create next cursor 109 + // Normalize publication records 110 + const normalizedPage = page 111 + .map(normalizePublicationRow) 112 + .filter(hasValidPublication); 113 + 114 + // Create next cursor based on last item in normalizedPage 115 + const lastItem = normalizedPage[normalizedPage.length - 1]; 102 116 const nextCursor = 103 - page.length === limit && startIndex + limit < allPubs.length 117 + normalizedPage.length > 0 && startIndex + limit < allPubs.length 104 118 ? order === "recentlyUpdated" 105 119 ? { 106 - indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 - uri: page[page.length - 1].uri, 120 + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, 121 + uri: lastItem.uri, 108 122 } 109 123 : { 110 - count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 - uri: page[page.length - 1].uri, 124 + count: lastItem.publication_subscriptions[0]?.count || 0, 125 + uri: lastItem.uri, 112 126 } 113 127 : null; 114 128 115 129 return { 116 - publications: page, 130 + publications: normalizedPage, 117 131 nextCursor, 118 132 }; 119 133 }
+44
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 1 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 + import { AtUri } from "@atproto/api"; 5 + 6 + export const BskyPostEmbedNotification = ( 7 + props: HydratedBskyPostEmbedNotification, 8 + ) => { 9 + const docRecord = props.normalizedDocument; 10 + const pubRecord = props.normalizedPublication; 11 + 12 + if (!docRecord) return null; 13 + 14 + const docUri = new AtUri(props.document.uri); 15 + const rkey = docUri.rkey; 16 + const did = docUri.host; 17 + 18 + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 19 + 20 + const embedder = props.documentCreatorHandle 21 + ? `@${props.documentCreatorHandle}` 22 + : "Someone"; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<BlueskyTiny />} 29 + actionText={<>{embedder} embedded your Bluesky post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + {props.bskyPostText && ( 33 + <pre 34 + style={{ wordBreak: "break-word" }} 35 + className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 36 + > 37 + {props.bskyPostText} 38 + </pre> 39 + )} 40 + </ContentLayout> 41 + } 42 + /> 43 + ); 44 + };
+11 -18
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 1 - import { 2 - AppBskyActorProfile, 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 1 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 7 2 import { HydratedCommentMentionNotification } from "src/notifications"; 8 3 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 4 import { MentionTiny } from "components/Icons/MentionTiny"; ··· 17 12 export const CommentMentionNotification = ( 18 13 props: HydratedCommentMentionNotification, 19 14 ) => { 20 - const docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 15 + const docRecord = props.normalizedDocument; 16 + if (!docRecord) return null; 17 + 22 18 const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 19 const profileRecord = props.commentData.bsky_profiles 24 20 ?.record as AppBskyActorProfile.Record; 25 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 - ?.publications?.record as PubLeafletPublication.Record | undefined; 21 + const pubRecord = props.normalizedPublication; 27 22 const docUri = new AtUri(props.commentData.documents?.uri!); 28 23 const rkey = docUri.rkey; 29 24 const did = docUri.host; 30 25 31 26 const href = pubRecord 32 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 27 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 33 28 : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 29 35 30 const commenter = props.commenterHandle ··· 37 32 : "Someone"; 38 33 39 34 let actionText: React.ReactNode; 40 - let mentionedDocRecord = props.mentionedDocument 41 - ?.data as PubLeafletDocument.Record; 35 + const mentionedDocRecord = props.normalizedMentionedDocument; 42 36 43 37 if (props.mention_type === "did") { 44 38 actionText = <>{commenter} mentioned you in a comment</>; ··· 46 40 props.mention_type === "publication" && 47 41 props.mentionedPublication 48 42 ) { 49 - const mentionedPubRecord = props.mentionedPublication 50 - .record as PubLeafletPublication.Record; 43 + const mentionedPubRecord = props.normalizedMentionedPublication; 51 44 actionText = ( 52 45 <> 53 46 {commenter} mentioned your publication{" "} 54 - <span className="italic">{mentionedPubRecord.name}</span> in a comment 47 + <span className="italic">{mentionedPubRecord?.name}</span> in a comment 55 48 </> 56 49 ); 57 - } else if (props.mention_type === "document" && props.mentionedDocument) { 50 + } else if (props.mention_type === "document" && mentionedDocRecord) { 58 51 actionText = ( 59 52 <> 60 53 {commenter} mentioned your post{" "} ··· 72 65 icon={<MentionTiny />} 73 66 actionText={actionText} 74 67 content={ 75 - <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 68 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 76 69 <CommentInNotification 77 70 className="" 78 71 avatar={
+13 -17
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 1 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 - import { 3 - AppBskyActorProfile, 4 - PubLeafletComment, 5 - PubLeafletDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 8 3 import { HydratedCommentNotification } from "src/notifications"; 9 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 5 import { Avatar } from "components/Avatar"; ··· 17 12 import { AtUri } from "@atproto/api"; 18 13 19 14 export const CommentNotification = (props: HydratedCommentNotification) => { 20 - let docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 22 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 23 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 24 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 25 22 const displayName = 26 - profileRecord.displayName || 23 + profileRecord?.displayName || 27 24 props.commentData.bsky_profiles?.handle || 28 25 "Someone"; 29 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 - ?.publications?.record as PubLeafletPublication.Record | undefined; 31 - let docUri = new AtUri(props.commentData.documents?.uri!); 32 - let rkey = docUri.rkey; 33 - let did = docUri.host; 26 + const pubRecord = props.normalizedPublication; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 34 30 35 31 const href = pubRecord 36 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 32 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 37 33 : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 34 39 35 return (
+3 -4
app/(home-pages)/notifications/FollowNotification.tsx
··· 2 2 import { Notification } from "./Notification"; 3 3 import { HydratedSubscribeNotification } from "src/notifications"; 4 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 5 + import { AppBskyActorProfile } from "lexicons/api"; 6 6 7 7 export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 8 const profileRecord = props.subscriptionData?.identities?.bsky_profiles ··· 11 11 profileRecord?.displayName || 12 12 props.subscriptionData?.identities?.bsky_profiles?.handle || 13 13 "Someone"; 14 - const pubRecord = props.subscriptionData?.publications 15 - ?.record as PubLeafletPublication.Record; 14 + const pubRecord = props.normalizedPublication; 16 15 const avatarSrc = 17 16 profileRecord?.avatar?.ref && 18 17 blobRefToSrc( ··· 23 22 return ( 24 23 <Notification 25 24 timestamp={props.created_at} 26 - href={`https://${pubRecord?.base_path}`} 25 + href={pubRecord ? pubRecord.url : "#"} 27 26 icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 28 27 actionText={ 29 28 <>
+11 -12
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedMentionNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { Agent, AtUri } from "@atproto/api"; 4 + import { AtUri } from "@atproto/api"; 6 5 7 6 export const MentionNotification = (props: HydratedMentionNotification) => { 8 - const docRecord = props.document.data as PubLeafletDocument.Record; 9 - const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 - ?.record as PubLeafletPublication.Record | undefined; 7 + const docRecord = props.normalizedDocument; 8 + const pubRecord = props.normalizedPublication; 9 + 10 + if (!docRecord) return null; 11 + 11 12 const docUri = new AtUri(props.document.uri); 12 13 const rkey = docUri.rkey; 13 14 const did = docUri.host; 14 15 15 16 const href = pubRecord 16 - ? `https://${pubRecord.base_path}/${rkey}` 17 + ? `${pubRecord.url}/${rkey}` 17 18 : `/p/${did}/${rkey}`; 18 19 19 20 let actionText: React.ReactNode; 20 21 let mentionedItemName: string | undefined; 21 - let mentionedDocRecord = props.mentionedDocument 22 - ?.data as PubLeafletDocument.Record; 22 + const mentionedDocRecord = props.normalizedMentionedDocument; 23 23 24 24 const mentioner = props.documentCreatorHandle 25 25 ? `@${props.documentCreatorHandle}` ··· 31 31 props.mention_type === "publication" && 32 32 props.mentionedPublication 33 33 ) { 34 - const mentionedPubRecord = props.mentionedPublication 35 - .record as PubLeafletPublication.Record; 36 - mentionedItemName = mentionedPubRecord.name; 34 + const mentionedPubRecord = props.normalizedMentionedPublication; 35 + mentionedItemName = mentionedPubRecord?.name; 37 36 actionText = ( 38 37 <> 39 38 {mentioner} mentioned your publication{" "} 40 39 <span className="italic">{mentionedItemName}</span> 41 40 </> 42 41 ); 43 - } else if (props.mention_type === "document" && props.mentionedDocument) { 42 + } else if (props.mention_type === "document" && mentionedDocRecord) { 44 43 mentionedItemName = mentionedDocRecord.title; 45 44 actionText = ( 46 45 <>
+5 -4
app/(home-pages)/notifications/Notification.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 3 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 4 - import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { PubLeafletRichtextFacet } from "lexicons/api"; 5 5 import { timeAgo } from "src/utils/timeAgo"; 6 6 import { useReplicache, useEntity } from "src/replicache"; 7 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 7 8 8 9 export const Notification = (props: { 9 10 icon: React.ReactNode; ··· 58 59 59 60 export const ContentLayout = (props: { 60 61 children: React.ReactNode; 61 - postTitle: string; 62 - pubRecord?: PubLeafletPublication.Record; 62 + postTitle: string | undefined; 63 + pubRecord?: NormalizedPublication | null; 63 64 }) => { 64 65 let { rootEntity } = useReplicache(); 65 66 let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data ··· 77 78 <> 78 79 <hr className="mt-1 mb-1 border-border-light" /> 79 80 <a 80 - href={`https://${props.pubRecord.base_path}`} 81 + href={props.pubRecord.url} 81 82 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!" 82 83 > 83 84 {props.pubRecord.name}
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 10 import { QuoteNotification } from "./QuoteNotification"; 11 + import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 11 12 import { MentionNotification } from "./MentionNotification"; 12 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 13 14 ··· 47 48 } 48 49 if (n.type === "quote") { 49 50 return <QuoteNotification key={n.id} {...n} />; 51 + } 52 + if (n.type === "bsky_post_embed") { 53 + return <BskyPostEmbedNotification key={n.id} {...n} />; 50 54 } 51 55 if (n.type === "mention") { 52 56 return <MentionNotification key={n.id} {...n} />;
+6 -5
app/(home-pages)/notifications/QuoteNotification.tsx
··· 1 1 import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedQuoteNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 4 import { AtUri } from "@atproto/api"; 6 5 import { Avatar } from "components/Avatar"; 7 6 ··· 9 8 const postView = props.bskyPost.post_view as any; 10 9 const author = postView.author; 11 10 const displayName = author.displayName || author.handle || "Someone"; 12 - const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 14 - ?.record as PubLeafletPublication.Record | undefined; 11 + const docRecord = props.normalizedDocument; 12 + const pubRecord = props.normalizedPublication; 13 + 14 + if (!docRecord) return null; 15 + 15 16 const docUri = new AtUri(props.document.uri); 16 17 const rkey = docUri.rkey; 17 18 const did = docUri.host; 18 19 const postText = postView.record?.text || ""; 19 20 20 21 const href = pubRecord 21 - ? `https://${pubRecord.base_path}/${rkey}` 22 + ? `${pubRecord.url}/${rkey}` 22 23 : `/p/${did}/${rkey}`; 23 24 24 25 return (
+16 -19
app/(home-pages)/notifications/ReplyNotification.tsx
··· 7 7 Notification, 8 8 } from "./Notification"; 9 9 import { HydratedCommentNotification } from "src/notifications"; 10 - import { 11 - PubLeafletComment, 12 - PubLeafletDocument, 13 - PubLeafletPublication, 14 - } from "lexicons/api"; 10 + import { PubLeafletComment } from "lexicons/api"; 15 11 import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 13 18 14 export const ReplyNotification = (props: HydratedCommentNotification) => { 19 - let docRecord = props.commentData.documents 20 - ?.data as PubLeafletDocument.Record; 21 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 22 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 23 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 24 22 const displayName = 25 - profileRecord.displayName || 23 + profileRecord?.displayName || 26 24 props.commentData.bsky_profiles?.handle || 27 25 "Someone"; 28 26 29 - let parentRecord = props.parentData?.record as PubLeafletComment.Record; 30 - let parentProfile = props.parentData?.bsky_profiles 27 + const parentRecord = props.parentData?.record as PubLeafletComment.Record; 28 + const parentProfile = props.parentData?.bsky_profiles 31 29 ?.record as AppBskyActorProfile.Record; 32 30 const parentDisplayName = 33 - parentProfile.displayName || 31 + parentProfile?.displayName || 34 32 props.parentData?.bsky_profiles?.handle || 35 33 "Someone"; 36 34 37 - let docUri = new AtUri(props.commentData.documents?.uri!); 38 - let rkey = docUri.rkey; 39 - let did = docUri.host; 40 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 - ?.publications?.record as PubLeafletPublication.Record | undefined; 35 + const docUri = new AtUri(props.commentData.documents?.uri!); 36 + const rkey = docUri.rkey; 37 + const did = docUri.host; 38 + const pubRecord = props.normalizedPublication; 42 39 43 40 const href = pubRecord 44 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 41 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 45 42 : `/p/${did}/${rkey}?interactionDrawer=comments`; 46 43 47 44 return (
+7 -15
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 - import { PubLeafletPublication } from "lexicons/api"; 4 3 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 5 4 import { colorToString } from "components/ThemeManager/useColorAttribute"; 6 5 import { PubIcon } from "components/ActionBar/Publications"; 7 - import { Json } from "supabase/database.types"; 6 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 8 7 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 9 8 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 9 import { SpeedyLink } from "components/SpeedyLink"; ··· 13 12 14 13 export const ProfileHeader = (props: { 15 14 profile: ProfileViewDetailed; 16 - publications: { record: Json; uri: string }[]; 15 + publications: { record: NormalizedPublication; uri: string }[]; 17 16 popover?: boolean; 18 17 }) => { 19 18 let profileRecord = props.profile; 20 - const profileUrl = `/p/${props.profile.handle}`; 19 + const profileUrl = `https://leaflet.pub/p/${props.profile.handle}`; 21 20 22 21 const avatarElement = ( 23 22 <Avatar ··· 80 79 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]"}`} 81 80 > 82 81 {props.publications.map((p) => ( 83 - <PublicationCard 84 - key={p.uri} 85 - record={p.record as PubLeafletPublication.Record} 86 - uri={p.uri} 87 - /> 82 + <PublicationCard key={p.uri} record={p.record} uri={p.uri} /> 88 83 ))} 89 84 </div> 90 85 </div> ··· 105 100 </div> 106 101 ); 107 102 }; 108 - const PublicationCard = (props: { 109 - record: PubLeafletPublication.Record; 110 - uri: string; 111 - }) => { 103 + const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { 112 104 const { record, uri } = props; 113 105 const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 114 106 115 107 return ( 116 108 <a 117 - href={`https://${record.base_path}`} 118 - className="profilePublicationCard border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2 " 109 + href={record.url} 110 + className="profilePublicationCard border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 119 111 style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 120 112 > 121 113 <div
+16 -3
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 5 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + import { 7 + normalizeDocumentRecord, 8 + normalizePublicationRecord, 9 + } from "src/utils/normalizeRecords"; 10 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 6 11 7 12 export type Cursor = { 8 13 indexed_at: string; ··· 34 39 ); 35 40 } 36 41 37 - let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 42 + let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([ 38 43 query, 39 44 supabaseServerClient 40 45 .from("publications") ··· 47 52 .single(), 48 53 ]); 49 54 55 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 56 + const docs = deduplicateByUriOrdered(rawDocs || []); 57 + const pubs = deduplicateByUriOrdered(rawPubs || []); 58 + 50 59 // Build a map of publications for quick lookup 51 60 let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 61 for (let pub of pubs || []) { ··· 58 67 let posts: Post[] = []; 59 68 60 69 for (let doc of docs || []) { 70 + // Normalize records - filter out unrecognized formats 71 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 72 + if (!normalizedData) continue; 73 + 61 74 let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 75 let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 76 64 77 let post: Post = { 65 78 author: handle, 66 79 documents: { 67 - data: doc.data, 80 + data: normalizedData, 68 81 uri: doc.uri, 69 82 indexed_at: doc.indexed_at, 70 83 comments_on_documents: doc.comments_on_documents, ··· 75 88 if (pub) { 76 89 post.publication = { 77 90 href: getPublicationURL(pub), 78 - pubRecord: pub.record, 91 + pubRecord: normalizePublicationRecord(pub.record), 79 92 uri: pub.uri, 80 93 }; 81 94 }
+49 -39
app/(home-pages)/reader/getReaderFeed.ts
··· 7 7 import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 8 import Client from "ioredis"; 9 9 import { AtUri } from "@atproto/api"; 10 - import { Json } from "supabase/database.types"; 11 10 import { idResolver } from "./idResolver"; 11 + import { 12 + normalizeDocumentRecord, 13 + normalizePublicationRecord, 14 + type NormalizedDocument, 15 + type NormalizedPublication, 16 + } from "src/utils/normalizeRecords"; 17 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 12 18 13 19 export type Cursor = { 14 20 timestamp: string; ··· 40 46 `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 41 47 ); 42 48 } 43 - let { data: feed, error } = await query; 49 + let { data: rawFeed, error } = await query; 44 50 45 - let posts = await Promise.all( 46 - feed?.map(async (post) => { 47 - let pub = post.documents_in_publications[0].publications!; 48 - let uri = new AtUri(post.uri); 49 - let handle = await idResolver.did.resolve(uri.host); 50 - let p: Post = { 51 - publication: { 52 - href: getPublicationURL(pub), 53 - pubRecord: pub?.record || null, 54 - uri: pub?.uri || "", 55 - }, 56 - author: handle?.alsoKnownAs?.[0] 57 - ? `@${handle.alsoKnownAs[0].slice(5)}` 58 - : null, 59 - documents: { 60 - comments_on_documents: post.comments_on_documents, 61 - document_mentions_in_bsky: post.document_mentions_in_bsky, 62 - data: post.data, 63 - uri: post.uri, 64 - indexed_at: post.indexed_at, 65 - }, 66 - }; 67 - return p; 68 - }) || [], 69 - ); 51 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 52 + const feed = deduplicateByUriOrdered(rawFeed || []); 53 + 54 + let posts = ( 55 + await Promise.all( 56 + feed.map(async (post) => { 57 + let pub = post.documents_in_publications[0].publications!; 58 + let uri = new AtUri(post.uri); 59 + let handle = await idResolver.did.resolve(uri.host); 60 + 61 + // Normalize records - filter out unrecognized formats 62 + const normalizedData = normalizeDocumentRecord(post.data, post.uri); 63 + if (!normalizedData) return null; 64 + 65 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 66 + 67 + let p: Post = { 68 + publication: { 69 + href: getPublicationURL(pub), 70 + pubRecord: normalizedPubRecord, 71 + uri: pub?.uri || "", 72 + }, 73 + author: handle?.alsoKnownAs?.[0] 74 + ? `@${handle.alsoKnownAs[0].slice(5)}` 75 + : null, 76 + documents: { 77 + comments_on_documents: post.comments_on_documents, 78 + document_mentions_in_bsky: post.document_mentions_in_bsky, 79 + data: normalizedData, 80 + uri: post.uri, 81 + indexed_at: post.indexed_at, 82 + }, 83 + }; 84 + return p; 85 + }) || [], 86 + ) 87 + ).filter((post): post is Post => post !== null); 70 88 const nextCursor = 71 89 posts.length > 0 72 90 ? { ··· 85 103 author: string | null; 86 104 publication?: { 87 105 href: string; 88 - pubRecord: Json; 106 + pubRecord: NormalizedPublication | null; 89 107 uri: string; 90 108 }; 91 109 documents: { 92 - data: Json; 110 + data: NormalizedDocument | null; 93 111 uri: string; 94 112 indexed_at: string; 95 - comments_on_documents: 96 - | { 97 - count: number; 98 - }[] 99 - | undefined; 100 - document_mentions_in_bsky: 101 - | { 102 - count: number; 103 - }[] 104 - | undefined; 113 + comments_on_documents: { count: number }[] | undefined; 114 + document_mentions_in_bsky: { count: number }[] | undefined; 105 115 }; 106 116 };
+23 -12
app/(home-pages)/reader/getSubscriptions.ts
··· 7 7 import { supabaseServerClient } from "supabase/serverClient"; 8 8 import { idResolver } from "./idResolver"; 9 9 import { Cursor } from "./getReaderFeed"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 10 14 11 15 export async function getSubscriptions( 12 16 did?: string | null, ··· 43 47 } 44 48 let { data: pubs, error } = await query; 45 49 46 - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 47 - pubs?.map(async (pub) => { 48 - let id = await idResolver.did.resolve(pub.publications?.identity_did!); 49 - return { 50 - ...pub.publications!, 51 - authorProfile: id?.alsoKnownAs?.[0] 52 - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 53 - : undefined, 54 - }; 55 - }) || [], 56 - ); 50 + const hydratedSubscriptions = ( 51 + await Promise.all( 52 + pubs?.map(async (pub) => { 53 + const normalizedRecord = normalizePublicationRecord( 54 + pub.publications?.record 55 + ); 56 + if (!normalizedRecord) return null; 57 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 58 + return { 59 + ...pub.publications!, 60 + record: normalizedRecord, 61 + authorProfile: id?.alsoKnownAs?.[0] 62 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 63 + : undefined, 64 + } as PublicationSubscription; 65 + }) || [] 66 + ) 67 + ).filter((sub): sub is PublicationSubscription => sub !== null); 57 68 58 69 const nextCursor = 59 70 pubs && pubs.length > 0 ··· 71 82 72 83 export type PublicationSubscription = { 73 84 authorProfile?: { handle: string }; 74 - record: Json; 85 + record: NormalizedPublication; 75 86 uri: string; 76 87 documents_in_publications: { 77 88 documents: { data?: Json; indexed_at: string } | null;
+19 -4
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 3 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { AtUri } from "@atproto/api"; 6 - import { Json } from "supabase/database.types"; 7 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 7 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 8 + import { 9 + normalizeDocumentRecord, 10 + normalizePublicationRecord, 11 + } from "src/utils/normalizeRecords"; 12 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 9 13 10 14 export async function getDocumentsByTag( 11 15 tag: string, 12 16 ): Promise<{ posts: Post[] }> { 13 17 // Query documents that have this tag 14 - const { data: documents, error } = await supabaseServerClient 18 + const { data: rawDocuments, error } = await supabaseServerClient 15 19 .from("documents") 16 20 .select( 17 21 `*, ··· 28 32 return { posts: [] }; 29 33 } 30 34 35 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 36 + const documents = deduplicateByUriOrdered(rawDocuments || []); 37 + 31 38 const posts = await Promise.all( 32 39 documents.map(async (doc) => { 33 40 const pub = doc.documents_in_publications[0]?.publications; ··· 37 44 return null; 38 45 } 39 46 47 + // Normalize the document data - skip unrecognized formats 48 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 49 + if (!normalizedData) { 50 + return null; 51 + } 52 + 53 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 54 + 40 55 const uri = new AtUri(doc.uri); 41 56 const handle = await idResolver.did.resolve(uri.host); 42 57 43 58 const post: Post = { 44 59 publication: { 45 60 href: getPublicationURL(pub), 46 - pubRecord: pub?.record || null, 61 + pubRecord: normalizedPubRecord, 47 62 uri: pub?.uri || "", 48 63 }, 49 64 author: handle?.alsoKnownAs?.[0] ··· 52 67 documents: { 53 68 comments_on_documents: doc.comments_on_documents, 54 69 document_mentions_in_bsky: doc.document_mentions_in_bsky, 55 - data: doc.data, 70 + data: normalizedData, 56 71 uri: doc.uri, 57 72 indexed_at: doc.indexed_at, 58 73 },
+11 -4
app/[leaflet_id]/actions/PublishButton.tsx
··· 22 22 import { SpeedyLink } from "components/SpeedyLink"; 23 23 import { useToaster } from "components/Toast"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 - import { PubLeafletPublication } from "lexicons/api"; 25 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 26 26 import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 - import { useState, useMemo } from "react"; 27 + import { useState, useMemo, useEffect } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; 29 29 import { useReplicache, useEntity } from "src/replicache"; 30 30 import { useSubscribe } from "src/replicache/useSubscribe"; ··· 40 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 41 import { AddTiny } from "components/Icons/AddTiny"; 42 42 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 + import { useLocalPublishedAt } from "components/Pages/Backdater"; 43 44 44 45 export const PublishButton = (props: { entityID: string }) => { 45 46 let { data: pub } = useLeafletPublicationData(); ··· 95 96 tx.get<string | null>("publication_cover_image"), 96 97 ); 97 98 99 + // Get local published at from Replicache (session-only state, not persisted to DB) 100 + let publishedAt = useLocalPublishedAt((s) => 101 + pub?.doc ? s[pub?.doc] : undefined, 102 + ); 103 + 98 104 return ( 99 105 <ActionButton 100 106 primary ··· 111 117 description: currentDescription, 112 118 tags: currentTags, 113 119 cover_image: coverImage, 120 + publishedAt: publishedAt?.toISOString(), 114 121 }); 115 122 setIsLoading(false); 116 123 mutate(); ··· 134 141 135 142 toaster({ 136 143 content: ( 137 - <div> 144 + <div className="font-bold"> 138 145 {pub.doc ? "Updated! " : "Published! "} 139 146 <SpeedyLink className="underline" href={docUrl}> 140 147 See Published Post ··· 370 377 </PubOption> 371 378 <hr className="border-border-light border-dashed " /> 372 379 {props.publications.map((p) => { 373 - let pubRecord = p.record as PubLeafletPublication.Record; 380 + let pubRecord = normalizePublicationRecord(p.record); 374 381 return ( 375 382 <PubOption 376 383 key={p.uri}
+1 -4
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 14 14 useLeafletPublicationData, 15 15 } from "components/PageSWRDataProvider"; 16 16 import { ShareSmall } from "components/Icons/ShareSmall"; 17 - import { PubLeafletDocument } from "lexicons/api"; 18 17 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 18 import { AtUri } from "@atproto/syntax"; 20 19 import { useIsMobile } from "src/hooks/isMobile"; ··· 88 87 isPub?: boolean; 89 88 }) => { 90 89 let { permission_token } = useReplicache(); 91 - let { data: pub } = useLeafletPublicationData(); 92 - 93 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 90 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 94 91 95 92 let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 93 let postLink = !docURI
+121 -18
app/[leaflet_id]/publish/PublishPost.tsx
··· 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 9 10 - import { PubLeafletPublication } from "lexicons/api"; 10 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; ··· 23 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 24 import { PubIcon } from "components/ActionBar/Publications"; 25 25 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 + import { DatePicker, TimePicker } from "components/DatePicker"; 27 + import { Popover } from "components/Popover"; 28 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 29 + import { Separator } from "react-aria-components"; 30 + import { setHours, setMinutes } from "date-fns"; 26 31 27 32 type Props = { 28 33 title: string; ··· 31 36 profile: ProfileViewDetailed; 32 37 description: string; 33 38 publication_uri?: string; 34 - record?: PubLeafletPublication.Record; 39 + record?: NormalizedPublication | null; 35 40 posts_in_pub?: number; 36 41 entitiesToDelete?: string[]; 37 42 hasDraft: boolean; ··· 78 83 ); 79 84 let [localTags, setLocalTags] = useState<string[]>([]); 80 85 86 + let [localPublishedAt, setLocalPublishedAt] = useState<Date | undefined>( 87 + undefined, 88 + ); 81 89 // Get cover image from Replicache 82 90 let replicacheCoverImage = useSubscribe(rep, (tx) => 83 91 tx.get<string | null>("publication_cover_image"), 84 92 ); 85 93 86 94 // Use Replicache tags only when we have a draft 87 - const hasDraft = props.hasDraft; 88 - const currentTags = hasDraft 95 + const currentTags = props.hasDraft 89 96 ? Array.isArray(replicacheTags) 90 97 ? replicacheTags 91 98 : [] ··· 93 100 94 101 // Update tags via Replicache mutation or local state depending on context 95 102 const handleTagsChange = async (newTags: string[]) => { 96 - if (hasDraft) { 103 + if (props.hasDraft) { 97 104 await rep?.mutate.updatePublicationDraft({ 98 105 tags: newTags, 99 106 }); ··· 116 123 tags: currentTags, 117 124 cover_image: replicacheCoverImage, 118 125 entitiesToDelete: props.entitiesToDelete, 126 + publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 119 127 }); 120 128 121 129 if (!result.success) { ··· 127 135 } 128 136 129 137 // Generate post URL based on whether it's in a publication or standalone 130 - let post_url = props.record?.base_path 131 - ? `https://${props.record.base_path}/${result.rkey}` 138 + let post_url = props.record?.url 139 + ? `${props.record.url}/${result.rkey}` 132 140 : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 133 141 134 142 let [text, facets] = editorStateRef.current ··· 168 176 record={props.record} 169 177 /> 170 178 <hr className="border-border" /> 171 - <ShareOptions 172 - setShareOption={setShareOption} 173 - shareOption={shareOption} 174 - charCount={charCount} 175 - setCharCount={setCharCount} 176 - editorStateRef={editorStateRef} 177 - {...props} 179 + 180 + <BackdateOptions 181 + publishedAt={localPublishedAt} 182 + setPublishedAt={setLocalPublishedAt} 178 183 /> 179 184 <hr className="border-border " /> 185 + 180 186 <div className="flex flex-col gap-2"> 181 187 <h4>Tags</h4> 182 188 <TagSelector ··· 184 190 setSelectedTags={handleTagsChange} 185 191 /> 186 192 </div> 193 + <hr className="border-border" /> 194 + <ShareOptions 195 + setShareOption={setShareOption} 196 + shareOption={shareOption} 197 + charCount={charCount} 198 + setCharCount={setCharCount} 199 + editorStateRef={editorStateRef} 200 + {...props} 201 + /> 187 202 <hr className="border-border mb-2" /> 188 203 189 204 <div className="flex flex-col gap-2"> ··· 219 234 ); 220 235 }; 221 236 237 + const BackdateOptions = (props: { 238 + publishedAt: Date | undefined; 239 + setPublishedAt: (date: Date | undefined) => void; 240 + }) => { 241 + const formattedDate = useLocalizedDate( 242 + props.publishedAt?.toISOString() || "", 243 + { 244 + month: "short", 245 + day: "numeric", 246 + year: "numeric", 247 + hour: "numeric", 248 + minute: "numeric", 249 + hour12: true, 250 + }, 251 + ); 252 + 253 + const [timeValue, setTimeValue] = useState<string>(() => { 254 + const date = props.publishedAt || new Date(); 255 + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; 256 + }); 257 + 258 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 259 + 260 + const handleTimeChange = (time: string) => { 261 + setTimeValue(time); 262 + if (!props.publishedAt) return; 263 + 264 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 265 + const newDate = setHours(setMinutes(props.publishedAt, minutes), hours); 266 + const currentDate = new Date(); 267 + 268 + if (newDate > currentDate) { 269 + props.setPublishedAt(currentDate); 270 + setTimeValue(currentTime); 271 + } else props.setPublishedAt(newDate); 272 + }; 273 + 274 + const handleDateChange = (date: Date | undefined) => { 275 + if (!date) { 276 + props.setPublishedAt(undefined); 277 + return; 278 + } 279 + const [hours, minutes] = timeValue 280 + .split(":") 281 + .map((str) => parseInt(str, 10)); 282 + const newDate = new Date( 283 + date.getFullYear(), 284 + date.getMonth(), 285 + date.getDate(), 286 + hours, 287 + minutes, 288 + ); 289 + const currentDate = new Date(); 290 + if (newDate > currentDate) { 291 + props.setPublishedAt(currentDate); 292 + setTimeValue(currentTime); 293 + } else props.setPublishedAt(newDate); 294 + }; 295 + 296 + return ( 297 + <div className="flex justify-between gap-2"> 298 + <h4>Publish Date</h4> 299 + <Popover 300 + className="w-64 px-2!" 301 + trigger={ 302 + props.publishedAt ? ( 303 + <div className="text-secondary">{formattedDate}</div> 304 + ) : ( 305 + <div className="text-tertiary italic">now</div> 306 + ) 307 + } 308 + > 309 + <div className="flex flex-col gap-3"> 310 + <DatePicker 311 + selected={props.publishedAt} 312 + onSelect={handleDateChange} 313 + disabled={(date) => date > new Date()} 314 + /> 315 + <Separator className="border-border" /> 316 + <div className="flex gap-4 pb-1 items-center"> 317 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 318 + </div> 319 + </div> 320 + </Popover> 321 + </div> 322 + ); 323 + }; 324 + 222 325 const ShareOptions = (props: { 223 326 shareOption: "quiet" | "bluesky"; 224 327 setShareOption: (option: typeof props.shareOption) => void; ··· 228 331 title: string; 229 332 profile: ProfileViewDetailed; 230 333 description: string; 231 - record?: PubLeafletPublication.Record; 334 + record?: NormalizedPublication | null; 232 335 }) => { 233 336 return ( 234 337 <div className="flex flex-col gap-2"> 235 - <h4>Notifications</h4> 338 + <h4>Share and Notify</h4> 236 339 <Radio 237 340 checked={props.shareOption === "quiet"} 238 341 onChange={(e) => { ··· 295 398 <div className="text-tertiary">{props.description}</div> 296 399 <hr className="border-border mt-2 mb-1" /> 297 400 <p className="text-xs text-tertiary"> 298 - {props.record?.base_path} 401 + {props.record?.url?.replace(/^https?:\/\//, "")} 299 402 </p> 300 403 </div> 301 404 </div> ··· 312 415 313 416 const PublishingTo = (props: { 314 417 publication_uri?: string; 315 - record?: PubLeafletPublication.Record; 418 + record?: NormalizedPublication | null; 316 419 }) => { 317 420 if (props.publication_uri && props.record) { 318 421 return (
+2 -2
app/[leaflet_id]/publish/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { PublishPost } from "./PublishPost"; 3 - import { PubLeafletPublication } from "lexicons/api"; 3 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 6 6 import { AtpAgent } from "@atproto/api"; ··· 118 118 title={title} 119 119 description={description} 120 120 publication_uri={publication?.uri} 121 - record={publication?.record as PubLeafletPublication.Record | undefined} 121 + record={normalizePublicationRecord(publication?.record)} 122 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 123 123 entitiesToDelete={entitiesToDelete} 124 124 hasDraft={hasDraft}
+5
app/api/inngest/client.ts
··· 21 21 }; 22 22 }; 23 23 "appview/come-online": { data: {} }; 24 + "user/migrate-to-standard": { 25 + data: { 26 + did: string; 27 + }; 28 + }; 24 29 }; 25 30 26 31 // Create a client to send and receive events
+32 -7
app/api/inngest/functions/index_post_mention.ts
··· 6 6 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 7 7 import { v7 } from "uuid"; 8 8 import { idResolver } from "app/(home-pages)/reader/idResolver"; 9 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 10 10 11 export const index_post_mention = inngest.createFunction( 11 12 { id: "index_post_mention" }, ··· 37 38 did = resolved; 38 39 } 39 40 40 - documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 41 + // Query the database to find the actual document URI (could be either namespace) 42 + const { data: docDataArr } = await supabaseServerClient 43 + .from("documents") 44 + .select("uri") 45 + .or(documentUriFilter(did, rkey)) 46 + .order("uri", { ascending: false }) 47 + .limit(1); 48 + const docData = docDataArr?.[0]; 49 + 50 + if (!docData) { 51 + return { message: `No document found for did:${did} rkey:${rkey}` }; 52 + } 53 + 54 + documentUri = docData.uri; 41 55 authorDid = did; 42 56 } else { 43 57 // Publication post: look up by custom domain 58 + // Support both old format (pub.leaflet.publication with base_path) and 59 + // new format (site.standard.publication with url as https://domain) 44 60 let { data: pub, error } = await supabaseServerClient 45 61 .from("publications") 46 62 .select("*") 47 - .eq("record->>base_path", url.host) 63 + .or(`record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`) 48 64 .single(); 49 65 50 66 if (!pub) { ··· 54 70 }; 55 71 } 56 72 57 - documentUri = AtUri.make( 58 - pub.identity_did, 59 - ids.PubLeafletDocument, 60 - path[0], 61 - ).toString(); 73 + // Query the database to find the actual document URI (could be either namespace) 74 + const { data: docDataArr } = await supabaseServerClient 75 + .from("documents") 76 + .select("uri") 77 + .or(documentUriFilter(pub.identity_did, path[0])) 78 + .order("uri", { ascending: false }) 79 + .limit(1); 80 + const docData = docDataArr?.[0]; 81 + 82 + if (!docData) { 83 + return { message: `No document found for publication ${url.host}/${path[0]}` }; 84 + } 85 + 86 + documentUri = docData.uri; 62 87 authorDid = pub.identity_did; 63 88 } 64 89
+501
app/api/inngest/functions/migrate_user_to_standard.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { 5 + AtpBaseClient, 6 + SiteStandardPublication, 7 + SiteStandardDocument, 8 + SiteStandardGraphSubscription, 9 + } from "lexicons/api"; 10 + import { AtUri } from "@atproto/syntax"; 11 + import { Json } from "supabase/database.types"; 12 + import { 13 + normalizePublicationRecord, 14 + normalizeDocumentRecord, 15 + } from "src/utils/normalizeRecords"; 16 + 17 + type MigrationResult = 18 + | { success: true; oldUri: string; newUri: string; skipped?: boolean } 19 + | { success: false; error: string }; 20 + 21 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 22 + const result = await restoreOAuthSession(did); 23 + if (!result.ok) { 24 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 25 + } 26 + const credentialSession = result.value; 27 + return new AtpBaseClient( 28 + credentialSession.fetchHandler.bind(credentialSession), 29 + ); 30 + } 31 + 32 + export const migrate_user_to_standard = inngest.createFunction( 33 + { id: "migrate_user_to_standard" }, 34 + { event: "user/migrate-to-standard" }, 35 + async ({ event, step }) => { 36 + const { did } = event.data; 37 + 38 + const stats = { 39 + publicationsMigrated: 0, 40 + documentsMigrated: 0, 41 + userSubscriptionsMigrated: 0, 42 + referencesUpdated: 0, 43 + errors: [] as string[], 44 + }; 45 + 46 + // Step 1: Verify OAuth session is valid 47 + await step.run("verify-oauth-session", async () => { 48 + const result = await restoreOAuthSession(did); 49 + if (!result.ok) { 50 + throw new Error( 51 + `Failed to restore OAuth session: ${result.error.message}`, 52 + ); 53 + } 54 + return { success: true }; 55 + }); 56 + 57 + // Step 2: Get user's pub.leaflet.publication records 58 + const oldPublications = await step.run( 59 + "fetch-old-publications", 60 + async () => { 61 + const { data, error } = await supabaseServerClient 62 + .from("publications") 63 + .select("*") 64 + .eq("identity_did", did) 65 + .like("uri", `at://${did}/pub.leaflet.publication/%`); 66 + 67 + if (error) 68 + throw new Error(`Failed to fetch publications: ${error.message}`); 69 + return data || []; 70 + }, 71 + ); 72 + 73 + // Step 3: Migrate all publications in parallel 74 + const publicationUriMap: Record<string, string> = {}; // old URI -> new URI 75 + 76 + // Prepare publications that need migration 77 + const publicationsToMigrate = oldPublications 78 + .map((pub) => { 79 + const aturi = new AtUri(pub.uri); 80 + 81 + // Skip if already a site.standard.publication 82 + if (aturi.collection === "site.standard.publication") { 83 + publicationUriMap[pub.uri] = pub.uri; 84 + return null; 85 + } 86 + 87 + const rkey = aturi.rkey; 88 + const normalized = normalizePublicationRecord(pub.record); 89 + 90 + if (!normalized) { 91 + stats.errors.push( 92 + `Publication ${pub.uri}: Failed to normalize publication record`, 93 + ); 94 + return null; 95 + } 96 + 97 + const newRecord: SiteStandardPublication.Record = { 98 + $type: "site.standard.publication", 99 + name: normalized.name, 100 + url: normalized.url, 101 + description: normalized.description, 102 + icon: normalized.icon, 103 + theme: normalized.theme, 104 + basicTheme: normalized.basicTheme, 105 + preferences: normalized.preferences, 106 + }; 107 + 108 + return { pub, rkey, normalized, newRecord }; 109 + }) 110 + .filter((x) => x !== null); 111 + 112 + // Run all PDS writes in parallel 113 + const pubPdsResults = await Promise.all( 114 + publicationsToMigrate.map(({ pub, rkey, newRecord }) => 115 + step.run(`pds-write-publication-${pub.uri}`, async () => { 116 + const agent = await createAuthenticatedAgent(did); 117 + const putResult = await agent.com.atproto.repo.putRecord({ 118 + repo: did, 119 + collection: "site.standard.publication", 120 + rkey, 121 + record: newRecord, 122 + validate: false, 123 + }); 124 + return { oldUri: pub.uri, newUri: putResult.data.uri }; 125 + }), 126 + ), 127 + ); 128 + 129 + // Run all DB writes in parallel 130 + const pubDbResults = await Promise.all( 131 + publicationsToMigrate.map(({ pub, normalized, newRecord }, index) => { 132 + const newUri = pubPdsResults[index].newUri; 133 + return step.run(`db-write-publication-${pub.uri}`, async () => { 134 + const { error: dbError } = await supabaseServerClient 135 + .from("publications") 136 + .upsert({ 137 + uri: newUri, 138 + identity_did: did, 139 + name: normalized.name, 140 + record: newRecord as Json, 141 + }); 142 + 143 + if (dbError) { 144 + return { 145 + success: false as const, 146 + oldUri: pub.uri, 147 + newUri, 148 + error: dbError.message, 149 + }; 150 + } 151 + return { success: true as const, oldUri: pub.uri, newUri }; 152 + }); 153 + }), 154 + ); 155 + 156 + // Process results 157 + for (const result of pubDbResults) { 158 + if (result.success) { 159 + publicationUriMap[result.oldUri] = result.newUri; 160 + stats.publicationsMigrated++; 161 + } else { 162 + stats.errors.push( 163 + `Publication ${result.oldUri}: Database error: ${result.error}`, 164 + ); 165 + } 166 + } 167 + 168 + // Step 4: Get ALL user's documents and their publication associations in parallel 169 + const [oldDocuments, allDocumentPublications] = await Promise.all([ 170 + step.run("fetch-old-documents", async () => { 171 + const { data, error } = await supabaseServerClient 172 + .from("documents") 173 + .select("uri, data") 174 + .like("uri", `at://${did}/pub.leaflet.document/%`); 175 + 176 + if (error) 177 + throw new Error(`Failed to fetch documents: ${error.message}`); 178 + return data || []; 179 + }), 180 + step.run("fetch-document-publications", async () => { 181 + const { data, error } = await supabaseServerClient 182 + .from("documents_in_publications") 183 + .select("document, publication") 184 + .like("document", `at://${did}/pub.leaflet.document/%`); 185 + 186 + if (error) 187 + throw new Error( 188 + `Failed to fetch document publications: ${error.message}`, 189 + ); 190 + return data || []; 191 + }), 192 + ]); 193 + 194 + // Create a map of document URI -> publication URI 195 + const documentPublicationMap: Record<string, string> = {}; 196 + for (const row of allDocumentPublications) { 197 + documentPublicationMap[row.document] = row.publication; 198 + } 199 + 200 + const documentUriMap: Record<string, string> = {}; // old URI -> new URI 201 + 202 + // Prepare documents that need migration 203 + const documentsToMigrate = oldDocuments 204 + .map((doc) => { 205 + const aturi = new AtUri(doc.uri); 206 + 207 + // Skip if already a site.standard.document 208 + if (aturi.collection === "site.standard.document") { 209 + documentUriMap[doc.uri] = doc.uri; 210 + return null; 211 + } 212 + 213 + const rkey = aturi.rkey; 214 + const normalized = normalizeDocumentRecord(doc.data, doc.uri); 215 + 216 + if (!normalized) { 217 + stats.errors.push( 218 + `Document ${doc.uri}: Failed to normalize document record`, 219 + ); 220 + return null; 221 + } 222 + 223 + // Determine the site field: 224 + // - If document is in a publication, use the new publication URI (if migrated) or old URI 225 + // - If standalone, use the HTTPS URL format 226 + const oldPubUri = documentPublicationMap[doc.uri]; 227 + let siteValue: string; 228 + 229 + if (oldPubUri) { 230 + // Document is in a publication - use new URI if migrated, otherwise keep old 231 + siteValue = publicationUriMap[oldPubUri] || oldPubUri; 232 + } else { 233 + // Standalone document - use HTTPS URL format 234 + siteValue = `https://leaflet.pub/p/${did}`; 235 + } 236 + 237 + // Build site.standard.document record 238 + const newRecord: SiteStandardDocument.Record = { 239 + $type: "site.standard.document", 240 + title: normalized.title || "Untitled", 241 + site: siteValue, 242 + path: rkey, 243 + publishedAt: normalized.publishedAt || new Date().toISOString(), 244 + description: normalized.description, 245 + content: normalized.content, 246 + tags: normalized.tags, 247 + coverImage: normalized.coverImage, 248 + bskyPostRef: normalized.bskyPostRef, 249 + }; 250 + 251 + return { doc, rkey, normalized, newRecord, oldPubUri }; 252 + }) 253 + .filter((x) => x !== null); 254 + 255 + // Run all PDS writes in parallel 256 + const docPdsResults = await Promise.all( 257 + documentsToMigrate.map(({ doc, rkey, newRecord }) => 258 + step.run(`pds-write-document-${doc.uri}`, async () => { 259 + const agent = await createAuthenticatedAgent(did); 260 + const putResult = await agent.com.atproto.repo.putRecord({ 261 + repo: did, 262 + collection: "site.standard.document", 263 + rkey, 264 + record: newRecord, 265 + validate: false, 266 + }); 267 + return { oldUri: doc.uri, newUri: putResult.data.uri }; 268 + }), 269 + ), 270 + ); 271 + 272 + // Run all DB writes in parallel 273 + const docDbResults = await Promise.all( 274 + documentsToMigrate.map(({ doc, newRecord, oldPubUri }, index) => { 275 + const newUri = docPdsResults[index].newUri; 276 + return step.run(`db-write-document-${doc.uri}`, async () => { 277 + const { error: dbError } = await supabaseServerClient 278 + .from("documents") 279 + .upsert({ 280 + uri: newUri, 281 + data: newRecord as Json, 282 + }); 283 + 284 + if (dbError) { 285 + return { 286 + success: false as const, 287 + oldUri: doc.uri, 288 + newUri, 289 + error: dbError.message, 290 + }; 291 + } 292 + 293 + // If document was in a publication, add to documents_in_publications with new URIs 294 + if (oldPubUri) { 295 + const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; 296 + await supabaseServerClient 297 + .from("documents_in_publications") 298 + .upsert({ 299 + publication: newPubUri, 300 + document: newUri, 301 + }); 302 + } 303 + 304 + return { success: true as const, oldUri: doc.uri, newUri }; 305 + }); 306 + }), 307 + ); 308 + 309 + // Process results 310 + for (const result of docDbResults) { 311 + if (result.success) { 312 + documentUriMap[result.oldUri] = result.newUri; 313 + stats.documentsMigrated++; 314 + } else { 315 + stats.errors.push( 316 + `Document ${result.oldUri}: Database error: ${result.error}`, 317 + ); 318 + } 319 + } 320 + 321 + // Step 5: Update references in database tables (all in parallel) 322 + await step.run("update-references", async () => { 323 + const pubEntries = Object.entries(publicationUriMap); 324 + const docEntries = Object.entries(documentUriMap); 325 + 326 + const updatePromises = [ 327 + // Update leaflets_in_publications - publication references 328 + ...pubEntries.map(([oldUri, newUri]) => 329 + supabaseServerClient 330 + .from("leaflets_in_publications") 331 + .update({ publication: newUri }) 332 + .eq("publication", oldUri), 333 + ), 334 + // Update leaflets_in_publications - doc references 335 + ...docEntries.map(([oldUri, newUri]) => 336 + supabaseServerClient 337 + .from("leaflets_in_publications") 338 + .update({ doc: newUri }) 339 + .eq("doc", oldUri), 340 + ), 341 + // Update leaflets_to_documents - document references 342 + ...docEntries.map(([oldUri, newUri]) => 343 + supabaseServerClient 344 + .from("leaflets_to_documents") 345 + .update({ document: newUri }) 346 + .eq("document", oldUri), 347 + ), 348 + // Update publication_domains - publication references 349 + ...pubEntries.map(([oldUri, newUri]) => 350 + supabaseServerClient 351 + .from("publication_domains") 352 + .update({ publication: newUri }) 353 + .eq("publication", oldUri), 354 + ), 355 + // Update comments_on_documents - document references 356 + ...docEntries.map(([oldUri, newUri]) => 357 + supabaseServerClient 358 + .from("comments_on_documents") 359 + .update({ document: newUri }) 360 + .eq("document", oldUri), 361 + ), 362 + // Update document_mentions_in_bsky - document references 363 + ...docEntries.map(([oldUri, newUri]) => 364 + supabaseServerClient 365 + .from("document_mentions_in_bsky") 366 + .update({ document: newUri }) 367 + .eq("document", oldUri), 368 + ), 369 + // Update subscribers_to_publications - publication references 370 + ...pubEntries.map(([oldUri, newUri]) => 371 + supabaseServerClient 372 + .from("subscribers_to_publications") 373 + .update({ publication: newUri }) 374 + .eq("publication", oldUri), 375 + ), 376 + // Update publication_subscriptions - publication references 377 + ...pubEntries.map(([oldUri, newUri]) => 378 + supabaseServerClient 379 + .from("publication_subscriptions") 380 + .update({ publication: newUri }) 381 + .eq("publication", oldUri), 382 + ), 383 + ]; 384 + 385 + const results = await Promise.all(updatePromises); 386 + stats.referencesUpdated = results.filter((r) => !r.error).length; 387 + return stats.referencesUpdated; 388 + }); 389 + 390 + // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications 391 + const userSubscriptions = await step.run( 392 + "fetch-user-subscriptions", 393 + async () => { 394 + const { data, error } = await supabaseServerClient 395 + .from("publication_subscriptions") 396 + .select("*") 397 + .eq("identity", did) 398 + .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); 399 + 400 + if (error) 401 + throw new Error( 402 + `Failed to fetch user subscriptions: ${error.message}`, 403 + ); 404 + return data || []; 405 + }, 406 + ); 407 + 408 + const userSubscriptionUriMap: Record<string, string> = {}; // old URI -> new URI 409 + 410 + // Prepare subscriptions that need migration 411 + const subscriptionsToMigrate = userSubscriptions 412 + .map((sub) => { 413 + const aturi = new AtUri(sub.uri); 414 + 415 + // Skip if already a site.standard.graph.subscription 416 + if (aturi.collection === "site.standard.graph.subscription") { 417 + userSubscriptionUriMap[sub.uri] = sub.uri; 418 + return null; 419 + } 420 + 421 + const rkey = aturi.rkey; 422 + const newRecord: SiteStandardGraphSubscription.Record = { 423 + $type: "site.standard.graph.subscription", 424 + publication: sub.publication, 425 + }; 426 + 427 + return { sub, rkey, newRecord }; 428 + }) 429 + .filter((x) => x !== null); 430 + 431 + // Run all PDS writes in parallel 432 + const subPdsResults = await Promise.all( 433 + subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 434 + step.run(`pds-write-subscription-${sub.uri}`, async () => { 435 + const agent = await createAuthenticatedAgent(did); 436 + const putResult = await agent.com.atproto.repo.putRecord({ 437 + repo: did, 438 + collection: "site.standard.graph.subscription", 439 + rkey, 440 + record: newRecord, 441 + validate: false, 442 + }); 443 + return { oldUri: sub.uri, newUri: putResult.data.uri }; 444 + }), 445 + ), 446 + ); 447 + 448 + // Run all DB writes in parallel 449 + const subDbResults = await Promise.all( 450 + subscriptionsToMigrate.map(({ sub, newRecord }, index) => { 451 + const newUri = subPdsResults[index].newUri; 452 + return step.run(`db-write-subscription-${sub.uri}`, async () => { 453 + const { error: dbError } = await supabaseServerClient 454 + .from("publication_subscriptions") 455 + .update({ 456 + uri: newUri, 457 + record: newRecord as Json, 458 + }) 459 + .eq("uri", sub.uri); 460 + 461 + if (dbError) { 462 + return { 463 + success: false as const, 464 + oldUri: sub.uri, 465 + newUri, 466 + error: dbError.message, 467 + }; 468 + } 469 + return { success: true as const, oldUri: sub.uri, newUri }; 470 + }); 471 + }), 472 + ); 473 + 474 + // Process results 475 + for (const result of subDbResults) { 476 + if (result.success) { 477 + userSubscriptionUriMap[result.oldUri] = result.newUri; 478 + stats.userSubscriptionsMigrated++; 479 + } else { 480 + stats.errors.push( 481 + `User subscription ${result.oldUri}: Database error: ${result.error}`, 482 + ); 483 + } 484 + } 485 + 486 + // NOTE: We intentionally keep old documents, publications, and documents_in_publications entries. 487 + // New entries are created with the new URIs, but the old entries remain so that: 488 + // 1. Notifications referencing old document/publication URIs can still resolve 489 + // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work 490 + // 3. The normalization layer handles both schemas transparently for reads 491 + // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 492 + 493 + return { 494 + success: stats.errors.length === 0, 495 + stats, 496 + publicationUriMap, 497 + documentUriMap, 498 + userSubscriptionUriMap, 499 + }; 500 + }, 501 + );
+2
app/api/inngest/route.tsx
··· 4 4 import { come_online } from "./functions/come_online"; 5 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 6 import { index_follows } from "./functions/index_follows"; 7 + import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 7 8 8 9 export const { GET, POST, PUT } = serve({ 9 10 client: inngest, ··· 12 13 come_online, 13 14 batched_update_profiles, 14 15 index_follows, 16 + migrate_user_to_standard, 15 17 ], 16 18 });
+22 -13
app/api/pub_icon/route.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { IdResolver } from "@atproto/identity"; 3 3 import { NextRequest, NextResponse } from "next/server"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 4 import { supabaseServerClient } from "supabase/serverClient"; 5 + import { 6 + normalizePublicationRecord, 7 + type NormalizedPublication, 8 + } from "src/utils/normalizeRecords"; 9 + import { 10 + isDocumentCollection, 11 + isPublicationCollection, 12 + } from "src/utils/collectionHelpers"; 13 + import { publicationUriFilter } from "src/utils/uriHelpers"; 6 14 import sharp from "sharp"; 7 15 8 16 const idResolver = new IdResolver(); ··· 29 37 return new NextResponse(null, { status: 400 }); 30 38 } 31 39 32 - let publicationRecord: PubLeafletPublication.Record | null = null; 40 + let normalizedPub: NormalizedPublication | null = null; 33 41 let publicationUri: string; 34 42 35 43 // Check if it's a document or publication 36 - if (uri.collection === "pub.leaflet.document") { 44 + if (isDocumentCollection(uri.collection)) { 37 45 // Query the documents_in_publications table to get the publication 38 46 const { data: docInPub } = await supabaseServerClient 39 47 .from("documents_in_publications") ··· 46 54 } 47 55 48 56 publicationUri = docInPub.publication; 49 - publicationRecord = docInPub.publications 50 - .record as PubLeafletPublication.Record; 51 - } else if (uri.collection === "pub.leaflet.publication") { 57 + normalizedPub = normalizePublicationRecord(docInPub.publications.record); 58 + } else if (isPublicationCollection(uri.collection)) { 52 59 // Query the publications table directly 53 - const { data: publication } = await supabaseServerClient 60 + const { data: publications } = await supabaseServerClient 54 61 .from("publications") 55 62 .select("record, uri") 56 - .eq("uri", at_uri) 57 - .single(); 63 + .or(publicationUriFilter(uri.host, uri.rkey)) 64 + .order("uri", { ascending: false }) 65 + .limit(1); 66 + const publication = publications?.[0]; 58 67 59 68 if (!publication || !publication.record) { 60 69 return new NextResponse(null, { status: 404 }); 61 70 } 62 71 63 72 publicationUri = publication.uri; 64 - publicationRecord = publication.record as PubLeafletPublication.Record; 73 + normalizedPub = normalizePublicationRecord(publication.record); 65 74 } else { 66 75 // Not a supported collection 67 76 return new NextResponse(null, { status: 404 }); 68 77 } 69 78 70 79 // Check if the publication has an icon 71 - if (!publicationRecord?.icon) { 80 + if (!normalizedPub?.icon) { 72 81 // Generate a placeholder with the first letter of the publication name 73 - const firstLetter = (publicationRecord?.name || "?") 82 + const firstLetter = (normalizedPub?.name || "?") 74 83 .slice(0, 1) 75 84 .toUpperCase(); 76 85 ··· 94 103 const pubUri = new AtUri(publicationUri); 95 104 96 105 // Get the CID from the icon blob 97 - const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 106 + const cid = (normalizedPub.icon.ref as unknown as { $link: string })[ 98 107 "$link" 99 108 ]; 100 109
+15 -2
app/api/rpc/[command]/get_profile_data.ts
··· 6 6 import { Agent } from "@atproto/api"; 7 7 import { getIdentityData } from "actions/getIdentityData"; 8 8 import { createOauthClient } from "src/atproto-oauth"; 9 + import { 10 + normalizePublicationRow, 11 + hasValidPublication, 12 + } from "src/utils/normalizeRecords"; 13 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 14 10 15 export type GetProfileDataReturnType = Awaited< 11 16 ReturnType<(typeof get_profile_data)["handler"]> ··· 54 59 .select("*") 55 60 .eq("identity_did", did); 56 61 57 - let [{ data: profile }, { data: publications }] = await Promise.all([ 62 + let [{ data: profile }, { data: rawPublications }] = await Promise.all([ 58 63 profileReq, 59 64 publicationsReq, 60 65 ]); 61 66 67 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 68 + const publications = deduplicateByUri(rawPublications || []); 69 + 70 + // Normalize publication records before returning 71 + const normalizedPublications = publications 72 + .map(normalizePublicationRow) 73 + .filter(hasValidPublication); 74 + 62 75 return { 63 76 result: { 64 77 profile, 65 - publications: publications || [], 78 + publications: normalizedPublications, 66 79 }, 67 80 }; 68 81 },
+56 -5
app/api/rpc/[command]/get_publication_data.ts
··· 3 3 import type { Env } from "./route"; 4 4 import { AtUri } from "@atproto/syntax"; 5 5 import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { ids } from "lexicons/api/lexicons"; 6 8 7 9 export type GetPublicationDataReturnType = Awaited< 8 10 ReturnType<(typeof get_publication_data)["handler"]> ··· 17 19 { did, publication_name }, 18 20 { supabase }: Pick<Env, "supabase">, 19 21 ) => { 20 - let uri; 22 + let pubLeafletUri; 23 + let siteStandardUri; 21 24 if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 22 - uri = AtUri.make( 25 + pubLeafletUri = AtUri.make( 23 26 did, 24 - "pub.leaflet.publication", 27 + ids.PubLeafletPublication, 28 + publication_name, 29 + ).toString(); 30 + siteStandardUri = AtUri.make( 31 + did, 32 + ids.SiteStandardPublication, 25 33 publication_name, 26 34 ).toString(); 27 35 } ··· 44 52 ) 45 53 )`, 46 54 ) 47 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 55 + .or( 56 + `name.eq."${publication_name}", uri.eq."${pubLeafletUri}", uri.eq."${siteStandardUri}"`, 57 + ) 48 58 .eq("identity_did", did) 59 + .order("uri", { ascending: false }) 60 + .limit(1) 49 61 .single(); 50 62 51 63 let leaflet_data = await getFactsFromHomeLeaflets.handler( ··· 58 70 { supabase }, 59 71 ); 60 72 61 - return { result: { publication, leaflet_data: leaflet_data.result } }; 73 + // Pre-normalize documents from documents_in_publications 74 + const documents = (publication?.documents_in_publications || []) 75 + .map((dip) => { 76 + if (!dip.documents) return null; 77 + const normalized = normalizeDocumentRecord( 78 + dip.documents.data, 79 + dip.documents.uri, 80 + ); 81 + if (!normalized) return null; 82 + return { 83 + uri: dip.documents.uri, 84 + record: normalized, 85 + indexed_at: dip.documents.indexed_at, 86 + data: dip.documents.data, 87 + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 88 + mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 89 + }; 90 + }) 91 + .filter((d): d is NonNullable<typeof d> => d !== null); 92 + 93 + // Pre-filter drafts (leaflets without published documents, not archived) 94 + const drafts = (publication?.leaflets_in_publications || []) 95 + .filter((l) => !l.documents) 96 + .filter((l) => !(l as { archived?: boolean }).archived) 97 + .map((l) => ({ 98 + leaflet: l.leaflet, 99 + title: l.title, 100 + permission_tokens: l.permission_tokens, 101 + // Keep the full leaflet data for LeafletList compatibility 102 + _raw: l, 103 + })); 104 + 105 + return { 106 + result: { 107 + publication, 108 + documents, 109 + drafts, 110 + leaflet_data: leaflet_data.result, 111 + }, 112 + }; 62 113 }, 63 114 });
+5 -1
app/api/rpc/[command]/search_publication_names.ts
··· 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 5 6 6 7 export type SearchPublicationNamesReturnType = Awaited< 7 8 ReturnType<(typeof search_publication_names)["handler"]> ··· 15 16 }), 16 17 handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 17 18 // Search publications by name in record (case-insensitive partial match) 18 - const { data: publications, error } = await supabase 19 + const { data: rawPublications, error } = await supabase 19 20 .from("publications") 20 21 .select("uri, record") 21 22 .ilike("record->>name", `%${query}%`) ··· 24 25 if (error) { 25 26 throw new Error(`Failed to search publications: ${error.message}`); 26 27 } 28 + 29 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 30 + const publications = deduplicateByUri(rawPublications || []); 27 31 28 32 const result = publications.map((p) => { 29 33 const record = p.record as { name?: string };
+2 -4
app/globals.css
··· 311 311 .ProseMirror .didMention.ProseMirror-selectednode { 312 312 @apply text-accent-contrast; 313 313 @apply px-0.5; 314 - @apply -mx-[3px]; /* extra px to account for the border*/ 315 - @apply -my-px; /*to account for the border*/ 316 314 @apply rounded-[4px]; 317 315 @apply box-decoration-clone; 318 316 background-color: rgba(var(--accent-contrast), 0.2); ··· 323 321 @apply cursor-pointer; 324 322 @apply text-accent-contrast; 325 323 @apply px-0.5; 326 - @apply -mx-[3px]; 327 - @apply -my-px; /*to account for the border*/ 328 324 @apply rounded-[4px]; 329 325 @apply box-decoration-clone; 330 326 background-color: rgba(var(--accent-contrast), 0.2); 331 327 border: 1px solid transparent; 328 + display: inline; 329 + white-space: normal; 332 330 } 333 331 334 332 .multiselected:focus-within .selection-highlight {
+6 -10
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { CSSProperties, useContext, useRef } from "react"; 5 + import { CSSProperties, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 7 import { PostContent, Block } from "../PostContent"; 8 8 import { ··· 15 15 } from "lexicons/api"; 16 16 import { AppBskyFeedDefs } from "@atproto/api"; 17 17 import { TextBlock } from "./TextBlock"; 18 - import { PostPageContext } from "../PostPageContext"; 18 + import { useDocument } from "contexts/DocumentContext"; 19 19 import { openPage, useOpenPages } from "../PostPages"; 20 20 import { 21 21 openInteractionDrawer, ··· 155 155 }) { 156 156 let previewRef = useRef<HTMLDivElement | null>(null); 157 157 let { rootEntity } = useReplicache(); 158 - let data = useContext(PostPageContext); 159 - let theme = data?.theme; 158 + const { theme } = useDocument(); 160 159 let pageWidth = `var(--page-width-unitless)`; 161 160 let cardBorderHidden = !theme?.showPageBackground; 162 161 return ( ··· 195 194 } 196 195 197 196 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 - const data = useContext(PostPageContext); 199 - const document_uri = data?.uri; 200 - if (!document_uri) 201 - throw new Error("document_uri not available in PostPageContext"); 202 - let comments = data.comments_on_documents.filter( 197 + const { uri: document_uri, comments: allComments, mentions } = useDocument(); 198 + let comments = allComments.filter( 203 199 (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 200 ).length; 205 - let quotes = data.document_mentions_in_bsky.filter((q) => 201 + let quotes = mentions.filter((q) => 206 202 q.link.includes(props.pageId), 207 203 ).length; 208 204
+4 -4
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 69 69 data={document} 70 70 profile={profile} 71 71 preferences={preferences} 72 - commentsCount={getCommentCount(document, pageId)} 73 - quotesCount={getQuoteCount(document, pageId)} 72 + commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 74 /> 75 75 <CanvasContent 76 76 blocks={blocks} ··· 216 216 <Interactions 217 217 quotesCount={props.quotesCount || 0} 218 218 commentsCount={props.commentsCount || 0} 219 - showComments={props.preferences.showComments} 220 - showMentions={props.preferences.showMentions} 219 + showComments={props.preferences.showComments !== false} 220 + showMentions={props.preferences.showMentions !== false} 221 221 pageId={props.pageId} 222 222 /> 223 223 {!props.isSubpage && (
+35 -38
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 1 1 import { AtpAgent } from "@atproto/api"; 2 - import { AtUri } from "@atproto/syntax"; 3 2 import { ids } from "lexicons/api/lexicons"; 4 3 import { 5 4 PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 5 PubLeafletPagesLinearDocument, 8 6 PubLeafletPagesCanvas, 9 - PubLeafletPublication, 10 7 } from "lexicons/api"; 11 8 import { QuoteHandler } from "./QuoteHandler"; 12 9 import { ··· 14 11 PublicationThemeProvider, 15 12 } from "components/ThemeManager/PublicationThemeProvider"; 16 13 import { getPostPageData } from "./getPostPageData"; 17 - import { PostPageContextProvider } from "./PostPageContext"; 18 14 import { PostPages } from "./PostPages"; 19 15 import { extractCodeBlocks } from "./extractCodeBlocks"; 20 16 import { LeafletLayout } from "components/LeafletLayout"; 21 17 import { fetchPollData } from "./fetchPollData"; 18 + import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; 19 + import { DocumentProvider } from "contexts/DocumentContext"; 20 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 22 21 23 22 export async function DocumentPageRenderer({ 24 23 did, ··· 37 36 }); 38 37 39 38 let [document, profile] = await Promise.all([ 40 - getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), 39 + getPostPageData(did, rkey), 41 40 agent.getProfile({ actor: did }), 42 41 ]); 43 42 44 - if (!document?.data) 43 + const record = document?.normalizedDocument; 44 + const pages = record ? getDocumentPages(record) : undefined; 45 + 46 + if (!document?.data || !record || !pages) 45 47 return ( 46 48 <div className="bg-bg-leaflet h-full p-3 text-center relative"> 47 49 <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> ··· 55 57 </div> 56 58 </div> 57 59 ); 58 - 59 - let record = document.data as PubLeafletDocument.Record; 60 60 let bskyPosts = 61 - record.pages.flatMap((p) => { 61 + pages.flatMap((p) => { 62 62 let page = p as PubLeafletPagesLinearDocument.Main; 63 63 return page.blocks?.filter( 64 64 (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, ··· 91 91 : []; 92 92 93 93 // Extract poll blocks and fetch vote data 94 - let pollBlocks = record.pages.flatMap((p) => { 94 + let pollBlocks = pages.flatMap((p) => { 95 95 let page = p as PubLeafletPagesLinearDocument.Main; 96 96 return ( 97 97 page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || ··· 102 102 pollBlocks.map((b) => (b.block as any).pollRef.uri), 103 103 ); 104 104 105 - // Get theme from publication or document (for standalone docs) 106 - let pubRecord = document.documents_in_publications[0]?.publications 107 - ?.record as PubLeafletPublication.Record | undefined; 108 - let theme = pubRecord?.theme || record.theme || null; 109 - let pub_creator = 110 - document.documents_in_publications[0]?.publications?.identity_did || did; 105 + const pubRecord = document.normalizedPublication; 106 + let pub_creator = document.publication?.identity_did || did; 111 107 let isStandalone = !pubRecord; 112 108 113 - let firstPage = record.pages[0]; 114 - 109 + let firstPage = pages[0]; 115 110 let firstPageBlocks = 116 111 ( 117 112 firstPage as ··· 121 116 let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 122 117 123 118 return ( 124 - <PostPageContextProvider value={document}> 125 - <PublicationThemeProvider theme={theme} pub_creator={pub_creator} isStandalone={isStandalone}> 126 - <PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}> 127 - <LeafletLayout> 128 - <PostPages 129 - document_uri={document.uri} 130 - preferences={pubRecord?.preferences || {}} 131 - pubRecord={pubRecord} 132 - profile={JSON.parse(JSON.stringify(profile.data))} 133 - document={document} 134 - bskyPostData={bskyPostData} 135 - did={did} 136 - prerenderedCodeBlocks={prerenderedCodeBlocks} 137 - pollData={pollData} 138 - /> 139 - </LeafletLayout> 119 + <DocumentProvider value={document}> 120 + <LeafletContentProvider value={{ pages }}> 121 + <PublicationThemeProvider theme={document.theme} pub_creator={pub_creator} isStandalone={isStandalone}> 122 + <PublicationBackgroundProvider theme={document.theme} pub_creator={pub_creator}> 123 + <LeafletLayout> 124 + <PostPages 125 + document_uri={document.uri} 126 + preferences={pubRecord?.preferences || {}} 127 + pubRecord={pubRecord} 128 + profile={JSON.parse(JSON.stringify(profile.data))} 129 + document={document} 130 + bskyPostData={bskyPostData} 131 + did={did} 132 + prerenderedCodeBlocks={prerenderedCodeBlocks} 133 + pollData={pollData} 134 + /> 135 + </LeafletLayout> 140 136 141 - <QuoteHandler /> 142 - </PublicationBackgroundProvider> 143 - </PublicationThemeProvider> 144 - </PostPageContextProvider> 137 + <QuoteHandler /> 138 + </PublicationBackgroundProvider> 139 + </PublicationThemeProvider> 140 + </LeafletContentProvider> 141 + </DocumentProvider> 145 142 ); 146 143 }
+6 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 17 17 pingIdentityToUpdateNotification, 18 18 } from "src/notifications"; 19 19 import { v7 } from "uuid"; 20 + import { 21 + isDocumentCollection, 22 + isPublicationCollection, 23 + } from "src/utils/collectionHelpers"; 20 24 21 25 type PublishCommentResult = 22 26 | { success: true; record: Json; profile: any; uri: string } ··· 180 184 if (notifiedRecipients.has(dedupeKey)) continue; 181 185 notifiedRecipients.add(dedupeKey); 182 186 183 - if (mentionedUri.collection === "pub.leaflet.publication") { 187 + if (isPublicationCollection(mentionedUri.collection)) { 184 188 notifications.push({ 185 189 id: v7(), 186 190 recipient: recipientDid, ··· 191 195 mentioned_uri: feature.atURI, 192 196 }, 193 197 }); 194 - } else if (mentionedUri.collection === "pub.leaflet.document") { 198 + } else if (isDocumentCollection(mentionedUri.collection)) { 195 199 notifications.push({ 196 200 id: v7(), 197 201 recipient: recipientDid,
+10 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 119 119 }) => { 120 120 const did = props.comment.bsky_profiles?.did; 121 121 122 + let timeAgoDate = timeAgo(props.record.createdAt); 123 + const formattedDate = useLocalizedDate(props.record.createdAt, { 124 + year: "numeric", 125 + month: "long", 126 + day: "2-digit", 127 + }); 128 + 122 129 return ( 123 130 <div id={props.comment.uri} className="comment"> 124 131 <div className="flex gap-2"> 125 - {did && ( 132 + {did ? ( 126 133 <ProfilePopover 127 134 didOrHandle={did} 128 135 trigger={ ··· 131 138 </div> 132 139 } 133 140 /> 134 - )} 141 + ) : null} 142 + <div className="text-sm text-tertiary">{timeAgoDate}</div> 135 143 </div> 136 144 {props.record.attachment && 137 145 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
+33 -61
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 8 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 - import { useContext } from "react"; 10 - import { PostPageContext } from "../PostPageContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 11 10 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 11 import { TagTiny } from "components/Icons/TagTiny"; 13 12 import { Tag } from "components/Tags"; 14 13 import { Popover } from "components/Popover"; 15 - import { PostPageData } from "../getPostPageData"; 16 - import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 + import { PubLeafletComment } from "lexicons/api"; 15 + import { type CommentOnDocument } from "contexts/DocumentContext"; 17 16 import { prefetchQuotesData } from "./Quotes"; 18 17 import { useIdentityData } from "components/IdentityProvider"; 19 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 107 106 quotesCount: number; 108 107 commentsCount: number; 109 108 className?: string; 110 - showComments?: boolean; 111 - showMentions?: boolean; 109 + showComments: boolean; 110 + showMentions: boolean; 112 111 pageId?: string; 113 112 }) => { 114 - const data = useContext(PostPageContext); 115 - const document_uri = data?.uri; 113 + const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 116 114 let { identity } = useIdentityData(); 117 - if (!document_uri) 118 - throw new Error("document_uri not available in PostPageContext"); 119 115 120 116 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 121 117 122 118 const handleQuotePrefetch = () => { 123 - if (data?.quotesAndMentions) { 124 - prefetchQuotesData(data.quotesAndMentions); 119 + if (quotesAndMentions) { 120 + prefetchQuotesData(quotesAndMentions); 125 121 } 126 122 }; 127 123 128 - const tags = (data?.data as any)?.tags as string[] | undefined; 124 + const tags = normalizedDocument.tags; 129 125 const tagCount = tags?.length || 0; 130 126 131 127 return ( ··· 168 164 quotesCount: number; 169 165 commentsCount: number; 170 166 className?: string; 171 - showComments?: boolean; 172 - showMentions?: boolean; 167 + showComments: boolean; 168 + showMentions: boolean; 173 169 pageId?: string; 174 170 }) => { 175 - const data = useContext(PostPageContext); 171 + const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 176 172 let { identity } = useIdentityData(); 177 173 178 - const document_uri = data?.uri; 179 - if (!document_uri) 180 - throw new Error("document_uri not available in PostPageContext"); 181 - 182 174 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 183 175 184 176 const handleQuotePrefetch = () => { 185 - if (data?.quotesAndMentions) { 186 - prefetchQuotesData(data.quotesAndMentions); 177 + if (quotesAndMentions) { 178 + prefetchQuotesData(quotesAndMentions); 187 179 } 188 180 }; 189 - let publication = data?.documents_in_publications[0]?.publications; 190 181 191 - const tags = (data?.data as any)?.tags as string[] | undefined; 182 + const tags = normalizedDocument.tags; 192 183 const tagCount = tags?.length || 0; 193 184 194 185 let noInteractions = !props.showComments && !props.showMentions; ··· 202 193 203 194 let isAuthor = 204 195 identity && 205 - identity.atp_did === 206 - data.documents_in_publications[0]?.publications?.identity_did && 207 - data.leaflets_in_publications[0]; 196 + identity.atp_did === publication?.identity_did && 197 + leafletId; 208 198 209 199 return ( 210 200 <div 211 201 className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 212 202 > 213 - {!subscribed && !isAuthor && publication && publication.record && ( 214 - <div className="text-center flex flex-col accent-container rounded-md mb-3"> 215 - <div className="flex flex-col py-4"> 216 - <div className="leading-snug flex flex-col pb-2 text-sm"> 217 - <div className="font-bold">Subscribe to {publication.name}</div>{" "} 218 - to get updates in Reader, RSS, or via Bluesky Feed 219 - </div> 220 - <SubscribeWithBluesky 221 - pubName={publication.name} 222 - pub_uri={publication.uri} 223 - base_url={getPublicationURL(publication)} 224 - subscribers={publication?.publication_subscriptions} 225 - /> 226 - </div> 227 - </div> 228 - )} 229 203 {tagCount > 0 && ( 230 204 <> 231 205 <hr className="border-border-light mb-3" /> ··· 242 216 ) : ( 243 217 <> 244 218 <div className="flex gap-2"> 245 - {props.quotesCount === 0 || 246 - props.showMentions === false ? null : ( 219 + {props.quotesCount === 0 || !props.showMentions ? null : ( 247 220 <button 248 221 className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 249 222 onClick={() => { ··· 266 239 >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 267 240 </button> 268 241 )} 269 - {props.showComments === false ? null : ( 242 + {!props.showComments ? null : ( 270 243 <button 271 244 className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 272 245 onClick={() => { ··· 299 272 </> 300 273 )} 301 274 302 - <EditButton document={data} /> 275 + <EditButton publication={publication} leafletId={leafletId} /> 303 276 {subscribed && publication && ( 304 277 <ManageSubscription 305 278 base_url={getPublicationURL(publication)} ··· 340 313 </div> 341 314 ); 342 315 }; 343 - export function getQuoteCount(document: PostPageData, pageId?: string) { 344 - if (!document) return; 345 - return getQuoteCountFromArray(document.quotesAndMentions, pageId); 316 + export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 317 + return getQuoteCountFromArray(quotesAndMentions, pageId); 346 318 } 347 319 348 320 export function getQuoteCountFromArray( ··· 366 338 } 367 339 } 368 340 369 - export function getCommentCount(document: PostPageData, pageId?: string) { 370 - if (!document) return; 341 + export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 371 342 if (pageId) 372 - return document.comments_on_documents.filter( 343 + return comments.filter( 373 344 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 374 345 ).length; 375 346 else 376 - return document.comments_on_documents.filter( 347 + return comments.filter( 377 348 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 378 349 ).length; 379 350 } 380 351 381 - const EditButton = (props: { document: PostPageData }) => { 352 + const EditButton = (props: { 353 + publication: { identity_did: string } | null; 354 + leafletId: string | null; 355 + }) => { 382 356 let { identity } = useIdentityData(); 383 - if (!props.document) return; 384 357 if ( 385 358 identity && 386 - identity.atp_did === 387 - props.document.documents_in_publications[0]?.publications?.identity_did && 388 - props.document.leaflets_in_publications[0] 359 + identity.atp_did === props.publication?.identity_did && 360 + props.leafletId 389 361 ) 390 362 return ( 391 363 <a 392 - href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 364 + href={`https://leaflet.pub/${props.leafletId}`} 393 365 className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 394 366 > 395 367 <EditTiny /> Edit Post 396 368 </a> 397 369 ); 398 - return; 370 + return null; 399 371 };
+7 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 1 1 "use client"; 2 2 import { CloseTiny } from "components/Icons/CloseTiny"; 3 - import { useContext } from "react"; 4 3 import { useIsMobile } from "src/hooks/isMobile"; 5 4 import { setInteractionState } from "./Interactions"; 6 5 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 6 import { AtUri, AppBskyFeedPost } from "@atproto/api"; 8 - import { PostPageContext } from "../PostPageContext"; 9 7 import { 10 8 PubLeafletBlocksText, 11 9 PubLeafletBlocksUnorderedList, 12 10 PubLeafletBlocksHeader, 13 - PubLeafletDocument, 14 11 PubLeafletPagesLinearDocument, 15 12 PubLeafletBlocksCode, 16 13 } from "lexicons/api"; 14 + import { useDocument } from "contexts/DocumentContext"; 15 + import { useLeafletContent } from "contexts/LeafletContentContext"; 17 16 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 18 17 import { useActiveHighlightState } from "../useHighlight"; 19 18 import { PostContent } from "../PostContent"; ··· 66 65 quotesAndMentions: { uri: string; link?: string }[]; 67 66 did: string; 68 67 }) => { 69 - let data = useContext(PostPageContext); 70 - const document_uri = data?.uri; 71 - if (!document_uri) 72 - throw new Error("document_uri not available in PostPageContext"); 68 + const { uri: document_uri } = useDocument(); 73 69 74 70 // Fetch Bluesky post data for all URIs 75 71 const uris = props.quotesAndMentions.map((q) => q.uri); ··· 182 178 did: string; 183 179 }) => { 184 180 let isMobile = useIsMobile(); 185 - const data = useContext(PostPageContext); 186 - const document_uri = data?.uri; 181 + const { uri: document_uri } = useDocument(); 182 + const { pages } = useLeafletContent(); 187 183 188 - let record = data?.data as PubLeafletDocument.Record; 189 184 let page: PubLeafletPagesLinearDocument.Main | undefined = ( 190 185 props.position.pageId 191 - ? record.pages.find( 186 + ? pages.find( 192 187 (p) => 193 188 (p as PubLeafletPagesLinearDocument.Main).id === 194 189 props.position.pageId, 195 190 ) 196 - : record.pages[0] 191 + : pages[0] 197 192 ) as PubLeafletPagesLinearDocument.Main; 198 193 // Extract blocks within the quote range 199 194 const content = extractQuotedBlocks(page.blocks || [], props.position, []);
+11 -16
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 3 + import { useLeafletContent } from "contexts/LeafletContentContext"; 8 4 import { PostPageData } from "./getPostPageData"; 9 5 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 6 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; ··· 25 21 import { PollData } from "./fetchPollData"; 26 22 import { SharedPageProps } from "./PostPages"; 27 23 import { PostPrevNextButtons } from "./PostPrevNextButtons"; 24 + import { PostSubscribe } from "./PostSubscribe"; 28 25 29 26 export function LinearDocumentPage({ 30 27 blocks, ··· 49 46 hasPageBackground, 50 47 } = props; 51 48 let drawer = useDrawerOpen(document_uri); 49 + const { pages } = useLeafletContent(); 52 50 53 51 if (!document) return null; 54 52 55 - let record = document.data as PubLeafletDocument.Record; 56 - 57 53 const isSubpage = !!pageId; 58 - 59 - console.log("prev/next?: " + preferences.showPrevNext); 60 54 61 55 return ( 62 56 <> ··· 78 72 )} 79 73 <PostContent 80 74 pollData={pollData} 81 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 75 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 82 76 pageId={pageId} 83 77 bskyPostData={bskyPostData} 84 78 blocks={blocks} 85 79 did={did} 86 80 prerenderedCodeBlocks={prerenderedCodeBlocks} 87 81 /> 82 + <PostSubscribe /> 88 83 <PostPrevNextButtons 89 - showPrevNext={preferences.showPrevNext && !isSubpage} 84 + showPrevNext={preferences.showPrevNext !== false && !isSubpage} 90 85 /> 91 86 <ExpandedInteractions 92 87 pageId={pageId} 93 - showComments={preferences.showComments} 94 - showMentions={preferences.showMentions} 95 - commentsCount={getCommentCount(document, pageId) || 0} 96 - quotesCount={getQuoteCount(document, pageId) || 0} 88 + showComments={preferences.showComments !== false} 89 + showMentions={preferences.showMentions !== false} 90 + commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 91 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 97 92 /> 98 93 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 99 94 </PageWrapper>
+7 -12
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 7 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 8 3 import { 9 4 Interactions, ··· 28 23 let { identity } = useIdentityData(); 29 24 let document = props.data; 30 25 31 - let record = document?.data as PubLeafletDocument.Record; 26 + const record = document?.normalizedDocument; 32 27 let profile = props.profile; 33 28 let pub = props.data?.documents_in_publications[0]?.publications; 34 29 35 30 const formattedDate = useLocalizedDate( 36 - record.publishedAt || new Date().toISOString(), 31 + record?.publishedAt || new Date().toISOString(), 37 32 { 38 33 year: "numeric", 39 34 month: "long", ··· 41 36 }, 42 37 ); 43 38 44 - if (!document?.data) return; 39 + if (!document?.data || !record) return null; 45 40 return ( 46 41 <PostHeaderLayout 47 42 pubLink={ ··· 90 85 ) : null} 91 86 </div> 92 87 <Interactions 93 - showComments={props.preferences.showComments} 94 - showMentions={props.preferences.showMentions} 95 - quotesCount={getQuoteCount(document) || 0} 96 - commentsCount={getCommentCount(document) || 0} 88 + showComments={props.preferences.showComments !== false} 89 + showMentions={props.preferences.showMentions !== false} 90 + quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 + commentsCount={getCommentCount(document?.comments_on_documents || []) || 0} 97 92 /> 98 93 </> 99 94 }
-19
app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx
··· 1 - "use client"; 2 - import { createContext } from "react"; 3 - import { PostPageData } from "./getPostPageData"; 4 - 5 - export const PostPageContext = createContext<PostPageData>(null); 6 - 7 - export const PostPageContextProvider = ({ 8 - children, 9 - value, 10 - }: { 11 - children: React.ReactNode; 12 - value: PostPageData; 13 - }) => { 14 - return ( 15 - <PostPageContext.Provider value={value}> 16 - {children} 17 - </PostPageContext.Provider> 18 - ); 19 - };
+12 -9
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 1 1 "use client"; 2 2 import { 3 - PubLeafletDocument, 4 3 PubLeafletPagesLinearDocument, 5 4 PubLeafletPagesCanvas, 6 5 PubLeafletPublication, 7 6 } from "lexicons/api"; 7 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 8 + import { useLeafletContent } from "contexts/LeafletContentContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 8 10 import { PostPageData } from "./getPostPageData"; 9 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 12 import { AppBskyFeedDefs } from "@atproto/api"; ··· 152 154 showMentions?: boolean; 153 155 showPrevNext?: boolean; 154 156 }; 155 - pubRecord?: PubLeafletPublication.Record; 157 + pubRecord?: NormalizedPublication | null; 156 158 theme?: PubLeafletPublication.Theme | null; 157 159 prerenderedCodeBlocks?: Map<string, string>; 158 160 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 206 208 document_uri: string; 207 209 document: PostPageData; 208 210 profile: ProfileViewDetailed; 209 - pubRecord?: PubLeafletPublication.Record; 211 + pubRecord?: NormalizedPublication | null; 210 212 did: string; 211 213 prerenderedCodeBlocks?: Map<string, string>; 212 214 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 220 222 let drawer = useDrawerOpen(document_uri); 221 223 useInitializeOpenPages(); 222 224 let openPageIds = useOpenPages(); 223 - if (!document) return null; 225 + const { pages } = useLeafletContent(); 226 + const { quotesAndMentions } = useDocument(); 227 + const record = document?.normalizedDocument; 228 + if (!document || !record) return null; 224 229 225 - let record = document.data as PubLeafletDocument.Record; 226 230 let theme = pubRecord?.theme || record.theme || null; 227 231 // For publication posts, respect the publication's showPageBackground setting 228 232 // For standalone documents, default to showing page background 229 233 let isInPublication = !!pubRecord; 230 234 let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; 231 - let quotesAndMentions = document.quotesAndMentions; 232 235 233 - let firstPage = record.pages[0] as 236 + let firstPage = pages[0] as 234 237 | PubLeafletPagesLinearDocument.Main 235 238 | PubLeafletPagesCanvas.Main; 236 239 ··· 250 253 pollData, 251 254 document_uri, 252 255 hasPageBackground, 253 - allPages: record.pages as ( 256 + allPages: pages as ( 254 257 | PubLeafletPagesLinearDocument.Main 255 258 | PubLeafletPagesCanvas.Main 256 259 )[], ··· 329 332 } 330 333 331 334 // Handle document pages 332 - let page = record.pages.find( 335 + let page = pages.find( 333 336 (p) => 334 337 ( 335 338 p as
+8 -16
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 1 "use client"; 2 - import { PubLeafletDocument } from "lexicons/api"; 3 - import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 3 import { AtUri } from "@atproto/api"; 6 - import { useParams } from "next/navigation"; 7 - import { getPostPageData } from "./getPostPageData"; 8 - import { PostPageContext } from "./PostPageContext"; 9 - import { useContext } from "react"; 4 + import { useDocument } from "contexts/DocumentContext"; 10 5 import { SpeedyLink } from "components/SpeedyLink"; 11 6 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 7 13 - export const PostPrevNextButtons = (props: { 14 - showPrevNext: boolean | undefined; 15 - }) => { 16 - let postData = useContext(PostPageContext); 17 - let pub = postData?.documents_in_publications[0]?.publications; 8 + export const PostPrevNextButtons = (props: { showPrevNext: boolean }) => { 9 + const { prevNext, publication } = useDocument(); 18 10 19 - if (!props.showPrevNext || !pub || !postData) return; 11 + if (!props.showPrevNext || !publication) return null; 20 12 21 13 function getPostLink(uri: string) { 22 - return pub && uri 23 - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 14 + return publication && uri 15 + ? `${getPublicationURL(publication)}/${new AtUri(uri).rkey}` 24 16 : "leaflet.pub/not-found"; 25 17 } 26 - let prevPost = postData?.prevNext?.prev; 27 - let nextPost = postData?.prevNext?.next; 18 + let prevPost = prevNext?.prev; 19 + let nextPost = prevNext?.next; 28 20 29 21 return ( 30 22 <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
+44
app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx
··· 1 + "use client"; 2 + import { useDocumentOptional } from "contexts/DocumentContext"; 3 + import { useIdentityData } from "components/IdentityProvider"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 5 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 + 7 + export const PostSubscribe = () => { 8 + const data = useDocumentOptional(); 9 + let { identity } = useIdentityData(); 10 + 11 + let publication = data?.publication; 12 + let normalizedPublication = data?.normalizedPublication; 13 + 14 + let subscribed = 15 + identity?.atp_did && 16 + publication?.publication_subscriptions && 17 + publication?.publication_subscriptions.find( 18 + (s) => s.identity === identity.atp_did, 19 + ); 20 + 21 + let isAuthor = 22 + identity && 23 + identity.atp_did === publication?.identity_did && 24 + data?.leafletId; 25 + 26 + if (!subscribed && !isAuthor && publication && normalizedPublication) 27 + return ( 28 + <div className="text-center flex flex-col accent-container rounded-md mb-3 mx-3 sm:mx-4"> 29 + <div className="flex flex-col py-4"> 30 + <div className="leading-snug flex flex-col pb-2 "> 31 + <div className="font-bold">Subscribe to {publication.name}</div> to 32 + get updates in Reader, RSS, or via Bluesky Feed 33 + </div> 34 + <SubscribeWithBluesky 35 + pubName={publication.name} 36 + pub_uri={publication.uri} 37 + base_url={getPublicationURL(publication)} 38 + subscribers={publication?.publication_subscriptions} 39 + /> 40 + </div> 41 + </div> 42 + ); 43 + else return null; 44 + };
+4 -10
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 3 3 import { CopyTiny } from "components/Icons/CopyTiny"; 4 4 import { Separator } from "components/Layout"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { useEffect, useMemo, useState, useContext } from "react"; 6 + import { useEffect, useMemo, useState } from "react"; 7 7 import { 8 8 encodeQuotePosition, 9 9 decodeQuotePosition, ··· 12 12 import { useIdentityData } from "components/IdentityProvider"; 13 13 import { CommentTiny } from "components/Icons/CommentTiny"; 14 14 import { setInteractionState } from "./Interactions/Interactions"; 15 - import { PostPageContext } from "./PostPageContext"; 16 - import { PubLeafletPublication } from "lexicons/api"; 15 + import { useDocument } from "contexts/DocumentContext"; 17 16 import { flushSync } from "react-dom"; 18 17 import { scrollIntoView } from "src/utils/scrollIntoView"; 19 18 ··· 148 147 export const QuoteOptionButtons = (props: { position: string }) => { 149 148 let smoker = useSmoker(); 150 149 let { identity } = useIdentityData(); 151 - const data = useContext(PostPageContext); 152 - const document_uri = data?.uri; 153 - if (!document_uri) 154 - throw new Error("document_uri not available in PostPageContext"); 150 + const { uri: document_uri, publication } = useDocument(); 155 151 let [url, position] = useMemo(() => { 156 152 let currentUrl = new URL(window.location.href); 157 153 let pos = decodeQuotePosition(props.position); ··· 169 165 currentUrl.hash = `#${fragmentId}`; 170 166 return [currentUrl.toString(), pos]; 171 167 }, [props.position]); 172 - let pubRecord = data.documents_in_publications[0]?.publications?.record as 173 - | PubLeafletPublication.Record 174 - | undefined; 168 + let pubRecord = publication?.record; 175 169 176 170 return ( 177 171 <>
+58 -27
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + normalizeDocumentRecord, 5 + normalizePublicationRecord, 6 + type NormalizedDocument, 7 + type NormalizedPublication, 8 + } from "src/utils/normalizeRecords"; 9 + import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 + import { documentUriFilter } from "src/utils/uriHelpers"; 4 11 5 - export async function getPostPageData(uri: string) { 6 - let { data: document } = await supabaseServerClient 12 + export async function getPostPageData(did: string, rkey: string) { 13 + let { data: documents } = await supabaseServerClient 7 14 .from("documents") 8 15 .select( 9 16 ` ··· 18 25 leaflets_in_publications(*) 19 26 `, 20 27 ) 21 - .eq("uri", uri) 22 - .single(); 28 + .or(documentUriFilter(did, rkey)) 29 + .order("uri", { ascending: false }) 30 + .limit(1); 31 + let document = documents?.[0]; 23 32 24 33 if (!document) return null; 25 34 35 + // Normalize the document record - this is the primary way consumers should access document data 36 + const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 37 + if (!normalizedDocument) return null; 38 + 39 + // Normalize the publication record - this is the primary way consumers should access publication data 40 + const normalizedPublication = normalizePublicationRecord( 41 + document.documents_in_publications[0]?.publications?.record 42 + ); 43 + 26 44 // Fetch constellation backlinks for mentions 27 - const pubRecord = document.documents_in_publications[0]?.publications 28 - ?.record as PubLeafletPublication.Record; 29 - let aturi = new AtUri(uri); 30 - const postUrl = pubRecord 31 - ? `https://${pubRecord?.base_path}/${aturi.rkey}` 45 + let aturi = new AtUri(document.uri); 46 + const postUrl = normalizedPublication 47 + ? `${normalizedPublication.url}/${aturi.rkey}` 32 48 : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 33 49 const constellationBacklinks = await getConstellationBacklinks(postUrl); 34 50 ··· 48 64 ...uniqueBacklinks, 49 65 ]; 50 66 51 - let theme = 52 - ( 53 - document?.documents_in_publications[0]?.publications 54 - ?.record as PubLeafletPublication.Record 55 - )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 67 + let theme = normalizedPublication?.theme || normalizedDocument?.theme; 56 68 57 69 // Calculate prev/next documents from the fetched publication documents 58 70 let prevNext: ··· 62 74 } 63 75 | undefined; 64 76 65 - const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 - ?.publishedAt; 77 + const currentPublishedAt = normalizedDocument.publishedAt; 67 78 const allDocs = 68 79 document.documents_in_publications[0]?.publications 69 80 ?.documents_in_publications; ··· 71 82 if (currentPublishedAt && allDocs) { 72 83 // Filter and sort documents by publishedAt 73 84 const sortedDocs = allDocs 74 - .map((dip) => ({ 75 - uri: dip?.documents?.uri, 76 - title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 - .publishedAt, 79 - })) 80 - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 85 + .map((dip) => { 86 + const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 87 + return { 88 + uri: dip?.documents?.uri, 89 + title: normalizedData?.title, 90 + publishedAt: normalizedData?.publishedAt, 91 + }; 92 + }) 93 + .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data 81 94 .sort( 82 95 (a, b) => 83 96 new Date(a.publishedAt!).getTime() - ··· 85 98 ); 86 99 87 100 // Find current document index 88 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 101 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 89 102 90 103 if (currentIndex !== -1) { 91 104 prevNext = { ··· 93 106 currentIndex > 0 94 107 ? { 95 108 uri: sortedDocs[currentIndex - 1].uri || "", 96 - title: sortedDocs[currentIndex - 1].title, 109 + title: sortedDocs[currentIndex - 1].title || "", 97 110 } 98 111 : undefined, 99 112 next: 100 113 currentIndex < sortedDocs.length - 1 101 114 ? { 102 115 uri: sortedDocs[currentIndex + 1].uri || "", 103 - title: sortedDocs[currentIndex + 1].title, 116 + title: sortedDocs[currentIndex + 1].title || "", 104 117 } 105 118 : undefined, 106 119 }; 107 120 } 108 121 } 109 122 123 + // Build explicit publication context for consumers 124 + const rawPub = document.documents_in_publications[0]?.publications; 125 + const publication = rawPub ? { 126 + uri: rawPub.uri, 127 + name: rawPub.name, 128 + identity_did: rawPub.identity_did, 129 + record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, 130 + publication_subscriptions: rawPub.publication_subscriptions || [], 131 + } : null; 132 + 110 133 return { 111 134 ...document, 135 + // Pre-normalized data - consumers should use these instead of normalizing themselves 136 + normalizedDocument, 137 + normalizedPublication, 112 138 quotesAndMentions, 113 139 theme, 114 140 prevNext, 141 + // Explicit relational data for DocumentContext 142 + publication, 143 + comments: document.comments_on_documents, 144 + mentions: document.document_mentions_in_bsky, 145 + leafletId: document.leaflets_in_publications[0]?.leaflet || null, 115 146 }; 116 147 } 117 148
+9 -8
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { ids } from "lexicons/api/lexicons"; 5 - import { PubLeafletDocument } from "lexicons/api"; 6 3 import { jsonToLex } from "@atproto/lexicon"; 7 4 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 5 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 6 + import { documentUriFilter } from "src/utils/uriHelpers"; 8 7 9 8 export const revalidate = 60; 10 9 ··· 15 14 let did = decodeURIComponent(params.did); 16 15 17 16 // Try to get the document's cover image 18 - let { data: document } = await supabaseServerClient 17 + let { data: documents } = await supabaseServerClient 19 18 .from("documents") 20 19 .select("data") 21 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 22 - .single(); 20 + .or(documentUriFilter(did, params.rkey)) 21 + .order("uri", { ascending: false }) 22 + .limit(1); 23 + let document = documents?.[0]; 23 24 24 25 if (document) { 25 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 26 - if (docRecord.coverImage) { 26 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 27 + if (docRecord?.coverImage) { 27 28 try { 28 29 // Get CID from the blob ref (handle both serialized and hydrated forms) 29 30 let cid =
+9 -7
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { ids } from "lexicons/api/lexicons"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 2 import { Metadata } from "next"; 6 3 import { DocumentPageRenderer } from "./DocumentPageRenderer"; 4 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 5 + import { documentUriFilter } from "src/utils/uriHelpers"; 7 6 8 7 export async function generateMetadata(props: { 9 8 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 12 11 let did = decodeURIComponent(params.did); 13 12 if (!did) return { title: "Publication 404" }; 14 13 15 - let [{ data: document }] = await Promise.all([ 14 + let [{ data: documents }] = await Promise.all([ 16 15 supabaseServerClient 17 16 .from("documents") 18 17 .select("*, documents_in_publications(publications(*))") 19 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 20 - .single(), 18 + .or(documentUriFilter(did, params.rkey)) 19 + .order("uri", { ascending: false }) 20 + .limit(1), 21 21 ]); 22 + let document = documents?.[0]; 22 23 if (!document) return { title: "404" }; 23 24 24 - let docRecord = document.data as PubLeafletDocument.Record; 25 + const docRecord = normalizeDocumentRecord(document.data); 26 + if (!docRecord) return { title: "404" }; 25 27 26 28 return { 27 29 icons: {
-3
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 2 2 "use client"; 3 3 4 4 import { useParams } from "next/navigation"; 5 - import { useContext } from "react"; 6 - import { PostPageContext } from "./PostPageContext"; 7 5 import { create } from "zustand"; 8 6 import { decodeQuotePosition, QuotePosition } from "./quotePosition"; 9 7 ··· 12 10 })); 13 11 14 12 export const useHighlight = (pos: number[], pageId?: string) => { 15 - let doc = useContext(PostPageContext); 16 13 let { quote } = useParams(); 17 14 let activeHighlight = useActiveHighlightState( 18 15 (state) => state.activeHighlight,
+32 -28
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 2 2 3 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 4 import React from "react"; 5 - import { usePublicationData } from "./PublicationSWRProvider"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + } from "./PublicationSWRProvider"; 6 9 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 10 8 11 export function DraftList(props: { ··· 10 13 showPageBackground: boolean; 11 14 }) { 12 15 let { data: pub_data } = usePublicationData(); 16 + // Normalize the publication record - skip rendering if unrecognized format 17 + const normalizedPubRecord = useNormalizedPublicationRecord(); 13 18 if (!pub_data?.publication) return null; 14 - let { leaflets_in_publications, ...publication } = pub_data.publication; 19 + const { drafts, leaflet_data } = pub_data; 20 + const { leaflets_in_publications, ...publication } = pub_data.publication; 21 + 22 + if (!normalizedPubRecord) return null; 23 + 15 24 return ( 16 25 <div className="flex flex-col gap-4"> 17 26 <NewDraftSecondaryButton ··· 23 32 searchValue={props.searchValue} 24 33 showPreview={false} 25 34 defaultDisplay="list" 26 - leaflets={leaflets_in_publications 27 - .filter((l) => !l.documents) 28 - .filter((l) => !l.archived) 29 - .map((l) => { 30 - return { 31 - archived: l.archived, 32 - added_at: "", 33 - token: { 34 - ...l.permission_tokens!, 35 - leaflets_in_publications: [ 36 - { 37 - ...l, 38 - publications: { 39 - ...publication, 40 - }, 41 - }, 42 - ], 43 - }, 44 - }; 45 - })} 46 - initialFacts={pub_data.leaflet_data.facts || {}} 35 + leaflets={drafts 36 + .filter((d) => d.permission_tokens) 37 + .map((d) => ({ 38 + archived: (d._raw as { archived?: boolean }).archived, 39 + added_at: "", 40 + token: { 41 + ...d.permission_tokens!, 42 + leaflets_in_publications: [ 43 + { 44 + ...d._raw, 45 + publications: publication, 46 + }, 47 + ], 48 + }, 49 + }))} 50 + initialFacts={leaflet_data.facts || {}} 47 51 titles={{ 48 - ...leaflets_in_publications.reduce( 49 - (acc, leaflet) => { 50 - if (leaflet.permission_tokens) 51 - acc[leaflet.permission_tokens.root_entity] = 52 - leaflet.title || "Untitled"; 52 + ...drafts.reduce( 53 + (acc, draft) => { 54 + if (draft.permission_tokens) 55 + acc[draft.permission_tokens.root_entity] = 56 + draft.title || "Untitled"; 53 57 return acc; 54 58 }, 55 59 {} as { [l: string]: string },
+2 -4
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 5 5 import { Actions } from "./Actions"; 6 6 import React, { useState } from "react"; 7 7 import { PublishedPostsList } from "./PublishedPostsLists"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { PublicationSubscribers } from "./PublicationSubscribers"; 10 - import { AtUri } from "@atproto/syntax"; 11 9 import { 12 - HomeDashboardControls, 13 10 DashboardLayout, 14 11 PublicationDashboardControls, 15 12 } from "components/PageLayouts/DashboardLayout"; 16 13 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 14 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 17 15 18 16 export default function PublicationDashboard({ 19 17 publication, 20 18 record, 21 19 }: { 22 - record: PubLeafletPublication.Record; 20 + record: NormalizedPublication; 23 21 publication: Exclude< 24 22 GetPublicationDataReturnType["result"]["publication"], 25 23 null
+22 -3
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext, useEffect } from "react"; 5 + import { createContext, useContext, useEffect, useMemo } from "react"; 6 6 import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 7 - import { produce, Draft } from "immer"; 7 + import { produce, Draft as ImmerDraft } from "immer"; 8 + import { 9 + normalizePublicationRecord, 10 + type NormalizedPublication, 11 + } from "src/utils/normalizeRecords"; 8 12 13 + // Derive all types from the RPC return type 9 14 export type PublicationData = GetPublicationDataReturnType["result"]; 15 + export type PublishedDocument = NonNullable<PublicationData>["documents"][number]; 16 + export type PublicationDraft = NonNullable<PublicationData>["drafts"][number]; 10 17 11 18 const PublicationContext = createContext({ name: "", did: "" }); 12 19 export function PublicationSWRDataProvider(props: { ··· 49 56 return { data, mutate }; 50 57 } 51 58 59 + /** 60 + * Returns the normalized publication record from the publication data. 61 + * Use this instead of manually calling normalizePublicationRecord on data.publication.record 62 + */ 63 + export function useNormalizedPublicationRecord(): NormalizedPublication | null { 64 + const { data } = usePublicationData(); 65 + return useMemo( 66 + () => normalizePublicationRecord(data?.publication?.record), 67 + [data?.publication?.record] 68 + ); 69 + } 70 + 52 71 export function mutatePublicationData( 53 72 mutate: KeyedMutator<PublicationData>, 54 - recipe: (draft: Draft<NonNullable<PublicationData>>) => void, 73 + recipe: (draft: ImmerDraft<NonNullable<PublicationData>>) => void, 55 74 ) { 56 75 mutate( 57 76 (data) => {
+123 -125
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 3 import { EditTiny } from "components/Icons/EditTiny"; 5 4 6 - import { usePublicationData } from "./PublicationSWRProvider"; 7 - import { Fragment, useState } from "react"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + type PublishedDocument, 9 + } from "./PublicationSWRProvider"; 10 + import { Fragment } from "react"; 8 11 import { useParams } from "next/navigation"; 9 12 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Menu"; 11 - import { deletePost } from "./deletePost"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 - import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 13 import { SpeedyLink } from "components/SpeedyLink"; 18 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 - import { CommentTiny } from "components/Icons/CommentTiny"; 20 14 import { InteractionPreview } from "components/InteractionsPreview"; 21 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 16 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; ··· 27 21 showPageBackground: boolean; 28 22 }) { 29 23 let { data } = usePublicationData(); 30 - let params = useParams(); 31 - let { publication } = data!; 32 - let pubRecord = publication?.record as PubLeafletPublication.Record; 24 + let { publication, documents } = data || {}; 25 + const pubRecord = useNormalizedPublicationRecord(); 33 26 34 27 if (!publication) return null; 35 - if (publication.documents_in_publications.length === 0) 28 + if (!documents || documents.length === 0) 36 29 return ( 37 30 <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 38 31 Nothing's been published yet... 39 32 </div> 40 33 ); 34 + 35 + // Sort by publishedAt (most recent first) 36 + const sortedDocuments = [...documents].sort((a, b) => { 37 + const aDate = a.record.publishedAt 38 + ? new Date(a.record.publishedAt) 39 + : new Date(0); 40 + const bDate = b.record.publishedAt 41 + ? new Date(b.record.publishedAt) 42 + : new Date(0); 43 + return bDate.getTime() - aDate.getTime(); 44 + }); 45 + 41 46 return ( 42 47 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 43 - {publication.documents_in_publications 44 - .sort((a, b) => { 45 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 46 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 47 - const aDate = aRecord.publishedAt 48 - ? new Date(aRecord.publishedAt) 49 - : new Date(0); 50 - const bDate = bRecord.publishedAt 51 - ? new Date(bRecord.publishedAt) 52 - : new Date(0); 53 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 54 - }) 55 - .map((doc) => { 56 - if (!doc.documents) return null; 57 - let leaflet = publication.leaflets_in_publications.find( 58 - (l) => doc.documents && l.doc === doc.documents.uri, 59 - ); 60 - let uri = new AtUri(doc.documents.uri); 61 - let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 - let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 - let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 - let tags = (postRecord?.tags as string[] | undefined) || []; 65 - 66 - let postLink = data?.publication 67 - ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` 68 - : ""; 48 + {sortedDocuments.map((doc) => ( 49 + <PublishedPostItem 50 + key={doc.uri} 51 + doc={doc} 52 + publication={publication} 53 + pubRecord={pubRecord} 54 + showPageBackground={props.showPageBackground} 55 + /> 56 + ))} 57 + </div> 58 + ); 59 + } 69 60 70 - return ( 71 - <Fragment key={doc.documents?.uri}> 72 - <div className="flex gap-2 w-full "> 73 - <div 74 - className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${props.showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 75 - style={{ 76 - backgroundColor: props.showPageBackground 77 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 78 - : "transparent", 79 - }} 80 - > 81 - <div className="flex justify-between gap-2"> 82 - <a 83 - className="hover:no-underline!" 84 - target="_blank" 85 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 - > 87 - <h3 className="text-primary grow leading-snug"> 88 - {postRecord.title} 89 - </h3> 90 - </a> 91 - <div className="flex justify-start align-top flex-row gap-1"> 92 - {leaflet && leaflet.permission_tokens && ( 93 - <> 94 - <SpeedyLink 95 - className="pt-[6px]" 96 - href={`/${leaflet.leaflet}`} 97 - > 98 - <EditTiny /> 99 - </SpeedyLink> 61 + function PublishedPostItem(props: { 62 + doc: PublishedDocument; 63 + publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 64 + pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 + showPageBackground: boolean; 66 + }) { 67 + const { doc, publication, pubRecord, showPageBackground } = props; 68 + const uri = new AtUri(doc.uri); 69 + const leaflet = publication.leaflets_in_publications.find( 70 + (l) => l.doc === doc.uri, 71 + ); 100 72 101 - <StaticLeafletDataContext 102 - value={{ 103 - ...leaflet.permission_tokens, 104 - leaflets_in_publications: [ 105 - { 106 - ...leaflet, 107 - publications: publication, 108 - documents: doc.documents 109 - ? { 110 - uri: doc.documents.uri, 111 - indexed_at: doc.documents.indexed_at, 112 - data: doc.documents.data, 113 - } 114 - : null, 115 - }, 116 - ], 117 - leaflets_to_documents: [], 118 - blocked_by_admin: null, 119 - custom_domain_routes: [], 120 - }} 121 - > 122 - <LeafletOptions loggedIn={true} /> 123 - </StaticLeafletDataContext> 124 - </> 125 - )} 126 - </div> 127 - </div> 73 + return ( 74 + <Fragment> 75 + <div className="flex gap-2 w-full "> 76 + <div 77 + className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 78 + style={{ 79 + backgroundColor: showPageBackground 80 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 81 + : "transparent", 82 + }} 83 + > 84 + <div className="flex justify-between gap-2"> 85 + <a 86 + className="hover:no-underline!" 87 + target="_blank" 88 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 89 + > 90 + <h3 className="text-primary grow leading-snug"> 91 + {doc.record.title} 92 + </h3> 93 + </a> 94 + <div className="flex justify-start align-top flex-row gap-1"> 95 + {leaflet && leaflet.permission_tokens && ( 96 + <> 97 + <SpeedyLink 98 + className="pt-[6px]" 99 + href={`/${leaflet.leaflet}`} 100 + > 101 + <EditTiny /> 102 + </SpeedyLink> 128 103 129 - {postRecord.description ? ( 130 - <p className="italic text-secondary"> 131 - {postRecord.description} 132 - </p> 133 - ) : null} 134 - <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 - {postRecord.publishedAt ? ( 136 - <PublishedDate dateString={postRecord.publishedAt} /> 137 - ) : null} 138 - <InteractionPreview 139 - quotesCount={quotes} 140 - commentsCount={comments} 141 - tags={tags} 142 - showComments={pubRecord?.preferences?.showComments} 143 - showMentions={pubRecord?.preferences?.showMentions} 144 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 145 - /> 146 - </div> 147 - </div> 148 - </div> 149 - {!props.showPageBackground && ( 150 - <hr className="last:hidden border-border-light" /> 104 + <StaticLeafletDataContext 105 + value={{ 106 + ...leaflet.permission_tokens, 107 + leaflets_in_publications: [ 108 + { 109 + ...leaflet, 110 + publications: publication, 111 + documents: { 112 + uri: doc.uri, 113 + indexed_at: doc.indexed_at, 114 + data: doc.data, 115 + }, 116 + }, 117 + ], 118 + leaflets_to_documents: [], 119 + blocked_by_admin: null, 120 + custom_domain_routes: [], 121 + }} 122 + > 123 + <LeafletOptions loggedIn={true} /> 124 + </StaticLeafletDataContext> 125 + </> 151 126 )} 152 - </Fragment> 153 - ); 154 - })} 155 - </div> 127 + </div> 128 + </div> 129 + 130 + {doc.record.description ? ( 131 + <p className="italic text-secondary"> 132 + {doc.record.description} 133 + </p> 134 + ) : null} 135 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 136 + {doc.record.publishedAt ? ( 137 + <PublishedDate dateString={doc.record.publishedAt} /> 138 + ) : null} 139 + <InteractionPreview 140 + quotesCount={doc.mentionsCount} 141 + commentsCount={doc.commentsCount} 142 + tags={doc.record.tags || []} 143 + showComments={pubRecord?.preferences?.showComments !== false} 144 + showMentions={pubRecord?.preferences?.showMentions !== false} 145 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 146 + /> 147 + </div> 148 + </div> 149 + </div> 150 + {!showPageBackground && ( 151 + <hr className="last:hidden border-border-light" /> 152 + )} 153 + </Fragment> 156 154 ); 157 155 } 158 156
+12 -2
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 39 39 } 40 40 41 41 await Promise.all([ 42 + // Delete from both PDS collections (document exists in one or the other) 42 43 agent.pub.leaflet.document.delete({ 43 44 repo: credentialSession.did, 44 45 rkey: uri.rkey, 45 - }), 46 + }).catch(() => {}), 47 + agent.site.standard.document.delete({ 48 + repo: credentialSession.did, 49 + rkey: uri.rkey, 50 + }).catch(() => {}), 46 51 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 47 52 supabaseServerClient 48 53 .from("leaflets_in_publications") ··· 83 88 } 84 89 85 90 await Promise.all([ 91 + // Delete from both PDS collections (document exists in one or the other) 86 92 agent.pub.leaflet.document.delete({ 87 93 repo: credentialSession.did, 88 94 rkey: uri.rkey, 89 - }), 95 + }).catch(() => {}), 96 + agent.site.standard.document.delete({ 97 + repo: credentialSession.did, 98 + rkey: uri.rkey, 99 + }).catch(() => {}), 90 100 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 91 101 ]); 92 102 revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
+3 -5
app/lish/[did]/[publication]/dashboard/page.tsx
··· 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 4 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 5 import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 6 - import { PubLeafletPublication } from "lexicons/api"; 7 6 import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 8 7 import { AtUri } from "@atproto/syntax"; 9 8 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 10 9 import PublicationDashboard from "./PublicationDashboard"; 11 - import Link from "next/link"; 10 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 12 11 13 12 export async function generateMetadata(props: { 14 13 params: Promise<{ publication: string; did: string }>; ··· 24 23 { supabase: supabaseServerClient }, 25 24 ); 26 25 let { publication } = publication_data; 27 - let record = 28 - (publication?.record as PubLeafletPublication.Record) || undefined; 26 + const record = normalizePublicationRecord(publication?.record); 29 27 if (!publication) return { title: "404 Publication" }; 30 28 return { title: record?.name || "Untitled Publication" }; 31 29 } ··· 56 54 { supabase: supabaseServerClient }, 57 55 ); 58 56 let { publication, leaflet_data } = publication_data; 59 - let record = publication?.record as PubLeafletPublication.Record | null; 57 + const record = normalizePublicationRecord(publication?.record); 60 58 61 59 if (!publication || identity.atp_did !== publication.identity_did || !record) 62 60 return <PubNotFound />;
+6 -5
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 - import { usePublicationData } from "../PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "../PublicationSWRProvider"; 3 5 import { PubSettingsHeader } from "./PublicationSettings"; 4 6 import { useState } from "react"; 5 7 import { Toggle } from "components/Toggle"; ··· 15 17 let { data } = usePublicationData(); 16 18 17 19 let { publication: pubData } = data || {}; 18 - let record = pubData?.record as PubLeafletPublication.Record; 20 + const record = useNormalizedPublicationRecord(); 19 21 20 22 let [showComments, setShowComments] = useState( 21 23 record?.preferences?.showComments === undefined ··· 37 39 return ( 38 40 <form 39 41 onSubmit={async (e) => { 40 - if (!pubData) return; 42 + if (!pubData || !record) return; 41 43 e.preventDefault(); 42 44 props.setLoading(true); 43 45 let data = await updatePublication({ ··· 54 56 }, 55 57 }); 56 58 toast({ type: "success", content: <strong>Posts Updated!</strong> }); 57 - console.log(record.preferences?.showPrevNext); 58 59 props.setLoading(false); 59 60 mutate("publication-data"); 60 61 }}
+34 -31
app/lish/[did]/[publication]/generateFeed.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { Feed } from "feed"; 3 - import { 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 3 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 8 4 import { createElement } from "react"; 9 5 import { StaticPostContent } from "./[rkey]/StaticPostContent"; 10 6 import { supabaseServerClient } from "supabase/serverClient"; 11 7 import { NextResponse } from "next/server"; 8 + import { 9 + normalizePublicationRecord, 10 + normalizeDocumentRecord, 11 + hasLeafletContent, 12 + } from "src/utils/normalizeRecords"; 13 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 12 14 13 15 export async function generateFeed( 14 16 did: string, ··· 17 19 let renderToReadableStream = await import("react-dom/server").then( 18 20 (module) => module.renderToReadableStream, 19 21 ); 20 - let uri; 21 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 22 - uri = AtUri.make( 23 - did, 24 - "pub.leaflet.publication", 25 - publication_name, 26 - ).toString(); 27 - } 28 - let { data: publication } = await supabaseServerClient 22 + let { data: publications, error } = await supabaseServerClient 29 23 .from("publications") 30 24 .select( 31 25 `*, ··· 34 28 `, 35 29 ) 36 30 .eq("identity_did", did) 37 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 38 - .single(); 31 + .or(publicationNameOrUriFilter(did, publication_name)) 32 + .order("uri", { ascending: false }) 33 + .limit(1); 34 + console.log(error); 35 + let publication = publications?.[0]; 39 36 40 - let pubRecord = publication?.record as PubLeafletPublication.Record; 37 + const pubRecord = normalizePublicationRecord(publication?.record); 41 38 if (!publication || !pubRecord) 42 39 return new NextResponse(null, { status: 404 }); 43 40 44 41 const feed = new Feed({ 45 42 title: pubRecord.name, 46 43 description: pubRecord.description, 47 - id: `https://${pubRecord.base_path}`, 48 - link: `https://${pubRecord.base_path}`, 44 + id: pubRecord.url, 45 + link: pubRecord.url, 49 46 language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 50 47 copyright: "", 51 48 feedLinks: { 52 - rss: `https://${pubRecord.base_path}/rss`, 53 - atom: `https://${pubRecord.base_path}/atom`, 54 - json: `https://${pubRecord.base_path}/json`, 49 + rss: `${pubRecord.url}/rss`, 50 + atom: `${pubRecord.url}/atom`, 51 + json: `${pubRecord.url}/json`, 55 52 }, 56 53 }); 57 54 58 55 await Promise.all( 59 56 publication.documents_in_publications.map(async (doc) => { 60 57 if (!doc.documents) return; 61 - let record = doc.documents?.data as PubLeafletDocument.Record; 62 - let uri = new AtUri(doc.documents?.uri); 63 - let rkey = uri.rkey; 58 + const record = normalizeDocumentRecord( 59 + doc.documents?.data, 60 + doc.documents?.uri, 61 + ); 62 + const uri = new AtUri(doc.documents?.uri); 63 + const rkey = uri.rkey; 64 64 if (!record) return; 65 - let firstPage = record.pages[0]; 65 + 66 66 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 67 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 68 - blocks = firstPage.blocks || []; 67 + if (hasLeafletContent(record) && record.content.pages[0]) { 68 + const firstPage = record.content.pages[0]; 69 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 70 + blocks = firstPage.blocks || []; 71 + } 69 72 } 70 - let stream = await renderToReadableStream( 73 + const stream = await renderToReadableStream( 71 74 createElement(StaticPostContent, { blocks, did: uri.host }), 72 75 ); 73 76 const reader = stream.getReader(); ··· 85 88 title: record.title, 86 89 description: record.description, 87 90 date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 88 - id: `https://${pubRecord.base_path}/${rkey}`, 89 - link: `https://${pubRecord.base_path}/${rkey}`, 91 + id: `${pubRecord.url}/${rkey}`, 92 + link: `${pubRecord.url}/${rkey}`, 90 93 content: chunks.join(""), 91 94 }); 92 95 }),
+9 -14
app/lish/[did]/[publication]/icon/route.ts
··· 1 1 import { NextRequest } from "next/server"; 2 2 import { IdResolver } from "@atproto/identity"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 3 import { supabaseServerClient } from "supabase/serverClient"; 6 4 import sharp from "sharp"; 7 5 import { redirect } from "next/navigation"; 6 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 7 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 8 8 9 9 let idResolver = new IdResolver(); 10 10 ··· 17 17 const params = await props.params; 18 18 try { 19 19 let did = decodeURIComponent(params.did); 20 - let uri; 21 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 22 - uri = AtUri.make( 23 - did, 24 - "pub.leaflet.publication", 25 - params.publication, 26 - ).toString(); 27 - } 28 - let { data: publication } = await supabaseServerClient 20 + let publication_name = decodeURIComponent(params.publication); 21 + let { data: publications } = await supabaseServerClient 29 22 .from("publications") 30 23 .select( 31 24 `*, ··· 34 27 `, 35 28 ) 36 29 .eq("identity_did", did) 37 - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 38 - .single(); 30 + .or(publicationNameOrUriFilter(did, publication_name)) 31 + .order("uri", { ascending: false }) 32 + .limit(1); 33 + let publication = publications?.[0]; 39 34 40 - let record = publication?.record as PubLeafletPublication.Record | null; 35 + const record = normalizePublicationRecord(publication?.record); 41 36 if (!record?.icon) return redirect("/icon.png"); 42 37 43 38 let identity = await idResolver.did.resolve(did);
+12 -18
app/lish/[did]/[publication]/layout.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 1 import { supabaseServerClient } from "supabase/serverClient"; 3 2 import { Metadata } from "next"; 4 - import { AtUri } from "@atproto/syntax"; 3 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 5 5 6 6 export default async function PublicationLayout(props: { 7 7 children: React.ReactNode; ··· 19 19 let did = decodeURIComponent(params.did); 20 20 if (!params.did || !params.publication) return { title: "Publication 404" }; 21 21 22 - let uri; 23 22 let publication_name = decodeURIComponent(params.publication); 24 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 25 - uri = AtUri.make( 26 - did, 27 - "pub.leaflet.publication", 28 - publication_name, 29 - ).toString(); 30 - } 31 - let { data: publication } = await supabaseServerClient 23 + let { data: publications } = await supabaseServerClient 32 24 .from("publications") 33 25 .select( 34 26 `*, ··· 37 29 `, 38 30 ) 39 31 .eq("identity_did", did) 40 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 41 - .single(); 32 + .or(publicationNameOrUriFilter(did, publication_name)) 33 + .order("uri", { ascending: false }) 34 + .limit(1); 35 + let publication = publications?.[0]; 42 36 if (!publication) return { title: "Publication 404" }; 43 37 44 - let pubRecord = publication?.record as PubLeafletPublication.Record; 38 + const pubRecord = normalizePublicationRecord(publication?.record); 45 39 46 40 return { 47 41 title: pubRecord?.name || "Untitled Publication", ··· 60 54 url: publication.uri, 61 55 }, 62 56 }, 63 - alternates: pubRecord?.base_path 57 + alternates: pubRecord?.url 64 58 ? { 65 59 types: { 66 - "application/rss+xml": `https://${pubRecord?.base_path}/rss`, 67 - "application/atom+xml": `https://${pubRecord?.base_path}/atom`, 68 - "application/json": `https://${pubRecord?.base_path}/json`, 60 + "application/rss+xml": `${pubRecord.url}/rss`, 61 + "application/atom+xml": `${pubRecord.url}/atom`, 62 + "application/json": `${pubRecord.url}/json`, 69 63 }, 70 64 } 71 65 : undefined,
+24 -25
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 - import Link from "next/link"; 5 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 4 import { BskyAgent } from "@atproto/api"; 5 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 7 6 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 8 7 import React from "react"; 9 8 import { ··· 12 11 } from "components/ThemeManager/PublicationThemeProvider"; 13 12 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 14 13 import { SpeedyLink } from "components/SpeedyLink"; 15 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 - import { CommentTiny } from "components/Icons/CommentTiny"; 17 14 import { InteractionPreview } from "components/InteractionsPreview"; 18 15 import { LocalizedDate } from "./LocalizedDate"; 19 16 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 17 import { PublicationAuthor } from "./PublicationAuthor"; 21 18 import { Separator } from "components/Layout"; 19 + import { 20 + normalizePublicationRecord, 21 + normalizeDocumentRecord, 22 + } from "src/utils/normalizeRecords"; 22 23 23 24 export default async function Publication(props: { 24 25 params: Promise<{ publication: string; did: string }>; ··· 27 28 let did = decodeURIComponent(params.did); 28 29 if (!did) return <PubNotFound />; 29 30 let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 30 - let uri; 31 31 let publication_name = decodeURIComponent(params.publication); 32 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 33 - uri = AtUri.make( 34 - did, 35 - "pub.leaflet.publication", 36 - publication_name, 37 - ).toString(); 38 - } 39 - let [{ data: publication }, { data: profile }] = await Promise.all([ 32 + let [{ data: publications }, { data: profile }] = await Promise.all([ 40 33 supabaseServerClient 41 34 .from("publications") 42 35 .select( ··· 50 43 `, 51 44 ) 52 45 .eq("identity_did", did) 53 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 54 - .single(), 46 + .or(publicationNameOrUriFilter(did, publication_name)) 47 + .order("uri", { ascending: false }) 48 + .limit(1), 55 49 agent.getProfile({ actor: did }), 56 50 ]); 51 + let publication = publications?.[0]; 57 52 58 - let record = publication?.record as PubLeafletPublication.Record | null; 53 + const record = normalizePublicationRecord(publication?.record); 59 54 60 55 let showPageBackground = record?.theme?.showPageBackground; 61 56 ··· 112 107 {publication.documents_in_publications 113 108 .filter((d) => !!d?.documents) 114 109 .sort((a, b) => { 115 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 116 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 117 - const aDate = aRecord.publishedAt 110 + const aRecord = normalizeDocumentRecord(a.documents?.data); 111 + const bRecord = normalizeDocumentRecord(b.documents?.data); 112 + const aDate = aRecord?.publishedAt 118 113 ? new Date(aRecord.publishedAt) 119 114 : new Date(0); 120 - const bDate = bRecord.publishedAt 115 + const bDate = bRecord?.publishedAt 121 116 ? new Date(bRecord.publishedAt) 122 117 : new Date(0); 123 118 return bDate.getTime() - aDate.getTime(); // Sort by most recent first 124 119 }) 125 120 .map((doc) => { 126 121 if (!doc.documents) return null; 122 + const doc_record = normalizeDocumentRecord(doc.documents.data); 123 + if (!doc_record) return null; 127 124 let uri = new AtUri(doc.documents.uri); 128 - let doc_record = doc.documents 129 - .data as PubLeafletDocument.Record; 130 125 let quotes = 131 126 doc.documents.document_mentions_in_bsky[0].count || 0; 132 127 let comments = 133 128 record?.preferences?.showComments === false 134 129 ? 0 135 130 : doc.documents.comments_on_documents[0].count || 0; 136 - let tags = (doc_record?.tags as string[] | undefined) || []; 131 + let tags = doc_record.tags || []; 137 132 138 133 return ( 139 134 <React.Fragment key={doc.documents?.uri}> ··· 171 166 commentsCount={comments} 172 167 tags={tags} 173 168 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 174 - showComments={record?.preferences?.showComments} 175 - showMentions={record?.preferences?.showMentions} 169 + showComments={ 170 + record?.preferences?.showComments !== false 171 + } 172 + showMentions={ 173 + record?.preferences?.showMentions !== false 174 + } 176 175 /> 177 176 </div> 178 177 </div>
+1 -1
app/lish/createPub/CreatePubForm.tsx
··· 57 57 showInDiscover, 58 58 showComments: true, 59 59 showMentions: true, 60 - showPrevNext: false, 60 + showPrevNext: true, 61 61 }, 62 62 }); 63 63
+10 -7
app/lish/createPub/UpdatePubForm.tsx
··· 7 7 updatePublication, 8 8 updatePublicationBasePath, 9 9 } from "./updatePublication"; 10 - import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 11 - import { PubLeafletPublication } from "lexicons/api"; 10 + import { 11 + usePublicationData, 12 + useNormalizedPublicationRecord, 13 + } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 12 14 import useSWR, { mutate } from "swr"; 13 15 import { AddTiny } from "components/Icons/AddTiny"; 14 16 import { DotLoader } from "components/utils/DotLoader"; ··· 30 32 }) => { 31 33 let { data } = usePublicationData(); 32 34 let { publication: pubData } = data || {}; 33 - let record = pubData?.record as PubLeafletPublication.Record; 35 + let record = useNormalizedPublicationRecord(); 34 36 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 35 37 36 38 let [nameValue, setNameValue] = useState(record?.name || ""); ··· 60 62 let [iconPreview, setIconPreview] = useState<string | null>(null); 61 63 let fileInputRef = useRef<HTMLInputElement>(null); 62 64 useEffect(() => { 63 - if (!pubData || !pubData.record) return; 65 + if (!pubData || !pubData.record || !record) return; 64 66 setNameValue(record.name); 65 67 setDescriptionValue(record.description || ""); 66 68 if (record.icon) 67 69 setIconPreview( 68 70 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, 69 71 ); 70 - }, [pubData]); 72 + }, [pubData, record]); 71 73 let toast = useToaster(); 72 74 73 75 return ( ··· 202 204 export function CustomDomainForm() { 203 205 let { data } = usePublicationData(); 204 206 let { publication: pubData } = data || {}; 207 + let record = useNormalizedPublicationRecord(); 205 208 if (!pubData) return null; 206 - let record = pubData?.record as PubLeafletPublication.Record; 209 + if (!record) return null; 207 210 let [state, setState] = useState< 208 211 | { type: "default" } 209 212 | { type: "addDomain" } ··· 243 246 <Domain 244 247 domain={d.domain} 245 248 publication_uri={pubData.uri} 246 - base_path={record.base_path || ""} 249 + base_path={record.url.replace(/^https?:\/\//, "")} 247 250 setDomain={(v) => { 248 251 setState({ 249 252 type: "domainSettings",
+54 -21
app/lish/createPub/createPublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 - import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + AtpBaseClient, 5 + PubLeafletPublication, 6 + SiteStandardPublication, 7 + } from "lexicons/api"; 4 8 import { 5 9 restoreOAuthSession, 6 10 OAuthSessionError, 7 11 } from "src/atproto-oauth"; 8 12 import { getIdentityData } from "actions/getIdentityData"; 9 13 import { supabaseServerClient } from "supabase/serverClient"; 10 - import { Un$Typed } from "@atproto/api"; 11 14 import { Json } from "supabase/database.types"; 12 15 import { Vercel } from "@vercel/sdk"; 13 16 import { isProductionDomain } from "src/utils/isProductionDeployment"; 14 17 import { string } from "zod"; 18 + import { getPublicationType } from "src/utils/collectionHelpers"; 19 + import { PubThemeDefaultsRGB } from "components/ThemeManager/themeDefaults"; 15 20 16 21 const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 17 22 const vercel = new Vercel({ ··· 64 69 let agent = new AtpBaseClient( 65 70 credentialSession.fetchHandler.bind(credentialSession), 66 71 ); 67 - let record: Un$Typed<PubLeafletPublication.Record> = { 68 - name, 69 - base_path: domain, 70 - preferences, 71 - }; 72 + 73 + // Use site.standard.publication for new publications 74 + const publicationType = getPublicationType(); 75 + const url = `https://${domain}`; 72 76 73 - if (description) { 74 - record.description = description; 75 - } 77 + // Build record based on publication type 78 + let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 + let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 76 80 77 81 // Upload the icon if provided 78 82 if (iconFile && iconFile.size > 0) { ··· 81 85 new Uint8Array(buffer), 82 86 { encoding: iconFile.type }, 83 87 ); 88 + iconBlob = uploadResult.data.blob; 89 + } 84 90 85 - if (uploadResult.data.blob) { 86 - record.icon = uploadResult.data.blob; 87 - } 91 + if (publicationType === "site.standard.publication") { 92 + record = { 93 + $type: "site.standard.publication", 94 + name, 95 + url, 96 + ...(description && { description }), 97 + ...(iconBlob && { icon: iconBlob }), 98 + basicTheme: { 99 + $type: "site.standard.theme.basic", 100 + background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, 101 + foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, 102 + accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, 103 + accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, 104 + }, 105 + preferences: { 106 + showInDiscover: preferences.showInDiscover, 107 + showComments: preferences.showComments, 108 + showMentions: preferences.showMentions, 109 + showPrevNext: preferences.showPrevNext, 110 + }, 111 + } satisfies SiteStandardPublication.Record; 112 + } else { 113 + record = { 114 + $type: "pub.leaflet.publication", 115 + name, 116 + base_path: domain, 117 + ...(description && { description }), 118 + ...(iconBlob && { icon: iconBlob }), 119 + preferences, 120 + } satisfies PubLeafletPublication.Record; 88 121 } 89 122 90 - let result = await agent.pub.leaflet.publication.create( 91 - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, 123 + let { data: result } = await agent.com.atproto.repo.putRecord({ 124 + repo: credentialSession.did!, 125 + rkey: TID.nextStr(), 126 + collection: publicationType, 92 127 record, 93 - ); 128 + validate: false, 129 + }); 94 130 95 131 //optimistically write to our db! 96 132 let { data: publication } = await supabaseServerClient ··· 98 134 .upsert({ 99 135 uri: result.uri, 100 136 identity_did: credentialSession.did!, 101 - name: record.name, 102 - record: { 103 - ...record, 104 - $type: "pub.leaflet.publication", 105 - } as unknown as Json, 137 + name, 138 + record: record as unknown as Json, 106 139 }) 107 140 .select() 108 141 .single();
+34 -9
app/lish/createPub/getPublicationURL.ts
··· 2 2 import { PubLeafletPublication } from "lexicons/api"; 3 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 4 import { Json } from "supabase/database.types"; 5 + import { 6 + normalizePublicationRecord, 7 + isLeafletPublication, 8 + type NormalizedPublication, 9 + } from "src/utils/normalizeRecords"; 5 10 6 - export function getPublicationURL(pub: { uri: string; record: Json }) { 7 - let record = pub.record as PubLeafletPublication.Record; 8 - if (isProductionDomain() && record?.base_path) 9 - return `https://${record.base_path}`; 10 - else return getBasePublicationURL(pub); 11 + type PublicationInput = 12 + | { uri: string; record: Json | NormalizedPublication | null } 13 + | { uri: string; record: unknown }; 14 + 15 + /** 16 + * Gets the public URL for a publication. 17 + * Works with both pub.leaflet.publication and site.standard.publication records. 18 + */ 19 + export function getPublicationURL(pub: PublicationInput): string { 20 + const normalized = normalizePublicationRecord(pub.record); 21 + 22 + // If we have a normalized record with a URL (site.standard format), use it 23 + if (normalized?.url && isProductionDomain()) { 24 + return normalized.url; 25 + } 26 + 27 + // Fall back to checking raw record for legacy base_path 28 + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { 29 + return `https://${pub.record.base_path}`; 30 + } 31 + 32 + return getBasePublicationURL(pub); 11 33 } 12 34 13 - export function getBasePublicationURL(pub: { uri: string; record: Json }) { 14 - let record = pub.record as PubLeafletPublication.Record; 15 - let aturi = new AtUri(pub.uri); 16 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 35 + export function getBasePublicationURL(pub: PublicationInput): string { 36 + const normalized = normalizePublicationRecord(pub.record); 37 + const aturi = new AtUri(pub.uri); 38 + 39 + // Use normalized name if available, fall back to rkey 40 + const name = normalized?.name || aturi.rkey; 41 + return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 17 42 }
+203 -155
app/lish/createPub/updatePublication.ts
··· 1 1 "use server"; 2 - import { TID } from "@atproto/common"; 3 2 import { 4 3 AtpBaseClient, 5 4 PubLeafletPublication, 6 5 PubLeafletThemeColor, 6 + SiteStandardPublication, 7 7 } from "lexicons/api"; 8 8 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 9 import { getIdentityData } from "actions/getIdentityData"; ··· 11 11 import { Json } from "supabase/database.types"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { $Typed } from "@atproto/api"; 14 + import { 15 + normalizePublicationRecord, 16 + type NormalizedPublication, 17 + } from "src/utils/normalizeRecords"; 18 + import { getPublicationType } from "src/utils/collectionHelpers"; 14 19 15 20 type UpdatePublicationResult = 16 21 | { success: true; publication: any } 17 22 | { success: false; error?: OAuthSessionError }; 18 23 19 - export async function updatePublication({ 20 - uri, 21 - name, 22 - description, 23 - iconFile, 24 - preferences, 25 - }: { 26 - uri: string; 27 - name: string; 28 - description?: string; 29 - iconFile?: File | null; 30 - preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 - }): Promise<UpdatePublicationResult> { 32 - let identity = await getIdentityData(); 24 + type PublicationType = "pub.leaflet.publication" | "site.standard.publication"; 25 + 26 + type RecordBuilder = (args: { 27 + normalizedPub: NormalizedPublication | null; 28 + existingBasePath: string | undefined; 29 + publicationType: PublicationType; 30 + agent: AtpBaseClient; 31 + }) => Promise<PubLeafletPublication.Record | SiteStandardPublication.Record>; 32 + 33 + /** 34 + * Shared helper for publication updates. Handles: 35 + * - Authentication and session restoration 36 + * - Fetching existing publication from database 37 + * - Normalizing the existing record 38 + * - Calling the record builder to create the updated record 39 + * - Writing to PDS via putRecord 40 + * - Writing to database 41 + */ 42 + async function withPublicationUpdate( 43 + uri: string, 44 + recordBuilder: RecordBuilder, 45 + ): Promise<UpdatePublicationResult> { 46 + // Get identity and validate authentication 47 + const identity = await getIdentityData(); 33 48 if (!identity || !identity.atp_did) { 34 49 return { 35 50 success: false, ··· 41 56 }; 42 57 } 43 58 59 + // Restore OAuth session 44 60 const sessionResult = await restoreOAuthSession(identity.atp_did); 45 61 if (!sessionResult.ok) { 46 62 return { success: false, error: sessionResult.error }; 47 63 } 48 - let credentialSession = sessionResult.value; 49 - let agent = new AtpBaseClient( 64 + const credentialSession = sessionResult.value; 65 + const agent = new AtpBaseClient( 50 66 credentialSession.fetchHandler.bind(credentialSession), 51 67 ); 52 - let { data: existingPub } = await supabaseServerClient 68 + 69 + // Fetch existing publication from database 70 + const { data: existingPub } = await supabaseServerClient 53 71 .from("publications") 54 72 .select("*") 55 73 .eq("uri", uri) ··· 57 75 if (!existingPub || existingPub.identity_did !== identity.atp_did) { 58 76 return { success: false }; 59 77 } 60 - let aturi = new AtUri(existingPub.uri); 61 78 62 - let record: PubLeafletPublication.Record = { 63 - $type: "pub.leaflet.publication", 64 - ...(existingPub.record as object), 65 - name, 66 - }; 67 - if (preferences) { 68 - record.preferences = preferences; 69 - } 79 + const aturi = new AtUri(existingPub.uri); 80 + const publicationType = getPublicationType(aturi.collection) as PublicationType; 70 81 71 - if (description !== undefined) { 72 - record.description = description; 73 - } 82 + // Normalize existing record 83 + const normalizedPub = normalizePublicationRecord(existingPub.record); 84 + const existingBasePath = normalizedPub?.url 85 + ? normalizedPub.url.replace(/^https?:\/\//, "") 86 + : undefined; 74 87 75 - // Upload the icon if provided How do I tell if there isn't a new one? 76 - if (iconFile && iconFile.size > 0) { 77 - const buffer = await iconFile.arrayBuffer(); 78 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 79 - new Uint8Array(buffer), 80 - { encoding: iconFile.type }, 81 - ); 88 + // Build the updated record 89 + const record = await recordBuilder({ 90 + normalizedPub, 91 + existingBasePath, 92 + publicationType, 93 + agent, 94 + }); 82 95 83 - if (uploadResult.data.blob) { 84 - record.icon = uploadResult.data.blob; 85 - } 86 - } 87 - 88 - let result = await agent.com.atproto.repo.putRecord({ 96 + // Write to PDS 97 + await agent.com.atproto.repo.putRecord({ 89 98 repo: credentialSession.did!, 90 99 rkey: aturi.rkey, 91 100 record, 92 - collection: record.$type, 101 + collection: publicationType, 93 102 validate: false, 94 103 }); 95 104 96 - //optimistically write to our db! 97 - let { data: publication, error } = await supabaseServerClient 105 + // Optimistically write to database 106 + const { data: publication } = await supabaseServerClient 98 107 .from("publications") 99 108 .update({ 100 109 name: record.name, ··· 103 112 .eq("uri", uri) 104 113 .select() 105 114 .single(); 115 + 106 116 return { success: true, publication }; 107 117 } 108 118 119 + /** Fields that can be overridden when building a record */ 120 + interface RecordOverrides { 121 + name?: string; 122 + description?: string; 123 + icon?: any; 124 + theme?: any; 125 + basicTheme?: NormalizedPublication["basicTheme"]; 126 + preferences?: NormalizedPublication["preferences"]; 127 + basePath?: string; 128 + } 129 + 130 + /** Merges override with existing value, respecting explicit undefined */ 131 + function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 132 + return hasOverride ? override : existing; 133 + } 134 + 135 + /** 136 + * Builds a pub.leaflet.publication record. 137 + * Uses base_path for the URL path component. 138 + */ 139 + function buildLeafletRecord( 140 + normalizedPub: NormalizedPublication | null, 141 + existingBasePath: string | undefined, 142 + overrides: RecordOverrides, 143 + ): PubLeafletPublication.Record { 144 + const preferences = overrides.preferences ?? normalizedPub?.preferences; 145 + 146 + return { 147 + $type: "pub.leaflet.publication", 148 + name: overrides.name ?? normalizedPub?.name ?? "", 149 + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 152 + base_path: overrides.basePath ?? existingBasePath, 153 + preferences: preferences ? { 154 + $type: "pub.leaflet.publication#preferences", 155 + showInDiscover: preferences.showInDiscover, 156 + showComments: preferences.showComments, 157 + showMentions: preferences.showMentions, 158 + showPrevNext: preferences.showPrevNext, 159 + } : undefined, 160 + }; 161 + } 162 + 163 + /** 164 + * Builds a site.standard.publication record. 165 + * Uses url for the full URL. Also supports basicTheme. 166 + */ 167 + function buildStandardRecord( 168 + normalizedPub: NormalizedPublication | null, 169 + existingBasePath: string | undefined, 170 + overrides: RecordOverrides, 171 + ): SiteStandardPublication.Record { 172 + const preferences = overrides.preferences ?? normalizedPub?.preferences; 173 + const basePath = overrides.basePath ?? existingBasePath; 174 + 175 + return { 176 + $type: "site.standard.publication", 177 + name: overrides.name ?? normalizedPub?.name ?? "", 178 + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 + basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 182 + url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 + preferences: preferences ? { 184 + showInDiscover: preferences.showInDiscover, 185 + showComments: preferences.showComments, 186 + showMentions: preferences.showMentions, 187 + showPrevNext: preferences.showPrevNext, 188 + } : undefined, 189 + }; 190 + } 191 + 192 + /** 193 + * Builds a record for the appropriate publication type. 194 + */ 195 + function buildRecord( 196 + normalizedPub: NormalizedPublication | null, 197 + existingBasePath: string | undefined, 198 + publicationType: PublicationType, 199 + overrides: RecordOverrides, 200 + ): PubLeafletPublication.Record | SiteStandardPublication.Record { 201 + if (publicationType === "pub.leaflet.publication") { 202 + return buildLeafletRecord(normalizedPub, existingBasePath, overrides); 203 + } 204 + return buildStandardRecord(normalizedPub, existingBasePath, overrides); 205 + } 206 + 207 + export async function updatePublication({ 208 + uri, 209 + name, 210 + description, 211 + iconFile, 212 + preferences, 213 + }: { 214 + uri: string; 215 + name: string; 216 + description?: string; 217 + iconFile?: File | null; 218 + preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 + }): Promise<UpdatePublicationResult> { 220 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 + // Upload icon if provided 222 + let iconBlob = normalizedPub?.icon; 223 + if (iconFile && iconFile.size > 0) { 224 + const buffer = await iconFile.arrayBuffer(); 225 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 + new Uint8Array(buffer), 227 + { encoding: iconFile.type }, 228 + ); 229 + if (uploadResult.data.blob) { 230 + iconBlob = uploadResult.data.blob; 231 + } 232 + } 233 + 234 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 + name, 236 + description, 237 + icon: iconBlob, 238 + preferences, 239 + }); 240 + }); 241 + } 242 + 109 243 export async function updatePublicationBasePath({ 110 244 uri, 111 245 base_path, ··· 113 247 uri: string; 114 248 base_path: string; 115 249 }): Promise<UpdatePublicationResult> { 116 - let identity = await getIdentityData(); 117 - if (!identity || !identity.atp_did) { 118 - return { 119 - success: false, 120 - error: { 121 - type: "oauth_session_expired", 122 - message: "Not authenticated", 123 - did: "", 124 - }, 125 - }; 126 - } 127 - 128 - const sessionResult = await restoreOAuthSession(identity.atp_did); 129 - if (!sessionResult.ok) { 130 - return { success: false, error: sessionResult.error }; 131 - } 132 - let credentialSession = sessionResult.value; 133 - let agent = new AtpBaseClient( 134 - credentialSession.fetchHandler.bind(credentialSession), 135 - ); 136 - let { data: existingPub } = await supabaseServerClient 137 - .from("publications") 138 - .select("*") 139 - .eq("uri", uri) 140 - .single(); 141 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 142 - return { success: false }; 143 - } 144 - let aturi = new AtUri(existingPub.uri); 145 - 146 - let record: PubLeafletPublication.Record = { 147 - ...(existingPub.record as PubLeafletPublication.Record), 148 - base_path, 149 - }; 150 - 151 - let result = await agent.com.atproto.repo.putRecord({ 152 - repo: credentialSession.did!, 153 - rkey: aturi.rkey, 154 - record, 155 - collection: record.$type, 156 - validate: false, 250 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 + basePath: base_path, 253 + }); 157 254 }); 158 - 159 - //optimistically write to our db! 160 - let { data: publication, error } = await supabaseServerClient 161 - .from("publications") 162 - .update({ 163 - name: record.name, 164 - record: record as Json, 165 - }) 166 - .eq("uri", uri) 167 - .select() 168 - .single(); 169 - return { success: true, publication }; 170 255 } 171 256 172 257 type Color = 173 258 | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 174 259 | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 260 + 175 261 export async function updatePublicationTheme({ 176 262 uri, 177 263 theme, ··· 189 275 accentText: Color; 190 276 }; 191 277 }): Promise<UpdatePublicationResult> { 192 - let identity = await getIdentityData(); 193 - if (!identity || !identity.atp_did) { 194 - return { 195 - success: false, 196 - error: { 197 - type: "oauth_session_expired", 198 - message: "Not authenticated", 199 - did: "", 200 - }, 201 - }; 202 - } 203 - 204 - const sessionResult = await restoreOAuthSession(identity.atp_did); 205 - if (!sessionResult.ok) { 206 - return { success: false, error: sessionResult.error }; 207 - } 208 - let credentialSession = sessionResult.value; 209 - let agent = new AtpBaseClient( 210 - credentialSession.fetchHandler.bind(credentialSession), 211 - ); 212 - let { data: existingPub } = await supabaseServerClient 213 - .from("publications") 214 - .select("*") 215 - .eq("uri", uri) 216 - .single(); 217 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 218 - return { success: false }; 219 - } 220 - let aturi = new AtUri(existingPub.uri); 221 - 222 - let oldRecord = existingPub.record as PubLeafletPublication.Record; 223 - let record: PubLeafletPublication.Record = { 224 - ...oldRecord, 225 - $type: "pub.leaflet.publication", 226 - theme: { 278 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 + // Build theme object 280 + const themeData = { 227 281 backgroundImage: theme.backgroundImage 228 282 ? { 229 283 $type: "pub.leaflet.theme.backgroundImage", ··· 238 292 } 239 293 : theme.backgroundImage === null 240 294 ? undefined 241 - : oldRecord.theme?.backgroundImage, 295 + : normalizedPub?.theme?.backgroundImage, 242 296 backgroundColor: theme.backgroundColor 243 297 ? { 244 298 ...theme.backgroundColor, ··· 258 312 accentText: { 259 313 ...theme.accentText, 260 314 }, 261 - }, 262 - }; 315 + }; 263 316 264 - let result = await agent.com.atproto.repo.putRecord({ 265 - repo: credentialSession.did!, 266 - rkey: aturi.rkey, 267 - record, 268 - collection: record.$type, 269 - validate: false, 270 - }); 317 + // Derive basicTheme from the theme colors for site.standard.publication 318 + const basicTheme: NormalizedPublication["basicTheme"] = { 319 + $type: "site.standard.theme.basic", 320 + background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 321 + foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 322 + accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 323 + accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 324 + }; 271 325 272 - //optimistically write to our db! 273 - let { data: publication, error } = await supabaseServerClient 274 - .from("publications") 275 - .update({ 276 - name: record.name, 277 - record: record as Json, 278 - }) 279 - .eq("uri", uri) 280 - .select() 281 - .single(); 282 - return { success: true, publication }; 326 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 327 + theme: themeData, 328 + basicTheme, 329 + }); 330 + }); 283 331 }
+7 -4
app/lish/feeds/[...path]/route.ts
··· 2 2 import { DidResolver } from "@atproto/identity"; 3 3 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - import { PubLeafletDocument } from "lexicons/api"; 5 + import { 6 + normalizeDocumentRecord, 7 + type NormalizedDocument, 8 + } from "src/utils/normalizeRecords"; 6 9 7 10 const serviceDid = "did:web:leaflet.pub:lish:feeds"; 8 11 export async function GET( ··· 34 37 let posts = pub.publications?.documents_in_publications || []; 35 38 return posts.flatMap((p) => { 36 39 if (!p.documents?.data) return []; 37 - let record = p.documents.data as PubLeafletDocument.Record; 38 - if (!record.postRef) return []; 39 - return { post: record.postRef.uri }; 40 + const normalizedDoc = normalizeDocumentRecord(p.documents.data, p.documents.uri); 41 + if (!normalizedDoc?.bskyPostRef) return []; 42 + return { post: normalizedDoc.bskyPostRef.uri }; 40 43 }); 41 44 }), 42 45 ],
+9 -5
app/lish/subscribeToPublication.ts
··· 48 48 let agent = new AtpBaseClient( 49 49 credentialSession.fetchHandler.bind(credentialSession), 50 50 ); 51 - let record = await agent.pub.leaflet.graph.subscription.create( 51 + let record = await agent.site.standard.graph.subscription.create( 52 52 { repo: credentialSession.did!, rkey: TID.nextStr() }, 53 53 { 54 54 publication, ··· 140 140 .eq("publication", publication) 141 141 .single(); 142 142 if (!existingSubscription) return { success: true }; 143 - await agent.pub.leaflet.graph.subscription.delete({ 144 - repo: credentialSession.did!, 145 - rkey: new AtUri(existingSubscription.uri).rkey, 146 - }); 143 + 144 + // Delete from both collections (old and new schema) - one or both may exist 145 + let rkey = new AtUri(existingSubscription.uri).rkey; 146 + await Promise.all([ 147 + agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 148 + agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 149 + ]); 150 + 147 151 await supabaseServerClient 148 152 .from("publication_subscriptions") 149 153 .delete()
+26 -24
app/lish/uri/[uri]/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 2 import { AtUri } from "@atproto/api"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { PubLeafletPublication } from "lexicons/api"; 4 + import { 5 + normalizePublicationRecord, 6 + type NormalizedPublication, 7 + } from "src/utils/normalizeRecords"; 8 + import { 9 + isDocumentCollection, 10 + isPublicationCollection, 11 + } from "src/utils/collectionHelpers"; 5 12 6 13 /** 7 14 * Redirect route for AT URIs (publications and documents) ··· 16 23 const atUriString = decodeURIComponent(uriParam); 17 24 const uri = new AtUri(atUriString); 18 25 19 - if (uri.collection === "pub.leaflet.publication") { 26 + if (isPublicationCollection(uri.collection)) { 20 27 // Get the publication record to retrieve base_path 21 28 const { data: publication } = await supabaseServerClient 22 29 .from("publications") ··· 28 35 return new NextResponse("Publication not found", { status: 404 }); 29 36 } 30 37 31 - const record = publication.record as PubLeafletPublication.Record; 32 - const basePath = record.base_path; 33 - 34 - if (!basePath) { 35 - return new NextResponse("Publication has no base_path", { 38 + const normalizedPub = normalizePublicationRecord(publication.record); 39 + if (!normalizedPub?.url) { 40 + return new NextResponse("Publication has no url", { 36 41 status: 404, 37 42 }); 38 43 } 39 44 40 - // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 41 - return NextResponse.redirect(basePath, 307); 42 - } else if (uri.collection === "pub.leaflet.document") { 45 + // Redirect to the publication's hosted domain (temporary redirect since url can change) 46 + return NextResponse.redirect(normalizedPub.url, 307); 47 + } else if (isDocumentCollection(uri.collection)) { 43 48 // Document link - need to find the publication it belongs to 44 49 const { data: docInPub } = await supabaseServerClient 45 50 .from("documents_in_publications") ··· 49 54 50 55 if (docInPub?.publication && docInPub.publications) { 51 56 // Document is in a publication - redirect to domain/rkey 52 - const record = docInPub.publications 53 - .record as PubLeafletPublication.Record; 54 - const basePath = record.base_path; 57 + const normalizedPub = normalizePublicationRecord( 58 + docInPub.publications.record, 59 + ); 55 60 56 - if (!basePath) { 57 - return new NextResponse("Publication has no base_path", { 61 + if (!normalizedPub?.url) { 62 + return new NextResponse("Publication has no url", { 58 63 status: 404, 59 64 }); 60 65 } 61 66 62 - // Ensure basePath ends without trailing slash 63 - const cleanBasePath = basePath.endsWith("/") 64 - ? basePath.slice(0, -1) 65 - : basePath; 67 + // Ensure url ends without trailing slash 68 + const cleanUrl = normalizedPub.url.endsWith("/") 69 + ? normalizedPub.url.slice(0, -1) 70 + : normalizedPub.url; 66 71 67 - // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 68 - return NextResponse.redirect( 69 - `https://${cleanBasePath}/${uri.rkey}`, 70 - 307, 71 - ); 72 + // Redirect to the document on the publication's domain (temporary redirect since url can change) 73 + return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307); 72 74 } 73 75 74 76 // If not in a publication, check if it's a standalone document
+9 -8
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { ids } from "lexicons/api/lexicons"; 5 - import { PubLeafletDocument } from "lexicons/api"; 6 3 import { jsonToLex } from "@atproto/lexicon"; 7 4 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 5 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 8 10 9 export const revalidate = 60; 11 10 ··· 28 27 29 28 if (did) { 30 29 // Try to get the document's cover image 31 - let { data: document } = await supabaseServerClient 30 + let { data: documents } = await supabaseServerClient 32 31 .from("documents") 33 32 .select("data") 34 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 35 - .single(); 33 + .or(documentUriFilter(did, params.rkey)) 34 + .order("uri", { ascending: false }) 35 + .limit(1); 36 + let document = documents?.[0]; 36 37 37 38 if (document) { 38 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 39 - if (docRecord.coverImage) { 39 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 40 + if (docRecord?.coverImage) { 40 41 try { 41 42 // Get CID from the blob ref (handle both serialized and hydrated forms) 42 43 let cid =
+11 -15
app/p/[didOrHandle]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { ids } from "lexicons/api/lexicons"; 4 - import { PubLeafletDocument } from "lexicons/api"; 5 2 import { Metadata } from "next"; 6 3 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 4 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 5 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 8 10 9 export async function generateMetadata(props: { 11 10 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 24 23 } 25 24 } 26 25 27 - let { data: document } = await supabaseServerClient 26 + let { data: documents } = await supabaseServerClient 28 27 .from("documents") 29 - .select("*, documents_in_publications(publications(*))") 30 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 31 - .single(); 28 + .select("*") 29 + .or(documentUriFilter(did, params.rkey)) 30 + .order("uri", { ascending: false }) 31 + .limit(1); 32 + let document = documents?.[0]; 32 33 33 34 if (!document) return { title: "404" }; 34 35 35 - let docRecord = document.data as PubLeafletDocument.Record; 36 - 37 - // For documents in publications, include publication name 38 - let publicationName = 39 - document.documents_in_publications[0]?.publications?.name; 36 + const docRecord = normalizeDocumentRecord(document.data); 37 + if (!docRecord) return { title: "404" }; 40 38 41 39 return { 42 40 icons: { ··· 45 43 url: document.uri, 46 44 }, 47 45 }, 48 - title: publicationName 49 - ? `${docRecord.title} - ${publicationName}` 50 - : docRecord.title, 46 + title: docRecord.title, 51 47 description: docRecord?.description || "", 52 48 }; 53 49 }
+98
appview/index.ts
··· 11 11 PubLeafletComment, 12 12 PubLeafletPollVote, 13 13 PubLeafletPollDefinition, 14 + SiteStandardDocument, 15 + SiteStandardPublication, 16 + SiteStandardGraphSubscription, 14 17 } from "lexicons/api"; 15 18 import { 16 19 AppBskyEmbedExternal, ··· 47 50 ids.PubLeafletPollDefinition, 48 51 // ids.AppBskyActorProfile, 49 52 "app.bsky.feed.post", 53 + ids.SiteStandardDocument, 54 + ids.SiteStandardPublication, 55 + ids.SiteStandardGraphSubscription, 50 56 ], 51 57 handleEvent, 52 58 onError: (err) => { ··· 207 213 if (evt.collection === ids.PubLeafletGraphSubscription) { 208 214 if (evt.event === "create" || evt.event === "update") { 209 215 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 216 + if (!record.success) return; 217 + await supabase 218 + .from("identities") 219 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 220 + await supabase.from("publication_subscriptions").upsert({ 221 + uri: evt.uri.toString(), 222 + identity: evt.did, 223 + publication: record.value.publication, 224 + record: record.value as Json, 225 + }); 226 + } 227 + if (evt.event === "delete") { 228 + await supabase 229 + .from("publication_subscriptions") 230 + .delete() 231 + .eq("uri", evt.uri.toString()); 232 + } 233 + } 234 + // site.standard.document records go into the main "documents" table 235 + // The normalization layer handles reading both pub.leaflet and site.standard formats 236 + if (evt.collection === ids.SiteStandardDocument) { 237 + if (evt.event === "create" || evt.event === "update") { 238 + let record = SiteStandardDocument.validateRecord(evt.record); 239 + if (!record.success) { 240 + console.log(record.error); 241 + return; 242 + } 243 + let docResult = await supabase.from("documents").upsert({ 244 + uri: evt.uri.toString(), 245 + data: record.value as Json, 246 + }); 247 + if (docResult.error) console.log(docResult.error); 248 + 249 + // site.standard.document uses "site" field to reference the publication 250 + // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) 251 + // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx) 252 + // Only link to publications table for AT-URI sites 253 + if (record.value.site && record.value.site.startsWith("at://")) { 254 + let siteURI = new AtUri(record.value.site); 255 + 256 + if (siteURI.host !== evt.uri.host) { 257 + console.log("Unauthorized to create document in site!"); 258 + return; 259 + } 260 + let docInPublicationResult = await supabase 261 + .from("documents_in_publications") 262 + .upsert({ 263 + publication: record.value.site, 264 + document: evt.uri.toString(), 265 + }); 266 + await supabase 267 + .from("documents_in_publications") 268 + .delete() 269 + .neq("publication", record.value.site) 270 + .eq("document", evt.uri.toString()); 271 + 272 + if (docInPublicationResult.error) 273 + console.log(docInPublicationResult.error); 274 + } 275 + } 276 + if (evt.event === "delete") { 277 + await supabase.from("documents").delete().eq("uri", evt.uri.toString()); 278 + } 279 + } 280 + 281 + // site.standard.publication records go into the main "publications" table 282 + if (evt.collection === ids.SiteStandardPublication) { 283 + if (evt.event === "create" || evt.event === "update") { 284 + let record = SiteStandardPublication.validateRecord(evt.record); 285 + if (!record.success) return; 286 + await supabase 287 + .from("identities") 288 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 289 + await supabase.from("publications").upsert({ 290 + uri: evt.uri.toString(), 291 + identity_did: evt.did, 292 + name: record.value.name, 293 + record: record.value as Json, 294 + }); 295 + } 296 + if (evt.event === "delete") { 297 + await supabase 298 + .from("publications") 299 + .delete() 300 + .eq("uri", evt.uri.toString()); 301 + } 302 + } 303 + 304 + // site.standard.graph.subscription records go into the main "publication_subscriptions" table 305 + if (evt.collection === ids.SiteStandardGraphSubscription) { 306 + if (evt.event === "create" || evt.event === "update") { 307 + let record = SiteStandardGraphSubscription.validateRecord(evt.record); 210 308 if (!record.success) return; 211 309 await supabase 212 310 .from("identities")
+7 -4
components/ActionBar/Publications.tsx
··· 5 5 import { theme } from "tailwind.config"; 6 6 import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 7 7 import { Json } from "supabase/database.types"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { AtUri } from "@atproto/syntax"; 10 9 import { ActionButton } from "./ActionButton"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 11 14 import { SpeedyLink } from "components/SpeedyLink"; 12 15 import { PublishSmall } from "components/Icons/PublishSmall"; 13 16 import { Popover } from "components/Popover"; ··· 85 88 record: Json; 86 89 current?: boolean; 87 90 }) => { 88 - let record = props.record as PubLeafletPublication.Record | null; 91 + let record = normalizePublicationRecord(props.record); 89 92 if (!record) return; 90 93 91 94 return ( ··· 181 184 }; 182 185 183 186 export const PubIcon = (props: { 184 - record: PubLeafletPublication.Record; 187 + record: NormalizedPublication | null; 185 188 uri: string; 186 189 small?: boolean; 187 190 large?: boolean; 188 191 className?: string; 189 192 }) => { 190 - if (!props.record) return; 193 + if (!props.record) return null; 191 194 192 195 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 193 196
+6 -2
components/AtMentionLink.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { atUriToUrl } from "src/utils/mentionUtils"; 3 + import { 4 + isDocumentCollection, 5 + isPublicationCollection, 6 + } from "src/utils/collectionHelpers"; 3 7 4 8 /** 5 9 * Component for rendering at-uri mentions (publications and documents) as clickable links. ··· 16 20 className?: string; 17 21 }) { 18 22 const aturi = new AtUri(atURI); 19 - const isPublication = aturi.collection === "pub.leaflet.publication"; 20 - const isDocument = aturi.collection === "pub.leaflet.document"; 23 + const isPublication = isPublicationCollection(aturi.collection); 24 + const isDocument = isDocumentCollection(aturi.collection); 21 25 22 26 // Show publication icon if available 23 27 const icon =
+2 -32
components/Blocks/DateTimeBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import { BlockProps, BlockLayout } from "./Block"; 3 - import { ChevronProps, DayPicker } from "react-day-picker"; 4 3 import { Popover } from "components/Popover"; 5 4 import { useEffect, useMemo, useState } from "react"; 6 5 import { useEntitySetContext } from "components/EntitySetProvider"; ··· 10 9 import { Checkbox } from "components/Checkbox"; 11 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 12 11 import { useSpring, animated } from "@react-spring/web"; 13 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 14 12 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 13 + import { DatePicker } from "components/DatePicker"; 15 14 16 15 export function DateTimeBlock(props: BlockProps) { 17 16 const [isClient, setIsClient] = useState(false); ··· 166 165 } 167 166 > 168 167 <div className="flex flex-col gap-3 "> 169 - <DayPicker 170 - components={{ 171 - Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 172 - }} 173 - classNames={{ 174 - months: "relative", 175 - month_caption: 176 - "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 177 - button_next: 178 - "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 179 - button_previous: 180 - "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center ", 181 - chevron: "text-inherit", 182 - month_grid: "w-full table-fixed", 183 - weekdays: "text-secondary text-sm", 184 - selected: "bg-accent-1! text-accent-2 rounded-md font-bold", 185 - 186 - day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 187 - outside: "text-border", 188 - today: "font-bold", 189 - }} 190 - mode="single" 168 + <DatePicker 191 169 selected={dateFact ? selectedDate : undefined} 192 170 onSelect={handleDaySelect} 193 171 /> ··· 229 207 let spring = useSpring({ opacity: props.active ? 1 : 0 }); 230 208 return <animated.div style={spring}>{props.children}</animated.div>; 231 209 }; 232 - 233 - const CustomChevron = (props: ChevronProps) => { 234 - return ( 235 - <div {...props} className="w-full pointer-events-none"> 236 - <ArrowRightTiny /> 237 - </div> 238 - ); 239 - };
+6 -6
components/Blocks/PublicationPollBlock.tsx
··· 11 11 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 12 import { 13 13 PubLeafletBlocksPoll, 14 - PubLeafletDocument, 15 14 PubLeafletPagesLinearDocument, 16 15 } from "lexicons/api"; 16 + import { getDocumentPages } from "src/utils/normalizeRecords"; 17 17 import { ids } from "lexicons/api/lexicons"; 18 18 19 19 /** ··· 33 33 ); 34 34 // Check if this poll has been published in a publication document 35 35 const isPublished = useMemo(() => { 36 - if (!publicationData?.documents?.data) return false; 36 + if (!normalizedDocument) return false; 37 37 38 - const docRecord = publicationData.documents 39 - .data as PubLeafletDocument.Record; 38 + const pages = getDocumentPages(normalizedDocument); 39 + if (!pages) return false; 40 40 41 41 // Search through all pages and blocks to find if this poll entity has been published 42 - for (const page of docRecord.pages || []) { 42 + for (const page of pages) { 43 43 if (page.$type === "pub.leaflet.pages.linearDocument") { 44 44 const linearPage = page as PubLeafletPagesLinearDocument.Main; 45 45 for (const blockWrapper of linearPage.blocks || []) { ··· 55 55 } 56 56 } 57 57 return false; 58 - }, [publicationData, props.entityID]); 58 + }, [normalizedDocument, props.entityID]); 59 59 60 60 return ( 61 61 <BlockLayout
+8 -4
components/Blocks/TextBlock/schema.ts
··· 2 2 import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 3 import { marks } from "prosemirror-schema-basic"; 4 4 import { theme } from "tailwind.config"; 5 + import { 6 + isDocumentCollection, 7 + isPublicationCollection, 8 + } from "src/utils/collectionHelpers"; 5 9 6 10 let baseSchema = { 7 11 marks: { ··· 149 153 // components/AtMentionLink.tsx. If you update one, update the other. 150 154 let className = "atMention mention"; 151 155 let aturi = new AtUri(node.attrs.atURI); 152 - if (aturi.collection === "pub.leaflet.publication") 156 + if (isPublicationCollection(aturi.collection)) 153 157 className += " font-bold"; 154 - if (aturi.collection === "pub.leaflet.document") className += " italic"; 158 + if (isDocumentCollection(aturi.collection)) className += " italic"; 155 159 156 160 // For publications and documents, show icon 157 161 if ( 158 - aturi.collection === "pub.leaflet.publication" || 159 - aturi.collection === "pub.leaflet.document" 162 + isPublicationCollection(aturi.collection) || 163 + isDocumentCollection(aturi.collection) 160 164 ) { 161 165 return [ 162 166 "span",
+4 -8
components/Canvas.tsx
··· 21 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 - import { 25 - PubLeafletPublication, 26 - PubLeafletPublicationRecord, 27 - } from "lexicons/api"; 28 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 25 import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 30 26 ··· 166 162 } 167 163 168 164 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 169 - let { data: pub } = useLeafletPublicationData(); 165 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 170 166 if (!pub || !pub.publications) return null; 171 167 172 - let pubRecord = pub.publications.record as PubLeafletPublication.Record; 173 - let showComments = pubRecord.preferences?.showComments; 174 - let showMentions = pubRecord.preferences?.showMentions; 168 + if (!normalizedPublication) return null; 169 + let showComments = normalizedPublication.preferences?.showComments !== false; 170 + let showMentions = normalizedPublication.preferences?.showMentions !== false; 175 171 176 172 return ( 177 173 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
+71
components/DatePicker.tsx
··· 1 + import { ChevronProps, DayPicker as ReactDayPicker } from "react-day-picker"; 2 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 3 + 4 + const CustomChevron = (props: ChevronProps) => { 5 + return ( 6 + <div {...props} className="w-full pointer-events-none"> 7 + <ArrowRightTiny /> 8 + </div> 9 + ); 10 + }; 11 + 12 + interface DayPickerProps { 13 + selected: Date | undefined; 14 + onSelect: (date: Date | undefined) => void; 15 + disabled?: (date: Date) => boolean; 16 + } 17 + 18 + export const DatePicker = ({ 19 + selected, 20 + onSelect, 21 + disabled, 22 + }: DayPickerProps) => { 23 + return ( 24 + <ReactDayPicker 25 + components={{ 26 + Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 27 + }} 28 + classNames={{ 29 + months: "relative", 30 + month_caption: 31 + "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 32 + button_next: 33 + "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 34 + button_previous: 35 + "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center", 36 + chevron: "text-inherit", 37 + month_grid: "w-full table-fixed", 38 + weekdays: "text-secondary text-sm", 39 + selected: "bg-accent-1! text-accent-2 rounded-md font-bold", 40 + day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 41 + outside: "text-tertiary", 42 + today: "font-bold", 43 + disabled: "text-border cursor-not-allowed hover:bg-transparent!", 44 + }} 45 + mode="single" 46 + selected={selected} 47 + defaultMonth={selected} 48 + onSelect={onSelect} 49 + disabled={disabled} 50 + /> 51 + ); 52 + }; 53 + 54 + export const TimePicker = (props: { 55 + value: string; 56 + onChange: (time: string) => void; 57 + className?: string; 58 + }) => { 59 + let handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { 60 + props.onChange(e.target.value); 61 + }; 62 + 63 + return ( 64 + <input 65 + type="time" 66 + value={props.value} 67 + onChange={handleTimeChange} 68 + className={`dateBlockTimeInput input-with-border bg-bg-page text-primary w-full ${props.className}`} 69 + /> 70 + ); 71 + };
+5 -5
components/InteractionsPreview.tsx
··· 13 13 commentsCount: number; 14 14 tags?: string[]; 15 15 postUrl: string; 16 - showComments: boolean | undefined; 17 - showMentions: boolean | undefined; 16 + showComments: boolean; 17 + showMentions: boolean; 18 18 19 19 share?: boolean; 20 20 }) => { 21 21 let smoker = useSmoker(); 22 22 let interactionsAvailable = 23 - (props.quotesCount > 0 && props.showMentions !== false) || 23 + (props.quotesCount > 0 && props.showMentions) || 24 24 (props.showComments !== false && props.commentsCount > 0); 25 25 26 26 const tagsCount = props.tags?.length || 0; ··· 38 38 </> 39 39 )} 40 40 41 - {props.showMentions === false || props.quotesCount === 0 ? null : ( 41 + {!props.showMentions || props.quotesCount === 0 ? null : ( 42 42 <SpeedyLink 43 43 aria-label="Post quotes" 44 44 href={`${props.postUrl}?interactionDrawer=quotes`} ··· 47 47 <QuoteTiny /> {props.quotesCount} 48 48 </SpeedyLink> 49 49 )} 50 - {props.showComments === false || props.commentsCount === 0 ? null : ( 50 + {!props.showComments || props.commentsCount === 0 ? null : ( 51 51 <SpeedyLink 52 52 aria-label="Post comments" 53 53 href={`${props.postUrl}?interactionDrawer=comments`}
+20 -1
components/PageSWRDataProvider.tsx
··· 6 6 import { callRPC } from "app/api/rpc/client"; 7 7 import { getPollData } from "actions/pollActions"; 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 - import { createContext, useContext } from "react"; 9 + import { createContext, useContext, useMemo } from "react"; 10 10 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 11 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 12 import { AtUri } from "@atproto/syntax"; 13 + import { 14 + normalizeDocumentRecord, 15 + normalizePublicationRecord, 16 + type NormalizedDocument, 17 + type NormalizedPublication, 18 + } from "src/utils/normalizeRecords"; 13 19 14 20 export const StaticLeafletDataContext = createContext< 15 21 null | GetLeafletDataReturnType["result"]["data"] ··· 73 79 // First check for leaflets in publications 74 80 let pubData = getPublicationMetadataFromLeafletData(data); 75 81 82 + // Normalize records so consumers don't have to 83 + const normalizedPublication = useMemo( 84 + () => normalizePublicationRecord(pubData?.publications?.record), 85 + [pubData?.publications?.record] 86 + ); 87 + const normalizedDocument = useMemo( 88 + () => normalizeDocumentRecord(pubData?.documents?.data), 89 + [pubData?.documents?.data] 90 + ); 91 + 76 92 return { 77 93 data: pubData || null, 94 + // Pre-normalized data - consumers should use these instead of normalizing themselves 95 + normalizedPublication, 96 + normalizedDocument, 78 97 mutate, 79 98 }; 80 99 }
+84
components/Pages/Backdater.tsx
··· 1 + "use client"; 2 + import { DatePicker, TimePicker } from "components/DatePicker"; 3 + import { useMemo, useState } from "react"; 4 + import { timeAgo } from "src/utils/timeAgo"; 5 + import { Popover } from "components/Popover"; 6 + import { Separator } from "react-aria-components"; 7 + import { useReplicache } from "src/replicache"; 8 + import { create } from "zustand"; 9 + 10 + export const useLocalPublishedAt = create<{ [uri: string]: Date }>(() => ({})); 11 + export const Backdater = (props: { publishedAt: string; docURI: string }) => { 12 + let { rep } = useReplicache(); 13 + let localPublishedAtDate = useLocalPublishedAt((s) => 14 + s[props.docURI] ? s[props.docURI] : null, 15 + ); 16 + let localPublishedAt = useMemo( 17 + () => localPublishedAtDate || new Date(props.publishedAt), 18 + [localPublishedAtDate, props.publishedAt], 19 + ); 20 + 21 + let [timeValue, setTimeValue] = useState( 22 + `${localPublishedAt.getHours().toString().padStart(2, "0")}:${localPublishedAt.getMinutes().toString().padStart(2, "0")}`, 23 + ); 24 + 25 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 26 + 27 + const handleTimeChange = async (time: string) => { 28 + setTimeValue(time); 29 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 30 + const newDate = new Date(localPublishedAt); 31 + newDate.setHours(hours); 32 + newDate.setMinutes(minutes); 33 + 34 + let currentDate = new Date(); 35 + if (newDate > currentDate) { 36 + useLocalPublishedAt.setState({ [props.docURI]: currentDate }); 37 + setTimeValue(currentTime); 38 + } else { 39 + useLocalPublishedAt.setState({ [props.docURI]: newDate }); 40 + } 41 + }; 42 + 43 + const handleDateChange = async (date: Date | undefined) => { 44 + if (!date) return; 45 + const [hours, minutes] = timeValue 46 + .split(":") 47 + .map((str) => parseInt(str, 10)); 48 + const newDate = new Date(date); 49 + newDate.setHours(hours); 50 + newDate.setMinutes(minutes); 51 + 52 + let currentDate = new Date(); 53 + if (newDate > currentDate) { 54 + useLocalPublishedAt.setState({ [props.docURI]: currentDate }); 55 + 56 + setTimeValue(currentTime); 57 + } else { 58 + useLocalPublishedAt.setState({ [props.docURI]: newDate }); 59 + } 60 + }; 61 + 62 + return ( 63 + <Popover 64 + className="w-64 z-10 px-2!" 65 + trigger={ 66 + <div className="underline"> 67 + {timeAgo(localPublishedAt.toISOString())} 68 + </div> 69 + } 70 + > 71 + <div className="flex flex-col gap-3"> 72 + <DatePicker 73 + selected={localPublishedAt} 74 + onSelect={handleDateChange} 75 + disabled={(date) => date > new Date()} 76 + /> 77 + <Separator className="border-border" /> 78 + <div className="flex gap-4 pb-1 items-center"> 79 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 80 + </div> 81 + </div> 82 + </Popover> 83 + ); 84 + };
+17 -19
components/Pages/PublicationMetadata.tsx
··· 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 8 import { 10 9 getBasePublicationURL, 11 10 getPublicationURL, ··· 20 19 import { TagSelector } from "components/Tags"; 21 20 import { useIdentityData } from "components/IdentityProvider"; 22 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22 + import { Backdater } from "./Backdater"; 23 + 23 24 export const PublicationMetadata = () => { 24 25 let { rep } = useReplicache(); 25 - let { data: pub } = useLeafletPublicationData(); 26 + let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 26 27 let { identity } = useIdentityData(); 27 28 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 28 29 let description = useSubscribe(rep, (tx) => 29 30 tx.get<string>("publication_description"), 30 31 ); 31 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 - let pubRecord = pub?.publications?.record as 33 - | PubLeafletPublication.Record 34 - | undefined; 35 - let publishedAt = record?.publishedAt; 32 + let publishedAt = normalizedDocument?.publishedAt; 36 33 37 34 if (!pub) return null; 38 35 ··· 96 93 {pub.doc ? ( 97 94 <div className="flex gap-2 items-center"> 98 95 <p className="text-sm text-tertiary"> 99 - Published {publishedAt && timeAgo(publishedAt)} 96 + Published{" "} 97 + {publishedAt && ( 98 + <Backdater publishedAt={publishedAt} docURI={pub.doc} /> 99 + )} 100 100 </p> 101 101 102 102 <Link ··· 118 118 {tags && ( 119 119 <> 120 120 <AddTags /> 121 - {pubRecord?.preferences?.showMentions || 122 - pubRecord?.preferences?.showComments ? ( 121 + {normalizedPublication?.preferences?.showMentions !== false || 122 + normalizedPublication?.preferences?.showComments !== false ? ( 123 123 <Separator classname="h-4!" /> 124 124 ) : null} 125 125 </> 126 126 )} 127 - {pubRecord?.preferences?.showMentions && ( 127 + {normalizedPublication?.preferences?.showMentions !== false && ( 128 128 <div className="flex gap-1 items-center"> 129 129 <QuoteTiny />— 130 130 </div> 131 131 )} 132 - {pubRecord?.preferences?.showComments && ( 132 + {normalizedPublication?.preferences?.showComments !== false && ( 133 133 <div className="flex gap-1 items-center"> 134 134 <CommentTiny />— 135 135 </div> ··· 213 213 }; 214 214 215 215 export const PublicationMetadataPreview = () => { 216 - let { data: pub } = useLeafletPublicationData(); 217 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 218 - let publishedAt = record?.publishedAt; 216 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 217 + let publishedAt = normalizedDocument?.publishedAt; 219 218 220 219 if (!pub) return null; 221 220 ··· 240 239 }; 241 240 242 241 const AddTags = () => { 243 - let { data: pub } = useLeafletPublicationData(); 242 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 244 243 let { rep } = useReplicache(); 245 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 246 244 247 245 // Get tags from Replicache local state or published document 248 246 let replicacheTags = useSubscribe(rep, (tx) => ··· 253 251 let tags: string[] = []; 254 252 if (Array.isArray(replicacheTags)) { 255 253 tags = replicacheTags; 256 - } else if (record?.tags && Array.isArray(record.tags)) { 257 - tags = record.tags as string[]; 254 + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 255 + tags = normalizedDocument.tags as string[]; 258 256 } 259 257 260 258 // Update tags in replicache local state
+1 -1
components/Popover/index.tsx
··· 43 43 <RadixPopover.Content 44 44 className={` 45 45 z-20 bg-bg-page 46 - px-3 py-2 46 + px-3 py-2 text-primary 47 47 max-w-(--radix-popover-content-available-width) 48 48 max-h-(--radix-popover-content-available-height) 49 49 border border-border rounded-md shadow-md
+14 -6
components/PostListing.tsx
··· 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 9 import { useSmoker } from "components/Toast"; 10 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import type { 12 + NormalizedDocument, 13 + NormalizedPublication, 14 + } from "src/utils/normalizeRecords"; 12 15 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 16 14 17 import Link from "next/link"; ··· 17 20 18 21 export const PostListing = (props: Post) => { 19 22 let pubRecord = props.publication?.pubRecord as 20 - | PubLeafletPublication.Record 23 + | NormalizedPublication 21 24 | undefined; 22 25 23 - let postRecord = props.documents.data as PubLeafletDocument.Record; 26 + let postRecord = props.documents.data as NormalizedDocument | null; 27 + 28 + // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) 29 + if (!postRecord) { 30 + return null; 31 + } 24 32 let postUri = new AtUri(props.documents.uri); 25 33 let uri = props.publication ? props.publication?.uri : props.documents.uri; 26 34 ··· 96 104 quotesCount={quotes} 97 105 commentsCount={comments} 98 106 tags={tags} 99 - showComments={pubRecord?.preferences?.showComments} 100 - showMentions={pubRecord?.preferences?.showMentions} 107 + showComments={pubRecord?.preferences?.showComments !== false} 108 + showMentions={pubRecord?.preferences?.showMentions !== false} 101 109 share 102 110 /> 103 111 </div> ··· 110 118 111 119 const PubInfo = (props: { 112 120 href: string; 113 - pubRecord: PubLeafletPublication.Record; 121 + pubRecord: NormalizedPublication; 114 122 uri: string; 115 123 }) => { 116 124 return (
+8 -8
components/ThemeManager/PubThemeSetter.tsx
··· 1 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 2 5 import { useState } from "react"; 3 6 import { pickers, SectionArrow } from "./ThemeSetter"; 4 7 import { Color } from "react-aria-components"; 5 - import { 6 - PubLeafletPublication, 7 - PubLeafletThemeBackgroundImage, 8 - } from "lexicons/api"; 8 + import { PubLeafletThemeBackgroundImage } from "lexicons/api"; 9 9 import { AtUri } from "@atproto/syntax"; 10 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 11 import { BaseThemeProvider } from "./ThemeProvider"; ··· 35 35 let [openPicker, setOpenPicker] = useState<pickers>("null"); 36 36 let { data, mutate } = usePublicationData(); 37 37 let { publication: pub } = data || {}; 38 - let record = pub?.record as PubLeafletPublication.Record | undefined; 38 + let record = useNormalizedPublicationRecord(); 39 39 let [showPageBackground, setShowPageBackground] = useState( 40 40 !!record?.theme?.showPageBackground, 41 41 ); ··· 246 246 }) => { 247 247 let { data } = usePublicationData(); 248 248 let { publication } = data || {}; 249 - let record = publication?.record as PubLeafletPublication.Record | null; 249 + let record = useNormalizedPublicationRecord(); 250 250 251 251 return ( 252 252 <div ··· 314 314 }) => { 315 315 let { data } = usePublicationData(); 316 316 let { publication } = data || {}; 317 - let record = publication?.record as PubLeafletPublication.Record | null; 317 + let record = useNormalizedPublicationRecord(); 318 318 return ( 319 319 <div 320 320 style={{
+9 -46
components/ThemeManager/PublicationThemeProvider.tsx
··· 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 9 + import { 10 + usePublicationData, 11 + useNormalizedPublicationRecord, 12 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 13 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 - 12 - const PubThemeDefaults = { 13 - backgroundColor: "#FDFCFA", 14 - pageBackground: "#FDFCFA", 15 - primary: "#272727", 16 - accentText: "#FFFFFF", 17 - accentBackground: "#0000FF", 18 - }; 14 + import { PubThemeDefaults } from "./themeDefaults"; 19 15 20 16 // Default page background for standalone leaflets (matches editor default) 21 17 const StandalonePageBackground = "#FFFFFF"; ··· 53 49 }) { 54 50 let { data } = usePublicationData(); 55 51 let { publication: pub } = data || {}; 52 + const normalizedPub = useNormalizedPublicationRecord(); 56 53 return ( 57 54 <PublicationThemeProvider 58 55 pub_creator={pub?.identity_did || ""} 59 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 56 + theme={normalizedPub?.theme} 60 57 > 61 58 <PublicationBackgroundProvider 62 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 59 + theme={normalizedPub?.theme} 63 60 pub_creator={pub?.identity_did || ""} 64 61 > 65 62 {props.children} ··· 171 168 ...localOverrides, 172 169 showPageBackground, 173 170 }; 174 - let newAccentContrast; 175 - let sortedAccents = [newTheme.accent1, newTheme.accent2].sort((a, b) => { 176 - return ( 177 - getColorDifference( 178 - colorToString(b, "rgb"), 179 - colorToString( 180 - showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 181 - "rgb", 182 - ), 183 - ) - 184 - getColorDifference( 185 - colorToString(a, "rgb"), 186 - colorToString( 187 - showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 188 - "rgb", 189 - ), 190 - ) 191 - ); 192 - }); 193 - if ( 194 - getColorDifference( 195 - colorToString(sortedAccents[0], "rgb"), 196 - colorToString(newTheme.primary, "rgb"), 197 - ) < 0.15 && 198 - getColorDifference( 199 - colorToString(sortedAccents[1], "rgb"), 200 - colorToString( 201 - showPageBackground ? newTheme.bgPage : newTheme.bgLeaflet, 202 - "rgb", 203 - ), 204 - ) > 0.08 205 - ) { 206 - newAccentContrast = sortedAccents[1]; 207 - } else newAccentContrast = sortedAccents[0]; 171 + 208 172 return { 209 173 ...newTheme, 210 - accentContrast: newAccentContrast, 211 174 }; 212 175 }, [pubTheme, localOverrides, showPageBackground]); 213 176 return {
+14 -17
components/ThemeManager/ThemeProvider.tsx
··· 21 21 PublicationBackgroundProvider, 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 - import { PubLeafletPublication } from "lexicons/api"; 25 24 import { getColorDifference } from "./themeUtils"; 26 25 27 26 // define a function to set an Aria Color to a CSS Variable in RGB ··· 40 39 children: React.ReactNode; 41 40 className?: string; 42 41 }) { 43 - let { data: pub } = useLeafletPublicationData(); 42 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 44 43 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 45 44 return ( 46 45 <PublicationThemeProvider 47 46 {...props} 48 - theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 47 + theme={normalizedPublication?.theme} 49 48 pub_creator={pub.publications?.identity_did} 50 49 /> 51 50 ); ··· 134 133 // pageBg should inherit from leafletBg 135 134 const bgPage = 136 135 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 137 - // set accent contrast to the accent color that has the highest contrast with the page background 136 + 138 137 let accentContrast; 139 - 140 - //sorting the accents by contrast on background 141 138 let sortedAccents = [accent1, accent2].sort((a, b) => { 139 + // sort accents by contrast against the background 142 140 return ( 143 141 getColorDifference( 144 142 colorToString(b, "rgb"), ··· 150 148 ) 151 149 ); 152 150 }); 153 - 154 - // if the contrast-y accent is too similar to the primary text color, 155 - // and the not contrast-y option is different from the backgrond, 156 - // then use the not contrasty option 157 - 158 151 if ( 152 + // if the contrast-y accent is too similar to text color 159 153 getColorDifference( 160 154 colorToString(sortedAccents[0], "rgb"), 161 155 colorToString(primary, "rgb"), 162 156 ) < 0.15 && 157 + // and if the other accent is different enough from the background 163 158 getColorDifference( 164 159 colorToString(sortedAccents[1], "rgb"), 165 160 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 166 - ) > 0.08 161 + ) > 0.31 167 162 ) { 163 + //then choose the less contrast-y accent 168 164 accentContrast = sortedAccents[1]; 169 - } else accentContrast = sortedAccents[0]; 165 + } else { 166 + // otherwise, choose the more contrast-y option 167 + accentContrast = sortedAccents[0]; 168 + } 170 169 171 170 useEffect(() => { 172 171 if (local) return; ··· 328 327 entityID: string; 329 328 children: React.ReactNode; 330 329 }) => { 331 - let { data: pub } = useLeafletPublicationData(); 330 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 332 331 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 333 332 let backgroundImageRepeat = useEntity( 334 333 props.entityID, ··· 338 337 return ( 339 338 <PublicationBackgroundProvider 340 339 pub_creator={pub?.publications.identity_did || ""} 341 - theme={ 342 - (pub.publications?.record as PubLeafletPublication.Record)?.theme 343 - } 340 + theme={normalizedPublication?.theme} 344 341 > 345 342 {props.children} 346 343 </PublicationBackgroundProvider>
+21
components/ThemeManager/themeDefaults.ts
··· 1 + /** 2 + * Default theme values for publications. 3 + * Shared between client and server code. 4 + */ 5 + 6 + // Hex color defaults 7 + export const PubThemeDefaults = { 8 + backgroundColor: "#FDFCFA", 9 + pageBackground: "#FDFCFA", 10 + primary: "#272727", 11 + accentText: "#FFFFFF", 12 + accentBackground: "#0000FF", 13 + } as const; 14 + 15 + // RGB color defaults (parsed from hex values above) 16 + export const PubThemeDefaultsRGB = { 17 + background: { r: 253, g: 252, b: 250 }, // #FDFCFA 18 + foreground: { r: 39, g: 39, b: 39 }, // #272727 19 + accent: { r: 0, g: 0, b: 255 }, // #0000FF 20 + accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF 21 + } as const;
+50
contexts/DocumentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 4 + 5 + // Derive types from PostPageData 6 + type NonNullPostPageData = NonNullable<PostPageData>; 7 + export type PublicationContext = NonNullPostPageData["publication"]; 8 + export type CommentOnDocument = NonNullPostPageData["comments"][number]; 9 + export type DocumentMention = NonNullPostPageData["mentions"][number]; 10 + export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; 11 + 12 + export type DocumentContextValue = Pick< 13 + NonNullPostPageData, 14 + | "uri" 15 + | "normalizedDocument" 16 + | "normalizedPublication" 17 + | "theme" 18 + | "prevNext" 19 + | "quotesAndMentions" 20 + | "publication" 21 + | "comments" 22 + | "mentions" 23 + | "leafletId" 24 + >; 25 + 26 + const DocumentContext = createContext<DocumentContextValue | null>(null); 27 + 28 + export function useDocument() { 29 + const ctx = useContext(DocumentContext); 30 + if (!ctx) throw new Error("useDocument must be used within DocumentProvider"); 31 + return ctx; 32 + } 33 + 34 + export function useDocumentOptional() { 35 + return useContext(DocumentContext); 36 + } 37 + 38 + export function DocumentProvider({ 39 + children, 40 + value, 41 + }: { 42 + children: React.ReactNode; 43 + value: DocumentContextValue; 44 + }) { 45 + return ( 46 + <DocumentContext.Provider value={value}> 47 + {children} 48 + </DocumentContext.Provider> 49 + ); 50 + }
+35
contexts/LeafletContentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PubLeafletContent } from "lexicons/api"; 4 + 5 + export type Page = PubLeafletContent.Main["pages"][number]; 6 + 7 + export type LeafletContentContextValue = { 8 + pages: Page[]; 9 + }; 10 + 11 + const LeafletContentContext = createContext<LeafletContentContextValue | null>(null); 12 + 13 + export function useLeafletContent() { 14 + const ctx = useContext(LeafletContentContext); 15 + if (!ctx) throw new Error("useLeafletContent must be used within LeafletContentProvider"); 16 + return ctx; 17 + } 18 + 19 + export function useLeafletContentOptional() { 20 + return useContext(LeafletContentContext); 21 + } 22 + 23 + export function LeafletContentProvider({ 24 + children, 25 + value, 26 + }: { 27 + children: React.ReactNode; 28 + value: LeafletContentContextValue; 29 + }) { 30 + return ( 31 + <LeafletContentContext.Provider value={value}> 32 + {children} 33 + </LeafletContentContext.Provider> 34 + ); 35 + }
+79 -24
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, leaflets_to_documents, permission_token_rights, leaflets_in_publications } from "./schema"; 3 3 4 4 export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { ··· 17 17 fields: [identities.home_page], 18 18 references: [permission_tokens.id] 19 19 }), 20 + site_standard_publications: many(site_standard_publications), 21 + site_standard_documents: many(site_standard_documents), 20 22 custom_domains_identity: many(custom_domains, { 21 23 relationName: "custom_domains_identity_identities_email" 22 24 }), ··· 33 35 permission_token_on_homepages: many(permission_token_on_homepage), 34 36 publication_domains: many(publication_domains), 35 37 publication_subscriptions: many(publication_subscriptions), 38 + site_standard_subscriptions: many(site_standard_subscriptions), 36 39 })); 37 40 38 41 export const publicationsRelations = relations(publications, ({one, many}) => ({ ··· 43 46 subscribers_to_publications: many(subscribers_to_publications), 44 47 documents_in_publications: many(documents_in_publications), 45 48 publication_domains: many(publication_domains), 46 - leaflets_in_publications: many(leaflets_in_publications), 47 49 publication_subscriptions: many(publication_subscriptions), 50 + leaflets_in_publications: many(leaflets_in_publications), 48 51 })); 49 52 50 53 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ ··· 62 65 comments_on_documents: many(comments_on_documents), 63 66 documents_in_publications: many(documents_in_publications), 64 67 document_mentions_in_bskies: many(document_mentions_in_bsky), 68 + leaflets_to_documents: many(leaflets_to_documents), 65 69 leaflets_in_publications: many(leaflets_in_publications), 66 70 })); 67 71 ··· 136 140 }), 137 141 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 138 142 permission_token_on_homepages: many(permission_token_on_homepage), 143 + leaflets_to_documents: many(leaflets_to_documents), 144 + permission_token_rights: many(permission_token_rights), 139 145 leaflets_in_publications: many(leaflets_in_publications), 140 - permission_token_rights: many(permission_token_rights), 141 146 })); 142 147 143 148 export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ ··· 147 152 }), 148 153 })); 149 154 155 + export const site_standard_publicationsRelations = relations(site_standard_publications, ({one, many}) => ({ 156 + identity: one(identities, { 157 + fields: [site_standard_publications.identity_did], 158 + references: [identities.atp_did] 159 + }), 160 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 161 + site_standard_subscriptions: many(site_standard_subscriptions), 162 + })); 163 + 150 164 export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 151 165 custom_domain: one(custom_domains, { 152 166 fields: [custom_domain_routes.domain], ··· 179 193 publication_domains: many(publication_domains), 180 194 })); 181 195 196 + export const site_standard_documentsRelations = relations(site_standard_documents, ({one, many}) => ({ 197 + identity: one(identities, { 198 + fields: [site_standard_documents.identity_did], 199 + references: [identities.atp_did] 200 + }), 201 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 202 + })); 203 + 182 204 export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 183 205 entity: one(entities, { 184 206 fields: [email_subscriptions_to_entity.entity], ··· 225 247 }), 226 248 })); 227 249 228 - export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 229 - identity: one(identities, { 230 - fields: [permission_token_on_homepage.identity], 231 - references: [identities.id] 250 + export const site_standard_documents_in_publicationsRelations = relations(site_standard_documents_in_publications, ({one}) => ({ 251 + site_standard_document: one(site_standard_documents, { 252 + fields: [site_standard_documents_in_publications.document], 253 + references: [site_standard_documents.uri] 232 254 }), 233 - permission_token: one(permission_tokens, { 234 - fields: [permission_token_on_homepage.token], 235 - references: [permission_tokens.id] 255 + site_standard_publication: one(site_standard_publications, { 256 + fields: [site_standard_documents_in_publications.publication], 257 + references: [site_standard_publications.uri] 236 258 }), 237 259 })); 238 260 ··· 262 284 document_mentions_in_bskies: many(document_mentions_in_bsky), 263 285 })); 264 286 287 + export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 288 + identity: one(identities, { 289 + fields: [permission_token_on_homepage.identity], 290 + references: [identities.id] 291 + }), 292 + permission_token: one(permission_tokens, { 293 + fields: [permission_token_on_homepage.token], 294 + references: [permission_tokens.id] 295 + }), 296 + })); 297 + 265 298 export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 266 299 custom_domain: one(custom_domains, { 267 300 fields: [publication_domains.domain], ··· 277 310 }), 278 311 })); 279 312 280 - export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 281 - document: one(documents, { 282 - fields: [leaflets_in_publications.doc], 283 - references: [documents.uri] 284 - }), 285 - permission_token: one(permission_tokens, { 286 - fields: [leaflets_in_publications.leaflet], 287 - references: [permission_tokens.id] 313 + export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 314 + identity: one(identities, { 315 + fields: [publication_subscriptions.identity], 316 + references: [identities.atp_did] 288 317 }), 289 318 publication: one(publications, { 290 - fields: [leaflets_in_publications.publication], 319 + fields: [publication_subscriptions.publication], 291 320 references: [publications.uri] 292 321 }), 293 322 })); 294 323 295 - export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 324 + export const site_standard_subscriptionsRelations = relations(site_standard_subscriptions, ({one}) => ({ 296 325 identity: one(identities, { 297 - fields: [publication_subscriptions.identity], 326 + fields: [site_standard_subscriptions.identity], 298 327 references: [identities.atp_did] 299 328 }), 300 - publication: one(publications, { 301 - fields: [publication_subscriptions.publication], 302 - references: [publications.uri] 329 + site_standard_publication: one(site_standard_publications, { 330 + fields: [site_standard_subscriptions.publication], 331 + references: [site_standard_publications.uri] 332 + }), 333 + })); 334 + 335 + export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ 336 + document: one(documents, { 337 + fields: [leaflets_to_documents.document], 338 + references: [documents.uri] 339 + }), 340 + permission_token: one(permission_tokens, { 341 + fields: [leaflets_to_documents.leaflet], 342 + references: [permission_tokens.id] 303 343 }), 304 344 })); 305 345 ··· 311 351 permission_token: one(permission_tokens, { 312 352 fields: [permission_token_rights.token], 313 353 references: [permission_tokens.id] 354 + }), 355 + })); 356 + 357 + export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 358 + document: one(documents, { 359 + fields: [leaflets_in_publications.doc], 360 + references: [documents.uri] 361 + }), 362 + permission_token: one(permission_tokens, { 363 + fields: [leaflets_in_publications.leaflet], 364 + references: [permission_tokens.id] 365 + }), 366 + publication: one(publications, { 367 + fields: [leaflets_in_publications.publication], 368 + references: [publications.uri] 314 369 }), 315 370 }));
+77 -19
drizzle/schema.ts
··· 136 136 export const identities = pgTable("identities", { 137 137 id: uuid("id").defaultRandom().primaryKey().notNull(), 138 138 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 139 - home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 139 + home_page: uuid("home_page").default(sql`create_identity_homepage()`).notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 140 140 email: text("email"), 141 141 atp_did: text("atp_did"), 142 142 interface_state: jsonb("interface_state"), ··· 173 173 } 174 174 }); 175 175 176 + export const site_standard_publications = pgTable("site_standard_publications", { 177 + uri: text("uri").primaryKey().notNull(), 178 + data: jsonb("data").notNull(), 179 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 180 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 181 + }); 182 + 176 183 export const custom_domain_routes = pgTable("custom_domain_routes", { 177 184 id: uuid("id").defaultRandom().primaryKey().notNull(), 178 185 domain: text("domain").notNull().references(() => custom_domains.domain), ··· 186 193 edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 187 194 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 188 195 } 196 + }); 197 + 198 + export const site_standard_documents = pgTable("site_standard_documents", { 199 + uri: text("uri").primaryKey().notNull(), 200 + data: jsonb("data").notNull(), 201 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 202 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 189 203 }); 190 204 191 205 export const custom_domains = pgTable("custom_domains", { ··· 260 274 } 261 275 }); 262 276 263 - export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 264 - token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 265 - identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 266 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 277 + export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { 278 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 279 + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), 280 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 267 281 }, 268 282 (table) => { 269 283 return { 270 - permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 284 + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), 271 285 } 272 286 }); 273 287 ··· 295 309 } 296 310 }); 297 311 312 + export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 313 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 314 + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 315 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 316 + archived: boolean("archived"), 317 + }, 318 + (table) => { 319 + return { 320 + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 321 + } 322 + }); 323 + 298 324 export const publication_domains = pgTable("publication_domains", { 299 325 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 300 326 domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), ··· 308 334 } 309 335 }); 310 336 311 - export const leaflets_in_publications = pgTable("leaflets_in_publications", { 337 + export const publication_subscriptions = pgTable("publication_subscriptions", { 312 338 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 313 - doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 314 - leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 315 - description: text("description").default('').notNull(), 316 - title: text("title").default('').notNull(), 339 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 340 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 341 + record: jsonb("record").notNull(), 342 + uri: text("uri").notNull(), 317 343 }, 318 344 (table) => { 319 345 return { 320 - leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 321 - publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 322 - leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 346 + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 347 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 348 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 323 349 } 324 350 }); 325 351 326 - export const publication_subscriptions = pgTable("publication_subscriptions", { 327 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 352 + export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { 353 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 328 354 identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 329 355 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 330 356 record: jsonb("record").notNull(), ··· 332 358 }, 333 359 (table) => { 334 360 return { 335 - publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 336 - publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 337 - publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 361 + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), 362 + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), 363 + } 364 + }); 365 + 366 + export const leaflets_to_documents = pgTable("leaflets_to_documents", { 367 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 368 + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 369 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 370 + title: text("title").default('').notNull(), 371 + description: text("description").default('').notNull(), 372 + tags: text("tags").default('RRAY[').array(), 373 + cover_image: text("cover_image"), 374 + }, 375 + (table) => { 376 + return { 377 + leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), 338 378 } 339 379 }); 340 380 ··· 352 392 token_idx: index("permission_token_rights_token_idx").on(table.token), 353 393 entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), 354 394 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 395 + } 396 + }); 397 + 398 + export const leaflets_in_publications = pgTable("leaflets_in_publications", { 399 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 400 + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 401 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 402 + description: text("description").default('').notNull(), 403 + title: text("title").default('').notNull(), 404 + archived: boolean("archived"), 405 + tags: text("tags").default('RRAY[').array(), 406 + cover_image: text("cover_image"), 407 + }, 408 + (table) => { 409 + return { 410 + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 411 + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 412 + leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 355 413 } 356 414 });
+8 -5
feeds/index.ts
··· 3 3 import { DidResolver } from "@atproto/identity"; 4 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { PubLeafletDocument } from "lexicons/api"; 6 + import { 7 + normalizeDocumentRecord, 8 + type NormalizedDocument, 9 + } from "src/utils/normalizeRecords"; 7 10 import { inngest } from "app/api/inngest/client"; 8 11 import { AtUri } from "@atproto/api"; 9 12 ··· 112 115 ); 113 116 } 114 117 query = query 115 - .not("data -> postRef", "is", null) 118 + .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 116 119 .order("indexed_at", { ascending: false }) 117 120 .order("uri", { ascending: false }) 118 121 .limit(25); ··· 133 136 cursor: newCursor || cursor, 134 137 feed: posts.flatMap((p) => { 135 138 if (!p.data) return []; 136 - let record = p.data as PubLeafletDocument.Record; 137 - if (!record.postRef) return []; 138 - return { post: record.postRef.uri }; 139 + const normalizedDoc = normalizeDocumentRecord(p.data, p.uri); 140 + if (!normalizedDoc?.bskyPostRef) return []; 141 + return { post: normalizedDoc.bskyPostRef.uri }; 139 142 }), 140 143 }); 141 144 });
+303
lexicons/api/index.ts
··· 38 38 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 39 39 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 40 40 import * as PubLeafletComment from './types/pub/leaflet/comment' 41 + import * as PubLeafletContent from './types/pub/leaflet/content' 41 42 import * as PubLeafletDocument from './types/pub/leaflet/document' 42 43 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 43 44 import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' ··· 48 49 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 49 50 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 50 51 import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 52 + import * as SiteStandardDocument from './types/site/standard/document' 53 + import * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 54 + import * as SiteStandardPublication from './types/site/standard/publication' 55 + import * as SiteStandardThemeBasic from './types/site/standard/theme/basic' 56 + import * as SiteStandardThemeColor from './types/site/standard/theme/color' 51 57 52 58 export * as AppBskyActorProfile from './types/app/bsky/actor/profile' 53 59 export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' ··· 78 84 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 79 85 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 80 86 export * as PubLeafletComment from './types/pub/leaflet/comment' 87 + export * as PubLeafletContent from './types/pub/leaflet/content' 81 88 export * as PubLeafletDocument from './types/pub/leaflet/document' 82 89 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 83 90 export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' ··· 88 95 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 89 96 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 90 97 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 98 + export * as SiteStandardDocument from './types/site/standard/document' 99 + export * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 100 + export * as SiteStandardPublication from './types/site/standard/publication' 101 + export * as SiteStandardThemeBasic from './types/site/standard/theme/basic' 102 + export * as SiteStandardThemeColor from './types/site/standard/theme/color' 91 103 92 104 export const PUB_LEAFLET_PAGES = { 93 105 CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', ··· 106 118 app: AppNS 107 119 com: ComNS 108 120 pub: PubNS 121 + site: SiteNS 109 122 110 123 constructor(options: FetchHandler | FetchHandlerOptions) { 111 124 super(options, schemas) 112 125 this.app = new AppNS(this) 113 126 this.com = new ComNS(this) 114 127 this.pub = new PubNS(this) 128 + this.site = new SiteNS(this) 115 129 } 116 130 117 131 /** @deprecated use `this` instead */ ··· 952 966 ) 953 967 } 954 968 } 969 + 970 + export class SiteNS { 971 + _client: XrpcClient 972 + standard: SiteStandardNS 973 + 974 + constructor(client: XrpcClient) { 975 + this._client = client 976 + this.standard = new SiteStandardNS(client) 977 + } 978 + } 979 + 980 + export class SiteStandardNS { 981 + _client: XrpcClient 982 + document: SiteStandardDocumentRecord 983 + publication: SiteStandardPublicationRecord 984 + graph: SiteStandardGraphNS 985 + theme: SiteStandardThemeNS 986 + 987 + constructor(client: XrpcClient) { 988 + this._client = client 989 + this.graph = new SiteStandardGraphNS(client) 990 + this.theme = new SiteStandardThemeNS(client) 991 + this.document = new SiteStandardDocumentRecord(client) 992 + this.publication = new SiteStandardPublicationRecord(client) 993 + } 994 + } 995 + 996 + export class SiteStandardGraphNS { 997 + _client: XrpcClient 998 + subscription: SiteStandardGraphSubscriptionRecord 999 + 1000 + constructor(client: XrpcClient) { 1001 + this._client = client 1002 + this.subscription = new SiteStandardGraphSubscriptionRecord(client) 1003 + } 1004 + } 1005 + 1006 + export class SiteStandardGraphSubscriptionRecord { 1007 + _client: XrpcClient 1008 + 1009 + constructor(client: XrpcClient) { 1010 + this._client = client 1011 + } 1012 + 1013 + async list( 1014 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1015 + ): Promise<{ 1016 + cursor?: string 1017 + records: { uri: string; value: SiteStandardGraphSubscription.Record }[] 1018 + }> { 1019 + const res = await this._client.call('com.atproto.repo.listRecords', { 1020 + collection: 'site.standard.graph.subscription', 1021 + ...params, 1022 + }) 1023 + return res.data 1024 + } 1025 + 1026 + async get( 1027 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1028 + ): Promise<{ 1029 + uri: string 1030 + cid: string 1031 + value: SiteStandardGraphSubscription.Record 1032 + }> { 1033 + const res = await this._client.call('com.atproto.repo.getRecord', { 1034 + collection: 'site.standard.graph.subscription', 1035 + ...params, 1036 + }) 1037 + return res.data 1038 + } 1039 + 1040 + async create( 1041 + params: OmitKey< 1042 + ComAtprotoRepoCreateRecord.InputSchema, 1043 + 'collection' | 'record' 1044 + >, 1045 + record: Un$Typed<SiteStandardGraphSubscription.Record>, 1046 + headers?: Record<string, string>, 1047 + ): Promise<{ uri: string; cid: string }> { 1048 + const collection = 'site.standard.graph.subscription' 1049 + const res = await this._client.call( 1050 + 'com.atproto.repo.createRecord', 1051 + undefined, 1052 + { collection, ...params, record: { ...record, $type: collection } }, 1053 + { encoding: 'application/json', headers }, 1054 + ) 1055 + return res.data 1056 + } 1057 + 1058 + async put( 1059 + params: OmitKey< 1060 + ComAtprotoRepoPutRecord.InputSchema, 1061 + 'collection' | 'record' 1062 + >, 1063 + record: Un$Typed<SiteStandardGraphSubscription.Record>, 1064 + headers?: Record<string, string>, 1065 + ): Promise<{ uri: string; cid: string }> { 1066 + const collection = 'site.standard.graph.subscription' 1067 + const res = await this._client.call( 1068 + 'com.atproto.repo.putRecord', 1069 + undefined, 1070 + { collection, ...params, record: { ...record, $type: collection } }, 1071 + { encoding: 'application/json', headers }, 1072 + ) 1073 + return res.data 1074 + } 1075 + 1076 + async delete( 1077 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1078 + headers?: Record<string, string>, 1079 + ): Promise<void> { 1080 + await this._client.call( 1081 + 'com.atproto.repo.deleteRecord', 1082 + undefined, 1083 + { collection: 'site.standard.graph.subscription', ...params }, 1084 + { headers }, 1085 + ) 1086 + } 1087 + } 1088 + 1089 + export class SiteStandardThemeNS { 1090 + _client: XrpcClient 1091 + 1092 + constructor(client: XrpcClient) { 1093 + this._client = client 1094 + } 1095 + } 1096 + 1097 + export class SiteStandardDocumentRecord { 1098 + _client: XrpcClient 1099 + 1100 + constructor(client: XrpcClient) { 1101 + this._client = client 1102 + } 1103 + 1104 + async list( 1105 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1106 + ): Promise<{ 1107 + cursor?: string 1108 + records: { uri: string; value: SiteStandardDocument.Record }[] 1109 + }> { 1110 + const res = await this._client.call('com.atproto.repo.listRecords', { 1111 + collection: 'site.standard.document', 1112 + ...params, 1113 + }) 1114 + return res.data 1115 + } 1116 + 1117 + async get( 1118 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1119 + ): Promise<{ uri: string; cid: string; value: SiteStandardDocument.Record }> { 1120 + const res = await this._client.call('com.atproto.repo.getRecord', { 1121 + collection: 'site.standard.document', 1122 + ...params, 1123 + }) 1124 + return res.data 1125 + } 1126 + 1127 + async create( 1128 + params: OmitKey< 1129 + ComAtprotoRepoCreateRecord.InputSchema, 1130 + 'collection' | 'record' 1131 + >, 1132 + record: Un$Typed<SiteStandardDocument.Record>, 1133 + headers?: Record<string, string>, 1134 + ): Promise<{ uri: string; cid: string }> { 1135 + const collection = 'site.standard.document' 1136 + const res = await this._client.call( 1137 + 'com.atproto.repo.createRecord', 1138 + undefined, 1139 + { collection, ...params, record: { ...record, $type: collection } }, 1140 + { encoding: 'application/json', headers }, 1141 + ) 1142 + return res.data 1143 + } 1144 + 1145 + async put( 1146 + params: OmitKey< 1147 + ComAtprotoRepoPutRecord.InputSchema, 1148 + 'collection' | 'record' 1149 + >, 1150 + record: Un$Typed<SiteStandardDocument.Record>, 1151 + headers?: Record<string, string>, 1152 + ): Promise<{ uri: string; cid: string }> { 1153 + const collection = 'site.standard.document' 1154 + const res = await this._client.call( 1155 + 'com.atproto.repo.putRecord', 1156 + undefined, 1157 + { collection, ...params, record: { ...record, $type: collection } }, 1158 + { encoding: 'application/json', headers }, 1159 + ) 1160 + return res.data 1161 + } 1162 + 1163 + async delete( 1164 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1165 + headers?: Record<string, string>, 1166 + ): Promise<void> { 1167 + await this._client.call( 1168 + 'com.atproto.repo.deleteRecord', 1169 + undefined, 1170 + { collection: 'site.standard.document', ...params }, 1171 + { headers }, 1172 + ) 1173 + } 1174 + } 1175 + 1176 + export class SiteStandardPublicationRecord { 1177 + _client: XrpcClient 1178 + 1179 + constructor(client: XrpcClient) { 1180 + this._client = client 1181 + } 1182 + 1183 + async list( 1184 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1185 + ): Promise<{ 1186 + cursor?: string 1187 + records: { uri: string; value: SiteStandardPublication.Record }[] 1188 + }> { 1189 + const res = await this._client.call('com.atproto.repo.listRecords', { 1190 + collection: 'site.standard.publication', 1191 + ...params, 1192 + }) 1193 + return res.data 1194 + } 1195 + 1196 + async get( 1197 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1198 + ): Promise<{ 1199 + uri: string 1200 + cid: string 1201 + value: SiteStandardPublication.Record 1202 + }> { 1203 + const res = await this._client.call('com.atproto.repo.getRecord', { 1204 + collection: 'site.standard.publication', 1205 + ...params, 1206 + }) 1207 + return res.data 1208 + } 1209 + 1210 + async create( 1211 + params: OmitKey< 1212 + ComAtprotoRepoCreateRecord.InputSchema, 1213 + 'collection' | 'record' 1214 + >, 1215 + record: Un$Typed<SiteStandardPublication.Record>, 1216 + headers?: Record<string, string>, 1217 + ): Promise<{ uri: string; cid: string }> { 1218 + const collection = 'site.standard.publication' 1219 + const res = await this._client.call( 1220 + 'com.atproto.repo.createRecord', 1221 + undefined, 1222 + { collection, ...params, record: { ...record, $type: collection } }, 1223 + { encoding: 'application/json', headers }, 1224 + ) 1225 + return res.data 1226 + } 1227 + 1228 + async put( 1229 + params: OmitKey< 1230 + ComAtprotoRepoPutRecord.InputSchema, 1231 + 'collection' | 'record' 1232 + >, 1233 + record: Un$Typed<SiteStandardPublication.Record>, 1234 + headers?: Record<string, string>, 1235 + ): Promise<{ uri: string; cid: string }> { 1236 + const collection = 'site.standard.publication' 1237 + const res = await this._client.call( 1238 + 'com.atproto.repo.putRecord', 1239 + undefined, 1240 + { collection, ...params, record: { ...record, $type: collection } }, 1241 + { encoding: 'application/json', headers }, 1242 + ) 1243 + return res.data 1244 + } 1245 + 1246 + async delete( 1247 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1248 + headers?: Record<string, string>, 1249 + ): Promise<void> { 1250 + await this._client.call( 1251 + 'com.atproto.repo.deleteRecord', 1252 + undefined, 1253 + { collection: 'site.standard.publication', ...params }, 1254 + { headers }, 1255 + ) 1256 + } 1257 + }
+282 -5
lexicons/api/lexicons.ts
··· 1400 1400 }, 1401 1401 }, 1402 1402 }, 1403 + PubLeafletContent: { 1404 + lexicon: 1, 1405 + id: 'pub.leaflet.content', 1406 + revision: 1, 1407 + description: 'A lexicon for long form rich media documents', 1408 + defs: { 1409 + main: { 1410 + type: 'object', 1411 + description: 'Content format for leaflet documents', 1412 + required: ['pages'], 1413 + properties: { 1414 + pages: { 1415 + type: 'array', 1416 + items: { 1417 + type: 'union', 1418 + refs: [ 1419 + 'lex:pub.leaflet.pages.linearDocument', 1420 + 'lex:pub.leaflet.pages.canvas', 1421 + ], 1422 + }, 1423 + }, 1424 + }, 1425 + }, 1426 + }, 1427 + }, 1403 1428 PubLeafletDocument: { 1404 1429 lexicon: 1, 1405 1430 id: 'pub.leaflet.document', ··· 1416 1441 properties: { 1417 1442 title: { 1418 1443 type: 'string', 1419 - maxLength: 1280, 1420 - maxGraphemes: 128, 1444 + maxLength: 5000, 1445 + maxGraphemes: 500, 1421 1446 }, 1422 1447 postRef: { 1423 1448 type: 'ref', ··· 1425 1450 }, 1426 1451 description: { 1427 1452 type: 'string', 1428 - maxLength: 3000, 1429 - maxGraphemes: 300, 1453 + maxLength: 30000, 1454 + maxGraphemes: 3000, 1430 1455 }, 1431 1456 publishedAt: { 1432 1457 type: 'string', ··· 1816 1841 }, 1817 1842 showPrevNext: { 1818 1843 type: 'boolean', 1819 - default: false, 1844 + default: true, 1820 1845 }, 1821 1846 }, 1822 1847 }, ··· 2082 2107 }, 2083 2108 }, 2084 2109 }, 2110 + SiteStandardDocument: { 2111 + defs: { 2112 + main: { 2113 + key: 'tid', 2114 + record: { 2115 + properties: { 2116 + bskyPostRef: { 2117 + ref: 'lex:com.atproto.repo.strongRef', 2118 + type: 'ref', 2119 + }, 2120 + content: { 2121 + closed: false, 2122 + refs: ['lex:pub.leaflet.content'], 2123 + type: 'union', 2124 + }, 2125 + coverImage: { 2126 + accept: ['image/*'], 2127 + maxSize: 1000000, 2128 + type: 'blob', 2129 + }, 2130 + description: { 2131 + maxGraphemes: 3000, 2132 + maxLength: 30000, 2133 + type: 'string', 2134 + }, 2135 + path: { 2136 + description: 2137 + 'combine with the publication url or the document site to construct a full url to the document', 2138 + type: 'string', 2139 + }, 2140 + publishedAt: { 2141 + format: 'datetime', 2142 + type: 'string', 2143 + }, 2144 + site: { 2145 + description: 2146 + 'URI to the site or publication this document belongs to. Supports both AT-URIs (at://did/collection/rkey) for publication references and HTTPS URLs (https://example.com) for standalone documents or external sites.', 2147 + format: 'uri', 2148 + type: 'string', 2149 + }, 2150 + tags: { 2151 + items: { 2152 + maxGraphemes: 50, 2153 + maxLength: 100, 2154 + type: 'string', 2155 + }, 2156 + type: 'array', 2157 + }, 2158 + textContent: { 2159 + type: 'string', 2160 + }, 2161 + theme: { 2162 + description: 2163 + 'Theme for standalone documents. For documents in publications, theme is inherited from the publication.', 2164 + ref: 'lex:pub.leaflet.publication#theme', 2165 + type: 'ref', 2166 + }, 2167 + title: { 2168 + maxGraphemes: 500, 2169 + maxLength: 5000, 2170 + type: 'string', 2171 + }, 2172 + updatedAt: { 2173 + format: 'datetime', 2174 + type: 'string', 2175 + }, 2176 + }, 2177 + required: ['site', 'title', 'publishedAt'], 2178 + type: 'object', 2179 + }, 2180 + type: 'record', 2181 + }, 2182 + }, 2183 + id: 'site.standard.document', 2184 + lexicon: 1, 2185 + }, 2186 + SiteStandardGraphSubscription: { 2187 + defs: { 2188 + main: { 2189 + description: 'Record declaring a subscription to a publication', 2190 + key: 'tid', 2191 + record: { 2192 + properties: { 2193 + publication: { 2194 + format: 'at-uri', 2195 + type: 'string', 2196 + }, 2197 + }, 2198 + required: ['publication'], 2199 + type: 'object', 2200 + }, 2201 + type: 'record', 2202 + }, 2203 + }, 2204 + id: 'site.standard.graph.subscription', 2205 + lexicon: 1, 2206 + }, 2207 + SiteStandardPublication: { 2208 + defs: { 2209 + main: { 2210 + key: 'tid', 2211 + record: { 2212 + properties: { 2213 + basicTheme: { 2214 + ref: 'lex:site.standard.theme.basic', 2215 + type: 'ref', 2216 + }, 2217 + theme: { 2218 + type: 'ref', 2219 + ref: 'lex:pub.leaflet.publication#theme', 2220 + }, 2221 + description: { 2222 + maxGraphemes: 300, 2223 + maxLength: 3000, 2224 + type: 'string', 2225 + }, 2226 + icon: { 2227 + accept: ['image/*'], 2228 + maxSize: 1000000, 2229 + type: 'blob', 2230 + }, 2231 + name: { 2232 + maxGraphemes: 128, 2233 + maxLength: 1280, 2234 + type: 'string', 2235 + }, 2236 + preferences: { 2237 + ref: 'lex:site.standard.publication#preferences', 2238 + type: 'ref', 2239 + }, 2240 + url: { 2241 + format: 'uri', 2242 + type: 'string', 2243 + }, 2244 + }, 2245 + required: ['url', 'name'], 2246 + type: 'object', 2247 + }, 2248 + type: 'record', 2249 + }, 2250 + preferences: { 2251 + properties: { 2252 + showInDiscover: { 2253 + default: true, 2254 + type: 'boolean', 2255 + }, 2256 + showComments: { 2257 + default: true, 2258 + type: 'boolean', 2259 + }, 2260 + showMentions: { 2261 + default: true, 2262 + type: 'boolean', 2263 + }, 2264 + showPrevNext: { 2265 + default: false, 2266 + type: 'boolean', 2267 + }, 2268 + }, 2269 + type: 'object', 2270 + }, 2271 + }, 2272 + id: 'site.standard.publication', 2273 + lexicon: 1, 2274 + }, 2275 + SiteStandardThemeBasic: { 2276 + defs: { 2277 + main: { 2278 + properties: { 2279 + accent: { 2280 + refs: ['lex:site.standard.theme.color#rgb'], 2281 + type: 'union', 2282 + }, 2283 + accentForeground: { 2284 + refs: ['lex:site.standard.theme.color#rgb'], 2285 + type: 'union', 2286 + }, 2287 + background: { 2288 + refs: ['lex:site.standard.theme.color#rgb'], 2289 + type: 'union', 2290 + }, 2291 + foreground: { 2292 + refs: ['lex:site.standard.theme.color#rgb'], 2293 + type: 'union', 2294 + }, 2295 + }, 2296 + required: ['background', 'foreground', 'accent', 'accentForeground'], 2297 + type: 'object', 2298 + }, 2299 + }, 2300 + id: 'site.standard.theme.basic', 2301 + lexicon: 1, 2302 + }, 2303 + SiteStandardThemeColor: { 2304 + lexicon: 1, 2305 + id: 'site.standard.theme.color', 2306 + defs: { 2307 + rgb: { 2308 + type: 'object', 2309 + required: ['r', 'g', 'b'], 2310 + properties: { 2311 + r: { 2312 + type: 'integer', 2313 + minimum: 0, 2314 + maximum: 255, 2315 + }, 2316 + g: { 2317 + type: 'integer', 2318 + minimum: 0, 2319 + maximum: 255, 2320 + }, 2321 + b: { 2322 + type: 'integer', 2323 + minimum: 0, 2324 + maximum: 255, 2325 + }, 2326 + }, 2327 + }, 2328 + rgba: { 2329 + type: 'object', 2330 + required: ['r', 'g', 'b', 'a'], 2331 + properties: { 2332 + r: { 2333 + type: 'integer', 2334 + minimum: 0, 2335 + maximum: 255, 2336 + }, 2337 + g: { 2338 + type: 'integer', 2339 + minimum: 0, 2340 + maximum: 255, 2341 + }, 2342 + b: { 2343 + type: 'integer', 2344 + minimum: 0, 2345 + maximum: 255, 2346 + }, 2347 + a: { 2348 + type: 'integer', 2349 + minimum: 0, 2350 + maximum: 100, 2351 + }, 2352 + }, 2353 + }, 2354 + }, 2355 + }, 2085 2356 } as const satisfies Record<string, LexiconDoc> 2086 2357 export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 2087 2358 export const lexicons: Lexicons = new Lexicons(schemas) ··· 2144 2415 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 2145 2416 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 2146 2417 PubLeafletComment: 'pub.leaflet.comment', 2418 + PubLeafletContent: 'pub.leaflet.content', 2147 2419 PubLeafletDocument: 'pub.leaflet.document', 2148 2420 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2149 2421 PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', ··· 2154 2426 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 2155 2427 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 2156 2428 PubLeafletThemeColor: 'pub.leaflet.theme.color', 2429 + SiteStandardDocument: 'site.standard.document', 2430 + SiteStandardGraphSubscription: 'site.standard.graph.subscription', 2431 + SiteStandardPublication: 'site.standard.publication', 2432 + SiteStandardThemeBasic: 'site.standard.theme.basic', 2433 + SiteStandardThemeColor: 'site.standard.theme.color', 2157 2434 } as const
+33
lexicons/api/types/pub/leaflet/content.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 9 + import type * as PubLeafletPagesCanvas from './pages/canvas' 10 + 11 + const is$typed = _is$typed, 12 + validate = _validate 13 + const id = 'pub.leaflet.content' 14 + 15 + /** Content format for leaflet documents */ 16 + export interface Main { 17 + $type?: 'pub.leaflet.content' 18 + pages: ( 19 + | $Typed<PubLeafletPagesLinearDocument.Main> 20 + | $Typed<PubLeafletPagesCanvas.Main> 21 + | { $type: string } 22 + )[] 23 + } 24 + 25 + const hashMain = 'main' 26 + 27 + export function isMain<V>(v: V) { 28 + return is$typed(v, id, hashMain) 29 + } 30 + 31 + export function validateMain<V>(v: V) { 32 + return validate<Main & V>(v, id, hashMain) 33 + }
+43
lexicons/api/types/site/standard/document.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletContent from '../../pub/leaflet/content' 10 + import type * as PubLeafletPublication from '../../pub/leaflet/publication' 11 + 12 + const is$typed = _is$typed, 13 + validate = _validate 14 + const id = 'site.standard.document' 15 + 16 + export interface Record { 17 + $type: 'site.standard.document' 18 + bskyPostRef?: ComAtprotoRepoStrongRef.Main 19 + content?: $Typed<PubLeafletContent.Main> | { $type: string } 20 + coverImage?: BlobRef 21 + description?: string 22 + /** combine with the publication url or the document site to construct a full url to the document */ 23 + path?: string 24 + publishedAt: string 25 + /** URI to the site or publication this document belongs to. Supports both AT-URIs (at://did/collection/rkey) for publication references and HTTPS URLs (https://example.com) for standalone documents or external sites. */ 26 + site: string 27 + tags?: string[] 28 + textContent?: string 29 + theme?: PubLeafletPublication.Theme 30 + title: string 31 + updatedAt?: string 32 + [k: string]: unknown 33 + } 34 + 35 + const hashRecord = 'main' 36 + 37 + export function isRecord<V>(v: V) { 38 + return is$typed(v, id, hashRecord) 39 + } 40 + 41 + export function validateRecord<V>(v: V) { 42 + return validate<Record & V>(v, id, hashRecord, true) 43 + }
+31
lexicons/api/types/site/standard/graph/subscription.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'site.standard.graph.subscription' 16 + 17 + export interface Record { 18 + $type: 'site.standard.graph.subscription' 19 + publication: string 20 + [k: string]: unknown 21 + } 22 + 23 + const hashRecord = 'main' 24 + 25 + export function isRecord<V>(v: V) { 26 + return is$typed(v, id, hashRecord) 27 + } 28 + 29 + export function validateRecord<V>(v: V) { 30 + return validate<Record & V>(v, id, hashRecord, true) 31 + }
+53
lexicons/api/types/site/standard/publication.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as SiteStandardThemeBasic from './theme/basic' 9 + import type * as PubLeafletPublication from '../../pub/leaflet/publication' 10 + 11 + const is$typed = _is$typed, 12 + validate = _validate 13 + const id = 'site.standard.publication' 14 + 15 + export interface Record { 16 + $type: 'site.standard.publication' 17 + basicTheme?: SiteStandardThemeBasic.Main 18 + theme?: PubLeafletPublication.Theme 19 + description?: string 20 + icon?: BlobRef 21 + name: string 22 + preferences?: Preferences 23 + url: string 24 + [k: string]: unknown 25 + } 26 + 27 + const hashRecord = 'main' 28 + 29 + export function isRecord<V>(v: V) { 30 + return is$typed(v, id, hashRecord) 31 + } 32 + 33 + export function validateRecord<V>(v: V) { 34 + return validate<Record & V>(v, id, hashRecord, true) 35 + } 36 + 37 + export interface Preferences { 38 + $type?: 'site.standard.publication#preferences' 39 + showInDiscover: boolean 40 + showComments: boolean 41 + showMentions: boolean 42 + showPrevNext: boolean 43 + } 44 + 45 + const hashPreferences = 'preferences' 46 + 47 + export function isPreferences<V>(v: V) { 48 + return is$typed(v, id, hashPreferences) 49 + } 50 + 51 + export function validatePreferences<V>(v: V) { 52 + return validate<Preferences & V>(v, id, hashPreferences) 53 + }
+34
lexicons/api/types/site/standard/theme/basic.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as SiteStandardThemeColor from './color' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'site.standard.theme.basic' 17 + 18 + export interface Main { 19 + $type?: 'site.standard.theme.basic' 20 + accent: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 21 + accentForeground: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 22 + background: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 23 + foreground: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 24 + } 25 + 26 + const hashMain = 'main' 27 + 28 + export function isMain<V>(v: V) { 29 + return is$typed(v, id, hashMain) 30 + } 31 + 32 + export function validateMain<V>(v: V) { 33 + return validate<Main & V>(v, id, hashMain) 34 + }
+50
lexicons/api/types/site/standard/theme/color.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'site.standard.theme.color' 16 + 17 + export interface Rgb { 18 + $type?: 'site.standard.theme.color#rgb' 19 + r: number 20 + g: number 21 + b: number 22 + } 23 + 24 + const hashRgb = 'rgb' 25 + 26 + export function isRgb<V>(v: V) { 27 + return is$typed(v, id, hashRgb) 28 + } 29 + 30 + export function validateRgb<V>(v: V) { 31 + return validate<Rgb & V>(v, id, hashRgb) 32 + } 33 + 34 + export interface Rgba { 35 + $type?: 'site.standard.theme.color#rgba' 36 + r: number 37 + g: number 38 + b: number 39 + a: number 40 + } 41 + 42 + const hashRgba = 'rgba' 43 + 44 + export function isRgba<V>(v: V) { 45 + return is$typed(v, id, hashRgba) 46 + } 47 + 48 + export function validateRgba<V>(v: V) { 49 + return validate<Rgba & V>(v, id, hashRgba) 50 + }
+2
lexicons/build.ts
··· 10 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 11 import { PubLeafletComment } from "./src/comment"; 12 12 import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 13 + import { PubLeafletContent } from "./src/content"; 13 14 14 15 const outdir = path.join("lexicons", "pub", "leaflet"); 15 16 ··· 20 21 21 22 const lexicons = [ 22 23 PubLeafletDocument, 24 + PubLeafletContent, 23 25 PubLeafletComment, 24 26 PubLeafletRichTextFacet, 25 27 PubLeafletAuthFullPermissions,
+27
lexicons/pub/leaflet/content.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.content", 4 + "revision": 1, 5 + "description": "A lexicon for long form rich media documents", 6 + "defs": { 7 + "main": { 8 + "type": "object", 9 + "description": "Content format for leaflet documents", 10 + "required": [ 11 + "pages" 12 + ], 13 + "properties": { 14 + "pages": { 15 + "type": "array", 16 + "items": { 17 + "type": "union", 18 + "refs": [ 19 + "pub.leaflet.pages.linearDocument", 20 + "pub.leaflet.pages.canvas" 21 + ] 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+4 -4
lexicons/pub/leaflet/document.json
··· 18 18 "properties": { 19 19 "title": { 20 20 "type": "string", 21 - "maxLength": 1280, 22 - "maxGraphemes": 128 21 + "maxLength": 5000, 22 + "maxGraphemes": 500 23 23 }, 24 24 "postRef": { 25 25 "type": "ref", ··· 27 27 }, 28 28 "description": { 29 29 "type": "string", 30 - "maxLength": 3000, 31 - "maxGraphemes": 300 30 + "maxLength": 30000, 31 + "maxGraphemes": 3000 32 32 }, 33 33 "publishedAt": { 34 34 "type": "string",
+1 -1
lexicons/pub/leaflet/publication.json
··· 58 58 }, 59 59 "showPrevNext": { 60 60 "type": "boolean", 61 - "default": false 61 + "default": true 62 62 } 63 63 } 64 64 },
+73
lexicons/site/standard/document.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "key": "tid", 5 + "record": { 6 + "properties": { 7 + "bskyPostRef": { 8 + "ref": "com.atproto.repo.strongRef", 9 + "type": "ref" 10 + }, 11 + "content": { 12 + "closed": false, 13 + "refs": ["pub.leaflet.content"], 14 + "type": "union" 15 + }, 16 + "coverImage": { 17 + "accept": ["image/*"], 18 + "maxSize": 1000000, 19 + "type": "blob" 20 + }, 21 + "description": { 22 + "maxGraphemes": 3000, 23 + "maxLength": 30000, 24 + "type": "string" 25 + }, 26 + "path": { 27 + "description": "combine with the publication url or the document site to construct a full url to the document", 28 + "type": "string" 29 + }, 30 + "publishedAt": { 31 + "format": "datetime", 32 + "type": "string" 33 + }, 34 + "site": { 35 + "description": "URI to the site or publication this document belongs to. Supports both AT-URIs (at://did/collection/rkey) for publication references and HTTPS URLs (https://example.com) for standalone documents or external sites.", 36 + "format": "uri", 37 + "type": "string" 38 + }, 39 + "tags": { 40 + "items": { 41 + "maxGraphemes": 50, 42 + "maxLength": 100, 43 + "type": "string" 44 + }, 45 + "type": "array" 46 + }, 47 + "textContent": { 48 + "type": "string" 49 + }, 50 + "theme": { 51 + "description": "Theme for standalone documents. For documents in publications, theme is inherited from the publication.", 52 + "ref": "pub.leaflet.publication#theme", 53 + "type": "ref" 54 + }, 55 + "title": { 56 + "maxGraphemes": 500, 57 + "maxLength": 5000, 58 + "type": "string" 59 + }, 60 + "updatedAt": { 61 + "format": "datetime", 62 + "type": "string" 63 + } 64 + }, 65 + "required": ["site", "title", "publishedAt"], 66 + "type": "object" 67 + }, 68 + "type": "record" 69 + } 70 + }, 71 + "id": "site.standard.document", 72 + "lexicon": 1 73 + }
+23
lexicons/site/standard/graph/subscription.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "description": "Record declaring a subscription to a publication", 5 + "key": "tid", 6 + "record": { 7 + "properties": { 8 + "publication": { 9 + "format": "at-uri", 10 + "type": "string" 11 + } 12 + }, 13 + "required": [ 14 + "publication" 15 + ], 16 + "type": "object" 17 + }, 18 + "type": "record" 19 + } 20 + }, 21 + "id": "site.standard.graph.subscription", 22 + "lexicon": 1 23 + }
+68
lexicons/site/standard/publication.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "key": "tid", 5 + "record": { 6 + "properties": { 7 + "basicTheme": { 8 + "ref": "site.standard.theme.basic", 9 + "type": "ref" 10 + }, 11 + "theme": { 12 + "type": "ref", 13 + "ref": "pub.leaflet.publication#theme" 14 + }, 15 + "description": { 16 + "maxGraphemes": 300, 17 + "maxLength": 3000, 18 + "type": "string" 19 + }, 20 + "icon": { 21 + "accept": ["image/*"], 22 + "maxSize": 1000000, 23 + "type": "blob" 24 + }, 25 + "name": { 26 + "maxGraphemes": 128, 27 + "maxLength": 1280, 28 + "type": "string" 29 + }, 30 + "preferences": { 31 + "ref": "#preferences", 32 + "type": "ref" 33 + }, 34 + "url": { 35 + "format": "uri", 36 + "type": "string" 37 + } 38 + }, 39 + "required": ["url", "name"], 40 + "type": "object" 41 + }, 42 + "type": "record" 43 + }, 44 + "preferences": { 45 + "properties": { 46 + "showInDiscover": { 47 + "default": true, 48 + "type": "boolean" 49 + }, 50 + "showComments": { 51 + "default": true, 52 + "type": "boolean" 53 + }, 54 + "showMentions": { 55 + "default": true, 56 + "type": "boolean" 57 + }, 58 + "showPrevNext": { 59 + "default": false, 60 + "type": "boolean" 61 + } 62 + }, 63 + "type": "object" 64 + } 65 + }, 66 + "id": "site.standard.publication", 67 + "lexicon": 1 68 + }
+41
lexicons/site/standard/theme/basic.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "properties": { 5 + "accent": { 6 + "refs": [ 7 + "site.standard.theme.color#rgb" 8 + ], 9 + "type": "union" 10 + }, 11 + "accentForeground": { 12 + "refs": [ 13 + "site.standard.theme.color#rgb" 14 + ], 15 + "type": "union" 16 + }, 17 + "background": { 18 + "refs": [ 19 + "site.standard.theme.color#rgb" 20 + ], 21 + "type": "union" 22 + }, 23 + "foreground": { 24 + "refs": [ 25 + "site.standard.theme.color#rgb" 26 + ], 27 + "type": "union" 28 + } 29 + }, 30 + "required": [ 31 + "background", 32 + "foreground", 33 + "accent", 34 + "accentForeground" 35 + ], 36 + "type": "object" 37 + } 38 + }, 39 + "id": "site.standard.theme.basic", 40 + "lexicon": 1 41 + }
+53
lexicons/site/standard/theme/color.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "site.standard.theme.color", 4 + "defs": { 5 + "rgb": { 6 + "type": "object", 7 + "required": ["r", "g", "b"], 8 + "properties": { 9 + "r": { 10 + "type": "integer", 11 + "minimum": 0, 12 + "maximum": 255 13 + }, 14 + "g": { 15 + "type": "integer", 16 + "minimum": 0, 17 + "maximum": 255 18 + }, 19 + "b": { 20 + "type": "integer", 21 + "minimum": 0, 22 + "maximum": 255 23 + } 24 + } 25 + }, 26 + "rgba": { 27 + "type": "object", 28 + "required": ["r", "g", "b", "a"], 29 + "properties": { 30 + "r": { 31 + "type": "integer", 32 + "minimum": 0, 33 + "maximum": 255 34 + }, 35 + "g": { 36 + "type": "integer", 37 + "minimum": 0, 38 + "maximum": 255 39 + }, 40 + "b": { 41 + "type": "integer", 42 + "minimum": 0, 43 + "maximum": 255 44 + }, 45 + "a": { 46 + "type": "integer", 47 + "minimum": 0, 48 + "maximum": 100 49 + } 50 + } 51 + } 52 + } 53 + }
+29
lexicons/src/content.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 + import { PubLeafletPagesCanvasDocument } from "./pages"; 4 + 5 + export const PubLeafletContent: LexiconDoc = { 6 + lexicon: 1, 7 + id: "pub.leaflet.content", 8 + revision: 1, 9 + description: "A lexicon for long form rich media documents", 10 + defs: { 11 + main: { 12 + type: "object", 13 + description: "Content format for leaflet documents", 14 + required: ["pages"], 15 + properties: { 16 + pages: { 17 + type: "array", 18 + items: { 19 + type: "union", 20 + refs: [ 21 + PubLeafletPagesLinearDocument.id, 22 + PubLeafletPagesCanvasDocument.id, 23 + ], 24 + }, 25 + }, 26 + }, 27 + }, 28 + }, 29 + };
+2 -2
lexicons/src/document.ts
··· 16 16 type: "object", 17 17 required: ["pages", "author", "title"], 18 18 properties: { 19 - title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 19 + title: { type: "string", maxLength: 5000, maxGraphemes: 500 }, 20 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 21 - description: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 21 + description: { type: "string", maxLength: 30000, maxGraphemes: 3000 }, 22 22 publishedAt: { type: "string", format: "datetime" }, 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" },
+282
lexicons/src/normalize.ts
··· 1 + /** 2 + * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats. 3 + * 4 + * The standard format (site.standard.*) is used as the canonical representation for 5 + * reading data from the database, while both formats are accepted for storage. 6 + * 7 + * ## Site Field Format 8 + * 9 + * The `site` field in site.standard.document supports two URI formats: 10 + * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication 11 + * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites 12 + * 13 + * Both formats are valid and should be handled by consumers. 14 + */ 15 + 16 + import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 + import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 + import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 + import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 + import type * as SiteStandardPublication from "../api/types/site/standard/publication"; 21 + import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; 22 + import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; 23 + import type { $Typed } from "../api/util"; 24 + import { AtUri } from "@atproto/syntax"; 25 + 26 + // Normalized document type - uses the generated site.standard.document type 27 + // with an additional optional theme field for backwards compatibility 28 + export type NormalizedDocument = SiteStandardDocument.Record & { 29 + // Keep the original theme for components that need leaflet-specific styling 30 + theme?: PubLeafletPublication.Theme; 31 + }; 32 + 33 + // Normalized publication type - uses the generated site.standard.publication type 34 + export type NormalizedPublication = SiteStandardPublication.Record; 35 + 36 + /** 37 + * Checks if the record is a pub.leaflet.document 38 + */ 39 + export function isLeafletDocument( 40 + record: unknown 41 + ): record is PubLeafletDocument.Record { 42 + if (!record || typeof record !== "object") return false; 43 + const r = record as Record<string, unknown>; 44 + return ( 45 + r.$type === "pub.leaflet.document" || 46 + // Legacy records without $type but with pages array 47 + (Array.isArray(r.pages) && typeof r.author === "string") 48 + ); 49 + } 50 + 51 + /** 52 + * Checks if the record is a site.standard.document 53 + */ 54 + export function isStandardDocument( 55 + record: unknown 56 + ): record is SiteStandardDocument.Record { 57 + if (!record || typeof record !== "object") return false; 58 + const r = record as Record<string, unknown>; 59 + return r.$type === "site.standard.document"; 60 + } 61 + 62 + /** 63 + * Checks if the record is a pub.leaflet.publication 64 + */ 65 + export function isLeafletPublication( 66 + record: unknown 67 + ): record is PubLeafletPublication.Record { 68 + if (!record || typeof record !== "object") return false; 69 + const r = record as Record<string, unknown>; 70 + return ( 71 + r.$type === "pub.leaflet.publication" || 72 + // Legacy records without $type but with name and no url 73 + (typeof r.name === "string" && !("url" in r)) 74 + ); 75 + } 76 + 77 + /** 78 + * Checks if the record is a site.standard.publication 79 + */ 80 + export function isStandardPublication( 81 + record: unknown 82 + ): record is SiteStandardPublication.Record { 83 + if (!record || typeof record !== "object") return false; 84 + const r = record as Record<string, unknown>; 85 + return r.$type === "site.standard.publication"; 86 + } 87 + 88 + /** 89 + * Extracts RGB values from a color union type 90 + */ 91 + function extractRgb( 92 + color: 93 + | $Typed<PubLeafletThemeColor.Rgba> 94 + | $Typed<PubLeafletThemeColor.Rgb> 95 + | { $type: string } 96 + | undefined 97 + ): { r: number; g: number; b: number } | undefined { 98 + if (!color || typeof color !== "object") return undefined; 99 + const c = color as Record<string, unknown>; 100 + if ( 101 + typeof c.r === "number" && 102 + typeof c.g === "number" && 103 + typeof c.b === "number" 104 + ) { 105 + return { r: c.r, g: c.g, b: c.b }; 106 + } 107 + return undefined; 108 + } 109 + 110 + /** 111 + * Converts a pub.leaflet theme to a site.standard.theme.basic format 112 + */ 113 + export function leafletThemeToBasicTheme( 114 + theme: PubLeafletPublication.Theme | undefined 115 + ): SiteStandardThemeBasic.Main | undefined { 116 + if (!theme) return undefined; 117 + 118 + const background = extractRgb(theme.backgroundColor); 119 + const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 120 + const accentForeground = extractRgb(theme.accentText); 121 + 122 + // If we don't have the required colors, return undefined 123 + if (!background || !accent) return undefined; 124 + 125 + // Default foreground to dark if not specified 126 + const foreground = { r: 0, g: 0, b: 0 }; 127 + 128 + // Default accent foreground to white if not specified 129 + const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; 130 + 131 + return { 132 + $type: "site.standard.theme.basic", 133 + background: { $type: "site.standard.theme.color#rgb", ...background }, 134 + foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, 135 + accent: { $type: "site.standard.theme.color#rgb", ...accent }, 136 + accentForeground: { 137 + $type: "site.standard.theme.color#rgb", 138 + ...finalAccentForeground, 139 + }, 140 + }; 141 + } 142 + 143 + /** 144 + * Normalizes a document record from either format to the standard format. 145 + * 146 + * @param record - The document record from the database (either pub.leaflet or site.standard) 147 + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 148 + * @returns A normalized document in site.standard format, or null if invalid/unrecognized 149 + */ 150 + export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 151 + if (!record || typeof record !== "object") return null; 152 + 153 + // Pass through site.standard records directly (theme is already in correct format if present) 154 + if (isStandardDocument(record)) { 155 + return { 156 + ...record, 157 + theme: record.theme, 158 + } as NormalizedDocument; 159 + } 160 + 161 + if (isLeafletDocument(record)) { 162 + // Convert from pub.leaflet to site.standard 163 + const publishedAt = record.publishedAt; 164 + 165 + if (!publishedAt) { 166 + return null; 167 + } 168 + 169 + // For standalone documents (no publication), construct a site URL from the author 170 + // This matches the pattern used in publishToPublication.ts for new standalone docs 171 + const site = record.publication || `https://leaflet.pub/p/${record.author}`; 172 + 173 + // Extract path from URI if available 174 + const path = uri ? new AtUri(uri).rkey : undefined; 175 + 176 + // Wrap pages in pub.leaflet.content structure 177 + const content: $Typed<PubLeafletContent.Main> | undefined = record.pages 178 + ? { 179 + $type: "pub.leaflet.content" as const, 180 + pages: record.pages, 181 + } 182 + : undefined; 183 + 184 + return { 185 + $type: "site.standard.document", 186 + title: record.title, 187 + site, 188 + path, 189 + publishedAt, 190 + description: record.description, 191 + tags: record.tags, 192 + coverImage: record.coverImage, 193 + bskyPostRef: record.postRef, 194 + content, 195 + theme: record.theme, 196 + }; 197 + } 198 + 199 + return null; 200 + } 201 + 202 + /** 203 + * Normalizes a publication record from either format to the standard format. 204 + * 205 + * @param record - The publication record from the database (either pub.leaflet or site.standard) 206 + * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 207 + */ 208 + export function normalizePublication( 209 + record: unknown 210 + ): NormalizedPublication | null { 211 + if (!record || typeof record !== "object") return null; 212 + 213 + // Pass through site.standard records directly 214 + if (isStandardPublication(record)) { 215 + return record; 216 + } 217 + 218 + if (isLeafletPublication(record)) { 219 + // Convert from pub.leaflet to site.standard 220 + const url = record.base_path ? `https://${record.base_path}` : undefined; 221 + 222 + if (!url) { 223 + return null; 224 + } 225 + 226 + const basicTheme = leafletThemeToBasicTheme(record.theme); 227 + 228 + // Convert preferences to site.standard format (strip/replace $type) 229 + const preferences: SiteStandardPublication.Preferences | undefined = 230 + record.preferences 231 + ? { 232 + showInDiscover: record.preferences.showInDiscover, 233 + showComments: record.preferences.showComments, 234 + showMentions: record.preferences.showMentions, 235 + showPrevNext: record.preferences.showPrevNext, 236 + } 237 + : undefined; 238 + 239 + return { 240 + $type: "site.standard.publication", 241 + name: record.name, 242 + url, 243 + description: record.description, 244 + icon: record.icon, 245 + basicTheme, 246 + theme: record.theme, 247 + preferences, 248 + }; 249 + } 250 + 251 + return null; 252 + } 253 + 254 + /** 255 + * Type guard to check if a normalized document has leaflet content 256 + */ 257 + export function hasLeafletContent( 258 + doc: NormalizedDocument 259 + ): doc is NormalizedDocument & { 260 + content: $Typed<PubLeafletContent.Main>; 261 + } { 262 + return ( 263 + doc.content !== undefined && 264 + (doc.content as { $type?: string }).$type === "pub.leaflet.content" 265 + ); 266 + } 267 + 268 + /** 269 + * Gets the pages array from a normalized document, handling both formats 270 + */ 271 + export function getDocumentPages( 272 + doc: NormalizedDocument 273 + ): PubLeafletContent.Main["pages"] | undefined { 274 + if (!doc.content) return undefined; 275 + 276 + if (hasLeafletContent(doc)) { 277 + return doc.content.pages; 278 + } 279 + 280 + // Unknown content type 281 + return undefined; 282 + }
+1 -1
lexicons/src/publication.ts
··· 28 28 showInDiscover: { type: "boolean", default: true }, 29 29 showComments: { type: "boolean", default: true }, 30 30 showMentions: { type: "boolean", default: true }, 31 - showPrevNext: { type: "boolean", default: false }, 31 + showPrevNext: { type: "boolean", default: true }, 32 32 }, 33 33 }, 34 34 theme: {
+403 -29
package-lock.json
··· 16 16 "@atproto/oauth-client-node": "^0.3.8", 17 17 "@atproto/sync": "^0.1.34", 18 18 "@atproto/syntax": "^0.3.3", 19 + "@atproto/tap": "^0.1.1", 19 20 "@atproto/xrpc": "^0.7.5", 20 21 "@atproto/xrpc-server": "^0.9.5", 21 22 "@hono/node-server": "^1.14.3", ··· 264 265 } 265 266 }, 266 267 "node_modules/@atproto/common-web": { 267 - "version": "0.4.3", 268 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", 269 - "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 268 + "version": "0.4.10", 269 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.10.tgz", 270 + "integrity": "sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==", 270 271 "license": "MIT", 271 272 "dependencies": { 272 - "graphemer": "^1.4.0", 273 - "multiformats": "^9.9.0", 274 - "uint8arrays": "3.0.0", 273 + "@atproto/lex-data": "0.0.6", 274 + "@atproto/lex-json": "0.0.6", 275 275 "zod": "^3.23.8" 276 276 } 277 277 }, 278 - "node_modules/@atproto/common-web/node_modules/multiformats": { 279 - "version": "9.9.0", 280 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 281 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 282 - "license": "(Apache-2.0 AND MIT)" 283 - }, 284 278 "node_modules/@atproto/common/node_modules/multiformats": { 285 279 "version": "9.9.0", 286 280 "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", ··· 288 282 "license": "(Apache-2.0 AND MIT)" 289 283 }, 290 284 "node_modules/@atproto/crypto": { 291 - "version": "0.4.4", 292 - "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.4.tgz", 293 - "integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==", 285 + "version": "0.4.5", 286 + "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz", 287 + "integrity": "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==", 294 288 "license": "MIT", 295 289 "dependencies": { 296 290 "@noble/curves": "^1.7.0", ··· 360 354 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 361 355 "license": "(Apache-2.0 AND MIT)" 362 356 }, 357 + "node_modules/@atproto/lex": { 358 + "version": "0.0.9", 359 + "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz", 360 + "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==", 361 + "license": "MIT", 362 + "dependencies": { 363 + "@atproto/lex-builder": "0.0.9", 364 + "@atproto/lex-client": "0.0.7", 365 + "@atproto/lex-data": "0.0.6", 366 + "@atproto/lex-installer": "0.0.9", 367 + "@atproto/lex-json": "0.0.6", 368 + "@atproto/lex-schema": "0.0.7", 369 + "tslib": "^2.8.1", 370 + "yargs": "^17.0.0" 371 + }, 372 + "bin": { 373 + "lex": "bin/lex", 374 + "ts-lex": "bin/lex" 375 + } 376 + }, 377 + "node_modules/@atproto/lex-builder": { 378 + "version": "0.0.9", 379 + "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz", 380 + "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==", 381 + "license": "MIT", 382 + "dependencies": { 383 + "@atproto/lex-document": "0.0.8", 384 + "@atproto/lex-schema": "0.0.7", 385 + "prettier": "^3.2.5", 386 + "ts-morph": "^27.0.0", 387 + "tslib": "^2.8.1" 388 + } 389 + }, 390 + "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": { 391 + "version": "0.28.1", 392 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", 393 + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", 394 + "license": "MIT", 395 + "dependencies": { 396 + "minimatch": "^10.0.1", 397 + "path-browserify": "^1.0.1", 398 + "tinyglobby": "^0.2.14" 399 + } 400 + }, 401 + "node_modules/@atproto/lex-builder/node_modules/minimatch": { 402 + "version": "10.1.1", 403 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", 404 + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", 405 + "license": "BlueOak-1.0.0", 406 + "dependencies": { 407 + "@isaacs/brace-expansion": "^5.0.0" 408 + }, 409 + "engines": { 410 + "node": "20 || >=22" 411 + }, 412 + "funding": { 413 + "url": "https://github.com/sponsors/isaacs" 414 + } 415 + }, 416 + "node_modules/@atproto/lex-builder/node_modules/ts-morph": { 417 + "version": "27.0.2", 418 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", 419 + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", 420 + "license": "MIT", 421 + "dependencies": { 422 + "@ts-morph/common": "~0.28.1", 423 + "code-block-writer": "^13.0.3" 424 + } 425 + }, 426 + "node_modules/@atproto/lex-cbor": { 427 + "version": "0.0.6", 428 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", 429 + "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", 430 + "license": "MIT", 431 + "dependencies": { 432 + "@atproto/lex-data": "0.0.6", 433 + "multiformats": "^9.9.0", 434 + "tslib": "^2.8.1" 435 + } 436 + }, 437 + "node_modules/@atproto/lex-cbor/node_modules/multiformats": { 438 + "version": "9.9.0", 439 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 440 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 441 + "license": "(Apache-2.0 AND MIT)" 442 + }, 363 443 "node_modules/@atproto/lex-cli": { 364 444 "version": "0.9.5", 365 445 "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz", ··· 390 470 "dev": true, 391 471 "license": "MIT" 392 472 }, 473 + "node_modules/@atproto/lex-client": { 474 + "version": "0.0.7", 475 + "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz", 476 + "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==", 477 + "license": "MIT", 478 + "dependencies": { 479 + "@atproto/lex-data": "0.0.6", 480 + "@atproto/lex-json": "0.0.6", 481 + "@atproto/lex-schema": "0.0.7", 482 + "tslib": "^2.8.1" 483 + } 484 + }, 485 + "node_modules/@atproto/lex-data": { 486 + "version": "0.0.6", 487 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", 488 + "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", 489 + "license": "MIT", 490 + "dependencies": { 491 + "@atproto/syntax": "0.4.2", 492 + "multiformats": "^9.9.0", 493 + "tslib": "^2.8.1", 494 + "uint8arrays": "3.0.0", 495 + "unicode-segmenter": "^0.14.0" 496 + } 497 + }, 498 + "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": { 499 + "version": "0.4.2", 500 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 501 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 502 + "license": "MIT" 503 + }, 504 + "node_modules/@atproto/lex-data/node_modules/multiformats": { 505 + "version": "9.9.0", 506 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 507 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 508 + "license": "(Apache-2.0 AND MIT)" 509 + }, 510 + "node_modules/@atproto/lex-document": { 511 + "version": "0.0.8", 512 + "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz", 513 + "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==", 514 + "license": "MIT", 515 + "dependencies": { 516 + "@atproto/lex-schema": "0.0.7", 517 + "core-js": "^3", 518 + "tslib": "^2.8.1" 519 + } 520 + }, 521 + "node_modules/@atproto/lex-installer": { 522 + "version": "0.0.9", 523 + "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz", 524 + "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==", 525 + "license": "MIT", 526 + "dependencies": { 527 + "@atproto/lex-builder": "0.0.9", 528 + "@atproto/lex-cbor": "0.0.6", 529 + "@atproto/lex-data": "0.0.6", 530 + "@atproto/lex-document": "0.0.8", 531 + "@atproto/lex-resolver": "0.0.8", 532 + "@atproto/lex-schema": "0.0.7", 533 + "@atproto/syntax": "0.4.2", 534 + "tslib": "^2.8.1" 535 + } 536 + }, 537 + "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": { 538 + "version": "0.4.2", 539 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 540 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 541 + "license": "MIT" 542 + }, 543 + "node_modules/@atproto/lex-json": { 544 + "version": "0.0.6", 545 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz", 546 + "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==", 547 + "license": "MIT", 548 + "dependencies": { 549 + "@atproto/lex-data": "0.0.6", 550 + "tslib": "^2.8.1" 551 + } 552 + }, 553 + "node_modules/@atproto/lex-resolver": { 554 + "version": "0.0.8", 555 + "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz", 556 + "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==", 557 + "license": "MIT", 558 + "dependencies": { 559 + "@atproto-labs/did-resolver": "0.2.5", 560 + "@atproto/crypto": "0.4.5", 561 + "@atproto/lex-client": "0.0.7", 562 + "@atproto/lex-data": "0.0.6", 563 + "@atproto/lex-document": "0.0.8", 564 + "@atproto/lex-schema": "0.0.7", 565 + "@atproto/repo": "0.8.12", 566 + "@atproto/syntax": "0.4.2", 567 + "tslib": "^2.8.1" 568 + } 569 + }, 570 + "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": { 571 + "version": "0.2.5", 572 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", 573 + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", 574 + "license": "MIT", 575 + "dependencies": { 576 + "@atproto-labs/fetch": "0.2.3", 577 + "@atproto-labs/pipe": "0.1.1", 578 + "@atproto-labs/simple-store": "0.3.0", 579 + "@atproto-labs/simple-store-memory": "0.1.4", 580 + "@atproto/did": "0.2.4", 581 + "zod": "^3.23.8" 582 + } 583 + }, 584 + "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": { 585 + "version": "0.2.4", 586 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz", 587 + "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==", 588 + "license": "MIT", 589 + "dependencies": { 590 + "zod": "^3.23.8" 591 + } 592 + }, 593 + "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": { 594 + "version": "0.4.2", 595 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 596 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 597 + "license": "MIT" 598 + }, 599 + "node_modules/@atproto/lex-schema": { 600 + "version": "0.0.7", 601 + "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz", 602 + "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==", 603 + "license": "MIT", 604 + "dependencies": { 605 + "@atproto/lex-data": "0.0.6", 606 + "@atproto/syntax": "0.4.2", 607 + "tslib": "^2.8.1" 608 + } 609 + }, 610 + "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": { 611 + "version": "0.4.2", 612 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 613 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 614 + "license": "MIT" 615 + }, 393 616 "node_modules/@atproto/lexicon": { 394 617 "version": "0.5.1", 395 618 "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", ··· 472 695 } 473 696 }, 474 697 "node_modules/@atproto/repo": { 475 - "version": "0.8.9", 476 - "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.9.tgz", 477 - "integrity": "sha512-FTePZS2KEv8++pkOB8GGvm46V6uJqd/95bPA1cXTDXyw0cqeVEOItfxkCH1ky/fY71QYr0NkmqMUwuwZ/gwEtQ==", 698 + "version": "0.8.12", 699 + "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", 700 + "integrity": "sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==", 478 701 "license": "MIT", 479 702 "dependencies": { 480 - "@atproto/common": "^0.4.12", 481 - "@atproto/common-web": "^0.4.3", 482 - "@atproto/crypto": "^0.4.4", 483 - "@atproto/lexicon": "^0.5.1", 703 + "@atproto/common": "^0.5.3", 704 + "@atproto/common-web": "^0.4.7", 705 + "@atproto/crypto": "^0.4.5", 706 + "@atproto/lexicon": "^0.6.0", 484 707 "@ipld/dag-cbor": "^7.0.0", 485 708 "multiformats": "^9.9.0", 486 709 "uint8arrays": "3.0.0", ··· 491 714 "node": ">=18.7.0" 492 715 } 493 716 }, 717 + "node_modules/@atproto/repo/node_modules/@atproto/common": { 718 + "version": "0.5.6", 719 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 720 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 721 + "license": "MIT", 722 + "dependencies": { 723 + "@atproto/common-web": "^0.4.10", 724 + "@atproto/lex-cbor": "0.0.6", 725 + "@atproto/lex-data": "0.0.6", 726 + "iso-datestring-validator": "^2.2.2", 727 + "multiformats": "^9.9.0", 728 + "pino": "^8.21.0" 729 + }, 730 + "engines": { 731 + "node": ">=18.7.0" 732 + } 733 + }, 734 + "node_modules/@atproto/repo/node_modules/@atproto/lexicon": { 735 + "version": "0.6.0", 736 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", 737 + "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", 738 + "license": "MIT", 739 + "dependencies": { 740 + "@atproto/common-web": "^0.4.7", 741 + "@atproto/syntax": "^0.4.2", 742 + "iso-datestring-validator": "^2.2.2", 743 + "multiformats": "^9.9.0", 744 + "zod": "^3.23.8" 745 + } 746 + }, 747 + "node_modules/@atproto/repo/node_modules/@atproto/syntax": { 748 + "version": "0.4.2", 749 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 750 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 751 + "license": "MIT" 752 + }, 494 753 "node_modules/@atproto/repo/node_modules/multiformats": { 495 754 "version": "9.9.0", 496 755 "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", ··· 535 794 "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 536 795 "license": "MIT" 537 796 }, 797 + "node_modules/@atproto/tap": { 798 + "version": "0.1.1", 799 + "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz", 800 + "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==", 801 + "license": "MIT", 802 + "dependencies": { 803 + "@atproto/common": "^0.5.6", 804 + "@atproto/lex": "^0.0.9", 805 + "@atproto/syntax": "^0.4.2", 806 + "@atproto/ws-client": "^0.0.4", 807 + "ws": "^8.12.0", 808 + "zod": "^3.23.8" 809 + }, 810 + "engines": { 811 + "node": ">=18.7.0" 812 + } 813 + }, 814 + "node_modules/@atproto/tap/node_modules/@atproto/common": { 815 + "version": "0.5.6", 816 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 817 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 818 + "license": "MIT", 819 + "dependencies": { 820 + "@atproto/common-web": "^0.4.10", 821 + "@atproto/lex-cbor": "0.0.6", 822 + "@atproto/lex-data": "0.0.6", 823 + "iso-datestring-validator": "^2.2.2", 824 + "multiformats": "^9.9.0", 825 + "pino": "^8.21.0" 826 + }, 827 + "engines": { 828 + "node": ">=18.7.0" 829 + } 830 + }, 831 + "node_modules/@atproto/tap/node_modules/@atproto/syntax": { 832 + "version": "0.4.2", 833 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 834 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 835 + "license": "MIT" 836 + }, 837 + "node_modules/@atproto/tap/node_modules/multiformats": { 838 + "version": "9.9.0", 839 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 840 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 841 + "license": "(Apache-2.0 AND MIT)" 842 + }, 843 + "node_modules/@atproto/ws-client": { 844 + "version": "0.0.4", 845 + "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz", 846 + "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==", 847 + "license": "MIT", 848 + "dependencies": { 849 + "@atproto/common": "^0.5.3", 850 + "ws": "^8.12.0" 851 + }, 852 + "engines": { 853 + "node": ">=18.7.0" 854 + } 855 + }, 856 + "node_modules/@atproto/ws-client/node_modules/@atproto/common": { 857 + "version": "0.5.6", 858 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 859 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 860 + "license": "MIT", 861 + "dependencies": { 862 + "@atproto/common-web": "^0.4.10", 863 + "@atproto/lex-cbor": "0.0.6", 864 + "@atproto/lex-data": "0.0.6", 865 + "iso-datestring-validator": "^2.2.2", 866 + "multiformats": "^9.9.0", 867 + "pino": "^8.21.0" 868 + }, 869 + "engines": { 870 + "node": ">=18.7.0" 871 + } 872 + }, 873 + "node_modules/@atproto/ws-client/node_modules/multiformats": { 874 + "version": "9.9.0", 875 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 876 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 877 + "license": "(Apache-2.0 AND MIT)" 878 + }, 538 879 "node_modules/@atproto/xrpc": { 539 880 "version": "0.7.5", 540 881 "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", ··· 2553 2894 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 2554 2895 "license": "(Apache-2.0 AND MIT)" 2555 2896 }, 2897 + "node_modules/@isaacs/balanced-match": { 2898 + "version": "4.0.1", 2899 + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", 2900 + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", 2901 + "license": "MIT", 2902 + "engines": { 2903 + "node": "20 || >=22" 2904 + } 2905 + }, 2906 + "node_modules/@isaacs/brace-expansion": { 2907 + "version": "5.0.0", 2908 + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", 2909 + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", 2910 + "license": "MIT", 2911 + "dependencies": { 2912 + "@isaacs/balanced-match": "^4.0.1" 2913 + }, 2914 + "engines": { 2915 + "node": "20 || >=22" 2916 + } 2917 + }, 2556 2918 "node_modules/@isaacs/fs-minipass": { 2557 2919 "version": "4.0.1", 2558 2920 "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", ··· 9108 9470 "version": "13.0.3", 9109 9471 "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 9110 9472 "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 9111 - "dev": true, 9112 9473 "license": "MIT" 9113 9474 }, 9114 9475 "node_modules/collapse-white-space": { ··· 9216 9577 "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 9217 9578 "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 9218 9579 "license": "MIT" 9580 + }, 9581 + "node_modules/core-js": { 9582 + "version": "3.47.0", 9583 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", 9584 + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", 9585 + "hasInstallScript": true, 9586 + "license": "MIT", 9587 + "funding": { 9588 + "type": "opencollective", 9589 + "url": "https://opencollective.com/core-js" 9590 + } 9219 9591 }, 9220 9592 "node_modules/crelt": { 9221 9593 "version": "1.0.6", ··· 11884 12256 "node_modules/graphemer": { 11885 12257 "version": "1.4.0", 11886 12258 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 11887 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 12259 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 12260 + "dev": true 11888 12261 }, 11889 12262 "node_modules/gzip-size": { 11890 12263 "version": "6.0.0", ··· 15638 16011 "version": "1.0.1", 15639 16012 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 15640 16013 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 15641 - "dev": true, 15642 16014 "license": "MIT" 15643 16015 }, 15644 16016 "node_modules/path-exists": { ··· 15920 16292 "version": "3.2.5", 15921 16293 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", 15922 16294 "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", 15923 - "dev": true, 15924 16295 "bin": { 15925 16296 "prettier": "bin/prettier.cjs" 15926 16297 }, ··· 18024 18395 "version": "0.2.15", 18025 18396 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 18026 18397 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 18027 - "dev": true, 18028 18398 "license": "MIT", 18029 18399 "dependencies": { 18030 18400 "fdir": "^6.5.0", ··· 18041 18411 "version": "6.5.0", 18042 18412 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 18043 18413 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 18044 - "dev": true, 18045 18414 "license": "MIT", 18046 18415 "engines": { 18047 18416 "node": ">=12.0.0" ··· 18059 18428 "version": "4.0.3", 18060 18429 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 18061 18430 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 18062 - "dev": true, 18063 18431 "license": "MIT", 18064 18432 "engines": { 18065 18433 "node": ">=12" ··· 18449 18817 "version": "6.21.0", 18450 18818 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 18451 18819 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 18820 + "license": "MIT" 18821 + }, 18822 + "node_modules/unicode-segmenter": { 18823 + "version": "0.14.5", 18824 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 18825 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 18452 18826 "license": "MIT" 18453 18827 }, 18454 18828 "node_modules/unified": {
+3 -1
package.json
··· 4 4 "description": "", 5 5 "main": "index.js", 6 6 "scripts": { 7 + "lint": "next lint", 7 8 "dev": "TZ=UTC next dev --turbo", 8 9 "publish-lexicons": "tsx lexicons/publish.ts", 9 10 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 11 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 11 12 "wrangler-dev": "wrangler dev", 12 13 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 14 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 26 27 "@atproto/oauth-client-node": "^0.3.8", 27 28 "@atproto/sync": "^0.1.34", 28 29 "@atproto/syntax": "^0.3.3", 30 + "@atproto/tap": "^0.1.1", 29 31 "@atproto/xrpc": "^0.7.5", 30 32 "@atproto/xrpc-server": "^0.9.5", 31 33 "@hono/node-server": "^1.14.3",
+284
patterns/lexicons.md
··· 1 + # Lexicon System 2 + 3 + ## Overview 4 + 5 + Lexicons define the schema for AT Protocol records. This project has two namespaces: 6 + - **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) 7 + - **`site.standard.*`** - Standard site lexicons for interoperability 8 + 9 + The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. 10 + 11 + ## Key Files 12 + 13 + - **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons 14 + - **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) 15 + - **`lexicons/build.ts`** - Builds TypeScript sources to JSON 16 + - **`lexicons/api/`** - Generated TypeScript types and client 17 + - **`package.json`** - Contains `lexgen` script 18 + 19 + ## Running Lexicon Generation 20 + 21 + ```bash 22 + npm run lexgen 23 + ``` 24 + 25 + This runs: 26 + 1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript 27 + 2. `lex gen-api` - Generates TypeScript types from all JSON lexicons 28 + 3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions 29 + 30 + ## Adding a New pub.leaflet Lexicon 31 + 32 + ### 1. Create the Source Definition 33 + 34 + Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): 35 + 36 + ```typescript 37 + import { LexiconDoc } from "@atproto/lexicon"; 38 + 39 + export const PubLeafletMyLexicon: LexiconDoc = { 40 + lexicon: 1, 41 + id: "pub.leaflet.myLexicon", 42 + defs: { 43 + main: { 44 + type: "record", // or "object" for non-record types 45 + key: "tid", 46 + record: { 47 + type: "object", 48 + required: ["field1"], 49 + properties: { 50 + field1: { type: "string", maxLength: 1000 }, 51 + field2: { type: "integer", minimum: 0 }, 52 + optionalRef: { type: "ref", ref: "other.lexicon#def" }, 53 + }, 54 + }, 55 + }, 56 + // Additional defs for sub-objects 57 + subType: { 58 + type: "object", 59 + properties: { 60 + nested: { type: "string" }, 61 + }, 62 + }, 63 + }, 64 + }; 65 + ``` 66 + 67 + ### 2. Add to Build 68 + 69 + Update `lexicons/build.ts`: 70 + 71 + ```typescript 72 + import { PubLeafletMyLexicon } from "./src/myLexicon"; 73 + 74 + const lexicons = [ 75 + // ... existing lexicons 76 + PubLeafletMyLexicon, 77 + ]; 78 + ``` 79 + 80 + ### 3. Update lexgen Command (if needed) 81 + 82 + If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: 83 + 84 + ```json 85 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." 86 + ``` 87 + 88 + Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. 89 + 90 + ### 4. Regenerate Types 91 + 92 + ```bash 93 + npm run lexgen 94 + ``` 95 + 96 + ### 5. Use the Generated Types 97 + 98 + ```typescript 99 + import { PubLeafletMyLexicon } from "lexicons/api"; 100 + 101 + // Type for the record 102 + type MyRecord = PubLeafletMyLexicon.Record; 103 + 104 + // Validation 105 + const result = PubLeafletMyLexicon.validateRecord(data); 106 + if (result.success) { 107 + // result.value is typed 108 + } 109 + 110 + // Type guard 111 + if (PubLeafletMyLexicon.isRecord(data)) { 112 + // data is typed as Record 113 + } 114 + ``` 115 + 116 + ## Adding a New site.standard Lexicon 117 + 118 + ### 1. Create the JSON Definition 119 + 120 + Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): 121 + 122 + ```json 123 + { 124 + "lexicon": 1, 125 + "id": "site.standard.myType", 126 + "defs": { 127 + "main": { 128 + "type": "record", 129 + "key": "tid", 130 + "record": { 131 + "type": "object", 132 + "required": ["field1"], 133 + "properties": { 134 + "field1": { 135 + "type": "string", 136 + "maxLength": 1000 137 + } 138 + } 139 + } 140 + } 141 + } 142 + } 143 + ``` 144 + 145 + ### 2. Regenerate Types 146 + 147 + ```bash 148 + npm run lexgen 149 + ``` 150 + 151 + The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. 152 + 153 + ## Common Lexicon Patterns 154 + 155 + ### Referencing Other Lexicons 156 + 157 + ```typescript 158 + // Reference another lexicon's main def 159 + { type: "ref", ref: "pub.leaflet.publication" } 160 + 161 + // Reference a specific def within a lexicon 162 + { type: "ref", ref: "pub.leaflet.publication#theme" } 163 + 164 + // Reference within the same lexicon 165 + { type: "ref", ref: "#myDef" } 166 + ``` 167 + 168 + ### Union Types 169 + 170 + ```typescript 171 + { 172 + type: "union", 173 + refs: [ 174 + "pub.leaflet.pages.linearDocument", 175 + "pub.leaflet.pages.canvas", 176 + ], 177 + } 178 + 179 + // Open union (allows unknown types) 180 + { 181 + type: "union", 182 + closed: false, // default is true 183 + refs: ["pub.leaflet.content"], 184 + } 185 + ``` 186 + 187 + ### Blob Types (for images/files) 188 + 189 + ```typescript 190 + { 191 + type: "blob", 192 + accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] 193 + maxSize: 1000000, // bytes 194 + } 195 + ``` 196 + 197 + ### Color Types 198 + 199 + The project has color types defined: 200 + - `pub.leaflet.theme.color#rgb` / `#rgba` 201 + - `site.standard.theme.color#rgb` / `#rgba` 202 + 203 + ```typescript 204 + // In lexicons/src/theme.ts 205 + export const ColorUnion = { 206 + type: "union", 207 + refs: [ 208 + "pub.leaflet.theme.color#rgba", 209 + "pub.leaflet.theme.color#rgb", 210 + ], 211 + }; 212 + ``` 213 + 214 + ## Normalization Between Formats 215 + 216 + Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: 217 + 218 + ```typescript 219 + import { 220 + normalizeDocument, 221 + normalizePublication, 222 + isLeafletDocument, 223 + isStandardDocument, 224 + getDocumentPages, 225 + } from "lexicons/src/normalize"; 226 + 227 + // Normalize a document from either format 228 + const normalized = normalizeDocument(record); 229 + if (normalized) { 230 + // normalized is always in site.standard.document format 231 + console.log(normalized.title, normalized.site); 232 + 233 + // Get pages if content is pub.leaflet.content 234 + const pages = getDocumentPages(normalized); 235 + } 236 + 237 + // Normalize a publication 238 + const pub = normalizePublication(record); 239 + if (pub) { 240 + console.log(pub.name, pub.url); 241 + } 242 + ``` 243 + 244 + ## Handling in Appview (Firehose Consumer) 245 + 246 + When processing records from the firehose in `appview/index.ts`: 247 + 248 + ```typescript 249 + import { ids } from "lexicons/api/lexicons"; 250 + import { PubLeafletMyLexicon } from "lexicons/api"; 251 + 252 + // In filterCollections: 253 + filterCollections: [ 254 + ids.PubLeafletMyLexicon, 255 + // ... 256 + ], 257 + 258 + // In handleEvent: 259 + if (evt.collection === ids.PubLeafletMyLexicon) { 260 + if (evt.event === "create" || evt.event === "update") { 261 + let record = PubLeafletMyLexicon.validateRecord(evt.record); 262 + if (!record.success) return; 263 + 264 + // Store in database 265 + await supabase.from("my_table").upsert({ 266 + uri: evt.uri.toString(), 267 + data: record.value as Json, 268 + }); 269 + } 270 + if (evt.event === "delete") { 271 + await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); 272 + } 273 + } 274 + ``` 275 + 276 + ## Publishing Lexicons 277 + 278 + To publish lexicons to an AT Protocol PDS: 279 + 280 + ```bash 281 + npm run publish-lexicons 282 + ``` 283 + 284 + This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
+143 -6
src/notifications.ts
··· 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 5 import { AtUri } from "@atproto/syntax"; 6 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + import { 8 + normalizeDocumentRecord, 9 + normalizePublicationRecord, 10 + type NormalizedDocument, 11 + type NormalizedPublication, 12 + } from "src/utils/normalizeRecords"; 7 13 8 14 type NotificationRow = Tables<"notifications">; 9 15 ··· 15 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 16 22 | { type: "subscribe"; subscription_uri: string } 17 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 + | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } 18 25 | { type: "mention"; document_uri: string; mention_type: "did" } 19 26 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 27 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 26 33 | HydratedCommentNotification 27 34 | HydratedSubscribeNotification 28 35 | HydratedQuoteNotification 36 + | HydratedBskyPostEmbedNotification 29 37 | HydratedMentionNotification 30 38 | HydratedCommentMentionNotification; 31 39 export async function hydrateNotifications( 32 40 notifications: NotificationRow[], 33 41 ): Promise<Array<HydratedNotification>> { 34 42 // Call all hydrators in parallel 35 - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 43 + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 36 44 hydrateCommentNotifications(notifications), 37 45 hydrateSubscribeNotifications(notifications), 38 46 hydrateQuoteNotifications(notifications), 47 + hydrateBskyPostEmbedNotifications(notifications), 39 48 hydrateMentionNotifications(notifications), 40 49 hydrateCommentMentionNotifications(notifications), 41 50 ]); 42 51 43 52 // Combine all hydrated notifications 44 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 53 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 45 54 46 55 // Sort by created_at to maintain order 47 56 allHydrated.sort( ··· 99 108 ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 109 : undefined, 101 110 commentData, 111 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 112 + normalizedPublication: normalizePublicationRecord( 113 + commentData.documents?.documents_in_publications[0]?.publications?.record, 114 + ), 102 115 }; 103 116 }) 104 117 .filter((n) => n !== null); ··· 140 153 type: "subscribe" as const, 141 154 subscription_uri: notification.data.subscription_uri, 142 155 subscriptionData, 156 + normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), 143 157 }; 144 158 }) 145 159 .filter((n) => n !== null); ··· 187 201 document_uri: notification.data.document_uri, 188 202 bskyPost, 189 203 document, 204 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 205 + normalizedPublication: normalizePublicationRecord( 206 + document.documents_in_publications[0]?.publications?.record, 207 + ), 208 + }; 209 + }) 210 + .filter((n) => n !== null); 211 + } 212 + 213 + export type HydratedBskyPostEmbedNotification = Awaited< 214 + ReturnType<typeof hydrateBskyPostEmbedNotifications> 215 + >[0]; 216 + 217 + async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { 218 + const bskyPostEmbedNotifications = notifications.filter( 219 + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => 220 + (n.data as NotificationData)?.type === "bsky_post_embed", 221 + ); 222 + 223 + if (bskyPostEmbedNotifications.length === 0) { 224 + return []; 225 + } 226 + 227 + // Fetch document data (the leaflet that embedded the post) 228 + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); 229 + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); 230 + 231 + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ 232 + supabaseServerClient 233 + .from("documents") 234 + .select("*, documents_in_publications(publications(*))") 235 + .in("uri", documentUris), 236 + supabaseServerClient 237 + .from("bsky_posts") 238 + .select("*") 239 + .in("uri", bskyPostUris), 240 + ]); 241 + 242 + // Find which posts we need to fetch from the API 243 + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); 244 + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); 245 + 246 + // Fetch missing posts from Bluesky API 247 + const fetchedPosts = new Map<string, { text: string } | null>(); 248 + if (missingPostUris.length > 0) { 249 + try { 250 + const { AtpAgent } = await import("@atproto/api"); 251 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 252 + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); 253 + for (const post of response.data.posts) { 254 + const record = post.record as { text?: string }; 255 + fetchedPosts.set(post.uri, { text: record.text ?? "" }); 256 + } 257 + } catch (error) { 258 + console.error("Failed to fetch Bluesky posts:", error); 259 + } 260 + } 261 + 262 + // Extract unique DIDs from document URIs to resolve handles 263 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 264 + 265 + // Resolve DIDs to handles in parallel 266 + const didToHandleMap = new Map<string, string | null>(); 267 + await Promise.all( 268 + documentCreatorDids.map(async (did) => { 269 + try { 270 + const resolved = await idResolver.did.resolve(did); 271 + const handle = resolved?.alsoKnownAs?.[0] 272 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 273 + : null; 274 + didToHandleMap.set(did, handle); 275 + } catch (error) { 276 + console.error(`Failed to resolve DID ${did}:`, error); 277 + didToHandleMap.set(did, null); 278 + } 279 + }), 280 + ); 281 + 282 + return bskyPostEmbedNotifications 283 + .map((notification) => { 284 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 285 + if (!document) return null; 286 + 287 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 288 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 289 + 290 + // Get post text from cache or fetched data 291 + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 292 + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; 293 + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; 294 + 295 + return { 296 + id: notification.id, 297 + recipient: notification.recipient, 298 + created_at: notification.created_at, 299 + type: "bsky_post_embed" as const, 300 + document_uri: notification.data.document_uri, 301 + bsky_post_uri: notification.data.bsky_post_uri, 302 + document, 303 + documentCreatorHandle, 304 + bskyPostText, 305 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 306 + normalizedPublication: normalizePublicationRecord( 307 + document.documents_in_publications[0]?.publications?.record, 308 + ), 190 309 }; 191 310 }) 192 311 .filter((n) => n !== null); ··· 269 388 const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 389 const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 390 391 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 392 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 393 + 272 394 return { 273 395 id: notification.id, 274 396 recipient: notification.recipient, ··· 279 401 mentioned_uri: mentionedUri, 280 402 document, 281 403 documentCreatorHandle, 282 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 404 + mentionedPublication, 405 + mentionedDocument: mentionedDoc, 406 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 407 + normalizedPublication: normalizePublicationRecord( 408 + document.documents_in_publications[0]?.publications?.record, 409 + ), 410 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 411 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), 284 412 }; 285 413 }) 286 414 .filter((n) => n !== null); ··· 365 493 const commenterDid = new AtUri(notification.data.comment_uri).host; 366 494 const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 495 496 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 497 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 498 + 368 499 return { 369 500 id: notification.id, 370 501 recipient: notification.recipient, ··· 375 506 mentioned_uri: mentionedUri, 376 507 commentData, 377 508 commenterHandle, 378 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 509 + mentionedPublication, 510 + mentionedDocument: mentionedDoc, 511 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 512 + normalizedPublication: normalizePublicationRecord( 513 + commentData.documents?.documents_in_publications[0]?.publications?.record, 514 + ), 515 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 516 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), 380 517 }; 381 518 }) 382 519 .filter((n) => n !== null);
+3
src/replicache/mutations.ts
··· 658 658 description?: string; 659 659 tags?: string[]; 660 660 cover_image?: string | null; 661 + localPublishedAt?: string | null; 661 662 }> = async (args, ctx) => { 662 663 await ctx.runOnServer(async (serverCtx) => { 663 664 console.log("updating"); ··· 697 698 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 698 699 if (args.cover_image !== undefined) 699 700 await tx.set("publication_cover_image", args.cover_image); 701 + if (args.localPublishedAt !== undefined) 702 + await tx.set("publication_local_published_at", args.localPublishedAt); 700 703 }); 701 704 }; 702 705
+57
src/utils/collectionHelpers.ts
··· 1 + import { ids } from "lexicons/api/lexicons"; 2 + 3 + /** 4 + * Check if a collection is a document collection (either namespace). 5 + */ 6 + export function isDocumentCollection(collection: string): boolean { 7 + return ( 8 + collection === ids.PubLeafletDocument || 9 + collection === ids.SiteStandardDocument 10 + ); 11 + } 12 + 13 + /** 14 + * Check if a collection is a publication collection (either namespace). 15 + */ 16 + export function isPublicationCollection(collection: string): boolean { 17 + return ( 18 + collection === ids.PubLeafletPublication || 19 + collection === ids.SiteStandardPublication 20 + ); 21 + } 22 + 23 + /** 24 + * Check if a collection belongs to the site.standard namespace. 25 + */ 26 + export function isSiteStandardCollection(collection: string): boolean { 27 + return collection.startsWith("site.standard."); 28 + } 29 + 30 + /** 31 + * Check if a collection belongs to the pub.leaflet namespace. 32 + */ 33 + export function isPubLeafletCollection(collection: string): boolean { 34 + return collection.startsWith("pub.leaflet."); 35 + } 36 + 37 + /** 38 + * Get the document $type to use based on an existing URI's collection. 39 + * If no existing URI or collection isn't a document, defaults to site.standard.document. 40 + */ 41 + export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" { 42 + if (existingCollection === ids.PubLeafletDocument) { 43 + return ids.PubLeafletDocument as "pub.leaflet.document"; 44 + } 45 + return ids.SiteStandardDocument as "site.standard.document"; 46 + } 47 + 48 + /** 49 + * Get the publication $type to use based on an existing URI's collection. 50 + * If no existing URI or collection isn't a publication, defaults to site.standard.publication. 51 + */ 52 + export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" { 53 + if (existingCollection === ids.PubLeafletPublication) { 54 + return ids.PubLeafletPublication as "pub.leaflet.publication"; 55 + } 56 + return ids.SiteStandardPublication as "site.standard.publication"; 57 + }
+122
src/utils/deduplicateRecords.ts
··· 1 + /** 2 + * Utilities for deduplicating records that may exist under both 3 + * pub.leaflet.* and site.standard.* namespaces. 4 + * 5 + * After the migration to site.standard.*, records can exist in both namespaces 6 + * with the same DID and rkey. This utility deduplicates them, preferring 7 + * site.standard.* records when available. 8 + */ 9 + 10 + import { AtUri } from "@atproto/syntax"; 11 + 12 + /** 13 + * Extracts the identity key (DID + rkey) from an AT URI. 14 + * This key uniquely identifies a record across namespaces. 15 + * 16 + * @example 17 + * getRecordIdentityKey("at://did:plc:abc/pub.leaflet.document/3abc") 18 + * // Returns: "did:plc:abc/3abc" 19 + * 20 + * getRecordIdentityKey("at://did:plc:abc/site.standard.document/3abc") 21 + * // Returns: "did:plc:abc/3abc" (same key, different namespace) 22 + */ 23 + function getRecordIdentityKey(uri: string): string | null { 24 + try { 25 + const parsed = new AtUri(uri); 26 + return `${parsed.host}/${parsed.rkey}`; 27 + } catch { 28 + return null; 29 + } 30 + } 31 + 32 + /** 33 + * Checks if a URI is from the site.standard namespace. 34 + */ 35 + function isSiteStandardUri(uri: string): boolean { 36 + return uri.includes("/site.standard."); 37 + } 38 + 39 + /** 40 + * Deduplicates an array of records that have a `uri` property. 41 + * 42 + * When records exist under both pub.leaflet.* and site.standard.* namespaces 43 + * (same DID and rkey), this function keeps only the site.standard version. 44 + * 45 + * @param records - Array of records with a `uri` property 46 + * @returns Deduplicated array, preferring site.standard records 47 + * 48 + * @example 49 + * const docs = [ 50 + * { uri: "at://did:plc:abc/pub.leaflet.document/3abc", data: {...} }, 51 + * { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} }, 52 + * { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} }, 53 + * ]; 54 + * const deduped = deduplicateByUri(docs); 55 + * // Returns: [ 56 + * // { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} }, 57 + * // { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} }, 58 + * // ] 59 + */ 60 + export function deduplicateByUri<T extends { uri: string }>(records: T[]): T[] { 61 + const recordsByKey = new Map<string, T>(); 62 + 63 + for (const record of records) { 64 + const key = getRecordIdentityKey(record.uri); 65 + if (!key) { 66 + // Invalid URI, keep the record as-is 67 + continue; 68 + } 69 + 70 + const existing = recordsByKey.get(key); 71 + if (!existing) { 72 + recordsByKey.set(key, record); 73 + } else { 74 + // Prefer site.standard records over pub.leaflet records 75 + if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.uri)) { 76 + recordsByKey.set(key, record); 77 + } 78 + // If both are same namespace or existing is already site.standard, keep existing 79 + } 80 + } 81 + 82 + return Array.from(recordsByKey.values()); 83 + } 84 + 85 + /** 86 + * Deduplicates records while preserving the original order based on the first 87 + * occurrence of each unique record. 88 + * 89 + * Same deduplication logic as deduplicateByUri, but maintains insertion order. 90 + * 91 + * @param records - Array of records with a `uri` property 92 + * @returns Deduplicated array in original order, preferring site.standard records 93 + */ 94 + export function deduplicateByUriOrdered<T extends { uri: string }>( 95 + records: T[] 96 + ): T[] { 97 + const recordsByKey = new Map<string, { record: T; index: number }>(); 98 + 99 + for (let i = 0; i < records.length; i++) { 100 + const record = records[i]; 101 + const key = getRecordIdentityKey(record.uri); 102 + if (!key) { 103 + continue; 104 + } 105 + 106 + const existing = recordsByKey.get(key); 107 + if (!existing) { 108 + recordsByKey.set(key, { record, index: i }); 109 + } else { 110 + // Prefer site.standard records over pub.leaflet records 111 + if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.record.uri)) { 112 + // Replace with site.standard but keep original position 113 + recordsByKey.set(key, { record, index: existing.index }); 114 + } 115 + } 116 + } 117 + 118 + // Sort by original index to maintain order 119 + return Array.from(recordsByKey.values()) 120 + .sort((a, b) => a.index - b.index) 121 + .map((entry) => entry.record); 122 + }
+30 -20
src/utils/getPublicationMetadataFromLeafletData.ts
··· 1 1 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 2 2 import { Json } from "supabase/database.types"; 3 3 4 + /** 5 + * Return type for publication metadata extraction. 6 + * Note: `publications.record` and `documents.data` are raw JSON from the database. 7 + * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` 8 + * from `src/utils/normalizeRecords` to get properly typed data. 9 + */ 10 + export type PublicationMetadata = { 11 + description: string; 12 + title: string; 13 + leaflet: string; 14 + doc: string | null; 15 + publications: { 16 + identity_did: string; 17 + name: string; 18 + indexed_at: string; 19 + /** Raw record - use normalizePublicationRecord() to get typed data */ 20 + record: Json | null; 21 + uri: string; 22 + } | null; 23 + documents: { 24 + /** Raw data - use normalizeDocumentRecord() to get typed data */ 25 + data: Json; 26 + indexed_at: string; 27 + uri: string; 28 + } | null; 29 + } | null; 30 + 4 31 export function getPublicationMetadataFromLeafletData( 5 32 data?: GetLeafletDataReturnType["result"]["data"], 6 - ) { 33 + ): PublicationMetadata { 7 34 if (!data) return null; 8 35 9 36 let pubData: 10 - | { 11 - description: string; 12 - title: string; 13 - leaflet: string; 14 - doc: string | null; 15 - publications: { 16 - identity_did: string; 17 - name: string; 18 - indexed_at: string; 19 - record: Json | null; 20 - uri: string; 21 - } | null; 22 - documents: { 23 - data: Json; 24 - indexed_at: string; 25 - uri: string; 26 - } | null; 27 - } 37 + | NonNullable<PublicationMetadata> 28 38 | undefined 29 39 | null = 30 40 data?.leaflets_in_publications?.[0] || ··· 46 56 doc: standaloneDoc.document, 47 57 }; 48 58 } 49 - return pubData; 59 + return pubData || null; 50 60 }
+6 -2
src/utils/mentionUtils.ts
··· 1 1 import { AtUri } from "@atproto/api"; 2 + import { 3 + isDocumentCollection, 4 + isPublicationCollection, 5 + } from "src/utils/collectionHelpers"; 2 6 3 7 /** 4 8 * Converts a DID to a Bluesky profile URL ··· 14 18 try { 15 19 const uri = new AtUri(atUri); 16 20 17 - if (uri.collection === "pub.leaflet.publication") { 21 + if (isPublicationCollection(uri.collection)) { 18 22 // Publication URL: /lish/{did}/{rkey} 19 23 return `/lish/${uri.host}/${uri.rkey}`; 20 - } else if (uri.collection === "pub.leaflet.document") { 24 + } else if (isDocumentCollection(uri.collection)) { 21 25 // Document URL - we need to resolve this via the API 22 26 // For now, create a redirect route that will handle it 23 27 return `/lish/uri/${encodeURIComponent(atUri)}`;
+134
src/utils/normalizeRecords.ts
··· 1 + /** 2 + * Utilities for normalizing pub.leaflet and site.standard records from database queries. 3 + * 4 + * These helpers apply the normalization functions from lexicons/src/normalize.ts 5 + * to database query results, providing properly typed normalized records. 6 + */ 7 + 8 + import { 9 + normalizeDocument, 10 + normalizePublication, 11 + type NormalizedDocument, 12 + type NormalizedPublication, 13 + } from "lexicons/src/normalize"; 14 + import type { Json } from "supabase/database.types"; 15 + 16 + /** 17 + * Normalizes a document record from a database query result. 18 + * Returns the normalized document or null if the record is invalid/unrecognized. 19 + * 20 + * @param data - The document record data from the database 21 + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 22 + * 23 + * @example 24 + * const doc = normalizeDocumentRecord(dbResult.data, dbResult.uri); 25 + * if (doc) { 26 + * // doc is NormalizedDocument with proper typing 27 + * console.log(doc.title, doc.site, doc.publishedAt); 28 + * } 29 + */ 30 + export function normalizeDocumentRecord( 31 + data: Json | unknown, 32 + uri?: string 33 + ): NormalizedDocument | null { 34 + return normalizeDocument(data, uri); 35 + } 36 + 37 + /** 38 + * Normalizes a publication record from a database query result. 39 + * Returns the normalized publication or null if the record is invalid/unrecognized. 40 + * 41 + * @example 42 + * const pub = normalizePublicationRecord(dbResult.record); 43 + * if (pub) { 44 + * // pub is NormalizedPublication with proper typing 45 + * console.log(pub.name, pub.url); 46 + * } 47 + */ 48 + export function normalizePublicationRecord( 49 + record: Json | unknown 50 + ): NormalizedPublication | null { 51 + return normalizePublication(record); 52 + } 53 + 54 + /** 55 + * Type helper for a document row from the database with normalized data. 56 + * Use this when you need the full row but with typed data. 57 + */ 58 + export type DocumentRowWithNormalizedData< 59 + T extends { data: Json | unknown } 60 + > = Omit<T, "data"> & { 61 + data: NormalizedDocument | null; 62 + }; 63 + 64 + /** 65 + * Type helper for a publication row from the database with normalized record. 66 + * Use this when you need the full row but with typed record. 67 + */ 68 + export type PublicationRowWithNormalizedRecord< 69 + T extends { record: Json | unknown } 70 + > = Omit<T, "record"> & { 71 + record: NormalizedPublication | null; 72 + }; 73 + 74 + /** 75 + * Normalizes a document row in place, returning a properly typed row. 76 + * If the row has a `uri` field, it will be used to extract the path. 77 + */ 78 + export function normalizeDocumentRow<T extends { data: Json | unknown; uri?: string }>( 79 + row: T 80 + ): DocumentRowWithNormalizedData<T> { 81 + return { 82 + ...row, 83 + data: normalizeDocumentRecord(row.data, row.uri), 84 + }; 85 + } 86 + 87 + /** 88 + * Normalizes a publication row in place, returning a properly typed row. 89 + */ 90 + export function normalizePublicationRow<T extends { record: Json | unknown }>( 91 + row: T 92 + ): PublicationRowWithNormalizedRecord<T> { 93 + return { 94 + ...row, 95 + record: normalizePublicationRecord(row.record), 96 + }; 97 + } 98 + 99 + /** 100 + * Type guard for filtering normalized document rows with non-null data. 101 + * Use with .filter() after .map(normalizeDocumentRow) to narrow the type. 102 + */ 103 + export function hasValidDocument<T extends { data: NormalizedDocument | null }>( 104 + row: T 105 + ): row is T & { data: NormalizedDocument } { 106 + return row.data !== null; 107 + } 108 + 109 + /** 110 + * Type guard for filtering normalized publication rows with non-null record. 111 + * Use with .filter() after .map(normalizePublicationRow) to narrow the type. 112 + */ 113 + export function hasValidPublication< 114 + T extends { record: NormalizedPublication | null } 115 + >(row: T): row is T & { record: NormalizedPublication } { 116 + return row.record !== null; 117 + } 118 + 119 + // Re-export the core types and functions for convenience 120 + export { 121 + normalizeDocument, 122 + normalizePublication, 123 + type NormalizedDocument, 124 + type NormalizedPublication, 125 + } from "lexicons/src/normalize"; 126 + 127 + export { 128 + isLeafletDocument, 129 + isStandardDocument, 130 + isLeafletPublication, 131 + isStandardPublication, 132 + hasLeafletContent, 133 + getDocumentPages, 134 + } from "lexicons/src/normalize";
+6
src/utils/timeAgo.ts
··· 6 6 const diffMinutes = Math.floor(diffSeconds / 60); 7 7 const diffHours = Math.floor(diffMinutes / 60); 8 8 const diffDays = Math.floor(diffHours / 24); 9 + const diffWeeks = Math.floor(diffDays / 7); 10 + const diffMonths = Math.floor(diffDays / 30); 9 11 const diffYears = Math.floor(diffDays / 365); 10 12 11 13 if (diffYears > 0) { 12 14 return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`; 15 + } else if (diffMonths > 0) { 16 + return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`; 17 + } else if (diffWeeks > 0) { 18 + return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`; 13 19 } else if (diffDays > 0) { 14 20 return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 15 21 } else if (diffHours > 0) {
+48
src/utils/uriHelpers.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { ids } from "lexicons/api/lexicons"; 3 + 4 + /** 5 + * Returns an OR filter string for Supabase queries to match either namespace URI. 6 + * Used for querying documents that may be stored under either pub.leaflet.document 7 + * or site.standard.document namespaces. 8 + */ 9 + export function documentUriFilter(did: string, rkey: string): string { 10 + const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString(); 11 + const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 12 + return `uri.eq.${standard},uri.eq.${legacy}`; 13 + } 14 + 15 + /** 16 + * Returns an OR filter string for Supabase queries to match either namespace URI. 17 + * Used for querying publications that may be stored under either pub.leaflet.publication 18 + * or site.standard.publication namespaces. 19 + */ 20 + export function publicationUriFilter(did: string, rkey: string): string { 21 + const standard = AtUri.make( 22 + did, 23 + ids.SiteStandardPublication, 24 + rkey, 25 + ).toString(); 26 + const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); 27 + return `uri.eq.${standard},uri.eq.${legacy}`; 28 + } 29 + 30 + /** 31 + * Returns an OR filter string for Supabase queries to match a publication by name 32 + * or by either namespace URI. Used when the rkey might be the publication name. 33 + */ 34 + export function publicationNameOrUriFilter( 35 + did: string, 36 + nameOrRkey: string, 37 + ): string { 38 + let standard, legacy; 39 + if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(nameOrRkey)) { 40 + standard = AtUri.make( 41 + did, 42 + ids.SiteStandardPublication, 43 + nameOrRkey, 44 + ).toString(); 45 + legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 46 + } 47 + return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`; 48 + }
+140 -1
supabase/database.types.ts
··· 586 586 doc: string | null 587 587 leaflet: string 588 588 publication: string 589 + tags: string[] | null 589 590 title: string 590 591 } 591 592 Insert: { ··· 595 596 doc?: string | null 596 597 leaflet: string 597 598 publication: string 599 + tags?: string[] | null 598 600 title?: string 599 601 } 600 602 Update: { ··· 604 606 doc?: string | null 605 607 leaflet?: string 606 608 publication?: string 609 + tags?: string[] | null 607 610 title?: string 608 611 } 609 612 Relationships: [ ··· 632 635 } 633 636 leaflets_to_documents: { 634 637 Row: { 638 + archived: boolean | null 635 639 cover_image: string | null 636 640 created_at: string 637 641 description: string 638 642 document: string 639 643 leaflet: string 644 + tags: string[] | null 640 645 title: string 641 646 } 642 647 Insert: { 648 + archived?: boolean | null 643 649 cover_image?: string | null 644 650 created_at?: string 645 651 description?: string 646 652 document: string 647 653 leaflet: string 654 + tags?: string[] | null 648 655 title?: string 649 656 } 650 657 Update: { 658 + archived?: boolean | null 651 659 cover_image?: string | null 652 660 created_at?: string 653 661 description?: string 654 662 document?: string 655 663 leaflet?: string 664 + tags?: string[] | null 656 665 title?: string 657 666 } 658 667 Relationships: [ ··· 762 771 referencedColumns: ["id"] 763 772 }, 764 773 { 765 - foreignKeyName: "permission_token_creator_token_fkey" 774 + foreignKeyName: "permission_token_on_homepage_token_fkey" 766 775 columns: ["token"] 767 776 isOneToOne: false 768 777 referencedRelation: "permission_tokens" ··· 1079 1088 last_mutation?: number 1080 1089 } 1081 1090 Relationships: [] 1091 + } 1092 + site_standard_documents: { 1093 + Row: { 1094 + data: Json 1095 + identity_did: string 1096 + indexed_at: string 1097 + uri: string 1098 + } 1099 + Insert: { 1100 + data: Json 1101 + identity_did: string 1102 + indexed_at?: string 1103 + uri: string 1104 + } 1105 + Update: { 1106 + data?: Json 1107 + identity_did?: string 1108 + indexed_at?: string 1109 + uri?: string 1110 + } 1111 + Relationships: [ 1112 + { 1113 + foreignKeyName: "site_standard_documents_identity_did_fkey" 1114 + columns: ["identity_did"] 1115 + isOneToOne: false 1116 + referencedRelation: "identities" 1117 + referencedColumns: ["atp_did"] 1118 + }, 1119 + ] 1120 + } 1121 + site_standard_documents_in_publications: { 1122 + Row: { 1123 + document: string 1124 + indexed_at: string 1125 + publication: string 1126 + } 1127 + Insert: { 1128 + document: string 1129 + indexed_at?: string 1130 + publication: string 1131 + } 1132 + Update: { 1133 + document?: string 1134 + indexed_at?: string 1135 + publication?: string 1136 + } 1137 + Relationships: [ 1138 + { 1139 + foreignKeyName: "site_standard_documents_in_publications_document_fkey" 1140 + columns: ["document"] 1141 + isOneToOne: false 1142 + referencedRelation: "site_standard_documents" 1143 + referencedColumns: ["uri"] 1144 + }, 1145 + { 1146 + foreignKeyName: "site_standard_documents_in_publications_publication_fkey" 1147 + columns: ["publication"] 1148 + isOneToOne: false 1149 + referencedRelation: "site_standard_publications" 1150 + referencedColumns: ["uri"] 1151 + }, 1152 + ] 1153 + } 1154 + site_standard_publications: { 1155 + Row: { 1156 + data: Json 1157 + identity_did: string 1158 + indexed_at: string 1159 + uri: string 1160 + } 1161 + Insert: { 1162 + data: Json 1163 + identity_did: string 1164 + indexed_at?: string 1165 + uri: string 1166 + } 1167 + Update: { 1168 + data?: Json 1169 + identity_did?: string 1170 + indexed_at?: string 1171 + uri?: string 1172 + } 1173 + Relationships: [ 1174 + { 1175 + foreignKeyName: "site_standard_publications_identity_did_fkey" 1176 + columns: ["identity_did"] 1177 + isOneToOne: false 1178 + referencedRelation: "identities" 1179 + referencedColumns: ["atp_did"] 1180 + }, 1181 + ] 1182 + } 1183 + site_standard_subscriptions: { 1184 + Row: { 1185 + created_at: string 1186 + identity: string 1187 + publication: string 1188 + record: Json 1189 + uri: string 1190 + } 1191 + Insert: { 1192 + created_at?: string 1193 + identity: string 1194 + publication: string 1195 + record: Json 1196 + uri: string 1197 + } 1198 + Update: { 1199 + created_at?: string 1200 + identity?: string 1201 + publication?: string 1202 + record?: Json 1203 + uri?: string 1204 + } 1205 + Relationships: [ 1206 + { 1207 + foreignKeyName: "site_standard_subscriptions_identity_fkey" 1208 + columns: ["identity"] 1209 + isOneToOne: false 1210 + referencedRelation: "identities" 1211 + referencedColumns: ["atp_did"] 1212 + }, 1213 + { 1214 + foreignKeyName: "site_standard_subscriptions_publication_fkey" 1215 + columns: ["publication"] 1216 + isOneToOne: false 1217 + referencedRelation: "site_standard_publications" 1218 + referencedColumns: ["uri"] 1219 + }, 1220 + ] 1082 1221 } 1083 1222 subscribers_to_publications: { 1084 1223 Row: {