a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+7680 -2813
+9
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "mcp__acp__Edit", 5 + "mcp__acp__Write", 6 + "mcp__acp__Bash" 7 + ] 8 + } 9 + }
-1
.github/pull_request_template.md
··· 2 2 - it looks good on both mobile and desktop 3 3 - it undo's like it ought to 4 4 - it handles keyboard interactions reasonably well 5 - - it behaves as you would expect if you lock it 6 5 - no build errors!!!
+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
+219 -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 203 + ? new AtUri(existingDocUri).collection 204 + : undefined; 205 + const documentType = getDocumentType(existingCollection); 203 206 204 - // Keep the same rkey if updating an existing document 205 - let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 207 + // Build the pages array (used by both formats) 208 + const pagesArray = pages.map((p) => { 209 + if (p.type === "canvas") { 210 + return { 211 + $type: "pub.leaflet.pages.canvas" as const, 212 + id: p.id, 213 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 214 + }; 215 + } else { 216 + return { 217 + $type: "pub.leaflet.pages.linearDocument" as const, 218 + id: p.id, 219 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 220 + }; 221 + } 222 + }); 223 + 224 + // Determine the rkey early since we need it for the path field 225 + const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 226 + 227 + // Create record based on the document type 228 + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 229 + 230 + if (documentType === "site.standard.document") { 231 + // site.standard.document format 232 + // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 233 + const siteUri = 234 + publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 235 + 236 + record = { 237 + $type: "site.standard.document", 238 + title: title || "Untitled", 239 + site: siteUri, 240 + path: "/" + rkey, 241 + publishedAt: 242 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 243 + ...(description && { description }), 244 + ...(tags !== undefined && { tags }), 245 + ...(coverImageBlob && { coverImage: coverImageBlob }), 246 + // Include theme for standalone documents (not for publication documents) 247 + ...(!publication_uri && theme && { theme }), 248 + content: { 249 + $type: "pub.leaflet.content" as const, 250 + pages: pagesArray, 251 + }, 252 + } satisfies SiteStandardDocument.Record; 253 + } else { 254 + // pub.leaflet.document format (legacy) 255 + record = { 256 + $type: "pub.leaflet.document", 257 + author: credentialSession.did!, 258 + ...(publication_uri && { publication: publication_uri }), 259 + ...(theme && { theme }), 260 + title: title || "Untitled", 261 + description: description || "", 262 + ...(tags !== undefined && { tags }), 263 + ...(coverImageBlob && { coverImage: coverImageBlob }), 264 + pages: pagesArray, 265 + publishedAt: 266 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 267 + } satisfies PubLeafletDocument.Record; 268 + } 269 + 206 270 let { data: result } = await agent.com.atproto.repo.putRecord({ 207 271 rkey, 208 272 repo: credentialSession.did!, ··· 214 278 // Optimistically create database entries 215 279 await supabaseServerClient.from("documents").upsert({ 216 280 uri: result.uri, 217 - data: record as Json, 281 + data: record as unknown as Json, 218 282 }); 219 283 220 284 if (publication_uri) { ··· 836 900 */ 837 901 async function createMentionNotifications( 838 902 documentUri: string, 839 - record: PubLeafletDocument.Record, 903 + record: PubLeafletDocument.Record | SiteStandardDocument.Record, 840 904 authorDid: string, 841 905 ) { 842 906 const mentionedDids = new Set<string>(); 843 907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 844 908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 909 + const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 845 910 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); 911 + // Extract pages from either format 912 + let pages: PubLeafletContent.Main["pages"] | undefined; 913 + if (record.$type === "site.standard.document") { 914 + const content = record.content; 915 + if (content && PubLeafletContent.isMain(content)) { 916 + pages = content.pages; 917 + } 918 + } else { 919 + pages = record.pages; 920 + } 921 + 922 + if (!pages) return; 923 + 924 + // Helper to extract blocks from all pages (both linear and canvas) 925 + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 926 + const blocks: ( 927 + | PubLeafletPagesLinearDocument.Block["block"] 928 + | PubLeafletPagesCanvas.Block["block"] 929 + )[] = []; 930 + for (const page of pages) { 931 + if (page.$type === "pub.leaflet.pages.linearDocument") { 932 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 933 + for (const blockWrapper of linearPage.blocks) { 934 + blocks.push(blockWrapper.block); 935 + } 936 + } else if (page.$type === "pub.leaflet.pages.canvas") { 937 + const canvasPage = page as PubLeafletPagesCanvas.Main; 938 + for (const blockWrapper of canvasPage.blocks) { 939 + blocks.push(blockWrapper.block); 940 + } 941 + } 942 + } 943 + return blocks; 944 + } 945 + 946 + const allBlocks = getAllBlocks(pages); 947 + 948 + // Extract mentions from all text blocks and embedded Bluesky posts 949 + for (const block of allBlocks) { 950 + // Check for embedded Bluesky posts 951 + if (PubLeafletBlocksBskyPost.isMain(block)) { 952 + const bskyPostUri = block.postRef.uri; 953 + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 954 + const postAuthorDid = new AtUri(bskyPostUri).host; 955 + if (postAuthorDid !== authorDid) { 956 + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 957 + } 958 + } 959 + 960 + // Check for text blocks with mentions 961 + if (block.$type === "pub.leaflet.blocks.text") { 962 + const textBlock = block as PubLeafletBlocksText.Main; 963 + if (textBlock.facets) { 964 + for (const facet of textBlock.facets) { 965 + for (const feature of facet.features) { 966 + // Check for DID mentions 967 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 968 + if (feature.did !== authorDid) { 969 + mentionedDids.add(feature.did); 970 + } 971 + } 972 + // Check for AT URI mentions (publications and documents) 973 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 974 + const uri = new AtUri(feature.atURI); 866 975 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(); 976 + if (isPublicationCollection(uri.collection)) { 977 + // Get the publication owner's DID 978 + const { data: publication } = await supabaseServerClient 979 + .from("publications") 980 + .select("identity_did") 981 + .eq("uri", feature.atURI) 982 + .single(); 874 983 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(); 984 + if (publication && publication.identity_did !== authorDid) { 985 + mentionedPublications.set( 986 + publication.identity_did, 987 + feature.atURI, 988 + ); 989 + } 990 + } else if (isDocumentCollection(uri.collection)) { 991 + // Get the document owner's DID 992 + const { data: document } = await supabaseServerClient 993 + .from("documents") 994 + .select("uri, data") 995 + .eq("uri", feature.atURI) 996 + .single(); 888 997 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 - } 998 + if (document) { 999 + const normalizedMentionedDoc = normalizeDocumentRecord( 1000 + document.data, 1001 + ); 1002 + // Get the author from the document URI (the DID is the host part) 1003 + const mentionedUri = new AtUri(feature.atURI); 1004 + const docAuthor = mentionedUri.host; 1005 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 1006 + mentionedDocuments.set(docAuthor, feature.atURI); 896 1007 } 897 1008 } 898 1009 } ··· 948 1059 }; 949 1060 await supabaseServerClient.from("notifications").insert(notification); 950 1061 await pingIdentityToUpdateNotification(recipientDid); 1062 + } 1063 + 1064 + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1065 + if (embeddedBskyPosts.size > 0) { 1066 + // Check which of the Bluesky post authors have Leaflet accounts 1067 + const { data: identities } = await supabaseServerClient 1068 + .from("identities") 1069 + .select("atp_did") 1070 + .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1071 + 1072 + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1073 + 1074 + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1075 + // Only notify if the post author has a Leaflet account 1076 + if (leafletUserDids.has(postAuthorDid)) { 1077 + const notification: Notification = { 1078 + id: v7(), 1079 + recipient: postAuthorDid, 1080 + data: { 1081 + type: "bsky_post_embed", 1082 + document_uri: documentUri, 1083 + bsky_post_uri: bskyPostUri, 1084 + }, 1085 + }; 1086 + await supabaseServerClient.from("notifications").insert(notification); 1087 + await pingIdentityToUpdateNotification(postAuthorDid); 1088 + } 1089 + } 951 1090 } 952 1091 }
+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 (
+4 -5
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}`} 27 - icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 25 + href={pubRecord ? pubRecord.url : "#"} 26 + icon={<Avatar src={avatarSrc} displayName={displayName} size="tiny" />} 28 27 actionText={ 29 28 <> 30 29 {displayName} subscribed to {pubRecord?.name}!
+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 (
+8 -13
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 24 23 src={profileRecord.avatar} 25 24 displayName={profileRecord.displayName} 26 25 className="profileAvatar mx-auto mt-3 sm:mt-4" 27 - giant 26 + size="giant" 28 27 /> 29 28 ); 30 29 ··· 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> ··· 106 101 ); 107 102 }; 108 103 const PublicationCard = (props: { 109 - record: PubLeafletPublication.Record; 104 + record: NormalizedPublication; 110 105 uri: string; 111 106 }) => { 112 107 const { record, uri } = props; ··· 114 109 115 110 return ( 116 111 <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 " 112 + href={record.url} 113 + className="profilePublicationCard border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 119 114 style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 120 115 > 121 116 <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 },
+17 -1
app/[leaflet_id]/Footer.tsx
··· 8 8 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 9 import { PublishButton } from "./actions/PublishButton"; 10 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 - import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 12 11 import { Watermark } from "components/Watermark"; 13 12 import { BackToPubButton } from "./actions/BackToPubButton"; 14 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 14 import { useIdentityData } from "components/IdentityProvider"; 15 + import { useEntity } from "src/replicache"; 16 + import { block } from "sharp"; 16 17 18 + export function hasBlockToolbar(blockType: string | null | undefined) { 19 + return ( 20 + blockType === "text" || 21 + blockType === "heading" || 22 + blockType === "blockquote" || 23 + blockType === "button" || 24 + blockType === "datetime" || 25 + blockType === "image" 26 + ); 27 + } 17 28 export function LeafletFooter(props: { entityID: string }) { 18 29 let focusedBlock = useUIState((s) => s.focusedEntity); 30 + 19 31 let entity_set = useEntitySetContext(); 20 32 let { identity } = useIdentityData(); 21 33 let { data: pub } = useLeafletPublicationData(); 34 + let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data 35 + .value; 22 36 23 37 return ( 24 38 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 25 39 {focusedBlock && 26 40 focusedBlock.entityType == "block" && 41 + hasBlockToolbar(blockType) && 27 42 entity_set.permissions.write ? ( 28 43 <div 29 44 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" ··· 34 49 <Toolbar 35 50 pageID={focusedBlock.parent} 36 51 blockID={focusedBlock.entityID} 52 + blockType={blockType} 37 53 /> 38 54 </div> 39 55 ) : entity_set.permissions.write ? (
+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}
+20
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 + }; 29 + "user/cleanup-expired-oauth-sessions": { 30 + data: {}; 31 + }; 32 + "user/check-oauth-session": { 33 + data: { 34 + identityId: string; 35 + did: string; 36 + tokenCount: number; 37 + }; 38 + }; 39 + "documents/fix-publication-references": { 40 + data: { 41 + documentUris: string[]; 42 + }; 43 + }; 24 44 }; 25 45 26 46 // Create a client to send and receive events
+123
app/api/inngest/functions/cleanup_expired_oauth_sessions.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + 5 + // Main function that fetches identities and publishes events for each one 6 + export const cleanup_expired_oauth_sessions = inngest.createFunction( 7 + { id: "cleanup_expired_oauth_sessions" }, 8 + { event: "user/cleanup-expired-oauth-sessions" }, 9 + async ({ step }) => { 10 + // Get all identities with an atp_did (OAuth users) that have at least one auth token 11 + const identities = await step.run("fetch-oauth-identities", async () => { 12 + const { data, error } = await supabaseServerClient 13 + .from("identities") 14 + .select("id, atp_did, email_auth_tokens(count)") 15 + .not("atp_did", "is", null); 16 + 17 + if (error) { 18 + throw new Error(`Failed to fetch identities: ${error.message}`); 19 + } 20 + 21 + // Filter to only include identities with at least one auth token 22 + return (data || []) 23 + .filter((identity) => { 24 + const tokenCount = identity.email_auth_tokens?.[0]?.count ?? 0; 25 + return tokenCount > 0; 26 + }) 27 + .map((identity) => ({ 28 + id: identity.id, 29 + atp_did: identity.atp_did!, 30 + tokenCount: identity.email_auth_tokens?.[0]?.count ?? 0, 31 + })); 32 + }); 33 + 34 + console.log( 35 + `Found ${identities.length} OAuth identities with active sessions to check`, 36 + ); 37 + 38 + // Publish events for each identity in batches 39 + const BATCH_SIZE = 100; 40 + let totalSent = 0; 41 + 42 + for (let i = 0; i < identities.length; i += BATCH_SIZE) { 43 + const batch = identities.slice(i, i + BATCH_SIZE); 44 + 45 + await step.run(`send-events-batch-${i}`, async () => { 46 + const events = batch.map((identity) => ({ 47 + name: "user/check-oauth-session" as const, 48 + data: { 49 + identityId: identity.id, 50 + did: identity.atp_did, 51 + tokenCount: identity.tokenCount, 52 + }, 53 + })); 54 + 55 + await inngest.send(events); 56 + return events.length; 57 + }); 58 + 59 + totalSent += batch.length; 60 + } 61 + 62 + console.log(`Published ${totalSent} check-oauth-session events`); 63 + 64 + return { 65 + success: true, 66 + identitiesQueued: totalSent, 67 + }; 68 + }, 69 + ); 70 + 71 + // Function that checks a single identity's OAuth session and cleans up if expired 72 + export const check_oauth_session = inngest.createFunction( 73 + { id: "check_oauth_session" }, 74 + { event: "user/check-oauth-session" }, 75 + async ({ event, step }) => { 76 + const { identityId, did, tokenCount } = event.data; 77 + 78 + const result = await step.run("check-and-cleanup", async () => { 79 + console.log(`Checking OAuth session for DID: ${did} (${tokenCount} tokens)`); 80 + 81 + const sessionResult = await restoreOAuthSession(did); 82 + 83 + if (sessionResult.ok) { 84 + console.log(` Session valid for ${did}`); 85 + return { valid: true, tokensDeleted: 0 }; 86 + } 87 + 88 + // Session is expired/invalid - delete associated auth tokens 89 + console.log( 90 + ` Session expired for ${did}: ${sessionResult.error.message}`, 91 + ); 92 + 93 + const { error: deleteError } = await supabaseServerClient 94 + .from("email_auth_tokens") 95 + .delete() 96 + .eq("identity", identityId); 97 + 98 + if (deleteError) { 99 + console.error( 100 + ` Error deleting tokens for identity ${identityId}: ${deleteError.message}`, 101 + ); 102 + return { 103 + valid: false, 104 + tokensDeleted: 0, 105 + error: deleteError.message, 106 + }; 107 + } 108 + 109 + console.log(` Deleted ${tokenCount} auth tokens for identity ${identityId}`); 110 + 111 + return { 112 + valid: false, 113 + tokensDeleted: tokenCount, 114 + }; 115 + }); 116 + 117 + return { 118 + identityId, 119 + did, 120 + ...result, 121 + }; 122 + }, 123 + );
+213
app/api/inngest/functions/fix_standard_document_publications.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { Json } from "supabase/database.types"; 7 + 8 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 9 + const result = await restoreOAuthSession(did); 10 + if (!result.ok) { 11 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 12 + } 13 + const credentialSession = result.value; 14 + return new AtpBaseClient( 15 + credentialSession.fetchHandler.bind(credentialSession), 16 + ); 17 + } 18 + 19 + /** 20 + * Fixes site.standard.document records that have stale pub.leaflet.publication 21 + * references in their site field. Updates both the PDS record and database. 22 + */ 23 + export const fix_standard_document_publications = inngest.createFunction( 24 + { id: "fix_standard_document_publications" }, 25 + { event: "documents/fix-publication-references" }, 26 + async ({ event, step }) => { 27 + const { documentUris } = event.data as { documentUris: string[] }; 28 + 29 + const stats = { 30 + documentsFixed: 0, 31 + joinEntriesFixed: 0, 32 + errors: [] as string[], 33 + }; 34 + 35 + if (!documentUris || documentUris.length === 0) { 36 + return { success: true, stats, message: "No documents to fix" }; 37 + } 38 + 39 + // Group documents by DID (author) for efficient OAuth session handling 40 + const docsByDid = new Map<string, string[]>(); 41 + for (const uri of documentUris) { 42 + try { 43 + const aturi = new AtUri(uri); 44 + const did = aturi.hostname; 45 + const existing = docsByDid.get(did) || []; 46 + existing.push(uri); 47 + docsByDid.set(did, existing); 48 + } catch (e) { 49 + stats.errors.push(`Invalid URI: ${uri}`); 50 + } 51 + } 52 + 53 + // Process each DID's documents 54 + for (const [did, uris] of docsByDid) { 55 + // Verify OAuth session for this user 56 + const oauthValid = await step.run( 57 + `verify-oauth-${did.slice(-8)}`, 58 + async () => { 59 + const result = await restoreOAuthSession(did); 60 + return result.ok; 61 + }, 62 + ); 63 + 64 + if (!oauthValid) { 65 + stats.errors.push(`No valid OAuth session for ${did}`); 66 + continue; 67 + } 68 + 69 + // Fix each document 70 + for (const docUri of uris) { 71 + const result = await step.run( 72 + `fix-doc-${docUri.slice(-12)}`, 73 + async () => { 74 + // Fetch the document 75 + const { data: doc, error: fetchError } = await supabaseServerClient 76 + .from("documents") 77 + .select("uri, data") 78 + .eq("uri", docUri) 79 + .single(); 80 + 81 + if (fetchError || !doc) { 82 + return { 83 + success: false as const, 84 + error: `Document not found: ${fetchError?.message || "no data"}`, 85 + }; 86 + } 87 + 88 + const data = doc.data as SiteStandardDocument.Record; 89 + const oldSite = data?.site; 90 + 91 + if (!oldSite || !oldSite.includes("/pub.leaflet.publication/")) { 92 + return { 93 + success: false as const, 94 + error: "Document does not have a pub.leaflet.publication site reference", 95 + }; 96 + } 97 + 98 + // Convert to new publication URI 99 + const oldPubAturi = new AtUri(oldSite); 100 + const newSite = `at://${oldPubAturi.hostname}/site.standard.publication/${oldPubAturi.rkey}`; 101 + 102 + // Update the record 103 + const updatedRecord: SiteStandardDocument.Record = { 104 + ...data, 105 + site: newSite, 106 + }; 107 + 108 + // Write to PDS 109 + const docAturi = new AtUri(docUri); 110 + const agent = await createAuthenticatedAgent(did); 111 + await agent.com.atproto.repo.putRecord({ 112 + repo: did, 113 + collection: "site.standard.document", 114 + rkey: docAturi.rkey, 115 + record: updatedRecord, 116 + validate: false, 117 + }); 118 + 119 + // Update database 120 + const { error: dbError } = await supabaseServerClient 121 + .from("documents") 122 + .update({ data: updatedRecord as Json }) 123 + .eq("uri", docUri); 124 + 125 + if (dbError) { 126 + return { 127 + success: false as const, 128 + error: `Database update failed: ${dbError.message}`, 129 + }; 130 + } 131 + 132 + return { 133 + success: true as const, 134 + oldSite, 135 + newSite, 136 + }; 137 + }, 138 + ); 139 + 140 + if (result.success) { 141 + stats.documentsFixed++; 142 + 143 + // Fix the documents_in_publications entry 144 + const joinResult = await step.run( 145 + `fix-join-${docUri.slice(-12)}`, 146 + async () => { 147 + // Find the publication URI that exists in the database 148 + const { data: doc } = await supabaseServerClient 149 + .from("documents") 150 + .select("data") 151 + .eq("uri", docUri) 152 + .single(); 153 + 154 + const newSite = (doc?.data as any)?.site; 155 + if (!newSite) { 156 + return { success: false as const, error: "Could not read updated document" }; 157 + } 158 + 159 + // Check which publication URI exists 160 + const newPubAturi = new AtUri(newSite); 161 + const oldPubUri = `at://${newPubAturi.hostname}/pub.leaflet.publication/${newPubAturi.rkey}`; 162 + 163 + const { data: pubs } = await supabaseServerClient 164 + .from("publications") 165 + .select("uri") 166 + .in("uri", [newSite, oldPubUri]); 167 + 168 + const existingPubUri = pubs?.find((p) => p.uri === newSite)?.uri || 169 + pubs?.find((p) => p.uri === oldPubUri)?.uri; 170 + 171 + if (!existingPubUri) { 172 + return { success: false as const, error: "No matching publication found" }; 173 + } 174 + 175 + // Delete any existing entries for this document 176 + await supabaseServerClient 177 + .from("documents_in_publications") 178 + .delete() 179 + .eq("document", docUri); 180 + 181 + // Insert the correct entry 182 + const { error: insertError } = await supabaseServerClient 183 + .from("documents_in_publications") 184 + .insert({ 185 + document: docUri, 186 + publication: existingPubUri, 187 + }); 188 + 189 + if (insertError) { 190 + return { success: false as const, error: insertError.message }; 191 + } 192 + 193 + return { success: true as const, publication: existingPubUri }; 194 + }, 195 + ); 196 + 197 + if (joinResult.success) { 198 + stats.joinEntriesFixed++; 199 + } else { 200 + stats.errors.push(`Join table fix failed for ${docUri}: ${"error" in joinResult ? joinResult.error : "unknown error"}`); 201 + } 202 + } else { 203 + stats.errors.push(`${docUri}: ${result.error}`); 204 + } 205 + } 206 + } 207 + 208 + return { 209 + success: stats.errors.length === 0, 210 + stats, 211 + }; 212 + }, 213 + );
+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
+635
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 + standardDocumentsFixed: 0, 42 + userSubscriptionsMigrated: 0, 43 + referencesUpdated: 0, 44 + errors: [] as string[], 45 + }; 46 + 47 + // Step 1: Verify OAuth session is valid 48 + const oauthValid = await step.run("verify-oauth-session", async () => { 49 + const result = await restoreOAuthSession(did); 50 + if (!result.ok) { 51 + // Mark identity as needing migration so we can retry later 52 + await supabaseServerClient 53 + .from("identities") 54 + .update({ 55 + metadata: { needsStandardSiteMigration: true }, 56 + }) 57 + .eq("atp_did", did); 58 + 59 + return { success: false, error: result.error.message }; 60 + } 61 + return { success: true }; 62 + }); 63 + 64 + if (!oauthValid.success) { 65 + return { 66 + success: false, 67 + error: `Failed to restore OAuth session`, 68 + stats, 69 + publicationUriMap: {}, 70 + documentUriMap: {}, 71 + userSubscriptionUriMap: {}, 72 + }; 73 + } 74 + 75 + // Step 2: Get user's pub.leaflet.publication records 76 + const oldPublications = await step.run( 77 + "fetch-old-publications", 78 + async () => { 79 + const { data, error } = await supabaseServerClient 80 + .from("publications") 81 + .select("*") 82 + .eq("identity_did", did) 83 + .like("uri", `at://${did}/pub.leaflet.publication/%`); 84 + 85 + if (error) 86 + throw new Error(`Failed to fetch publications: ${error.message}`); 87 + return data || []; 88 + }, 89 + ); 90 + 91 + // Step 3: Migrate all publications in parallel 92 + const publicationUriMap: Record<string, string> = {}; // old URI -> new URI 93 + 94 + // Prepare publications that need migration 95 + const publicationsToMigrate = oldPublications 96 + .map((pub) => { 97 + const aturi = new AtUri(pub.uri); 98 + 99 + // Skip if already a site.standard.publication 100 + if (aturi.collection === "site.standard.publication") { 101 + publicationUriMap[pub.uri] = pub.uri; 102 + return null; 103 + } 104 + 105 + const rkey = aturi.rkey; 106 + const normalized = normalizePublicationRecord(pub.record); 107 + 108 + if (!normalized) { 109 + stats.errors.push( 110 + `Publication ${pub.uri}: Failed to normalize publication record`, 111 + ); 112 + return null; 113 + } 114 + 115 + const newRecord: SiteStandardPublication.Record = { 116 + $type: "site.standard.publication", 117 + name: normalized.name, 118 + url: normalized.url, 119 + description: normalized.description, 120 + icon: normalized.icon, 121 + theme: normalized.theme, 122 + basicTheme: normalized.basicTheme, 123 + preferences: normalized.preferences, 124 + }; 125 + 126 + return { pub, rkey, normalized, newRecord }; 127 + }) 128 + .filter((x) => x !== null); 129 + 130 + // Run PDS + DB writes together for each publication 131 + const pubResults = await Promise.all( 132 + publicationsToMigrate.map(({ pub, rkey, normalized, newRecord }) => 133 + step.run(`migrate-publication-${pub.uri}`, async () => { 134 + // PDS write 135 + const agent = await createAuthenticatedAgent(did); 136 + const putResult = await agent.com.atproto.repo.putRecord({ 137 + repo: did, 138 + collection: "site.standard.publication", 139 + rkey, 140 + record: newRecord, 141 + validate: false, 142 + }); 143 + const newUri = putResult.data.uri; 144 + 145 + // DB write 146 + const { error: dbError } = await supabaseServerClient 147 + .from("publications") 148 + .upsert({ 149 + uri: newUri, 150 + identity_did: did, 151 + name: normalized.name, 152 + record: newRecord as Json, 153 + }); 154 + 155 + if (dbError) { 156 + return { 157 + success: false as const, 158 + oldUri: pub.uri, 159 + newUri, 160 + error: dbError.message, 161 + }; 162 + } 163 + return { success: true as const, oldUri: pub.uri, newUri }; 164 + }), 165 + ), 166 + ); 167 + 168 + // Process results 169 + for (const result of pubResults) { 170 + if (result.success) { 171 + publicationUriMap[result.oldUri] = result.newUri; 172 + stats.publicationsMigrated++; 173 + } else { 174 + stats.errors.push( 175 + `Publication ${result.oldUri}: Database error: ${result.error}`, 176 + ); 177 + } 178 + } 179 + 180 + // Step 4: Get ALL user's documents and their publication associations in parallel 181 + const [oldDocuments, allDocumentPublications] = await Promise.all([ 182 + step.run("fetch-old-documents", async () => { 183 + const { data, error } = await supabaseServerClient 184 + .from("documents") 185 + .select("uri, data") 186 + .like("uri", `at://${did}/pub.leaflet.document/%`); 187 + 188 + if (error) 189 + throw new Error(`Failed to fetch documents: ${error.message}`); 190 + return data || []; 191 + }), 192 + step.run("fetch-document-publications", async () => { 193 + const { data, error } = await supabaseServerClient 194 + .from("documents_in_publications") 195 + .select("document, publication") 196 + .like("document", `at://${did}/pub.leaflet.document/%`); 197 + 198 + if (error) 199 + throw new Error( 200 + `Failed to fetch document publications: ${error.message}`, 201 + ); 202 + return data || []; 203 + }), 204 + ]); 205 + 206 + // Create a map of document URI -> publication URI 207 + const documentPublicationMap: Record<string, string> = {}; 208 + for (const row of allDocumentPublications) { 209 + documentPublicationMap[row.document] = row.publication; 210 + } 211 + 212 + const documentUriMap: Record<string, string> = {}; // old URI -> new URI 213 + 214 + // Prepare documents that need migration 215 + const documentsToMigrate = oldDocuments 216 + .map((doc) => { 217 + const aturi = new AtUri(doc.uri); 218 + 219 + // Skip if already a site.standard.document 220 + if (aturi.collection === "site.standard.document") { 221 + documentUriMap[doc.uri] = doc.uri; 222 + return null; 223 + } 224 + 225 + const rkey = aturi.rkey; 226 + const normalized = normalizeDocumentRecord(doc.data, doc.uri); 227 + 228 + if (!normalized) { 229 + stats.errors.push( 230 + `Document ${doc.uri}: Failed to normalize document record`, 231 + ); 232 + return null; 233 + } 234 + 235 + // Determine the site field: 236 + // - If document is in a publication, use the new publication URI (if migrated) or old URI 237 + // - If standalone, use the HTTPS URL format 238 + const oldPubUri = documentPublicationMap[doc.uri]; 239 + let siteValue: string; 240 + 241 + if (oldPubUri) { 242 + // Document is in a publication - use new URI if migrated, otherwise keep old 243 + siteValue = publicationUriMap[oldPubUri] || oldPubUri; 244 + } else { 245 + // Standalone document - use HTTPS URL format 246 + siteValue = `https://leaflet.pub/p/${did}`; 247 + } 248 + 249 + // Build site.standard.document record 250 + const newRecord: SiteStandardDocument.Record = { 251 + $type: "site.standard.document", 252 + title: normalized.title || "Untitled", 253 + site: siteValue, 254 + path: "/" + rkey, 255 + publishedAt: normalized.publishedAt || new Date().toISOString(), 256 + description: normalized.description, 257 + content: normalized.content, 258 + tags: normalized.tags, 259 + coverImage: normalized.coverImage, 260 + bskyPostRef: normalized.bskyPostRef, 261 + }; 262 + 263 + return { doc, rkey, normalized, newRecord, oldPubUri }; 264 + }) 265 + .filter((x) => x !== null); 266 + 267 + // Run PDS + DB writes together for each document 268 + const docResults = await Promise.all( 269 + documentsToMigrate.map(({ doc, rkey, newRecord, oldPubUri }) => 270 + step.run(`migrate-document-${doc.uri}`, async () => { 271 + // PDS write 272 + const agent = await createAuthenticatedAgent(did); 273 + const putResult = await agent.com.atproto.repo.putRecord({ 274 + repo: did, 275 + collection: "site.standard.document", 276 + rkey, 277 + record: newRecord, 278 + validate: false, 279 + }); 280 + const newUri = putResult.data.uri; 281 + 282 + // DB write 283 + const { error: dbError } = await supabaseServerClient 284 + .from("documents") 285 + .upsert({ 286 + uri: newUri, 287 + data: newRecord as Json, 288 + }); 289 + 290 + if (dbError) { 291 + return { 292 + success: false as const, 293 + oldUri: doc.uri, 294 + newUri, 295 + error: dbError.message, 296 + }; 297 + } 298 + 299 + // If document was in a publication, add to documents_in_publications with new URIs 300 + if (oldPubUri) { 301 + const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; 302 + await supabaseServerClient 303 + .from("documents_in_publications") 304 + .upsert({ 305 + publication: newPubUri, 306 + document: newUri, 307 + }); 308 + } 309 + 310 + return { success: true as const, oldUri: doc.uri, newUri }; 311 + }), 312 + ), 313 + ); 314 + 315 + // Process results 316 + for (const result of docResults) { 317 + if (result.success) { 318 + documentUriMap[result.oldUri] = result.newUri; 319 + stats.documentsMigrated++; 320 + } else { 321 + stats.errors.push( 322 + `Document ${result.oldUri}: Database error: ${result.error}`, 323 + ); 324 + } 325 + } 326 + 327 + // Step 4b: Fix existing site.standard.document records that reference pub.leaflet.publication 328 + // This handles the case where site.standard.document records were created pointing to 329 + // pub.leaflet.publication URIs before the publication was migrated to site.standard.publication 330 + const existingStandardDocs = await step.run( 331 + "fetch-existing-standard-documents", 332 + async () => { 333 + const { data, error } = await supabaseServerClient 334 + .from("documents") 335 + .select("uri, data") 336 + .like("uri", `at://${did}/site.standard.document/%`); 337 + 338 + if (error) 339 + throw new Error( 340 + `Failed to fetch existing standard documents: ${error.message}`, 341 + ); 342 + return data || []; 343 + }, 344 + ); 345 + 346 + // Find documents that reference pub.leaflet.publication and need their site field updated 347 + const standardDocsToFix = existingStandardDocs 348 + .map((doc) => { 349 + const data = doc.data as SiteStandardDocument.Record; 350 + const site = data?.site; 351 + 352 + // Check if site field references a pub.leaflet.publication 353 + if (!site || !site.includes("/pub.leaflet.publication/")) { 354 + return null; 355 + } 356 + 357 + try { 358 + const oldPubAturi = new AtUri(site); 359 + const newPubUri = `at://${oldPubAturi.hostname}/site.standard.publication/${oldPubAturi.rkey}`; 360 + 361 + // Only fix if we have the new publication in our map (meaning it was migrated) 362 + // or if the new publication exists (check against all migrated publications) 363 + if ( 364 + publicationUriMap[site] || 365 + Object.values(publicationUriMap).includes(newPubUri) 366 + ) { 367 + const docAturi = new AtUri(doc.uri); 368 + const updatedRecord: SiteStandardDocument.Record = { 369 + ...data, 370 + site: newPubUri, 371 + }; 372 + 373 + return { 374 + doc, 375 + rkey: docAturi.rkey, 376 + oldSite: site, 377 + newSite: newPubUri, 378 + updatedRecord, 379 + }; 380 + } 381 + } catch (e) { 382 + stats.errors.push(`Invalid site URI in document ${doc.uri}: ${site}`); 383 + } 384 + 385 + return null; 386 + }) 387 + .filter((x) => x !== null); 388 + 389 + // Update these documents on PDS and in database 390 + if (standardDocsToFix.length > 0) { 391 + const fixResults = await Promise.all( 392 + standardDocsToFix.map(({ doc, rkey, oldSite, newSite, updatedRecord }) => 393 + step.run(`fix-standard-document-${doc.uri}`, async () => { 394 + // PDS write to update the site field 395 + const agent = await createAuthenticatedAgent(did); 396 + await agent.com.atproto.repo.putRecord({ 397 + repo: did, 398 + collection: "site.standard.document", 399 + rkey, 400 + record: updatedRecord, 401 + validate: false, 402 + }); 403 + 404 + // DB write 405 + const { error: dbError } = await supabaseServerClient 406 + .from("documents") 407 + .update({ data: updatedRecord as Json }) 408 + .eq("uri", doc.uri); 409 + 410 + if (dbError) { 411 + return { 412 + success: false as const, 413 + uri: doc.uri, 414 + error: dbError.message, 415 + }; 416 + } 417 + 418 + // Update documents_in_publications to point to new publication URI 419 + await supabaseServerClient 420 + .from("documents_in_publications") 421 + .upsert({ 422 + publication: newSite, 423 + document: doc.uri, 424 + }); 425 + 426 + // Remove old publication reference if different 427 + if (oldSite !== newSite) { 428 + await supabaseServerClient 429 + .from("documents_in_publications") 430 + .delete() 431 + .eq("publication", oldSite) 432 + .eq("document", doc.uri); 433 + } 434 + 435 + return { success: true as const, uri: doc.uri }; 436 + }), 437 + ), 438 + ); 439 + 440 + for (const result of fixResults) { 441 + if (result.success) { 442 + stats.standardDocumentsFixed++; 443 + } else { 444 + stats.errors.push( 445 + `Fix standard document ${result.uri}: Database error: ${result.error}`, 446 + ); 447 + } 448 + } 449 + } 450 + 451 + // Step 5: Update references in database tables (all in parallel) 452 + await step.run("update-references", async () => { 453 + const pubEntries = Object.entries(publicationUriMap); 454 + const docEntries = Object.entries(documentUriMap); 455 + 456 + const updatePromises = [ 457 + // Update leaflets_in_publications - publication references 458 + ...pubEntries.map(([oldUri, newUri]) => 459 + supabaseServerClient 460 + .from("leaflets_in_publications") 461 + .update({ publication: newUri }) 462 + .eq("publication", oldUri), 463 + ), 464 + // Update leaflets_in_publications - doc references 465 + ...docEntries.map(([oldUri, newUri]) => 466 + supabaseServerClient 467 + .from("leaflets_in_publications") 468 + .update({ doc: newUri }) 469 + .eq("doc", oldUri), 470 + ), 471 + // Update leaflets_to_documents - document references 472 + ...docEntries.map(([oldUri, newUri]) => 473 + supabaseServerClient 474 + .from("leaflets_to_documents") 475 + .update({ document: newUri }) 476 + .eq("document", oldUri), 477 + ), 478 + // Update publication_domains - publication references 479 + ...pubEntries.map(([oldUri, newUri]) => 480 + supabaseServerClient 481 + .from("publication_domains") 482 + .update({ publication: newUri }) 483 + .eq("publication", oldUri), 484 + ), 485 + // Update comments_on_documents - document references 486 + ...docEntries.map(([oldUri, newUri]) => 487 + supabaseServerClient 488 + .from("comments_on_documents") 489 + .update({ document: newUri }) 490 + .eq("document", oldUri), 491 + ), 492 + // Update document_mentions_in_bsky - document references 493 + ...docEntries.map(([oldUri, newUri]) => 494 + supabaseServerClient 495 + .from("document_mentions_in_bsky") 496 + .update({ document: newUri }) 497 + .eq("document", oldUri), 498 + ), 499 + // Update subscribers_to_publications - publication references 500 + ...pubEntries.map(([oldUri, newUri]) => 501 + supabaseServerClient 502 + .from("subscribers_to_publications") 503 + .update({ publication: newUri }) 504 + .eq("publication", oldUri), 505 + ), 506 + // Update publication_subscriptions - publication references 507 + ...pubEntries.map(([oldUri, newUri]) => 508 + supabaseServerClient 509 + .from("publication_subscriptions") 510 + .update({ publication: newUri }) 511 + .eq("publication", oldUri), 512 + ), 513 + ]; 514 + 515 + const results = await Promise.all(updatePromises); 516 + stats.referencesUpdated = results.filter((r) => !r.error).length; 517 + return stats.referencesUpdated; 518 + }); 519 + 520 + // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications 521 + const userSubscriptions = await step.run( 522 + "fetch-user-subscriptions", 523 + async () => { 524 + const { data, error } = await supabaseServerClient 525 + .from("publication_subscriptions") 526 + .select("*") 527 + .eq("identity", did) 528 + .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); 529 + 530 + if (error) 531 + throw new Error( 532 + `Failed to fetch user subscriptions: ${error.message}`, 533 + ); 534 + return data || []; 535 + }, 536 + ); 537 + 538 + const userSubscriptionUriMap: Record<string, string> = {}; // old URI -> new URI 539 + 540 + // Prepare subscriptions that need migration 541 + const subscriptionsToMigrate = userSubscriptions 542 + .map((sub) => { 543 + const aturi = new AtUri(sub.uri); 544 + 545 + // Skip if already a site.standard.graph.subscription 546 + if (aturi.collection === "site.standard.graph.subscription") { 547 + userSubscriptionUriMap[sub.uri] = sub.uri; 548 + return null; 549 + } 550 + 551 + const rkey = aturi.rkey; 552 + const newRecord: SiteStandardGraphSubscription.Record = { 553 + $type: "site.standard.graph.subscription", 554 + publication: sub.publication, 555 + }; 556 + 557 + return { sub, rkey, newRecord }; 558 + }) 559 + .filter((x) => x !== null); 560 + 561 + // Run PDS + DB writes together for each subscription 562 + const subResults = await Promise.all( 563 + subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 564 + step.run(`migrate-subscription-${sub.uri}`, async () => { 565 + // PDS write 566 + const agent = await createAuthenticatedAgent(did); 567 + const putResult = await agent.com.atproto.repo.putRecord({ 568 + repo: did, 569 + collection: "site.standard.graph.subscription", 570 + rkey, 571 + record: newRecord, 572 + validate: false, 573 + }); 574 + const newUri = putResult.data.uri; 575 + 576 + // DB write 577 + const { error: dbError } = await supabaseServerClient 578 + .from("publication_subscriptions") 579 + .update({ 580 + uri: newUri, 581 + record: newRecord as Json, 582 + }) 583 + .eq("uri", sub.uri); 584 + 585 + if (dbError) { 586 + return { 587 + success: false as const, 588 + oldUri: sub.uri, 589 + newUri, 590 + error: dbError.message, 591 + }; 592 + } 593 + return { success: true as const, oldUri: sub.uri, newUri }; 594 + }), 595 + ), 596 + ); 597 + 598 + // Process results 599 + for (const result of subResults) { 600 + if (result.success) { 601 + userSubscriptionUriMap[result.oldUri] = result.newUri; 602 + stats.userSubscriptionsMigrated++; 603 + } else { 604 + stats.errors.push( 605 + `User subscription ${result.oldUri}: Database error: ${result.error}`, 606 + ); 607 + } 608 + } 609 + 610 + // NOTE: We intentionally keep old documents, publications, and documents_in_publications entries. 611 + // New entries are created with the new URIs, but the old entries remain so that: 612 + // 1. Notifications referencing old document/publication URIs can still resolve 613 + // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work 614 + // 3. The normalization layer handles both schemas transparently for reads 615 + // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 616 + 617 + // Clear the migration flag on success 618 + if (stats.errors.length === 0) { 619 + await step.run("clear-migration-flag", async () => { 620 + await supabaseServerClient 621 + .from("identities") 622 + .update({ metadata: null }) 623 + .eq("atp_did", did); 624 + }); 625 + } 626 + 627 + return { 628 + success: stats.errors.length === 0, 629 + stats, 630 + publicationUriMap, 631 + documentUriMap, 632 + userSubscriptionUriMap, 633 + }; 634 + }, 635 + );
+10
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"; 8 + import { fix_standard_document_publications } from "./functions/fix_standard_document_publications"; 9 + import { 10 + cleanup_expired_oauth_sessions, 11 + check_oauth_session, 12 + } from "./functions/cleanup_expired_oauth_sessions"; 7 13 8 14 export const { GET, POST, PUT } = serve({ 9 15 client: inngest, ··· 12 18 come_online, 13 19 batched_update_profiles, 14 20 index_follows, 21 + migrate_user_to_standard, 22 + fix_standard_document_publications, 23 + cleanup_expired_oauth_sessions, 24 + check_oauth_session, 15 25 ], 16 26 });
+11
app/api/oauth/[route]/route.ts
··· 11 11 ActionAfterSignIn, 12 12 parseActionFromSearchParam, 13 13 } from "./afterSignInActions"; 14 + import { inngest } from "app/api/inngest/client"; 14 15 15 16 type OauthRequestClientState = { 16 17 redirect: string | null; ··· 84 85 .single(); 85 86 identity = data; 86 87 } 88 + 89 + // Trigger migration if identity needs it 90 + const metadata = identity?.metadata as Record<string, unknown> | null; 91 + if (metadata?.needsStandardSiteMigration) { 92 + await inngest.send({ 93 + name: "user/migrate-to-standard", 94 + data: { did: session.did }, 95 + }); 96 + } 97 + 87 98 let { data: token } = await supabaseServerClient 88 99 .from("email_auth_tokens") 89 100 .insert({
+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 };
+4 -4
app/globals.css
··· 274 274 @apply p-2; 275 275 @apply rounded-md; 276 276 @apply overflow-auto; 277 + @apply sm:min-h-12; 278 + @apply min-h-10; 277 279 278 280 @media (min-width: 640px) { 279 281 @apply p-3; ··· 309 311 .ProseMirror .didMention.ProseMirror-selectednode { 310 312 @apply text-accent-contrast; 311 313 @apply px-0.5; 312 - @apply -mx-[3px]; /* extra px to account for the border*/ 313 - @apply -my-px; /*to account for the border*/ 314 314 @apply rounded-[4px]; 315 315 @apply box-decoration-clone; 316 316 background-color: rgba(var(--accent-contrast), 0.2); ··· 321 321 @apply cursor-pointer; 322 322 @apply text-accent-contrast; 323 323 @apply px-0.5; 324 - @apply -mx-[3px]; 325 - @apply -my-px; /*to account for the border*/ 326 324 @apply rounded-[4px]; 327 325 @apply box-decoration-clone; 328 326 background-color: rgba(var(--accent-contrast), 0.2); 329 327 border: 1px solid transparent; 328 + display: inline; 329 + white-space: normal; 330 330 } 331 331 332 332 .multiselected:focus-within .selection-highlight {
+34
app/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts
··· 1 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export async function GET( 5 + req: Request, 6 + props: { 7 + params: Promise<{ publication: string; did: string }>; 8 + }, 9 + ) { 10 + let params = await props.params; 11 + let did = decodeURIComponent(params.did); 12 + let publication_name = decodeURIComponent(params.publication); 13 + let [{ data: publications }] = await Promise.all([ 14 + supabaseServerClient 15 + .from("publications") 16 + .select( 17 + `*, 18 + publication_subscriptions(*), 19 + documents_in_publications(documents( 20 + *, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count) 23 + )) 24 + `, 25 + ) 26 + .eq("identity_did", did) 27 + .or(publicationNameOrUriFilter(did, publication_name)) 28 + .order("uri", { ascending: false }) 29 + .limit(1), 30 + ]); 31 + let publication = publications?.[0]; 32 + if (!did || !publication) return new Response(null, { status: 404 }); 33 + return new Response(publication.uri); 34 + }
+13 -113
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 13 13 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 14 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 15 import { openPage } from "../PostPages"; 16 + import { BskyPostContent } from "../BskyPostContent"; 16 17 17 18 export const PubBlueskyPostBlock = (props: { 18 19 post: PostView; ··· 49 50 50 51 //getting the url to the post 51 52 let postId = post.uri.split("/")[4]; 53 + let postView = post as PostView; 54 + 52 55 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 56 54 57 const parent = props.pageId ··· 56 59 : undefined; 57 60 58 61 return ( 59 - <div 60 - onClick={handleOpenThread} 61 - className={` 62 - ${props.className} 63 - block-border 64 - mb-2 65 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 66 - cursor-pointer hover:border-accent-contrast 67 - `} 68 - > 69 - {post.author && record && ( 70 - <> 71 - <div className="bskyAuthor w-full flex items-center gap-2"> 72 - {post.author.avatar && ( 73 - <img 74 - src={post.author?.avatar} 75 - alt={`${post.author?.displayName}'s avatar`} 76 - className="shink-0 w-8 h-8 rounded-full border border-border-light" 77 - /> 78 - )} 79 - <div className="grow flex flex-col gap-0.5 leading-tight"> 80 - <div className=" font-bold text-secondary"> 81 - {post.author?.displayName} 82 - </div> 83 - <a 84 - className="text-xs text-tertiary hover:underline" 85 - target="_blank" 86 - href={`https://bsky.app/profile/${post.author?.handle}`} 87 - onClick={(e) => e.stopPropagation()} 88 - > 89 - @{post.author?.handle} 90 - </a> 91 - </div> 92 - </div> 93 - 94 - <div className="flex flex-col gap-2 "> 95 - <div> 96 - <pre className="whitespace-pre-wrap"> 97 - {BlueskyRichText({ 98 - record: record as AppBskyFeedPost.Record | null, 99 - })} 100 - </pre> 101 - </div> 102 - {post.embed && ( 103 - <div onClick={(e) => e.stopPropagation()}> 104 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 105 - </div> 106 - )} 107 - </div> 108 - </> 109 - )} 110 - <div className="w-full flex gap-2 items-center justify-between"> 111 - <ClientDate date={timestamp} /> 112 - <div className="flex gap-2 items-center"> 113 - {post.replyCount != null && post.replyCount > 0 && ( 114 - <> 115 - <ThreadLink 116 - threadUri={post.uri} 117 - parent={parent} 118 - className="flex items-center gap-1 hover:text-accent-contrast" 119 - onClick={(e) => e.stopPropagation()} 120 - > 121 - {post.replyCount} 122 - <CommentTiny /> 123 - </ThreadLink> 124 - <Separator classname="h-4" /> 125 - </> 126 - )} 127 - {post.quoteCount != null && post.quoteCount > 0 && ( 128 - <> 129 - <QuotesLink 130 - postUri={post.uri} 131 - parent={parent} 132 - className="flex items-center gap-1 hover:text-accent-contrast" 133 - onClick={(e) => e.stopPropagation()} 134 - > 135 - {post.quoteCount} 136 - <QuoteTiny /> 137 - </QuotesLink> 138 - <Separator classname="h-4" /> 139 - </> 140 - )} 141 - 142 - <a 143 - className="" 144 - target="_blank" 145 - href={url} 146 - onClick={(e) => e.stopPropagation()} 147 - > 148 - <BlueskyTiny /> 149 - </a> 150 - </div> 151 - </div> 152 - </div> 62 + <BskyPostContent 63 + post={postView} 64 + parent={undefined} 65 + showBlueskyLink={true} 66 + showEmbed={true} 67 + avatarSize="large" 68 + quoteEnabled 69 + replyEnabled 70 + className="text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 71 + /> 153 72 ); 154 73 } 155 74 }; 156 - 157 - const ClientDate = (props: { date?: string }) => { 158 - let pageLoaded = useHasPageLoaded(); 159 - const formattedDate = useLocalizedDate( 160 - props.date || new Date().toISOString(), 161 - { 162 - month: "short", 163 - day: "numeric", 164 - year: "numeric", 165 - hour: "numeric", 166 - minute: "numeric", 167 - hour12: true, 168 - }, 169 - ); 170 - 171 - if (!pageLoaded) return null; 172 - 173 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 174 - };
+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
+31 -31
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 7 7 import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 8 import { openPage } from "./PostPages"; 9 9 import { BskyPostContent } from "./BskyPostContent"; 10 - import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 10 + import { 11 + QuotesLink, 12 + getQuotesKey, 13 + fetchQuotes, 14 + prefetchQuotes, 15 + } from "./PostLinks"; 11 16 12 17 // Re-export for backwards compatibility 13 18 export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; ··· 27 32 data: quotesData, 28 33 isLoading, 29 34 error, 30 - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 35 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => 36 + fetchQuotes(postUri), 37 + ); 31 38 32 39 return ( 33 40 <PageWrapper 34 41 pageType="doc" 35 42 fullPageScroll={false} 36 43 id={`post-page-${pageId}`} 37 - drawerOpen={!!drawer} 44 + drawerOpen={false} 38 45 pageOptions={pageOptions} 46 + fixedWidth 39 47 > 40 48 <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 41 - <div className="text-secondary font-bold mb-3 flex items-center gap-2"> 42 - <QuoteTiny /> 43 - Bluesky Quotes 44 - </div> 49 + <h4 className="text-secondary font-bold mb-2">Bluesky Quotes</h4> 45 50 {isLoading ? ( 46 51 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 47 52 <span>loading quotes</span> ··· 68 73 69 74 return ( 70 75 <div className="flex flex-col gap-0"> 71 - {posts.map((post) => ( 72 - <QuotePost 73 - key={post.uri} 74 - post={post} 75 - quotesUri={postUri} 76 - /> 76 + {posts.map((post, index) => ( 77 + <> 78 + <QuotePost key={post.uri} post={post} quotesUri={postUri} /> 79 + {posts.length !== index + 1 && ( 80 + <hr className="border-border-light my-4" /> 81 + )} 82 + </> 77 83 ))} 78 84 </div> 79 85 ); 80 86 } 81 87 82 - function QuotePost(props: { 83 - post: PostView; 84 - quotesUri: string; 85 - }) { 88 + function QuotePost(props: { post: PostView; quotesUri: string }) { 86 89 const { post, quotesUri } = props; 87 90 const parent = { type: "quotes" as const, uri: quotesUri }; 88 91 89 92 return ( 90 - <div 91 - className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 92 - onClick={() => openPage(parent, { type: "thread", uri: post.uri })} 93 - > 94 - <BskyPostContent 95 - post={post} 96 - parent={parent} 97 - linksEnabled={true} 98 - showEmbed={true} 99 - showBlueskyLink={true} 100 - onLinkClick={(e) => e.stopPropagation()} 101 - onEmbedClick={(e) => e.stopPropagation()} 102 - /> 103 - </div> 93 + <BskyPostContent 94 + post={post} 95 + parent={parent} 96 + showEmbed={true} 97 + compactEmbed 98 + showBlueskyLink={true} 99 + quoteEnabled 100 + replyEnabled 101 + onEmbedClick={(e) => e.stopPropagation()} 102 + className="relative rounded cursor-pointer text-sm" 103 + /> 104 104 ); 105 105 }
+244 -102
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 1 "use client"; 2 2 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { 4 - BlueskyEmbed, 5 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 3 + import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 6 4 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 7 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 6 import { CommentTiny } from "components/Icons/CommentTiny"; ··· 10 8 import { Separator } from "components/Layout"; 11 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 - import { OpenPage } from "./PostPages"; 11 + import { OpenPage, openPage } from "./PostPages"; 14 12 import { ThreadLink, QuotesLink } from "./PostLinks"; 13 + import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 14 + import { Avatar } from "components/Avatar"; 15 + import { timeAgo } from "src/utils/timeAgo"; 16 + import { ProfilePopover } from "components/ProfilePopover"; 15 17 16 18 type PostView = AppBskyFeedDefs.PostView; 17 19 18 20 export function BskyPostContent(props: { 19 21 post: PostView; 20 - parent?: OpenPage; 21 - linksEnabled?: boolean; 22 - avatarSize?: "sm" | "md"; 22 + parent: OpenPage | undefined; 23 + avatarSize?: "tiny" | "small" | "medium" | "large" | "giant"; 24 + className?: string; 23 25 showEmbed?: boolean; 26 + compactEmbed?: boolean; 24 27 showBlueskyLink?: boolean; 25 28 onEmbedClick?: (e: React.MouseEvent) => void; 26 - onLinkClick?: (e: React.MouseEvent) => void; 29 + quoteEnabled?: boolean; 30 + replyEnabled?: boolean; 31 + replyOnClick?: (e: React.MouseEvent) => void; 32 + replyLine?: { 33 + onToggle: (e: React.MouseEvent) => void; 34 + }; 27 35 }) { 28 36 const { 29 37 post, 30 38 parent, 31 - linksEnabled = true, 32 - avatarSize = "md", 39 + avatarSize = "medium", 33 40 showEmbed = true, 41 + compactEmbed = false, 34 42 showBlueskyLink = true, 35 43 onEmbedClick, 36 - onLinkClick, 44 + quoteEnabled, 45 + replyEnabled, 46 + replyOnClick, 47 + replyLine, 37 48 } = props; 38 49 39 50 const record = post.record as AppBskyFeedPost.Record; 40 51 const postId = post.uri.split("/")[4]; 41 52 const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 53 43 - const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 44 - 45 54 return ( 46 - <> 47 - <div className="flex flex-col items-center shrink-0"> 48 - {post.author.avatar ? ( 49 - <img 55 + // pointer events non so that is there is a replyLine, it can be clicked even though its underneath the postContent (buttons here have pointer-events-auto applied to make them clickable) 56 + <div className="bskyPost relative flex flex-col w-full pointer-events-none"> 57 + <div className={`flex gap-2 text-left w-full ${props.className}`}> 58 + <div className="flex flex-col items-start shrink-0 w-fit"> 59 + <Avatar 50 60 src={post.author.avatar} 51 - alt={`${post.author.displayName}'s avatar`} 52 - className={`${avatarClass} rounded-full border border-border-light`} 61 + displayName={post.author.displayName} 62 + size={avatarSize ? avatarSize : "medium"} 53 63 /> 54 - ) : ( 55 - <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 - )} 64 + </div> 65 + <div 66 + className={`flex flex-col min-w-0 w-full z-0 ${props.replyLine ? "mt-2" : ""}`} 67 + > 68 + <button 69 + className={`bskyPostTextContent flex flex-col grow text-left w-full pointer-events-auto ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 70 + onClick={() => { 71 + openPage(parent, { type: "thread", uri: post.uri }); 72 + }} 73 + > 74 + <PostInfo 75 + displayName={post.author.displayName} 76 + handle={post.author.handle} 77 + createdAt={record.createdAt} 78 + /> 79 + 80 + <div className={`postContent flex flex-col gap-2 mt-0.5`}> 81 + <div className="text-secondary"> 82 + <BlueskyRichText record={record} /> 83 + </div> 84 + {showEmbed && post.embed && ( 85 + <div onClick={onEmbedClick}> 86 + <BlueskyEmbed 87 + embed={post.embed} 88 + compact={compactEmbed} 89 + postUrl={url} 90 + className="text-sm" 91 + /> 92 + </div> 93 + )} 94 + </div> 95 + </button> 96 + {props.showBlueskyLink || 97 + (props.post.quoteCount && props.post.quoteCount > 0) || 98 + (props.post.replyCount && props.post.replyCount > 0) ? ( 99 + <div 100 + className={`postCountsAndLink flex gap-2 items-center justify-between mt-2 pointer-events-auto`} 101 + > 102 + <PostCounts 103 + post={post} 104 + parent={parent} 105 + replyEnabled={replyEnabled} 106 + replyOnClick={replyOnClick} 107 + quoteEnabled={quoteEnabled} 108 + showBlueskyLink={showBlueskyLink} 109 + url={url} 110 + /> 111 + 112 + <div className="flex gap-3 items-center"> 113 + {showBlueskyLink && ( 114 + <> 115 + <a 116 + className="text-tertiary hover:text-accent-contrast" 117 + target="_blank" 118 + href={url} 119 + > 120 + <BlueskyLinkTiny /> 121 + </a> 122 + </> 123 + )} 124 + </div> 125 + </div> 126 + ) : null} 127 + </div> 57 128 </div> 129 + </div> 130 + ); 131 + } 58 132 59 - <div className="flex flex-col grow min-w-0"> 60 - <div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}> 61 - <div className="font-bold text-secondary"> 62 - {post.author.displayName} 63 - </div> 64 - <a 65 - className="text-xs text-tertiary hover:underline" 66 - target="_blank" 67 - href={`https://bsky.app/profile/${post.author.handle}`} 68 - onClick={onLinkClick} 69 - > 70 - @{post.author.handle} 71 - </a> 133 + export function CompactBskyPostContent(props: { 134 + post: PostView; 135 + parent: OpenPage; 136 + className?: string; 137 + quoteEnabled?: boolean; 138 + replyEnabled?: boolean; 139 + replyOnClick?: (e: React.MouseEvent) => void; 140 + replyLine?: { 141 + onToggle: (e: React.MouseEvent) => void; 142 + }; 143 + }) { 144 + const { post, parent, quoteEnabled, replyEnabled, replyOnClick, replyLine } = 145 + props; 146 + 147 + const record = post.record as AppBskyFeedPost.Record; 148 + const postId = post.uri.split("/")[4]; 149 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 150 + 151 + return ( 152 + <div className="bskyPost relative flex flex-col w-full"> 153 + <div className={`flex gap-2 text-left w-full ${props.className}`}> 154 + <div className="flex flex-col items-start shrink-0 w-fit"> 155 + <Avatar 156 + src={post.author.avatar} 157 + displayName={post.author.displayName} 158 + size="small" 159 + /> 160 + {replyLine && ( 161 + <button 162 + onClick={(e) => { 163 + replyLine.onToggle(e); 164 + }} 165 + className="relative w-full grow flex" 166 + aria-label="Toggle replies" 167 + > 168 + <div className="w-0.5 h-full bg-border-light mx-auto" /> 169 + </button> 170 + )} 72 171 </div> 172 + <div 173 + className={`flex flex-col min-w-0 w-full z-0 ${replyLine ? "mb-2" : ""}`} 174 + > 175 + <button 176 + className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 177 + onClick={() => { 178 + openPage(parent, { type: "thread", uri: post.uri }); 179 + }} 180 + > 181 + <PostInfo 182 + displayName={post.author.displayName} 183 + handle={post.author.handle} 184 + createdAt={record.createdAt} 185 + compact 186 + /> 73 187 74 - <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 75 - <div className="text-sm text-secondary"> 76 - <BlueskyRichText record={record} /> 77 - </div> 78 - {showEmbed && post.embed && ( 79 - <div onClick={onEmbedClick}> 80 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 188 + <div className="postContent flex flex-col gap-2 mt-0.5"> 189 + <div className="line-clamp-3 text-tertiary text-xs"> 190 + <BlueskyRichText record={record} /> 191 + </div> 81 192 </div> 82 - )} 193 + </button> 194 + {(post.quoteCount && post.quoteCount > 0) || 195 + (post.replyCount && post.replyCount > 0) ? ( 196 + <div className="postCountsAndLink flex gap-2 items-center justify-between mt-2"> 197 + <PostCounts 198 + post={post} 199 + parent={parent} 200 + replyEnabled={replyEnabled} 201 + replyOnClick={replyOnClick} 202 + quoteEnabled={quoteEnabled} 203 + showBlueskyLink={false} 204 + url={url} 205 + /> 206 + </div> 207 + ) : null} 83 208 </div> 209 + </div> 210 + </div> 211 + ); 212 + } 84 213 85 - <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 - <ClientDate date={record.createdAt} /> 87 - <PostCounts 88 - post={post} 89 - parent={parent} 90 - linksEnabled={linksEnabled} 91 - showBlueskyLink={showBlueskyLink} 92 - url={url} 93 - onLinkClick={onLinkClick} 214 + function PostInfo(props: { 215 + displayName?: string; 216 + handle: string; 217 + createdAt: string; 218 + compact?: boolean; 219 + }) { 220 + const { displayName, handle, createdAt, compact = false } = props; 221 + 222 + return ( 223 + <div className="postInfo flex items-center gap-2 leading-tight w-full"> 224 + <div className="flex gap-2 items-center min-w-0"> 225 + <div className={`font-bold text-secondary truncate`}> 226 + {displayName} 227 + </div> 228 + <div className="truncate items-end flex"> 229 + <ProfilePopover 230 + trigger={ 231 + <div 232 + className={`${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline w-full truncate `} 233 + > 234 + @{handle} 235 + </div> 236 + } 237 + didOrHandle={handle} 94 238 /> 95 239 </div> 96 240 </div> 97 - </> 241 + <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 242 + <div 243 + className={`${compact ? "text-xs" : "text-sm"} text-tertiary shrink-0`} 244 + > 245 + {timeAgo(createdAt, { compact: true })} 246 + </div> 247 + </div> 98 248 ); 99 249 } 100 250 101 251 function PostCounts(props: { 102 252 post: PostView; 103 253 parent?: OpenPage; 104 - linksEnabled: boolean; 254 + quoteEnabled?: boolean; 255 + replyEnabled?: boolean; 256 + replyOnClick?: (e: React.MouseEvent) => void; 105 257 showBlueskyLink: boolean; 106 258 url: string; 107 - onLinkClick?: (e: React.MouseEvent) => void; 108 259 }) { 109 - const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 260 + const replyContent = props.post.replyCount != null && 261 + props.post.replyCount > 0 && ( 262 + <div className="postRepliesCount flex items-center gap-1 text-xs"> 263 + <CommentTiny /> 264 + {props.post.replyCount} 265 + </div> 266 + ); 267 + 268 + const quoteContent = props.post.quoteCount != null && 269 + props.post.quoteCount > 0 && ( 270 + <div className="postQuoteCount flex items-center gap-1 text-xs"> 271 + <QuoteTiny /> 272 + {props.post.quoteCount} 273 + </div> 274 + ); 110 275 111 276 return ( 112 - <div className="flex gap-2 items-center"> 113 - {post.replyCount != null && post.replyCount > 0 && ( 114 - <> 115 - <Separator classname="h-3" /> 116 - {linksEnabled ? ( 117 - <ThreadLink 118 - threadUri={post.uri} 119 - parent={parent} 120 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 - onClick={onLinkClick} 122 - > 123 - {post.replyCount} 124 - <CommentTiny /> 125 - </ThreadLink> 126 - ) : ( 127 - <div className="flex items-center gap-1 text-tertiary text-xs"> 128 - {post.replyCount} 129 - <CommentTiny /> 130 - </div> 131 - )} 132 - </> 133 - )} 134 - {post.quoteCount != null && post.quoteCount > 0 && ( 135 - <> 136 - <Separator classname="h-3" /> 277 + <div className="postCounts flex gap-2 items-center w-full text-tertiary"> 278 + {replyContent && 279 + (props.replyEnabled ? ( 280 + <ThreadLink 281 + postUri={props.post.uri} 282 + parent={props.parent} 283 + className="relative postRepliesLink hover:text-accent-contrast" 284 + onClick={props.replyOnClick} 285 + > 286 + {replyContent} 287 + </ThreadLink> 288 + ) : ( 289 + replyContent 290 + ))} 291 + {quoteContent && 292 + (props.quoteEnabled ? ( 137 293 <QuotesLink 138 - postUri={post.uri} 139 - parent={parent} 140 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 141 - onClick={onLinkClick} 294 + postUri={props.post.uri} 295 + parent={props.parent} 296 + className="relative hover:text-accent-contrast" 142 297 > 143 - {post.quoteCount} 144 - <QuoteTiny /> 298 + {quoteContent} 145 299 </QuotesLink> 146 - </> 147 - )} 148 - {showBlueskyLink && ( 149 - <> 150 - <Separator classname="h-3" /> 151 - <a 152 - className="text-tertiary" 153 - target="_blank" 154 - href={url} 155 - onClick={onLinkClick} 156 - > 157 - <BlueskyTiny /> 158 - </a> 159 - </> 160 - )} 300 + ) : ( 301 + quoteContent 302 + ))} 161 303 </div> 162 304 ); 163 305 }
+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,
+15 -9
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 25 25 uri: string; 26 26 bsky_profiles: { record: Json; did: string } | null; 27 27 }; 28 - export function Comments(props: { 28 + export function CommentsDrawerContent(props: { 29 29 document_uri: string; 30 30 comments: Comment[]; 31 31 pageId?: string; ··· 55 55 id={"commentsDrawer"} 56 56 className="flex flex-col gap-2 relative text-sm text-secondary" 57 57 > 58 - <div className="w-full flex justify-between text-secondary font-bold"> 59 - Comments 58 + <div className="w-full flex justify-between"> 59 + <h4> Comments</h4> 60 60 <button 61 61 className="text-tertiary" 62 62 onClick={() => ··· 75 75 </div> 76 76 )} 77 77 <hr className="border-border-light" /> 78 - <div className="flex flex-col gap-6 py-2"> 78 + <div className="flex flex-col gap-4 py-2"> 79 79 {comments 80 80 .sort((a, b) => { 81 81 let aRecord = a.record as PubLeafletComment.Record; ··· 119 119 }) => { 120 120 const did = props.comment.bsky_profiles?.did; 121 121 122 + let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 123 + 122 124 return ( 123 125 <div id={props.comment.uri} className="comment"> 124 - <div className="flex gap-2"> 125 - {did && ( 126 + <div className="flex gap-2 items-center"> 127 + {did ? ( 126 128 <ProfilePopover 127 129 didOrHandle={did} 128 130 trigger={ 129 - <div className="text-sm text-tertiary font-bold hover:underline"> 131 + <div className="text-sm text-secondary font-bold hover:underline"> 130 132 {props.profile.displayName} 131 133 </div> 132 134 } 133 135 /> 134 - )} 136 + ) : null} 137 + 138 + <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 139 + <div className="text-sm text-tertiary">{timeAgoDate}</div> 135 140 </div> 136 141 {props.record.attachment && 137 142 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 202 207 setReplyBoxOpen(false); 203 208 }} 204 209 > 205 - <CommentTiny className="text-border" /> {replies.length} 210 + <CommentTiny className="text-border" />{" "} 211 + {replies.length !== 0 && replies.length} 206 212 </button> 207 213 {identity?.atp_did && ( 208 214 <>
+9 -6
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 - import { Quotes } from "./Quotes"; 3 + import { MentionsDrawerContent } from "./Quotes"; 4 4 import { InteractionState, useInteractionState } from "./Interactions"; 5 5 import { Json } from "supabase/database.types"; 6 - import { Comment, Comments } from "./Comments"; 6 + import { Comment, CommentsDrawerContent } from "./Comments"; 7 7 import { useSearchParams } from "next/navigation"; 8 8 import { SandwichSpacer } from "components/LeafletLayout"; 9 9 import { decodeQuotePosition } from "../quotePosition"; ··· 36 36 return ( 37 37 <> 38 38 <SandwichSpacer noWidth /> 39 - <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 + <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 40 40 <div 41 41 id="interaction-drawer" 42 - className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`} 42 + className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 43 43 > 44 44 {drawer.drawer === "quotes" ? ( 45 - <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> 45 + <MentionsDrawerContent 46 + {...props} 47 + quotesAndMentions={filteredQuotesAndMentions} 48 + /> 46 49 ) : ( 47 - <Comments 50 + <CommentsDrawerContent 48 51 document_uri={props.document_uri} 49 52 comments={filteredComments} 50 53 pageId={props.pageId}
+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 };
+111 -146
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"; ··· 25 24 import { CommentTiny } from "components/Icons/CommentTiny"; 26 25 import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 26 import { ThreadLink, QuotesLink } from "../PostLinks"; 27 + import { BskyPostContent } from "../BskyPostContent"; 28 28 29 29 // Helper to get SWR key for quotes 30 30 export function getQuotesSWRKey(uris: string[]) { ··· 62 62 } 63 63 } 64 64 65 - export const Quotes = (props: { 65 + export const MentionsDrawerContent = (props: { 66 66 quotesAndMentions: { uri: string; link?: string }[]; 67 67 did: string; 68 68 }) => { 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"); 69 + const { uri: document_uri } = useDocument(); 73 70 74 71 // Fetch Bluesky post data for all URIs 75 72 const uris = props.quotesAndMentions.map((q) => q.uri); ··· 89 86 }); 90 87 91 88 return ( 92 - <div className="flex flex-col gap-2"> 93 - <div className="w-full flex justify-between text-secondary font-bold"> 94 - Quotes 95 - <button 96 - className="text-tertiary" 97 - onClick={() => 98 - setInteractionState(document_uri, { drawerOpen: false }) 99 - } 100 - > 101 - <CloseTiny /> 102 - </button> 103 - </div> 89 + <div className="relative w-full flex justify-between "> 90 + <button 91 + className="text-tertiary absolute top-0 right-0" 92 + onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 93 + > 94 + <CloseTiny /> 95 + </button> 104 96 {props.quotesAndMentions.length === 0 ? ( 105 97 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 106 98 <div className="font-bold">no quotes yet!</div> ··· 112 104 <DotLoader /> 113 105 </div> 114 106 ) : ( 115 - <div className="quotes flex flex-col gap-8"> 116 - {/* Quotes with links (quoted content) */} 117 - {quotesWithLinks.map((q, index) => { 118 - const pv = postViewMap.get(q.uri); 119 - if (!pv || !q.link) return null; 120 - const url = new URL(q.link); 121 - const quoteParam = url.pathname.split("/l-quote/")[1]; 122 - if (!quoteParam) return null; 123 - const quotePosition = decodeQuotePosition(quoteParam); 124 - if (!quotePosition) return null; 125 - return ( 126 - <div key={`quote-${index}`} className="flex flex-col "> 127 - <QuoteContent 128 - index={index} 129 - did={props.did} 130 - position={quotePosition} 131 - /> 132 - 133 - <div className="h-5 w-1 ml-5 border-l border-border-light" /> 134 - <BskyPost 135 - uri={pv.uri} 136 - rkey={new AtUri(pv.uri).rkey} 137 - content={pv.record.text as string} 138 - user={pv.author.displayName || pv.author.handle} 139 - profile={pv.author} 140 - handle={pv.author.handle} 141 - replyCount={pv.replyCount} 142 - quoteCount={pv.quoteCount} 143 - /> 144 - </div> 145 - ); 146 - })} 147 - 107 + <div className="flex flex-col gap-8 w-full"> 108 + {quotesWithLinks.length > 0 && ( 109 + <div className="flex flex-col w-full"> 110 + <h4 className="mb-2">Quotes on Bluesky</h4> 111 + {/* Quotes with links (quoted content) */} 112 + {quotesWithLinks.map((q, index) => { 113 + return ( 114 + <> 115 + <Quote 116 + key={q.uri} 117 + q={q} 118 + index={index} 119 + did={props.did} 120 + postViewMap={postViewMap} 121 + /> 122 + {quotesWithLinks.length !== index + 1 && ( 123 + <hr className="border-border-light my-4" /> 124 + )} 125 + </> 126 + ); 127 + })} 128 + </div> 129 + )} 148 130 {/* Direct post mentions (without quoted content) */} 149 131 {directMentions.length > 0 && ( 150 - <div className="flex flex-col gap-4"> 151 - <div className="text-secondary font-bold">Post Mentions</div> 152 - <div className="flex flex-col gap-8"> 153 - {directMentions.map((q, index) => { 154 - const pv = postViewMap.get(q.uri); 155 - if (!pv) return null; 156 - return ( 157 - <BskyPost 132 + <div className="flex flex-col"> 133 + <h4 className="mb-2">Mentions on Bluesky</h4> 134 + {directMentions.map((q, index) => { 135 + const post = postViewMap.get(q.uri); 136 + if (!post) return null; 137 + 138 + const parent = { type: "thread" as const, uri: q.uri }; 139 + return ( 140 + <> 141 + <BskyPostContent 158 142 key={`mention-${index}`} 159 - uri={pv.uri} 160 - rkey={new AtUri(pv.uri).rkey} 161 - content={pv.record.text as string} 162 - user={pv.author.displayName || pv.author.handle} 163 - profile={pv.author} 164 - handle={pv.author.handle} 165 - replyCount={pv.replyCount} 166 - quoteCount={pv.quoteCount} 143 + post={post} 144 + parent={parent} 145 + showBlueskyLink={true} 146 + showEmbed={true} 147 + avatarSize="medium" 148 + quoteEnabled 149 + replyEnabled 150 + className="text-sm" 151 + compactEmbed 167 152 /> 168 - ); 169 - })} 170 - </div> 153 + {directMentions.length !== index + 1 && ( 154 + <hr className="border-border-light my-4" /> 155 + )} 156 + </> 157 + ); 158 + })} 171 159 </div> 172 160 )} 173 161 </div> ··· 176 164 ); 177 165 }; 178 166 167 + const Quote = (props: { 168 + q: { 169 + uri: string; 170 + link?: string; 171 + }; 172 + index: number; 173 + did: string; 174 + postViewMap: Map<string, PostView>; 175 + }) => { 176 + const post = props.postViewMap.get(props.q.uri); 177 + if (!post || !props.q.link) return null; 178 + const parent = { type: "thread" as const, uri: props.q.uri }; 179 + const url = new URL(props.q.link); 180 + const quoteParam = url.pathname.split("/l-quote/")[1]; 181 + if (!quoteParam) return null; 182 + const quotePosition = decodeQuotePosition(quoteParam); 183 + if (!quotePosition) return null; 184 + 185 + return ( 186 + <div key={`quote-${props.index}`} className="flex flex-col w-full"> 187 + <QuoteContent 188 + index={props.index} 189 + did={props.did} 190 + position={quotePosition} 191 + /> 192 + 193 + <div className="h-3 w-1 ml-[11px] border-l border-border-light" /> 194 + <BskyPostContent 195 + post={post} 196 + parent={parent} 197 + showBlueskyLink={true} 198 + showEmbed={false} 199 + avatarSize="medium" 200 + quoteEnabled 201 + replyEnabled 202 + className="text-sm" 203 + /> 204 + </div> 205 + ); 206 + }; 207 + 179 208 export const QuoteContent = (props: { 180 209 position: QuotePosition; 181 210 index: number; 182 211 did: string; 183 212 }) => { 184 213 let isMobile = useIsMobile(); 185 - const data = useContext(PostPageContext); 186 - const document_uri = data?.uri; 214 + const { uri: document_uri } = useDocument(); 215 + const { pages } = useLeafletContent(); 187 216 188 - let record = data?.data as PubLeafletDocument.Record; 189 217 let page: PubLeafletPagesLinearDocument.Main | undefined = ( 190 218 props.position.pageId 191 - ? record.pages.find( 219 + ? pages.find( 192 220 (p) => 193 221 (p as PubLeafletPagesLinearDocument.Main).id === 194 222 props.position.pageId, 195 223 ) 196 - : record.pages[0] 224 + : pages[0] 197 225 ) as PubLeafletPagesLinearDocument.Main; 198 226 // Extract blocks within the quote range 199 227 const content = extractQuotedBlocks(page.blocks || [], props.position, []); ··· 211 239 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 212 240 onClick={(e) => { 213 241 if (props.position.pageId) 214 - flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); 242 + flushSync(() => 243 + openPage(undefined, { type: "doc", id: props.position.pageId! }), 244 + ); 215 245 let scrollMargin = isMobile 216 246 ? 16 217 247 : e.currentTarget.getBoundingClientRect().top; 218 248 let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 219 - let scrollContainer = window.document.getElementById(scrollContainerId); 249 + let scrollContainer = 250 + window.document.getElementById(scrollContainerId); 220 251 let el = window.document.getElementById( 221 252 props.position.start.block.join("."), 222 253 ); ··· 241 272 blocks={content} 242 273 did={props.did} 243 274 preview 244 - className="py-0!" 275 + className="py-0! px-0! text-tertiary" 245 276 /> 246 - </div> 247 - </div> 248 - </div> 249 - ); 250 - }; 251 - 252 - export const BskyPost = (props: { 253 - uri: string; 254 - rkey: string; 255 - content: string; 256 - user: string; 257 - handle: string; 258 - profile: ProfileViewBasic; 259 - replyCount?: number; 260 - quoteCount?: number; 261 - }) => { 262 - const handleOpenThread = () => { 263 - openPage(undefined, { type: "thread", uri: props.uri }); 264 - }; 265 - 266 - return ( 267 - <div 268 - onClick={handleOpenThread} 269 - className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded" 270 - > 271 - {props.profile.avatar && ( 272 - <img 273 - className="rounded-full w-6 h-6 shrink-0" 274 - src={props.profile.avatar} 275 - alt={props.profile.displayName} 276 - /> 277 - )} 278 - <div className="flex flex-col min-w-0"> 279 - <div className="flex items-center gap-2 flex-wrap"> 280 - <div className="font-bold">{props.user}</div> 281 - <a 282 - className="text-tertiary hover:underline" 283 - href={`https://bsky.app/profile/${props.handle}`} 284 - target="_blank" 285 - onClick={(e) => e.stopPropagation()} 286 - > 287 - @{props.handle} 288 - </a> 289 - </div> 290 - <div className="text-primary">{props.content}</div> 291 - <div className="flex gap-2 items-center mt-1"> 292 - {props.replyCount != null && props.replyCount > 0 && ( 293 - <ThreadLink 294 - threadUri={props.uri} 295 - onClick={(e) => e.stopPropagation()} 296 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 297 - > 298 - <CommentTiny /> 299 - {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 300 - </ThreadLink> 301 - )} 302 - {props.quoteCount != null && props.quoteCount > 0 && ( 303 - <QuotesLink 304 - postUri={props.uri} 305 - onClick={(e) => e.stopPropagation()} 306 - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 307 - > 308 - <QuoteTiny /> 309 - {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 310 - </QuotesLink> 311 - )} 312 277 </div> 313 278 </div> 314 279 </div>
+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 }
+6 -5
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 55 55 56 56 // Link component for opening thread pages with prefetching 57 57 export function ThreadLink(props: { 58 - threadUri: string; 58 + postUri: string; 59 59 parent?: OpenPage; 60 60 children: React.ReactNode; 61 61 className?: string; 62 62 onClick?: (e: React.MouseEvent) => void; 63 63 }) { 64 - const { threadUri, parent, children, className, onClick } = props; 64 + const { postUri, parent, children, className, onClick } = props; 65 65 66 66 const handleClick = (e: React.MouseEvent) => { 67 + e.stopPropagation(); 67 68 onClick?.(e); 68 69 if (e.defaultPrevented) return; 69 - openPage(parent, { type: "thread", uri: threadUri }); 70 + openPage(parent, { type: "thread", uri: postUri }); 70 71 }; 71 72 72 73 const handlePrefetch = () => { 73 - prefetchThread(threadUri); 74 + prefetchThread(postUri); 74 75 }; 75 76 76 77 return ( ··· 96 97 const { postUri, parent, children, className, onClick } = props; 97 98 98 99 const handleClick = (e: React.MouseEvent) => { 100 + e.stopPropagation(); 99 101 onClick?.(e); 100 102 if (e.defaultPrevented) return; 101 103 openPage(parent, { type: "quotes", uri: postUri }); ··· 104 106 const handlePrefetch = () => { 105 107 prefetchQuotes(postUri); 106 108 }; 107 - 108 109 return ( 109 110 <button 110 111 className={className}
-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 - };
+13 -10
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 )[], ··· 294 297 <Fragment key={pageKey}> 295 298 <SandwichSpacer /> 296 299 <ThreadPageComponent 297 - threadUri={openPage.uri} 300 + parentUri={openPage.uri} 298 301 pageId={pageKey} 299 302 hasPageBackground={hasPageBackground} 300 303 pageOptions={ ··· 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 <>
+148 -137
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 6 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 7 7 import { DotLoader } from "components/utils/DotLoader"; 8 8 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 - import { openPage } from "./PostPages"; 10 9 import { useThreadState } from "src/useThreadState"; 11 - import { BskyPostContent, ClientDate } from "./BskyPostContent"; 10 + import { 11 + BskyPostContent, 12 + CompactBskyPostContent, 13 + ClientDate, 14 + } from "./BskyPostContent"; 12 15 import { 13 16 ThreadLink, 14 17 getThreadKey, ··· 25 28 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 26 29 27 30 export function ThreadPage(props: { 28 - threadUri: string; 31 + parentUri: string; 29 32 pageId: string; 30 33 pageOptions?: React.ReactNode; 31 34 hasPageBackground: boolean; 32 35 }) { 33 - const { threadUri, pageId, pageOptions } = props; 34 - const drawer = useDrawerOpen(threadUri); 36 + const { parentUri, pageId, pageOptions } = props; 37 + const drawer = useDrawerOpen(parentUri); 35 38 36 39 const { 37 40 data: thread, 38 41 isLoading, 39 42 error, 40 - } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 41 - fetchThread(threadUri), 43 + } = useSWR(parentUri ? getThreadKey(parentUri) : null, () => 44 + fetchThread(parentUri), 42 45 ); 43 46 44 47 return ( ··· 46 49 pageType="doc" 47 50 fullPageScroll={false} 48 51 id={`post-page-${pageId}`} 49 - drawerOpen={!!drawer} 52 + drawerOpen={false} 50 53 pageOptions={pageOptions} 54 + fixedWidth 51 55 > 52 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 56 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 53 57 {isLoading ? ( 54 58 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 55 59 <span>loading thread</span> ··· 60 64 Failed to load thread 61 65 </div> 62 66 ) : thread ? ( 63 - <ThreadContent thread={thread} threadUri={threadUri} /> 67 + <ThreadContent post={thread} parentUri={parentUri} /> 64 68 ) : null} 65 69 </div> 66 70 </PageWrapper> 67 71 ); 68 72 } 69 73 70 - function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 71 - const { thread, threadUri } = props; 74 + function ThreadContent(props: { post: ThreadType; parentUri: string }) { 75 + const { post, parentUri } = props; 72 76 const mainPostRef = useRef<HTMLDivElement>(null); 73 77 74 78 // Scroll the main post into view when the thread loads ··· 81 85 } 82 86 }, []); 83 87 84 - if (AppBskyFeedDefs.isNotFoundPost(thread)) { 88 + if (AppBskyFeedDefs.isNotFoundPost(post)) { 85 89 return <PostNotAvailable />; 86 90 } 87 91 88 - if (AppBskyFeedDefs.isBlockedPost(thread)) { 92 + if (AppBskyFeedDefs.isBlockedPost(post)) { 89 93 return ( 90 94 <div className="text-tertiary italic text-sm text-center py-8"> 91 95 This post is blocked ··· 93 97 ); 94 98 } 95 99 96 - if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 100 + if (!AppBskyFeedDefs.isThreadViewPost(post)) { 97 101 return <PostNotAvailable />; 98 102 } 99 103 100 104 // Collect all parent posts in order (oldest first) 101 105 const parents: ThreadViewPost[] = []; 102 - let currentParent = thread.parent; 106 + let currentParent = post.parent; 103 107 while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 104 108 parents.unshift(currentParent); 105 109 currentParent = currentParent.parent; 106 110 } 107 111 108 112 return ( 109 - <div className="flex flex-col gap-0"> 110 - {/* Parent posts */} 111 - {parents.map((parent, index) => ( 112 - <div key={parent.post.uri} className="flex flex-col"> 113 - <ThreadPost 114 - post={parent} 115 - isMainPost={false} 116 - showReplyLine={index < parents.length - 1 || true} 117 - threadUri={threadUri} 118 - /> 119 - </div> 113 + <div 114 + className={`threadContent flex flex-col gap-0 w-full ${parents.length !== 0 && "pt-1"}`} 115 + > 116 + {/* grandparent posts, if any */} 117 + {parents.map((parentPost, index) => ( 118 + <ThreadPost 119 + key={parentPost.post.uri} 120 + post={parentPost} 121 + isMainPost={false} 122 + pageUri={parentUri} 123 + /> 120 124 ))} 121 125 122 126 {/* Main post */} 123 127 <div ref={mainPostRef}> 124 - <ThreadPost 125 - post={thread} 126 - isMainPost={true} 127 - showReplyLine={false} 128 - threadUri={threadUri} 129 - /> 128 + <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 130 129 </div> 131 130 132 131 {/* Replies */} 133 - {thread.replies && thread.replies.length > 0 && ( 134 - <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 135 - <div className="text-tertiary text-xs font-bold mb-2 px-2"> 136 - Replies 137 - </div> 132 + {post.replies && post.replies.length > 0 && ( 133 + <div className="threadReplies flex flex-col mt-4 pt-1 border-t border-border-light w-full"> 138 134 <Replies 139 - replies={thread.replies as any[]} 140 - threadUri={threadUri} 135 + replies={post.replies as any[]} 136 + pageUri={post.post.uri} 137 + parentPostUri={post.post.uri} 141 138 depth={0} 142 - parentAuthorDid={thread.post.author.did} 139 + parentAuthorDid={post.post.author.did} 143 140 /> 144 141 </div> 145 142 )} ··· 150 147 function ThreadPost(props: { 151 148 post: ThreadViewPost; 152 149 isMainPost: boolean; 153 - showReplyLine: boolean; 154 - threadUri: string; 150 + pageUri: string; 155 151 }) { 156 - const { post, isMainPost, showReplyLine, threadUri } = props; 152 + const { post, isMainPost, pageUri } = props; 157 153 const postView = post.post; 158 - const parent = { type: "thread" as const, uri: threadUri }; 154 + const page = { type: "thread" as const, uri: pageUri }; 155 + 156 + if (isMainPost) { 157 + return ( 158 + <div className="threadMainPost flex gap-2 relative w-full"> 159 + <BskyPostContent 160 + post={postView} 161 + parent={page} 162 + avatarSize="large" 163 + showBlueskyLink={true} 164 + showEmbed={true} 165 + compactEmbed 166 + quoteEnabled 167 + /> 168 + </div> 169 + ); 170 + } 159 171 160 172 return ( 161 - <div className="flex gap-2 relative"> 162 - {/* Reply line connector */} 163 - {showReplyLine && ( 164 - <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 - )} 166 - 167 - <BskyPostContent 173 + <div className="threadGrandparentPost flex gap-2 relative w-full pl-1"> 174 + <div className="absolute top-0 bottom-0 left-1 w-5 "> 175 + <div className="bg-border-light w-[2px] h-full mx-auto" /> 176 + </div> 177 + <CompactBskyPostContent 168 178 post={postView} 169 - parent={parent} 170 - linksEnabled={!isMainPost} 171 - showBlueskyLink={true} 172 - showEmbed={true} 179 + parent={page} 180 + quoteEnabled 181 + replyEnabled 173 182 /> 174 183 </div> 175 184 ); ··· 177 186 178 187 function Replies(props: { 179 188 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 180 - threadUri: string; 181 189 depth: number; 182 190 parentAuthorDid?: string; 191 + pageUri: string; 192 + parentPostUri: string; 183 193 }) { 184 - const { replies, threadUri, depth, parentAuthorDid } = props; 194 + const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props; 185 195 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 186 196 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 187 197 ··· 201 211 : replies; 202 212 203 213 return ( 204 - <div className="flex flex-col gap-0"> 214 + <div className="replies flex flex-col gap-0 pt-2 pb-1 pointer-events-none"> 205 215 {sortedReplies.map((reply, index) => { 206 216 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 207 217 return ( 208 218 <div 209 219 key={`not-found-${index}`} 210 - className="text-tertiary italic text-xs py-2 px-2" 220 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 211 221 > 212 222 Post not found 213 223 </div> ··· 218 228 return ( 219 229 <div 220 230 key={`blocked-${index}`} 221 - className="text-tertiary italic text-xs py-2 px-2" 231 + className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 222 232 > 223 233 Post blocked 224 234 </div> ··· 231 241 232 242 const hasReplies = reply.replies && reply.replies.length > 0; 233 243 const isCollapsed = collapsedThreads.has(reply.post.uri); 234 - const replyCount = reply.replies?.length ?? 0; 235 244 236 245 return ( 237 - <div key={reply.post.uri} className="flex flex-col"> 238 - <ReplyPost 239 - post={reply} 240 - showReplyLine={hasReplies || index < replies.length - 1} 241 - isLast={index === replies.length - 1 && !hasReplies} 242 - threadUri={threadUri} 243 - /> 244 - {hasReplies && depth < 3 && ( 245 - <div className="ml-2 flex"> 246 - {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 247 - <button 248 - onClick={(e) => { 249 - e.stopPropagation(); 250 - toggleCollapsed(reply.post.uri); 251 - }} 252 - className="group w-8 flex justify-center cursor-pointer shrink-0" 253 - aria-label={ 254 - isCollapsed ? "Expand replies" : "Collapse replies" 255 - } 256 - > 257 - <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 258 - </button> 259 - {isCollapsed ? ( 260 - <button 261 - onClick={(e) => { 262 - e.stopPropagation(); 263 - toggleCollapsed(reply.post.uri); 264 - }} 265 - className="text-xs text-accent-contrast hover:underline py-1 pl-1" 266 - > 267 - Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 268 - </button> 269 - ) : ( 270 - <div className="grow"> 271 - <Replies 272 - replies={reply.replies as any[]} 273 - threadUri={threadUri} 274 - depth={depth + 1} 275 - parentAuthorDid={reply.post.author.did} 276 - /> 277 - </div> 278 - )} 279 - </div> 280 - )} 281 - {hasReplies && depth >= 3 && ( 282 - <ThreadLink 283 - threadUri={reply.post.uri} 284 - parent={{ type: "thread", uri: threadUri }} 285 - className="ml-12 text-xs text-accent-contrast hover:underline py-1" 286 - > 287 - View more replies 288 - </ThreadLink> 289 - )} 290 - </div> 246 + <ReplyPost 247 + key={reply.post.uri} 248 + post={reply} 249 + isLast={index === replies.length - 1 && !hasReplies} 250 + pageUri={pageUri} 251 + parentPostUri={parentPostUri} 252 + toggleCollapsed={(uri) => toggleCollapsed(uri)} 253 + isCollapsed={isCollapsed} 254 + depth={props.depth} 255 + /> 291 256 ); 292 257 })} 258 + {pageUri && depth > 0 && replies.length > 3 && ( 259 + <ThreadLink 260 + postUri={pageUri} 261 + parent={{ type: "thread", uri: pageUri }} 262 + className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 263 + > 264 + <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> 265 + View {replies.length - 3} more{" "} 266 + {replies.length === 4 ? "reply" : "replies"} 267 + </ThreadLink> 268 + )} 293 269 </div> 294 270 ); 295 271 } 296 272 297 - function ReplyPost(props: { 273 + const ReplyPost = (props: { 298 274 post: ThreadViewPost; 299 - showReplyLine: boolean; 300 275 isLast: boolean; 301 - threadUri: string; 302 - }) { 303 - const { post, threadUri } = props; 276 + pageUri: string; 277 + parentPostUri: string; 278 + toggleCollapsed: (uri: string) => void; 279 + isCollapsed: boolean; 280 + depth: number; 281 + }) => { 282 + const { post, pageUri, parentPostUri } = props; 304 283 const postView = post.post; 305 - const parent = { type: "thread" as const, uri: threadUri }; 284 + 285 + const hasReplies = props.post.replies && props.post.replies.length > 0; 306 286 307 287 return ( 308 - <div 309 - className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 310 - onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 311 - > 312 - <BskyPostContent 313 - post={postView} 314 - parent={parent} 315 - linksEnabled={true} 316 - avatarSize="sm" 317 - showEmbed={false} 318 - showBlueskyLink={false} 319 - onLinkClick={(e) => e.stopPropagation()} 320 - onEmbedClick={(e) => e.stopPropagation()} 321 - /> 288 + <div className="flex h-fit"> 289 + {props.depth > 0 && ( 290 + <button 291 + className="replyLine relative w-6 h-auto -mr-6 pointer-events-auto" 292 + onClick={() => { 293 + props.toggleCollapsed(parentPostUri); 294 + }} 295 + > 296 + <div className="bg-border-light w-[2px] h-full mx-auto" /> 297 + </button> 298 + )} 299 + <div 300 + className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-2"} ${props.depth > 0 && "pointer-events-none"}`} 301 + > 302 + <BskyPostContent 303 + post={postView} 304 + parent={{ type: "thread", uri: pageUri }} 305 + showEmbed={false} 306 + showBlueskyLink={false} 307 + quoteEnabled 308 + replyEnabled 309 + replyOnClick={(e) => { 310 + e.preventDefault(); 311 + props.toggleCollapsed(post.post.uri); 312 + console.log(post.post.uri); 313 + }} 314 + onEmbedClick={(e) => e.stopPropagation()} 315 + className="text-sm" 316 + /> 317 + {hasReplies && props.depth < 3 && ( 318 + <div className="ml-[28px] flex pointer-events-none"> 319 + {!props.isCollapsed && ( 320 + <div className="grow pointer-events-none"> 321 + <Replies 322 + pageUri={pageUri} 323 + parentPostUri={post.post.uri} 324 + replies={props.post.replies as any[]} 325 + depth={props.depth + 1} 326 + parentAuthorDid={props.post.post.author.did} 327 + /> 328 + </div> 329 + )} 330 + </div> 331 + )} 332 + </div> 322 333 </div> 323 334 ); 324 - } 335 + };
+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 =
+16 -11
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: { ··· 33 35 sizes: "32x32", 34 36 type: "image/png", 35 37 }, 36 - other: { 37 - rel: "alternate", 38 - url: document.uri, 39 - }, 38 + other: [ 39 + { 40 + rel: "alternate", 41 + url: document.uri, 42 + }, 43 + { rel: "site.standard.document", url: document.uri }, 44 + ], 40 45 }, 41 46 title: 42 47 docRecord.title +
-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 -15
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 ··· 14 14 request: NextRequest, 15 15 props: { params: Promise<{ did: string; publication: string }> }, 16 16 ) { 17 - console.log("are we getting here?"); 18 17 const params = await props.params; 19 18 try { 20 19 let did = decodeURIComponent(params.did); 21 - let uri; 22 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 23 - uri = AtUri.make( 24 - did, 25 - "pub.leaflet.publication", 26 - params.publication, 27 - ).toString(); 28 - } 29 - let { data: publication } = await supabaseServerClient 20 + let publication_name = decodeURIComponent(params.publication); 21 + let { data: publications } = await supabaseServerClient 30 22 .from("publications") 31 23 .select( 32 24 `*, ··· 35 27 `, 36 28 ) 37 29 .eq("identity_did", did) 38 - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 39 - .single(); 30 + .or(publicationNameOrUriFilter(did, publication_name)) 31 + .order("uri", { ascending: false }) 32 + .limit(1); 33 + let publication = publications?.[0]; 40 34 41 - let record = publication?.record as PubLeafletPublication.Record | null; 35 + const record = normalizePublicationRecord(publication?.record); 42 36 if (!record?.icon) return redirect("/icon.png"); 43 37 44 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 }
+204 -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 - } 74 - 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 - ); 82 + // Normalize existing record 83 + const normalizedPub = normalizePublicationRecord(existingPub.record); 84 + const existingBasePath = normalizedPub?.url 85 + ? normalizedPub.url.replace(/^https?:\/\//, "") 86 + : undefined; 82 87 83 - if (uploadResult.data.blob) { 84 - record.icon = uploadResult.data.blob; 85 - } 86 - } 88 + // Build the updated record 89 + const record = await recordBuilder({ 90 + normalizedPub, 91 + existingBasePath, 92 + publicationType, 93 + agent, 94 + }); 87 95 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 = { 281 + $type: "pub.leaflet.publication#theme" as const, 227 282 backgroundImage: theme.backgroundImage 228 283 ? { 229 284 $type: "pub.leaflet.theme.backgroundImage", ··· 238 293 } 239 294 : theme.backgroundImage === null 240 295 ? undefined 241 - : oldRecord.theme?.backgroundImage, 296 + : normalizedPub?.theme?.backgroundImage, 242 297 backgroundColor: theme.backgroundColor 243 298 ? { 244 299 ...theme.backgroundColor, ··· 258 313 accentText: { 259 314 ...theme.accentText, 260 315 }, 261 - }, 262 - }; 316 + }; 263 317 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 - }); 318 + // Derive basicTheme from the theme colors for site.standard.publication 319 + const basicTheme: NormalizedPublication["basicTheme"] = { 320 + $type: "site.standard.theme.basic", 321 + background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 322 + foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 323 + accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 324 + accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 325 + }; 271 326 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 }; 327 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 + theme: themeData, 329 + basicTheme, 330 + }); 331 + }); 283 332 }
+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 =
+18 -19
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: { 43 - other: { 44 - rel: "alternate", 45 - url: document.uri, 46 - }, 41 + other: [ 42 + { 43 + rel: "alternate", 44 + url: document.uri, 45 + }, 46 + { rel: "site.standard.document", url: document.uri }, 47 + ], 47 48 }, 48 - title: publicationName 49 - ? `${docRecord.title} - ${publicationName}` 50 - : docRecord.title, 49 + title: docRecord.title, 51 50 description: docRecord?.description || "", 52 51 }; 53 52 }
+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 =
+25 -6
components/Avatar.tsx
··· 4 4 src: string | undefined; 5 5 displayName: string | undefined; 6 6 className?: string; 7 - tiny?: boolean; 8 - large?: boolean; 9 - giant?: boolean; 7 + size?: "tiny" | "small" | "medium" | "large" | "giant"; 10 8 }) => { 9 + let sizeClassName = 10 + props.size === "tiny" 11 + ? "w-4 h-4" 12 + : props.size === "small" 13 + ? "w-5 h-5" 14 + : props.size === "medium" 15 + ? "h-6 w-6" 16 + : props.size === "large" 17 + ? "w-8 h-8" 18 + : props.size === "giant" 19 + ? "h-16 w-16" 20 + : "w-6 h-6"; 21 + 11 22 if (props.src) 12 23 return ( 13 24 <img 14 - className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 25 + className={`${sizeClassName} relative rounded-full shrink-0 border border-border-light ${props.className}`} 15 26 src={props.src} 16 27 alt={ 17 28 props.displayName ··· 23 34 else 24 35 return ( 25 36 <div 26 - className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`} 37 + className={` relative bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${sizeClassName}`} 27 38 > 28 - <AccountTiny className={props.tiny ? "scale-80" : "scale-90"} /> 39 + <AccountTiny 40 + className={ 41 + props.size === "tiny" 42 + ? "scale-80" 43 + : props.size === "small" 44 + ? "scale-90" 45 + : "" 46 + } 47 + /> 29 48 </div> 30 49 ); 31 50 };
+142 -67
components/Blocks/Block.tsx
··· 32 32 import { HorizontalRule } from "./HorizontalRule"; 33 33 import { deepEquals } from "src/utils/deepEquals"; 34 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 + import { focusPage } from "src/utils/focusPage"; 36 + import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 + import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 + import { Separator } from "components/Layout"; 39 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 40 + import { deleteBlock } from "src/utils/deleteBlock"; 35 41 36 42 export type Block = { 37 43 factID: string; ··· 62 68 // Block handles all block level events like 63 69 // mouse events, keyboard events and longPress, and setting AreYouSure state 64 70 // and shared styling like padding and flex for list layouting 65 - 66 71 let mouseHandlers = useBlockMouseHandlers(props); 67 72 let handleDrop = useHandleDrop({ 68 73 parent: props.parent, ··· 71 76 }); 72 77 let entity_set = useEntitySetContext(); 73 78 74 - let { isLongPress, handlers } = useLongPress(() => { 79 + let { isLongPress, longPressHandlers } = useLongPress(() => { 75 80 if (isTextBlock[props.type]) return; 76 81 if (isLongPress.current) { 77 82 focusBlock( ··· 84 89 let selected = useUIState( 85 90 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 86 91 ); 92 + let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 93 + 94 + let alignmentStyle = 95 + props.type === "button" || props.type === "image" 96 + ? "justify-center" 97 + : "justify-start"; 98 + 99 + if (alignment) 100 + alignmentStyle = { 101 + left: "justify-start", 102 + right: "justify-end", 103 + center: "justify-center", 104 + justify: "justify-start", 105 + }[alignment]; 87 106 88 107 let [areYouSure, setAreYouSure] = useState(false); 89 108 useEffect(() => { ··· 97 116 98 117 return ( 99 118 <div 100 - {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 119 + {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers } : {})} 101 120 id={ 102 121 !props.preview ? elementId.block(props.entityID).container : undefined 103 122 } ··· 116 135 blockWrapper relative 117 136 flex flex-row gap-2 118 137 px-3 sm:px-4 138 + z-1 w-full 139 + ${alignmentStyle} 119 140 ${ 120 141 !props.nextBlock 121 142 ? "pb-3 sm:pb-4" ··· 254 275 ) => { 255 276 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 256 277 let BlockTypeComponent = BlockTypeComponents[props.type]; 257 - let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 258 - 259 - let alignmentStyle = 260 - props.type === "button" || props.type === "image" 261 - ? "justify-center" 262 - : "justify-start"; 263 - 264 - if (alignment) 265 - alignmentStyle = { 266 - left: "justify-start", 267 - right: "justify-end", 268 - center: "justify-center", 269 - justify: "justify-start", 270 - }[alignment]; 271 278 272 279 if (!BlockTypeComponent) return <div>unknown block</div>; 273 280 return ( 274 - <div 275 - className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`} 276 - > 281 + <> 277 282 {props.listData && <ListMarker {...props} />} 278 283 {props.areYouSure ? ( 279 284 <AreYouSure ··· 286 291 ) : ( 287 292 <BlockTypeComponent {...props} preview={props.preview} /> 288 293 )} 289 - </div> 294 + </> 290 295 ); 291 296 }; 292 297 ··· 325 330 s.selectedBlocks.length > 1, 326 331 ); 327 332 328 - let isSelected = useUIState((s) => 329 - s.selectedBlocks.find((b) => b.value === props.entityID), 330 - ); 331 - let isLocked = useEntity(props.value, "block/is-locked"); 332 - 333 333 let nextBlockSelected = useUIState((s) => 334 334 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 335 335 ); ··· 337 337 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 338 338 ); 339 339 340 - if (isMultiselected || (isLocked?.data.value && isSelected)) 341 - // not sure what multiselected and selected classes are doing (?) 342 - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 343 - 340 + if (isMultiselected) 344 341 return ( 345 342 <> 346 343 <div ··· 353 350 ${!prevBlockSelected && "rounded-t-md"} 354 351 ${!nextBlockSelected && "rounded-b-md"} 355 352 `} 356 - style={ 357 - isLocked?.data.value 358 - ? { 359 - maskImage: "var(--hatchSVG)", 360 - maskRepeat: "repeat repeat", 361 - } 362 - : {} 363 - } 364 - ></div> 365 - {isLocked?.data.value && ( 366 - <div 367 - className={` 368 - blockSelectionLockIndicator z-10 369 - flex items-center 370 - text-border rounded-full 371 - absolute right-3 372 - 373 - ${ 374 - props.type === "heading" || props.type === "text" 375 - ? "top-[6px]" 376 - : "top-0" 377 - }`} 378 - > 379 - <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 380 - </div> 381 - )} 353 + /> 382 354 </> 383 355 ); 384 356 }; 385 357 386 358 export const BlockLayout = (props: { 387 - isSelected?: boolean; 359 + isSelected: boolean; 388 360 children: React.ReactNode; 389 361 className?: string; 362 + optionsClassName?: string; 390 363 hasBackground?: "accent" | "page"; 391 364 borderOnHover?: boolean; 365 + hasAlignment?: boolean; 366 + areYouSure?: boolean; 367 + setAreYouSure?: (value: boolean) => void; 392 368 }) => { 369 + // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 393 370 return ( 394 371 <div 395 - className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 372 + className={`nonTextBlockAndControls relative ${props.hasAlignment ? "w-fit" : "w-full"}`} 373 + > 374 + <div 375 + className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden 376 + ${props.hasAlignment ? "w-fit" : "w-full"} 396 377 ${props.isSelected ? "block-border-selected " : "block-border"} 397 378 ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 398 - style={{ 399 - backgroundColor: 400 - props.hasBackground === "accent" 401 - ? "var(--accent-light)" 402 - : props.hasBackground === "page" 403 - ? "rgb(var(--bg-page))" 404 - : "transparent", 405 - }} 379 + style={{ 380 + backgroundColor: 381 + props.hasBackground === "accent" 382 + ? "var(--accent-light)" 383 + : props.hasBackground === "page" 384 + ? "rgb(var(--bg-page))" 385 + : "transparent", 386 + }} 387 + > 388 + {props.children} 389 + </div> 390 + {props.isSelected && ( 391 + <NonTextBlockOptions 392 + optionsClassName={props.optionsClassName} 393 + areYouSure={props.areYouSure} 394 + setAreYouSure={props.setAreYouSure} 395 + /> 396 + )} 397 + </div> 398 + ); 399 + }; 400 + 401 + let debounced: null | number = null; 402 + 403 + const NonTextBlockOptions = (props: { 404 + areYouSure?: boolean; 405 + setAreYouSure?: (value: boolean) => void; 406 + optionsClassName?: string; 407 + }) => { 408 + let { rep } = useReplicache(); 409 + let entity_set = useEntitySetContext(); 410 + let focusedEntity = useUIState((s) => s.focusedEntity); 411 + let focusedEntityType = useEntity( 412 + focusedEntity?.entityType === "page" 413 + ? focusedEntity.entityID 414 + : focusedEntity?.parent || null, 415 + "page/type", 416 + ); 417 + 418 + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); 419 + if (focusedEntity?.entityType === "page") return; 420 + 421 + if (isMultiselected) return; 422 + 423 + return ( 424 + <div 425 + className={`flex gap-1 absolute -top-[25px] right-2 pb-0.5 pt-1 px-1 rounded-t-md bg-border text-bg-page ${props.optionsClassName}`} 406 426 > 407 - {props.children} 427 + {focusedEntityType?.data.value !== "canvas" && ( 428 + <> 429 + <button 430 + onClick={async (e) => { 431 + e.stopPropagation(); 432 + 433 + if (!rep) return; 434 + await moveBlockDown(rep, entity_set.set); 435 + }} 436 + > 437 + <ArrowDownTiny /> 438 + </button> 439 + <button 440 + onClick={async (e) => { 441 + e.stopPropagation(); 442 + 443 + if (!rep) return; 444 + await moveBlockUp(rep); 445 + }} 446 + > 447 + <ArrowDownTiny className="rotate-180" /> 448 + </button> 449 + <Separator classname="border-bg-page! h-4! mx-0.5" /> 450 + </> 451 + )} 452 + <button 453 + onClick={async (e) => { 454 + e.stopPropagation(); 455 + if (!rep || !focusedEntity) return; 456 + 457 + if (props.areYouSure !== undefined && props.setAreYouSure) { 458 + if (!props.areYouSure) { 459 + props.setAreYouSure(true); 460 + debounced = window.setTimeout(() => { 461 + debounced = null; 462 + }, 300); 463 + return; 464 + } 465 + 466 + if (props.areYouSure) { 467 + if (debounced) { 468 + window.clearTimeout(debounced); 469 + debounced = window.setTimeout(() => { 470 + debounced = null; 471 + }, 300); 472 + return; 473 + } 474 + await deleteBlock([focusedEntity.entityID], rep); 475 + } 476 + } else { 477 + await deleteBlock([focusedEntity.entityID], rep); 478 + } 479 + }} 480 + > 481 + <DeleteTiny /> 482 + </button> 408 483 </div> 409 484 ); 410 485 };
+57 -45
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 10 10 AppBskyGraphDefs, 11 11 AppBskyLabelerDefs, 12 12 } from "@atproto/api"; 13 + import { Avatar } from "components/Avatar"; 13 14 14 15 export const BlueskyEmbed = (props: { 15 16 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 16 17 postUrl?: string; 18 + className?: string; 19 + compact?: boolean; 17 20 }) => { 18 21 // check this file from bluesky for ref 19 22 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx ··· 21 24 case AppBskyEmbedImages.isView(props.embed): 22 25 let imageEmbed = props.embed; 23 26 return ( 24 - <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 27 + <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 25 28 {imageEmbed.images.map( 26 29 ( 27 30 image: { ··· 68 71 let isGif = externalEmbed.external.uri.includes(".gif"); 69 72 if (isGif) { 70 73 return ( 71 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 74 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full "> 72 75 <img 73 76 src={externalEmbed.external.uri} 74 77 alt={externalEmbed.external.title} ··· 81 84 <a 82 85 href={externalEmbed.external.uri} 83 86 target="_blank" 84 - className="group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border" 87 + className={`externalLinkEmbed group border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border w-full ${props.compact ? "flex items-stretch" : "flex flex-col"} 88 + ${props.className}`} 85 89 > 86 90 {externalEmbed.external.thumb === undefined ? null : ( 87 91 <> 88 - <div className="w-full aspect-[1.91/1] overflow-hidden"> 92 + <div 93 + className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`} 94 + > 89 95 <img 90 96 src={externalEmbed.external.thumb} 91 97 alt={externalEmbed.external.title} 92 - className="w-full h-full object-cover" 98 + className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`} 93 99 /> 94 100 </div> 95 - <hr className="border-border-light" /> 101 + {!props.compact && <hr className="border-border-light" />} 96 102 </> 97 103 )} 98 - <div className="p-2 flex flex-col gap-1"> 99 - <div className="flex flex-col"> 100 - <h4>{externalEmbed.external.title}</h4> 101 - <p className="text-secondary"> 104 + <div 105 + className={`p-2 flex flex-col w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`} 106 + > 107 + <h4 className="truncate shrink-0" style={{ fontSize: "inherit" }}> 108 + {externalEmbed.external.title}{" "} 109 + </h4> 110 + <div className="grow"> 111 + <p className="text-secondary line-clamp-2"> 102 112 {externalEmbed.external.description} 103 113 </p> 104 114 </div> 105 - <hr className="border-border-light mt-1" /> 106 - <div className="text-tertiary text-xs sm:group-hover:text-accent-contrast"> 115 + 116 + <hr className="border-border-light my-1" /> 117 + <div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate"> 107 118 {externalEmbed.external.uri} 108 119 </div> 109 120 </div> ··· 116 127 : 16 / 9; 117 128 return ( 118 129 <div 119 - className="rounded-md overflow-hidden relative w-full" 130 + className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 120 131 style={{ aspectRatio: String(videoAspectRatio) }} 121 132 > 122 133 <img ··· 147 158 text = (record.value as AppBskyFeedPost.Record).text; 148 159 } 149 160 return ( 150 - <div 151 - className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 152 - > 153 - <div className="bskyAuthor w-full flex items-center "> 154 - {record.author.avatar && ( 155 - <img 156 - src={record.author?.avatar} 157 - alt={`${record.author?.displayName}'s avatar`} 158 - className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 - /> 160 - )} 161 - <div className=" font-bold text-secondary mr-1"> 162 - {record.author?.displayName} 161 + <div className="bskyPostEmbed w-full flex gap-2 items-start relative overflow-hidden p-2! text-xs block-border"> 162 + <Avatar 163 + src={record.author?.avatar} 164 + displayName={record.author?.displayName} 165 + size="small" 166 + /> 167 + <div className="flex flex-col "> 168 + <div className="flex gap-1"> 169 + <div className=" font-bold text-secondary mr-1"> 170 + {record.author?.displayName} 171 + </div> 172 + <a 173 + className="text-xs text-tertiary hover:underline" 174 + target="_blank" 175 + href={`https://bsky.app/profile/${record.author?.handle}`} 176 + > 177 + @{record.author?.handle} 178 + </a> 163 179 </div> 164 - <a 165 - className="text-xs text-tertiary hover:underline" 166 - target="_blank" 167 - href={`https://bsky.app/profile/${record.author?.handle}`} 168 - > 169 - @{record.author?.handle} 170 - </a> 171 - </div> 172 - 173 - <div className="flex flex-col gap-2 "> 174 - {text && ( 175 - <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 - )} 177 - {record.embeds !== undefined 178 - ? record.embeds.map((embed, index) => ( 179 - <BlueskyEmbed embed={embed} key={index} /> 180 - )) 181 - : null} 180 + <div className="flex flex-col gap-2 "> 181 + {text && ( 182 + <pre 183 + className={`whitespace-pre-wrap text-secondary ${props.compact ? "line-clamp-6" : ""}`} 184 + > 185 + {text} 186 + </pre> 187 + )} 188 + {/*{record.embeds !== undefined 189 + ? record.embeds.map((embed, index) => ( 190 + <BlueskyEmbed embed={embed} key={index} compact /> 191 + )) 192 + : null}*/} 193 + </div> 182 194 </div> 183 195 </div> 184 196 ); ··· 207 219 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 208 220 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 209 221 return ( 210 - <div className={`flex flex-col gap-2`}> 222 + <div className={`bskyEmbed flex flex-col gap-2`}> 211 223 <BlueskyEmbed embed={props.embed.media} /> 212 224 <BlueskyEmbed 213 225 embed={{
+1 -3
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 18 18 let isSelected = useUIState((s) => 19 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 20 ); 21 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 22 21 23 22 let entity_set = useEntitySetContext(); 24 23 let [urlValue, setUrlValue] = useState(""); ··· 91 90 className="w-full grow border-none outline-hidden bg-transparent " 92 91 placeholder="bsky.app/post-url" 93 92 value={urlValue} 94 - disabled={isLocked} 95 93 onChange={(e) => setUrlValue(e.target.value)} 96 94 onKeyDown={(e) => { 97 95 if (e.key === "Enter") { ··· 109 107 <button 110 108 type="submit" 111 109 id="bluesky-post-block-submit" 112 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 110 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 113 111 onMouseDown={(e) => { 114 112 e.preventDefault(); 115 113 errorSmokers(e.clientX + 12, e.clientY);
+13 -63
components/Blocks/BlueskyPostBlock/index.tsx
··· 13 13 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 14 import { CommentTiny } from "components/Icons/CommentTiny"; 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 + import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 17 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 16 18 17 19 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 18 20 let { permissions } = useEntitySetContext(); ··· 76 78 77 79 //getting the url to the post 78 80 let postId = post.post.uri.split("/")[4]; 81 + let postView = post.post as PostView; 79 82 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 80 83 81 84 return ( 82 85 <BlockLayout 83 86 isSelected={!!isSelected} 84 87 hasBackground="page" 85 - className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 88 + borderOnHover 89 + className="blueskyPostBlock sm:px-3! sm:py-2! px-2! py-1!" 86 90 > 87 - {post.post.author && record && ( 88 - <> 89 - <div className="bskyAuthor w-full flex items-center gap-2"> 90 - {post.post.author?.avatar ? ( 91 - <img 92 - src={post.post.author?.avatar} 93 - alt={`${post.post.author?.displayName}'s avatar`} 94 - className="shrink-0 w-8 h-8 rounded-full border border-border-light" 95 - /> 96 - ) : ( 97 - <div className="shrink-0 w-8 h-8 rounded-full border border-border-light bg-border"></div> 98 - )} 99 - <div className="grow flex flex-col gap-0.5 leading-tight"> 100 - <div className=" font-bold text-secondary"> 101 - {post.post.author?.displayName} 102 - </div> 103 - <a 104 - className="text-xs text-tertiary hover:underline" 105 - target="_blank" 106 - href={`https://bsky.app/profile/${post.post.author?.handle}`} 107 - > 108 - @{post.post.author?.handle} 109 - </a> 110 - </div> 111 - </div> 112 - 113 - <div className="flex flex-col gap-2 "> 114 - <div> 115 - <pre className="whitespace-pre-wrap"> 116 - {BlueskyRichText({ 117 - record: record as AppBskyFeedPost.Record | null, 118 - })} 119 - </pre> 120 - </div> 121 - {post.post.embed && ( 122 - <BlueskyEmbed embed={post.post.embed} postUrl={url} /> 123 - )} 124 - </div> 125 - </> 126 - )} 127 - <div className="w-full flex gap-2 items-center justify-between"> 128 - {timestamp && <PostDate timestamp={timestamp} />} 129 - <div className="flex gap-2 items-center"> 130 - {post.post.replyCount != null && post.post.replyCount > 0 && ( 131 - <> 132 - <a 133 - className="flex items-center gap-1 hover:no-underline" 134 - target="_blank" 135 - href={url} 136 - > 137 - {post.post.replyCount} 138 - <CommentTiny /> 139 - </a> 140 - <Separator classname="h-4" /> 141 - </> 142 - )} 143 - 144 - <a className="" target="_blank" href={url}> 145 - <BlueskyTiny /> 146 - </a> 147 - </div> 148 - </div> 91 + <BskyPostContent 92 + post={postView} 93 + parent={undefined} 94 + showBlueskyLink={true} 95 + showEmbed={true} 96 + avatarSize="large" 97 + className="text-sm text-secondary " 98 + /> 149 99 </BlockLayout> 150 100 ); 151 101 }
+38 -15
components/Blocks/ButtonBlock.tsx
··· 24 24 let isSelected = useUIState((s) => 25 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 26 ); 27 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 27 28 28 29 if (!url) { 29 30 if (!permissions.write) return null; ··· 31 32 } 32 33 33 34 return ( 34 - <a 35 - href={url?.data.value} 36 - target="_blank" 37 - className={`hover:outline-accent-contrast rounded-md! ${isSelected ? "block-border-selected border-0!" : "block-border border-transparent! border-0!"}`} 35 + <BlockLayout 36 + isSelected={!!isSelected} 37 + borderOnHover 38 + hasAlignment={alignment !== "justify"} 39 + className={`p-0! rounded-md! border-none!`} 38 40 > 39 - <ButtonPrimary role="link" type="submit"> 40 - {text?.data.value} 41 - </ButtonPrimary> 42 - </a> 41 + <a 42 + href={url?.data.value} 43 + target="_blank" 44 + className={` ${alignment === "justify" ? "w-full" : "w-fit"}`} 45 + > 46 + <ButtonPrimary 47 + role="link" 48 + type="submit" 49 + fullWidth={alignment === "justify"} 50 + > 51 + {text?.data.value} 52 + </ButtonPrimary> 53 + </a> 54 + </BlockLayout> 43 55 ); 44 56 }; 45 57 ··· 51 63 let isSelected = useUIState((s) => 52 64 s.selectedBlocks.find((b) => b.value === props.entityID), 53 65 ); 54 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 55 66 56 67 let [textValue, setTextValue] = useState(""); 57 68 let [urlValue, setUrlValue] = useState(""); 58 69 let text = textValue; 59 70 let url = urlValue; 71 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 60 72 61 73 let submit = async () => { 62 74 let entity = props.entityID; ··· 106 118 }; 107 119 108 120 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 - <ButtonPrimary className="mx-auto"> 121 + <div 122 + className={`buttonBlockSettingsWrapper flex flex-col gap-2 w-full 123 + `} 124 + > 125 + <ButtonPrimary 126 + className={`relative ${ 127 + alignment === "center" 128 + ? "place-self-center" 129 + : alignment === "left" 130 + ? "place-self-start" 131 + : alignment === "right" 132 + ? "place-self-end" 133 + : "place-self-center" 134 + }`} 135 + fullWidth={alignment === "justify"} 136 + > 111 137 {text !== "" ? text : "Button"} 112 138 </ButtonPrimary> 113 139 <BlockLayout ··· 167 193 <Separator /> 168 194 <Input 169 195 type="text" 170 - autoFocus 171 196 className="w-full grow border-none outline-hidden bg-transparent" 172 197 placeholder="button text" 173 198 value={textValue} 174 - disabled={isLocked} 175 199 onChange={(e) => setTextValue(e.target.value)} 176 200 onKeyDown={(e) => { 177 201 if ( ··· 194 218 className="w-full grow border-none outline-hidden bg-transparent" 195 219 placeholder="www.example.com" 196 220 value={urlValue} 197 - disabled={isLocked} 198 221 onChange={(e) => setUrlValue(e.target.value)} 199 222 onKeyDown={(e) => { 200 223 if (e.key === "Backspace" && !e.currentTarget.value) ··· 205 228 <button 206 229 id="button-block-settings" 207 230 type="submit" 208 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 231 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 232 > 210 233 <div className="sm:hidden block">Save</div> 211 234 <CheckTiny />
+57 -76
components/Blocks/CodeBlock.tsx
··· 14 14 import { flushSync } from "react-dom"; 15 15 import { elementId } from "src/utils/elementId"; 16 16 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 + import { focusBlock } from "src/utils/focusBlock"; 17 18 18 19 export function CodeBlock(props: BlockProps) { 19 20 let { rep, rootEntity } = useReplicache(); ··· 42 43 }, [content, lang, theme]); 43 44 44 45 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 45 - let selection = window.getSelection(); 46 - if (!selection || selection.rangeCount === 0) return; 47 - let range = selection.getRangeAt(0); 48 - if (!range) return; 49 - let length = range.toString().length; 50 - range.setStart(e.currentTarget, 0); 51 - let end = range.toString().length; 52 - let start = end - length; 53 - 54 - flushSync(() => { 55 - useUIState.getState().setSelectedBlock(props); 56 - useUIState.getState().setFocusedBlock({ 57 - entityType: "block", 58 - entityID: props.value, 59 - parent: props.parent, 60 - }); 61 - }); 62 - let el = document.getElementById( 63 - elementId.block(props.entityID).input, 64 - ) as HTMLTextAreaElement; 65 - if (!el) return; 66 - el.focus(); 67 - el.setSelectionRange(start, end); 46 + focusBlock( 47 + { parent: props.parent, value: props.value, type: "code" }, 48 + { type: "end" }, 49 + ); 68 50 }, []); 69 51 return ( 70 52 <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 71 - {permissions.write && ( 72 - <div className="text-sm text-tertiary flex justify-between"> 73 - <div className="flex gap-1"> 74 - Theme:{" "} 75 - <select 76 - className="codeBlockLang text-left bg-transparent pr-1 sm:max-w-none max-w-24" 77 - onClick={(e) => { 78 - e.preventDefault(); 79 - e.stopPropagation(); 80 - }} 81 - value={theme} 82 - onChange={async (e) => { 83 - await rep?.mutate.assertFact({ 84 - attribute: "theme/code-theme", 85 - entity: rootEntity, 86 - data: { type: "string", value: e.target.value }, 87 - }); 88 - }} 89 - > 90 - {bundledThemesInfo.map((t) => ( 91 - <option key={t.id} value={t.id}> 92 - {t.displayName} 93 - </option> 94 - ))} 95 - </select> 96 - </div> 97 - <select 98 - className="codeBlockLang text-right bg-transparent pr-1 sm:max-w-none max-w-24" 99 - onClick={(e) => { 100 - e.preventDefault(); 101 - e.stopPropagation(); 102 - }} 103 - value={lang} 104 - onChange={async (e) => { 105 - localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 - await rep?.mutate.assertFact({ 107 - attribute: "block/code-language", 108 - entity: props.entityID, 109 - data: { type: "string", value: e.target.value }, 110 - }); 111 - }} 112 - > 113 - <option value="plaintext">Plaintext</option> 114 - {bundledLanguagesInfo.map((l) => ( 115 - <option key={l.id} value={l.id}> 116 - {l.name} 117 - </option> 118 - ))} 119 - </select> 120 - </div> 121 - )} 122 - 123 53 <BlockLayout 124 54 isSelected={focusedBlock} 125 55 hasBackground="accent" 126 56 borderOnHover 127 - className="p-0! min-h-[48px]" 57 + className="p-0! min-h-10 sm:min-h-12" 128 58 > 129 59 {focusedBlock && permissions.write ? ( 130 60 <BaseTextareaBlock ··· 171 101 /> 172 102 )} 173 103 </BlockLayout> 104 + {permissions.write && ( 105 + <div className="text-sm text-tertiary flex w-full justify-between"> 106 + <div className="codeBlockTheme grow flex gap-1"> 107 + Theme:{" "} 108 + <select 109 + className="codeBlockThemeSelect text-left bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 110 + onClick={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + value={theme} 115 + onChange={async (e) => { 116 + await rep?.mutate.assertFact({ 117 + attribute: "theme/code-theme", 118 + entity: rootEntity, 119 + data: { type: "string", value: e.target.value }, 120 + }); 121 + }} 122 + > 123 + {bundledThemesInfo.map((t) => ( 124 + <option key={t.id} value={t.id}> 125 + {t.displayName} 126 + </option> 127 + ))} 128 + </select> 129 + </div> 130 + <select 131 + className="codeBlockLang grow text-right bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 132 + onClick={(e) => { 133 + e.preventDefault(); 134 + e.stopPropagation(); 135 + }} 136 + value={lang} 137 + onChange={async (e) => { 138 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 139 + await rep?.mutate.assertFact({ 140 + attribute: "block/code-language", 141 + entity: props.entityID, 142 + data: { type: "string", value: e.target.value }, 143 + }); 144 + }} 145 + > 146 + <option value="plaintext">Plaintext</option> 147 + {bundledLanguagesInfo.map((l) => ( 148 + <option key={l.id} value={l.id}> 149 + {l.name} 150 + </option> 151 + ))} 152 + </select> 153 + </div> 154 + )} 174 155 </div> 175 156 ); 176 157 }
+4 -35
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); ··· 54 53 s.selectedBlocks.find((b) => b.value === props.entityID), 55 54 ); 56 55 57 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 58 56 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 59 57 60 58 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 118 116 119 117 return ( 120 118 <Popover 121 - disabled={isLocked || !permissions.write} 119 + disabled={!permissions.write} 122 120 className="w-64 z-10 px-2!" 123 121 trigger={ 124 122 <BlockLayout ··· 134 132 {dateFact ? ( 135 133 <div 136 134 className={`font-bold 137 - ${!permissions.write || isLocked ? "" : "group-hover/date:underline"} 135 + ${!permissions.write ? "" : "group-hover/date:underline"} 138 136 `} 139 137 > 140 138 {selectedDate.toLocaleDateString(undefined, { ··· 167 165 } 168 166 > 169 167 <div className="flex flex-col gap-3 "> 170 - <DayPicker 171 - components={{ 172 - Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 173 - }} 174 - classNames={{ 175 - months: "relative", 176 - month_caption: 177 - "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 178 - button_next: 179 - "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 180 - button_previous: 181 - "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center ", 182 - chevron: "text-inherit", 183 - month_grid: "w-full table-fixed", 184 - weekdays: "text-secondary text-sm", 185 - selected: "bg-accent-1! text-accent-2 rounded-md font-bold", 186 - 187 - day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 188 - outside: "text-border", 189 - today: "font-bold", 190 - }} 191 - mode="single" 168 + <DatePicker 192 169 selected={dateFact ? selectedDate : undefined} 193 170 onSelect={handleDaySelect} 194 171 /> ··· 230 207 let spring = useSpring({ opacity: props.active ? 1 : 0 }); 231 208 return <animated.div style={spring}>{props.children}</animated.div>; 232 209 }; 233 - 234 - const CustomChevron = (props: ChevronProps) => { 235 - return ( 236 - <div {...props} className="w-full pointer-events-none"> 237 - <ArrowRightTiny /> 238 - </div> 239 - ); 240 - };
+4 -4
components/Blocks/EmbedBlock.tsx
··· 111 111 <div 112 112 data-draggable 113 113 className={`resizeHandle 114 + 115 + 114 116 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 115 - absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px] 117 + absolute bottom-[3px] right-1/2 translate-x-1/2 116 118 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 117 119 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 118 120 {...heightHandle.handlers} ··· 129 131 let isSelected = useUIState((s) => 130 132 s.selectedBlocks.find((b) => b.value === props.entityID), 131 133 ); 132 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 133 134 134 135 let entity_set = useEntitySetContext(); 135 136 let [linkValue, setLinkValue] = useState(""); ··· 250 251 className="w-full grow border-none outline-hidden bg-transparent " 251 252 placeholder="www.example.com" 252 253 value={linkValue} 253 - disabled={isLocked} 254 254 onChange={(e) => setLinkValue(e.target.value)} 255 255 /> 256 256 <button 257 257 type="submit" 258 258 id="embed-block-submit" 259 259 disabled={loading} 260 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 260 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 261 261 onMouseDown={(e) => { 262 262 e.preventDefault(); 263 263 if (loading) return;
+1 -3
components/Blocks/ExternalLinkBlock.tsx
··· 118 118 let isSelected = useUIState((s) => 119 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 120 ); 121 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 122 121 let entity_set = useEntitySetContext(); 123 122 let [linkValue, setLinkValue] = useState(""); 124 123 let { rep } = useReplicache(); ··· 173 172 !props.preview ? elementId.block(props.entityID).input : undefined 174 173 } 175 174 type="url" 176 - disabled={isLocked} 177 175 className="w-full grow border-none outline-hidden bg-transparent " 178 176 placeholder="www.example.com" 179 177 value={linkValue} ··· 199 197 <div className="flex items-center gap-3 "> 200 198 <button 201 199 autoFocus={false} 202 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 200 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 203 201 onMouseDown={(e) => { 204 202 e.preventDefault(); 205 203 if (!linkValue || linkValue === "") {
+43 -34
components/Blocks/ImageBlock.tsx
··· 19 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 21 import { useSubscribe } from "src/replicache/useSubscribe"; 22 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 22 + import { 23 + ImageCoverImage, 24 + ImageCoverImageRemove, 25 + } from "components/Icons/ImageCoverImage"; 26 + import { 27 + ButtonPrimary, 28 + ButtonSecondary, 29 + ButtonTertiary, 30 + } from "components/Buttons"; 31 + import { CheckTiny } from "components/Icons/CheckTiny"; 23 32 24 33 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 34 let { rep } = useReplicache(); ··· 28 37 let isSelected = useUIState((s) => 29 38 s.selectedBlocks.find((b) => b.value === props.value), 30 39 ); 31 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 40 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 41 let isFirst = props.previousBlock === null; 34 42 let isLast = props.nextBlock === null; ··· 84 92 return ( 85 93 <BlockLayout 86 94 hasBackground="accent" 87 - isSelected={!!isSelected && !isLocked} 95 + isSelected={!!isSelected} 88 96 borderOnHover 89 97 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 98 > 91 99 <label 92 100 className={` 93 - 94 101 w-full h-full hover:cursor-pointer 95 102 flex flex-col items-center justify-center 96 - ${props.pageType === "canvas" && "bg-bg-page"}`} 103 + `} 97 104 onMouseDown={(e) => e.preventDefault()} 98 105 onDragOver={(e) => { 99 106 e.preventDefault(); ··· 102 109 onDrop={async (e) => { 103 110 e.preventDefault(); 104 111 e.stopPropagation(); 105 - if (isLocked) return; 106 112 const files = e.dataTransfer.files; 107 113 if (files && files.length > 0) { 108 114 const file = files[0]; ··· 119 125 Upload An Image 120 126 </div> 121 127 <input 122 - disabled={isLocked} 123 128 className="h-0 w-0 hidden" 124 129 type="file" 125 130 accept="image/*" ··· 134 139 ); 135 140 } 136 141 137 - let imageClassName = isFullBleed 138 - ? "" 139 - : isSelected 140 - ? "block-border-selected border-transparent! " 141 - : "block-border border-transparent!"; 142 - 143 142 let isLocalUpload = localImages.get(image.data.src); 144 143 145 144 let blockClassName = ` 146 145 relative group/image border-transparent! p-0! w-fit! 147 - ${isFullBleed && "-mx-3 sm:-mx-4"} 146 + ${isFullBleed && "-mx-[14px] sm:-mx-[18px] rounded-[0px]! sm:outline-offset-[-16px]! -outline-offset[-12px]!"} 148 147 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 148 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 149 `; 151 150 152 151 return ( 153 - <BlockLayout isSelected={!!isSelected} className={blockClassName}> 152 + <BlockLayout 153 + hasAlignment 154 + isSelected={!!isSelected} 155 + className={blockClassName} 156 + optionsClassName={isFullBleed ? "top-[-8px]!" : ""} 157 + > 154 158 {isLocalUpload || image.data.local ? ( 155 159 <img 156 160 loading="lazy" ··· 168 172 } 169 173 height={image?.data.height} 170 174 width={image?.data.width} 171 - className={imageClassName} 172 175 /> 173 176 )} 174 177 {altText !== undefined && !props.preview ? ( ··· 204 207 ); 205 208 206 209 // Only show if focused, in a publication, has write permissions, and no cover image is set 207 - if ( 208 - !isFocused || 209 - !pubData?.publications || 210 - !entity_set.permissions.write || 211 - coverImage 212 - ) 210 + if (!isFocused || !pubData?.publications || !entity_set.permissions.write) 213 211 return null; 214 - 215 - return ( 216 - <div className="absolute top-2 left-2"> 217 - <button 218 - className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 212 + if (coverImage) 213 + return ( 214 + <ButtonSecondary 215 + className="absolute top-2 right-2" 219 216 onClick={async (e) => { 220 217 e.preventDefault(); 221 218 e.stopPropagation(); 222 219 await rep?.mutate.updatePublicationDraft({ 223 - cover_image: props.entityID, 220 + cover_image: null, 224 221 }); 225 222 }} 226 223 > 227 - <span className="w-4 h-4 flex items-center justify-center"> 228 - <ImageCoverImage /> 229 - </span> 230 - Set as Cover 231 - </button> 232 - </div> 224 + Remove Cover Image 225 + <ImageCoverImageRemove /> 226 + </ButtonSecondary> 227 + ); 228 + return ( 229 + <ButtonPrimary 230 + className="absolute top-2 right-2" 231 + onClick={async (e) => { 232 + e.preventDefault(); 233 + e.stopPropagation(); 234 + await rep?.mutate.updatePublicationDraft({ 235 + cover_image: props.entityID, 236 + }); 237 + }} 238 + > 239 + Use as Cover Image 240 + <ImageCoverImage /> 241 + </ButtonPrimary> 233 242 ); 234 243 }; 235 244
+18 -3
components/Blocks/MailboxBlock.tsx
··· 26 26 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 27 27 import { InfoSmall } from "components/Icons/InfoSmall"; 28 28 29 - export const MailboxBlock = (props: BlockProps) => { 29 + export const MailboxBlock = ( 30 + props: BlockProps & { 31 + areYouSure?: boolean; 32 + setAreYouSure?: (value: boolean) => void; 33 + }, 34 + ) => { 30 35 let isSubscribed = useSubscriptionStatus(props.entityID); 31 36 let isSelected = useUIState((s) => 32 37 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 41 46 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 42 47 if (!permission) 43 48 return ( 44 - <MailboxReaderView entityID={props.entityID} parent={props.parent} /> 49 + <MailboxReaderView 50 + entityID={props.entityID} 51 + parent={props.parent} 52 + /> 45 53 ); 46 54 47 55 return ( ··· 49 57 <BlockLayout 50 58 isSelected={!!isSelected} 51 59 hasBackground={"accent"} 60 + areYouSure={props.areYouSure} 61 + setAreYouSure={props.setAreYouSure} 52 62 className="flex gap-2 items-center justify-center" 53 63 > 54 64 <ButtonPrimary ··· 120 130 ); 121 131 }; 122 132 123 - const MailboxReaderView = (props: { entityID: string; parent: string }) => { 133 + const MailboxReaderView = (props: { 134 + entityID: string; 135 + parent: string; 136 + 137 + }) => { 124 138 let isSubscribed = useSubscriptionStatus(props.entityID); 125 139 let isSelected = useUIState((s) => 126 140 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 133 147 <BlockLayout 134 148 isSelected={!!isSelected} 135 149 hasBackground={"accent"} 150 + 136 151 className="`h-full flex flex-col gap-2 items-center justify-center" 137 152 > 138 153 {!isSubscribed?.confirmed ? (
+9 -1
components/Blocks/PageLinkBlock.tsx
··· 13 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 14 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 15 15 16 - export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 16 + export function PageLinkBlock( 17 + props: BlockProps & { 18 + preview?: boolean; 19 + areYouSure?: boolean; 20 + setAreYouSure?: (value: boolean) => void; 21 + }, 22 + ) { 17 23 let page = useEntity(props.entityID, "block/card"); 18 24 let type = 19 25 useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; ··· 32 38 <BlockLayout 33 39 hasBackground="page" 34 40 isSelected={!!isSelected} 41 + areYouSure={props.areYouSure} 42 + setAreYouSure={props.setAreYouSure} 35 43 className={`cursor-pointer 36 44 pageLinkBlockWrapper relative group/pageLinkBlock 37 45 flex overflow-clip p-0!
+14 -2
components/Blocks/PollBlock/index.tsx
··· 20 20 import { PublicationPollBlock } from "../PublicationPollBlock"; 21 21 import { usePollBlockUIState } from "./pollBlockState"; 22 22 23 - export const PollBlock = (props: BlockProps) => { 23 + export const PollBlock = ( 24 + props: BlockProps & { 25 + areYouSure?: boolean; 26 + setAreYouSure?: (value: boolean) => void; 27 + }, 28 + ) => { 24 29 let { data: pub } = useLeafletPublicationData(); 25 30 if (!pub) return <LeafletPollBlock {...props} />; 26 31 return <PublicationPollBlock {...props} />; 27 32 }; 28 33 29 - export const LeafletPollBlock = (props: BlockProps) => { 34 + export const LeafletPollBlock = ( 35 + props: BlockProps & { 36 + areYouSure?: boolean; 37 + setAreYouSure?: (value: boolean) => void; 38 + }, 39 + ) => { 30 40 let isSelected = useUIState((s) => 31 41 s.selectedBlocks.find((b) => b.value === props.entityID), 32 42 ); ··· 64 74 <BlockLayout 65 75 isSelected={!!isSelected} 66 76 hasBackground={"accent"} 77 + areYouSure={props.areYouSure} 78 + setAreYouSure={props.setAreYouSure} 67 79 className="poll flex flex-col gap-2 w-full" 68 80 > 69 81 {pollState === "editing" ? (
+16 -8
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 /** ··· 21 21 * It allows adding/editing options when the poll hasn't been published yet, 22 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 23 */ 24 - export const PublicationPollBlock = (props: BlockProps) => { 25 - let { data: publicationData } = useLeafletPublicationData(); 24 + export const PublicationPollBlock = ( 25 + props: BlockProps & { 26 + areYouSure?: boolean; 27 + setAreYouSure?: (value: boolean) => void; 28 + }, 29 + ) => { 30 + let { data: publicationData, normalizedDocument } = 31 + useLeafletPublicationData(); 26 32 let isSelected = useUIState((s) => 27 33 s.selectedBlocks.find((b) => b.value === props.entityID), 28 34 ); 29 35 // Check if this poll has been published in a publication document 30 36 const isPublished = useMemo(() => { 31 - if (!publicationData?.documents?.data) return false; 37 + if (!normalizedDocument) return false; 32 38 33 - const docRecord = publicationData.documents 34 - .data as PubLeafletDocument.Record; 39 + const pages = getDocumentPages(normalizedDocument); 40 + if (!pages) return false; 35 41 36 42 // Search through all pages and blocks to find if this poll entity has been published 37 - for (const page of docRecord.pages || []) { 43 + for (const page of pages) { 38 44 if (page.$type === "pub.leaflet.pages.linearDocument") { 39 45 const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 46 for (const blockWrapper of linearPage.blocks || []) { ··· 50 56 } 51 57 } 52 58 return false; 53 - }, [publicationData, props.entityID]); 59 + }, [normalizedDocument, props.entityID]); 54 60 55 61 return ( 56 62 <BlockLayout 57 63 className="poll flex flex-col gap-2" 58 64 hasBackground={"accent"} 59 65 isSelected={!!isSelected} 66 + areYouSure={props.areYouSure} 67 + setAreYouSure={props.setAreYouSure} 60 68 > 61 69 <EditPollForPublication 62 70 entityID={props.entityID}
+8 -1
components/Blocks/RSVPBlock/index.tsx
··· 24 24 } 25 25 | { state: "contact_details"; status: RSVP_Status }; 26 26 27 - export function RSVPBlock(props: BlockProps) { 27 + export function RSVPBlock( 28 + props: BlockProps & { 29 + areYouSure?: boolean; 30 + setAreYouSure?: (value: boolean) => void; 31 + }, 32 + ) { 28 33 let isSelected = useUIState((s) => 29 34 s.selectedBlocks.find((b) => b.value === props.entityID), 30 35 ); ··· 32 37 <BlockLayout 33 38 isSelected={!!isSelected} 34 39 hasBackground={"accent"} 40 + areYouSure={props.areYouSure} 41 + setAreYouSure={props.setAreYouSure} 35 42 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 36 43 > 37 44 <RSVPForm entityID={props.entityID} />
+2 -8
components/Blocks/TextBlock/index.tsx
··· 41 41 preview?: boolean; 42 42 }, 43 43 ) { 44 - let isLocked = useEntity(props.entityID, "block/is-locked"); 45 44 let initialized = useHasPageLoaded(); 46 45 let first = props.previousBlock === null; 47 46 let permission = useEntitySetContext().permissions.write; 48 47 49 48 return ( 50 49 <> 51 - {(!initialized || 52 - !permission || 53 - props.preview || 54 - isLocked?.data.value) && ( 50 + {(!initialized || !permission || props.preview) && ( 55 51 <RenderedTextBlock 56 52 type={props.type} 57 53 entityID={props.entityID} ··· 61 57 previousBlock={props.previousBlock} 62 58 /> 63 59 )} 64 - {permission && !props.preview && !isLocked?.data.value && ( 60 + {permission && !props.preview && ( 65 61 <div 66 62 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 67 63 > ··· 330 326 let { editorState } = props; 331 327 let rep = useReplicache(); 332 328 let smoker = useSmoker(); 333 - let isLocked = useEntity(props.entityID, "block/is-locked"); 334 329 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 335 330 336 331 let isBlueskyPost = ··· 340 335 // if its bluesky, change text to embed post 341 336 342 337 if ( 343 - !isLocked && 344 338 focused && 345 339 editorState && 346 340 betterIsUrl(editorState.doc.textContent) &&
+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",
+1 -3
components/Blocks/index.tsx
··· 181 181 : null, 182 182 ); 183 183 184 - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 185 184 if (!entity_set.permissions.write) return null; 186 185 if ( 187 - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 188 - props.lastBlock?.type === "heading") && 186 + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 189 187 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 190 188 ) 191 189 return null;
+5 -15
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 23 ) { 24 24 let { rep, undoManager } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 27 26 28 27 let isSelected = useUIState((s) => { 29 28 let selectedBlocks = s.selectedBlocks; ··· 70 69 entity_set, 71 70 areYouSure, 72 71 setAreYouSure, 73 - isLocked, 74 72 }); 75 73 undoManager.endGroup(); 76 74 }; 77 75 window.addEventListener("keydown", listener); 78 76 return () => window.removeEventListener("keydown", listener); 79 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 77 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 80 78 } 81 79 82 80 type Args = { 83 81 e: KeyboardEvent; 84 - isLocked: boolean; 85 82 props: BlockProps; 86 83 rep: Replicache<ReplicacheMutators>; 87 84 entity_set: { set: string }; ··· 133 130 } 134 131 135 132 let debounced: null | number = null; 136 - async function Backspace({ 137 - e, 138 - props, 139 - rep, 140 - areYouSure, 141 - setAreYouSure, 142 - isLocked, 143 - }: Args) { 133 + async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 144 134 // if this is a textBlock, let the textBlock/keymap handle the backspace 145 - if (isLocked) return; 146 135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 147 136 let el = e.target as HTMLElement; 148 137 if ( ··· 154 143 if ((el as HTMLInputElement).value !== "") return; 155 144 } 156 145 157 - // if the block is a card or mailbox... 146 + // if the block is a card, mailbox, rsvp, or poll... 158 147 if ( 159 148 props.type === "card" || 160 149 props.type === "mailbox" || 161 - props.type === "rsvp" 150 + props.type === "rsvp" || 151 + props.type === "poll" 162 152 ) { 163 153 // ...and areYouSure state is false, set it to true 164 154 if (!areYouSure) {
+33 -3
components/Blocks/useBlockMouseHandlers.ts
··· 1 1 import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 - import { MouseEvent, useCallback, useRef } from "react"; 2 + import { MouseEvent, useCallback } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block"; 5 5 import { isTextBlock } from "src/utils/isTextBlock"; ··· 8 8 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 10 import { useIsMobile } from "src/hooks/isMobile"; 11 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 12 + import { elementId } from "src/utils/elementId"; 11 13 12 14 let debounce: number | null = null; 15 + 16 + // Track scrolling state for mobile 17 + let isScrolling = false; 18 + let scrollTimeout: number | null = null; 19 + 20 + if (typeof window !== "undefined") { 21 + window.addEventListener( 22 + "scroll", 23 + () => { 24 + isScrolling = true; 25 + if (scrollTimeout) window.clearTimeout(scrollTimeout); 26 + scrollTimeout = window.setTimeout(() => { 27 + isScrolling = false; 28 + }, 150); 29 + }, 30 + true, 31 + ); 32 + } 13 33 export function useBlockMouseHandlers(props: Block) { 14 34 let entity_set = useEntitySetContext(); 15 35 let isMobile = useIsMobile(); ··· 20 40 if ((e.target as Element).tagName === "BUTTON") return; 21 41 if ((e.target as Element).tagName === "SELECT") return; 22 42 if ((e.target as Element).tagName === "OPTION") return; 23 - if (isMobile) return; 43 + if (isMobile && isScrolling) return; 24 44 if (!entity_set.permissions.write) return; 25 45 useSelectingMouse.setState({ start: props.value }); 26 46 if (e.shiftKey) { ··· 39 59 parent: props.parent, 40 60 }); 41 61 useUIState.getState().setSelectedBlock(props); 62 + 63 + // scroll to the page containing the block, if offscreen 64 + let parentPage = elementId.page(props.parent).container; 65 + setTimeout(() => { 66 + scrollIntoViewIfNeeded( 67 + document.getElementById(parentPage), 68 + false, 69 + "smooth", 70 + ); 71 + }, 50); 42 72 } 43 73 }, 44 74 [props, entity_set.permissions.write, isMobile], 45 75 ); 46 76 let onMouseEnter = useCallback( 47 77 async (e: MouseEvent) => { 48 - if (isMobile) return; 78 + if (isMobile && isScrolling) return; 49 79 if (!entity_set.permissions.write) return; 50 80 if (debounce) window.clearTimeout(debounce); 51 81 debounce = window.setTimeout(async () => {
+51 -42
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"; 25 + import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 29 26 30 27 export function Canvas(props: { 31 28 entityID: string; ··· 165 162 } 166 163 167 164 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 - let { data: pub } = useLeafletPublicationData(); 165 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 169 166 if (!pub || !pub.publications) return null; 170 167 171 - let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 - let showComments = pubRecord.preferences?.showComments; 173 - let showMentions = pubRecord.preferences?.showMentions; 168 + if (!normalizedPublication) return null; 169 + let showComments = normalizedPublication.preferences?.showComments !== false; 170 + let showMentions = normalizedPublication.preferences?.showMentions !== false; 174 171 175 172 return ( 176 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"> ··· 290 287 }, 291 288 [props, rep, permissions], 292 289 ); 293 - let { dragDelta, handlers } = useDrag({ 290 + let { dragDelta, handlers: dragHandlers } = useDrag({ 294 291 onDragEnd, 295 - delay: isMobile, 296 292 }); 297 293 298 294 let widthOnDragEnd = useCallback( ··· 339 335 ); 340 336 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 341 337 342 - let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { 343 - if (isLongPress.current && permissions.write) { 344 - focusBlock( 345 - { 346 - type: type?.data.value || "text", 347 - value: props.entityID, 348 - parent: props.parent, 349 - }, 350 - { type: "start" }, 351 - ); 352 - } 353 - }); 338 + let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( 339 + () => { 340 + if (isLongPress.current && permissions.write) { 341 + focusBlock( 342 + { 343 + type: type?.data.value || "text", 344 + value: props.entityID, 345 + parent: props.parent, 346 + }, 347 + { type: "start" }, 348 + ); 349 + } 350 + }, 351 + ); 354 352 let angle = 0; 355 353 if (rotateHandle.dragDelta) { 356 354 let originX = rect.x + rect.width / 2; ··· 387 385 }; 388 386 }, [props, type?.data.value]); 389 387 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 388 + let mouseHandlers = useBlockMouseHandlers(blockProps); 389 + 390 390 let isList = useEntity(props.entityID, "block/is-list"); 391 391 let isFocused = useUIState( 392 392 (s) => s.focusedEntity?.entityID === props.entityID, ··· 395 395 return ( 396 396 <div 397 397 ref={ref} 398 - {...(!props.preview ? { ...longPressHandlers } : {})} 399 - {...(isMobile && permissions.write ? { ...handlers } : {})} 398 + {...(!props.preview ? { ...longPressHandlers, ...mouseHandlers } : {})} 400 399 id={props.preview ? undefined : elementId.block(props.entityID).container} 401 - className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 400 + className={`canvasBlockWrapper absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 402 401 style={{ 403 402 top: 0, 404 403 left: 0, ··· 407 406 transform, 408 407 }} 409 408 > 410 - {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 411 - {!props.preview && permissions.write && <Gripper {...handlers} />} 409 + {!props.preview && permissions.write && ( 410 + <Gripper isFocused={isFocused} {...dragHandlers} /> 411 + )} 412 + 412 413 <div 413 - className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 414 + className={` w-full ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 414 415 > 415 416 <BaseBlock 416 417 {...blockProps} ··· 428 429 <div 429 430 className={`resizeHandle 430 431 cursor-e-resize shrink-0 z-10 431 - hidden group-hover/canvas-block:block 432 - w-[5px] h-6 -ml-[3px] 433 - absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 434 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 432 + group-hover/canvas-block:block 433 + sm:w-[5px] w-3 sm:h-6 h-8 434 + absolute top-1/2 sm:right-2 right-1 -translate-y-1/2 435 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 436 + ${isFocused ? "block" : "hidden"} 437 + 438 + `} 435 439 {...widthHandle.handlers} 436 440 /> 437 441 )} ··· 440 444 <div 441 445 className={`rotateHandle 442 446 cursor-grab shrink-0 z-10 443 - hidden group-hover/canvas-block:block 444 - w-[8px] h-[8px] 445 - absolute bottom-0 -right-0 447 + group-hover/canvas-block:block 448 + sm:w-[8px] sm:h-[8px] w-4 h-4 449 + absolute sm:bottom-0 sm:right-0 -bottom-1 -right-1 446 450 -translate-y-1/2 -translate-x-1/2 447 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 451 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 452 + ${isFocused ? "block" : "hidden"} 453 + `} 448 454 {...rotateHandle.handlers} 449 455 /> 450 456 )} ··· 565 571 } 566 572 }; 567 573 568 - const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 574 + const Gripper = (props: { 575 + onMouseDown: (e: React.MouseEvent) => void; 576 + isFocused: boolean; 577 + }) => { 569 578 return ( 570 579 <div 571 580 onMouseDown={props.onMouseDown} 572 581 onPointerDown={props.onMouseDown} 573 - className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 582 + className="gripper w-[9px] shrink-0 py-1 mr-1 cursor-grab touch-none" 574 583 > 575 - <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 584 + <div className="h-full grid grid-cols-1 grid-rows-1 "> 576 585 {/* the gripper is two svg's stacked on top of each other. 577 586 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 578 587 <div 579 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 588 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 580 589 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 581 590 /> 582 591 <div 583 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 592 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 584 593 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 585 594 /> 586 - </Media> 595 + </div> 587 596 </div> 588 597 ); 589 598 };
+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 + };
+7
components/DesktopFooter.tsx
··· 4 4 import { Toolbar } from "./Toolbar"; 5 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { hasBlockToolbar } from "app/[leaflet_id]/Footer"; 8 + import { useEntity } from "src/replicache"; 7 9 8 10 export function DesktopPageFooter(props: { pageID: string }) { 9 11 let focusedEntity = useUIState((s) => s.focusedEntity); ··· 13 15 : focusedEntity?.parent; 14 16 let entity_set = useEntitySetContext(); 15 17 18 + let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data 19 + .value; 20 + 16 21 return ( 17 22 <Media 18 23 mobile={false} ··· 20 25 > 21 26 {focusedEntity && 22 27 focusedEntity.entityType === "block" && 28 + hasBlockToolbar(blockType) && 23 29 entity_set.permissions.write && 24 30 focusedBlockParentID === props.pageID && ( 25 31 <div ··· 29 35 }} 30 36 > 31 37 <Toolbar 38 + blockType={blockType} 32 39 pageID={focusedBlockParentID} 33 40 blockID={focusedEntity.entityID} 34 41 />
+19
components/Icons/DeleteTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DeleteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M4.84013 0.946324C5.45222 0.762486 6.19573 0.729124 6.97782 0.820347C7.32039 0.860495 7.56546 1.1711 7.52568 1.51371C7.48568 1.85656 7.17517 2.10252 6.83232 2.06253C6.16005 1.98415 5.5998 2.02378 5.20048 2.14359C4.79524 2.26531 4.64378 2.43772 4.59599 2.56351C4.34149 3.23723 5.58865 4.19255 6.08232 4.51761C6.54562 4.82266 7.09489 5.10483 7.70536 5.33597C8.80491 5.75229 9.85164 5.90795 10.6409 5.84769C11.4848 5.78311 11.8008 5.50429 11.8714 5.31839C11.9189 5.19263 11.9202 4.96386 11.6976 4.60453C11.478 4.25029 11.0842 3.84861 10.5286 3.46195C10.2455 3.26479 10.1754 2.87508 10.3724 2.59183C10.5696 2.30876 10.9592 2.23946 11.2425 2.43656C11.8885 2.88612 12.4235 3.40237 12.7601 3.94535C13.0932 4.48275 13.2786 5.13116 13.0403 5.76078C12.9083 6.10935 12.6779 6.37566 12.3909 6.57621C12.366 6.82447 12.311 7.37448 12.2425 8.01957C12.1491 8.89816 12.0306 9.96291 11.9329 10.6856C11.809 11.602 11.638 12.5144 11.4153 13.4121C11.237 14.0305 10.6957 14.4725 10.0989 14.75C9.43935 15.0567 8.58506 15.2285 7.63212 15.2285C5.83513 15.2285 4.30017 14.7464 3.83818 13.3604C3.76287 13.0881 3.70695 12.8096 3.6497 12.5332C3.55128 12.058 3.42826 11.4025 3.33134 10.6856C3.23301 9.95806 3.11264 8.74578 3.01689 7.72562C2.96878 7.21315 2.92679 6.74483 2.89677 6.40531C2.87398 6.14754 2.83052 5.88349 2.86454 5.62503C2.95213 4.95974 3.29709 4.69093 3.98271 4.32035C3.46821 3.68093 3.13499 2.89278 3.42704 2.12113C3.66559 1.49131 4.23434 1.12831 4.84013 0.946324ZM4.26103 7.60843C4.35712 8.63219 4.47618 9.82018 4.5706 10.5186C4.66257 11.1989 4.7799 11.8245 4.87431 12.2803C4.92135 12.5074 4.9622 12.6916 4.9915 12.8184C5.24073 13.8967 6.74216 13.9785 7.63212 13.9785C8.44689 13.9785 9.11364 13.8297 9.57255 13.6162C9.98848 13.4227 10.1815 13.2132 10.2727 12.8184C10.3608 12.4373 10.5558 11.538 10.6936 10.5186C10.7887 9.81541 10.9059 8.76587 10.9993 7.88675C11.027 7.62595 11.0512 7.38071 11.0735 7.16507C10.278 7.59273 9.18805 7.88086 7.96318 7.88089C7.26324 7.88089 6.60019 7.80834 6.0081 7.67875C5.37465 7.54007 4.79262 7.29359 4.20732 7.02347C4.22408 7.20782 4.24189 7.4046 4.26103 7.60843ZM5.65849 9.89847C5.93369 9.87744 6.17434 10.0843 6.1956 10.3594C6.21921 10.6683 6.24342 10.9585 6.26396 11.1602C6.29655 11.4801 6.34417 11.7883 6.38407 12.0176C6.40539 12.1401 6.4278 12.2622 6.45341 12.3838C6.51991 12.6498 6.33262 12.9262 6.06962 12.9825C5.79972 13.0399 5.55695 12.8734 5.47587 12.5977C5.43009 12.442 5.42115 12.3183 5.39872 12.1895C5.3563 11.9458 5.30461 11.6127 5.26884 11.2618C5.24694 11.0467 5.22223 10.7457 5.19853 10.4356C5.17773 10.1606 5.38354 9.91976 5.65849 9.89847ZM5.59208 8.40238C6.25224 8.40238 6.25244 9.45121 5.59208 9.45121C4.93177 9.4506 4.93197 8.40299 5.59208 8.40238ZM9.06767 1.26566C9.29634 1.00742 9.69106 0.983602 9.9495 1.21195C10.2079 1.44072 10.2319 1.83535 10.0032 2.09378L9.137 3.07132L8.9622 3.54496C8.84186 3.86848 8.48103 4.03346 8.15751 3.91312C7.83456 3.79265 7.67047 3.43357 7.79032 3.11039L8.00614 2.52738C8.03292 2.45553 8.07353 2.38952 8.12431 2.33207L9.06767 1.26566Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+20
components/Icons/ImageCoverImage.tsx
··· 1 + import { Props } from "./Props"; 2 + 1 3 export const ImageCoverImage = () => ( 2 4 <svg 3 5 width="24" ··· 12 14 /> 13 15 </svg> 14 16 ); 17 + 18 + export const ImageCoverImageRemove = (props: Props) => { 19 + return ( 20 + <svg 21 + width="24" 22 + height="24" 23 + viewBox="0 0 24 24" 24 + fill="none" 25 + xmlns="http://www.w3.org/2000/svg" 26 + {...props} 27 + > 28 + <path 29 + d="M21.7744 1.48538C22.0673 1.19249 22.542 1.19259 22.835 1.48538C23.1279 1.77824 23.1278 2.253 22.835 2.54592L2.22461 23.1602C1.93172 23.4526 1.45683 23.4528 1.16406 23.1602C0.871349 22.8674 0.871599 22.3926 1.16406 22.0996L21.7744 1.48538ZM22.9229 4.22561C23.1551 4.66244 23.2881 5.16029 23.2881 5.68948V18.3106C23.2879 20.0361 21.8886 21.4362 20.1631 21.4365H5.71582L6.96582 20.1865H20.1631C21.1982 20.1862 22.0379 19.3457 22.0381 18.3106V15.8067H11.3447L12.5947 14.5567H22.0381V10.1299L21.1738 9.42385L19.9775 10.9678C19.5515 11.5167 18.8233 11.7335 18.166 11.5078L16.3213 10.875C16.3092 10.8709 16.2965 10.8691 16.2842 10.8662L17.2705 9.87893L18.5723 10.3262C18.7238 10.378 18.892 10.3278 18.9902 10.2012L20.2061 8.63284L19.2764 7.87307L20.1641 6.9844L22.0381 8.51565V5.68948C22.0381 5.51371 22.0121 5.34397 21.9668 5.18264L22.9229 4.22561ZM17.6797 3.81448H3.83789C2.80236 3.81448 1.96289 4.65394 1.96289 5.68948V11.4805L4.8291 8.91897C5.18774 8.59894 5.70727 8.54438 6.12207 8.77346L6.20312 8.82327L10.083 11.4112L9.18164 12.3125L8.81055 12.0655L8.03027 12.8692C7.55062 13.3622 6.78587 13.438 6.21875 13.0489C6.17034 13.0156 6.10737 13.0113 6.05469 13.0371L4.79883 13.6573C4.258 13.9241 3.61319 13.8697 3.125 13.5157L2.26172 12.8887L1.96289 13.1573V14.5567H6.93945L5.68945 15.8067H1.96289V18.3115C1.96305 18.6614 2.06054 18.9882 2.22754 19.2686L1.32812 20.168C0.943293 19.6486 0.713079 19.0075 0.712891 18.3115V5.68948C0.712891 3.96359 2.112 2.56448 3.83789 2.56448H18.9287L17.6797 3.81448ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5937 15.2383 18.0078C15.2382 18.422 14.9025 18.7578 14.4883 18.7578H8.39453L9.89453 17.2578H14.4883ZM3.14941 18.3467C3.09734 18.2446 3.06545 18.1302 3.06543 18.0078C3.0655 17.5938 3.40144 17.2581 3.81543 17.2578H4.23828L3.14941 18.3467ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0362L3.85938 12.5039C3.97197 12.5854 4.12047 12.5976 4.24512 12.5362L5.50098 11.917C5.95928 11.6909 6.5044 11.7294 6.92578 12.0186C6.99104 12.0633 7.07958 12.0547 7.13477 11.9981L7.75488 11.3604L5.58984 9.91506L4.71094 10.7012ZM8.94629 4.52249C9.92559 4.52266 10.7195 5.31662 10.7197 6.29592C10.7197 7.27533 9.92567 8.06919 8.94629 8.06936C7.96687 8.06924 7.17292 7.27536 7.17285 6.29592C7.17304 5.31659 7.96694 4.52261 8.94629 4.52249ZM8.94629 5.52249C8.51923 5.52261 8.17304 5.86888 8.17285 6.29592C8.17292 6.72307 8.51915 7.06924 8.94629 7.06936C9.37338 7.06919 9.71966 6.72304 9.71973 6.29592C9.71954 5.86891 9.37331 5.52266 8.94629 5.52249Z" 30 + fill="currentColor" 31 + /> 32 + </svg> 33 + ); 34 + };
+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 + };
+2 -1
components/Pages/Page.tsx
··· 80 80 onClickAction?: (e: React.MouseEvent) => void; 81 81 pageType: "canvas" | "doc"; 82 82 drawerOpen: boolean | undefined; 83 + fixedWidth?: boolean; 83 84 }) => { 84 85 const cardBorderHidden = useCardBorderHidden(); 85 86 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); ··· 112 113 } 113 114 ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 114 115 ${props.fullPageScroll && "max-w-full "} 115 - ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 116 + ${props.pageType === "doc" && !props.fullPageScroll ? (props.fixedWidth ? "sm:max-w-prose max-w-[var(--page-width-units)]" : "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]") : ""} 116 117 ${ 117 118 props.pageType === "canvas" && 118 119 !props.fullPageScroll &&
+1 -1
components/Pages/PageOptions.tsx
··· 93 93 94 94 <PageOptionButton 95 95 secondary 96 - onClick={() => undoManager.undo()} 96 + onClick={() => undoManager.redo()} 97 97 disabled={!undoState.canRedo} 98 98 > 99 99 <RedoTiny />
+19 -18
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 - <Separator classname="h-4!" /> 121 + {normalizedPublication?.preferences?.showMentions !== false || 122 + normalizedPublication?.preferences?.showComments !== false ? ( 123 + <Separator classname="h-4!" /> 124 + ) : null} 122 125 </> 123 126 )} 124 - {pubRecord?.preferences?.showMentions && ( 127 + {normalizedPublication?.preferences?.showMentions !== false && ( 125 128 <div className="flex gap-1 items-center"> 126 129 <QuoteTiny />โ€” 127 130 </div> 128 131 )} 129 - {pubRecord?.preferences?.showComments && ( 132 + {normalizedPublication?.preferences?.showComments !== false && ( 130 133 <div className="flex gap-1 items-center"> 131 134 <CommentTiny />โ€” 132 135 </div> ··· 210 213 }; 211 214 212 215 export const PublicationMetadataPreview = () => { 213 - let { data: pub } = useLeafletPublicationData(); 214 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 215 - let publishedAt = record?.publishedAt; 216 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 217 + let publishedAt = normalizedDocument?.publishedAt; 216 218 217 219 if (!pub) return null; 218 220 ··· 237 239 }; 238 240 239 241 const AddTags = () => { 240 - let { data: pub } = useLeafletPublicationData(); 242 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 241 243 let { rep } = useReplicache(); 242 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 243 244 244 245 // Get tags from Replicache local state or published document 245 246 let replicacheTags = useSubscribe(rep, (tx) => ··· 250 251 let tags: string[] = []; 251 252 if (Array.isArray(replicacheTags)) { 252 253 tags = replicacheTags; 253 - } else if (record?.tags && Array.isArray(record.tags)) { 254 - tags = record.tags as string[]; 254 + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 255 + tags = normalizedDocument.tags as string[]; 255 256 } 256 257 257 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 (
+5 -67
components/SelectionManager/index.tsx
··· 17 17 import { schema } from "../Blocks/TextBlock/schema"; 18 18 import { MarkType } from "prosemirror-model"; 19 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 20 21 21 22 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 23 // How does this relate to *when dragging* ? ··· 240 241 shift: true, 241 242 key: ["ArrowDown", "J"], 242 243 handler: async () => { 243 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 244 - let block = sortedBlocks[0]; 245 - let nextBlock = siblings 246 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 247 - .find( 248 - (f) => 249 - f.listData && 250 - block.listData && 251 - !f.listData.path.find((f) => f.entity === block.value), 252 - ); 253 - if ( 254 - nextBlock?.listData && 255 - block.listData && 256 - nextBlock.listData.depth === block.listData.depth - 1 257 - ) { 258 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 259 - useUIState.getState().toggleFold(nextBlock.value); 260 - await rep?.mutate.moveBlock({ 261 - block: block.value, 262 - oldParent: block.listData?.parent, 263 - newParent: nextBlock.value, 264 - position: { type: "first" }, 265 - }); 266 - } else { 267 - await rep?.mutate.moveBlockDown({ 268 - entityID: block.value, 269 - parent: block.listData?.parent || block.parent, 270 - }); 271 - } 244 + if (!rep) return; 245 + await moveBlockDown(rep, entity_set.set); 272 246 }, 273 247 }, 274 248 { ··· 276 250 shift: true, 277 251 key: ["ArrowUp", "K"], 278 252 handler: async () => { 279 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 280 - let block = sortedBlocks[0]; 281 - let previousBlock = 282 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 283 - if (previousBlock.value === block.listData?.parent) { 284 - previousBlock = 285 - siblings?.[ 286 - siblings.findIndex((s) => s.value === block.value) - 2 287 - ]; 288 - } 289 - 290 - if ( 291 - previousBlock?.listData && 292 - block.listData && 293 - block.listData.depth > 1 && 294 - !previousBlock.listData.path.find( 295 - (f) => f.entity === block.listData?.parent, 296 - ) 297 - ) { 298 - let depth = block.listData.depth; 299 - let newParent = previousBlock.listData.path.find( 300 - (f) => f.depth === depth - 1, 301 - ); 302 - if (!newParent) return; 303 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 304 - useUIState.getState().toggleFold(newParent.entity); 305 - rep?.mutate.moveBlock({ 306 - block: block.value, 307 - oldParent: block.listData?.parent, 308 - newParent: newParent.entity, 309 - position: { type: "end" }, 310 - }); 311 - } else { 312 - rep?.mutate.moveBlockUp({ 313 - entityID: block.value, 314 - parent: block.listData?.parent || block.parent, 315 - }); 316 - } 253 + if (!rep) return; 254 + await moveBlockUp(rep); 317 255 }, 318 256 }, 319 257
+2 -2
components/ThemeManager/Pickers/PageWidthSetter.tsx
··· 89 89 <div 90 90 className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 91 91 > 92 - default (624px) 92 + default ({defaultPreset}px) 93 93 </div> 94 94 </Radio> 95 95 </label> ··· 111 111 <div 112 112 className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 113 113 > 114 - wide (756px) 114 + wide ({widePreset}px) 115 115 </div> 116 116 </Radio> 117 117 </label>
+2 -2
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
··· 76 76 <div 77 77 className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 78 78 > 79 - default (624px) 79 + default ({defaultPreset}px) 80 80 </div> 81 81 </Radio> 82 82 </label> ··· 98 98 <div 99 99 className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 100 100 > 101 - wide (756px) 101 + wide ({widePreset}px) 102 102 </div> 103 103 </Radio> 104 104 </label>
+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;
components/Toolbar/BlockToolbar.1.tsx

This is a binary file and will not be displayed.

-223
components/Toolbar/BlockToolbar.tsx
··· 1 - import { useEntity, useReplicache } from "src/replicache"; 2 - import { ToolbarButton } from "."; 3 - import { Separator, ShortcutKey } from "components/Layout"; 4 - import { metaKey } from "src/utils/metaKey"; 5 - import { useUIState } from "src/useUIState"; 6 - import { LockBlockButton } from "./LockBlockButton"; 7 - import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { 9 - ImageFullBleedButton, 10 - ImageAltTextButton, 11 - ImageCoverButton, 12 - } from "./ImageToolbar"; 13 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 - import { getSortedSelection } from "components/SelectionManager/selectionState"; 15 - 16 - export const BlockToolbar = (props: { 17 - setToolbarState: ( 18 - state: "areYouSure" | "block" | "text-alignment" | "img-alt-text", 19 - ) => void; 20 - }) => { 21 - let focusedEntity = useUIState((s) => s.focusedEntity); 22 - let focusedEntityType = useEntity( 23 - focusedEntity?.entityType === "page" 24 - ? focusedEntity.entityID 25 - : focusedEntity?.parent || null, 26 - "page/type", 27 - ); 28 - let blockType = useEntity( 29 - focusedEntity?.entityType === "block" ? focusedEntity?.entityID : null, 30 - "block/type", 31 - )?.data.value; 32 - 33 - return ( 34 - <div className="flex items-center gap-2 justify-between w-full"> 35 - <div className="flex items-center gap-2"> 36 - <ToolbarButton 37 - onClick={() => { 38 - props.setToolbarState("areYouSure"); 39 - }} 40 - tooltipContent="Delete Block" 41 - > 42 - <DeleteSmall /> 43 - </ToolbarButton> 44 - <Separator classname="h-6!" /> 45 - <MoveBlockButtons /> 46 - {blockType === "image" && ( 47 - <> 48 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 49 - <ImageFullBleedButton /> 50 - <ImageAltTextButton setToolbarState={props.setToolbarState} /> 51 - <ImageCoverButton /> 52 - {focusedEntityType?.data.value !== "canvas" && ( 53 - <Separator classname="h-6!" /> 54 - )} 55 - </> 56 - )} 57 - {(blockType === "button" || blockType === "datetime") && ( 58 - <> 59 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 60 - {focusedEntityType?.data.value !== "canvas" && ( 61 - <Separator classname="h-6!" /> 62 - )} 63 - </> 64 - )} 65 - 66 - <LockBlockButton /> 67 - </div> 68 - </div> 69 - ); 70 - }; 71 - 72 - const MoveBlockButtons = () => { 73 - let { rep } = useReplicache(); 74 - return ( 75 - <> 76 - <ToolbarButton 77 - hiddenOnCanvas 78 - onClick={async () => { 79 - if (!rep) return; 80 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 81 - if (sortedBlocks.length > 1) return; 82 - let block = sortedBlocks[0]; 83 - let previousBlock = 84 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 85 - if (previousBlock.value === block.listData?.parent) { 86 - previousBlock = 87 - siblings?.[ 88 - siblings.findIndex((s) => s.value === block.value) - 2 89 - ]; 90 - } 91 - 92 - if ( 93 - previousBlock?.listData && 94 - block.listData && 95 - block.listData.depth > 1 && 96 - !previousBlock.listData.path.find( 97 - (f) => f.entity === block.listData?.parent, 98 - ) 99 - ) { 100 - let depth = block.listData.depth; 101 - let newParent = previousBlock.listData.path.find( 102 - (f) => f.depth === depth - 1, 103 - ); 104 - if (!newParent) return; 105 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 106 - useUIState.getState().toggleFold(newParent.entity); 107 - rep?.mutate.moveBlock({ 108 - block: block.value, 109 - oldParent: block.listData?.parent, 110 - newParent: newParent.entity, 111 - position: { type: "end" }, 112 - }); 113 - } else { 114 - rep?.mutate.moveBlockUp({ 115 - entityID: block.value, 116 - parent: block.listData?.parent || block.parent, 117 - }); 118 - } 119 - }} 120 - tooltipContent={ 121 - <div className="flex flex-col gap-1 justify-center"> 122 - <div className="text-center">Move Up</div> 123 - <div className="flex gap-1"> 124 - <ShortcutKey>Shift</ShortcutKey> +{" "} 125 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 126 - <ShortcutKey> โ†‘ </ShortcutKey> 127 - </div> 128 - </div> 129 - } 130 - > 131 - <MoveBlockUp /> 132 - </ToolbarButton> 133 - 134 - <ToolbarButton 135 - hiddenOnCanvas 136 - onClick={async () => { 137 - if (!rep) return; 138 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 139 - if (sortedBlocks.length > 1) return; 140 - let block = sortedBlocks[0]; 141 - let nextBlock = siblings 142 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 143 - .find( 144 - (f) => 145 - f.listData && 146 - block.listData && 147 - !f.listData.path.find((f) => f.entity === block.value), 148 - ); 149 - if ( 150 - nextBlock?.listData && 151 - block.listData && 152 - nextBlock.listData.depth === block.listData.depth - 1 153 - ) { 154 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 155 - useUIState.getState().toggleFold(nextBlock.value); 156 - rep?.mutate.moveBlock({ 157 - block: block.value, 158 - oldParent: block.listData?.parent, 159 - newParent: nextBlock.value, 160 - position: { type: "first" }, 161 - }); 162 - } else { 163 - rep?.mutate.moveBlockDown({ 164 - entityID: block.value, 165 - parent: block.listData?.parent || block.parent, 166 - }); 167 - } 168 - }} 169 - tooltipContent={ 170 - <div className="flex flex-col gap-1 justify-center"> 171 - <div className="text-center">Move Down</div> 172 - <div className="flex gap-1"> 173 - <ShortcutKey>Shift</ShortcutKey> +{" "} 174 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 175 - <ShortcutKey> โ†“ </ShortcutKey> 176 - </div> 177 - </div> 178 - } 179 - > 180 - <MoveBlockDown /> 181 - </ToolbarButton> 182 - <Separator classname="h-6!" /> 183 - </> 184 - ); 185 - }; 186 - 187 - const MoveBlockDown = () => { 188 - return ( 189 - <svg 190 - width="24" 191 - height="24" 192 - viewBox="0 0 24 24" 193 - fill="none" 194 - xmlns="http://www.w3.org/2000/svg" 195 - > 196 - <path 197 - fillRule="evenodd" 198 - clipRule="evenodd" 199 - d="M18.3444 3.56272L3.89705 5.84775C3.48792 5.91246 3.20871 6.29658 3.27342 6.7057L3.83176 10.2358C3.89647 10.645 4.28058 10.9242 4.68971 10.8595L19.137 8.57444C19.5462 8.50973 19.8254 8.12561 19.7607 7.71649L19.2023 4.18635C19.1376 3.77722 18.7535 3.49801 18.3444 3.56272ZM3.70177 4.61309C2.69864 4.77175 1.9884 5.65049 2.01462 6.63905C1.6067 6.92894 1.37517 7.43373 1.45854 7.96083L2.02167 11.5213C2.19423 12.6123 3.21854 13.3568 4.30955 13.1843L15.5014 11.4142L15.3472 10.4394L16.6131 10.2392L17.2948 13.9166L15.3038 12.4752C14.9683 12.2322 14.4994 12.3073 14.2565 12.6428C14.0135 12.9783 14.0886 13.4472 14.4241 13.6902L18.5417 16.6712L21.5228 12.5536C21.7658 12.2181 21.6907 11.7492 21.3552 11.5063C21.0197 11.2634 20.5508 11.3385 20.3079 11.674L18.7926 13.7669L18.0952 10.0048L19.3323 9.80909C20.4233 9.63654 21.1679 8.61222 20.9953 7.52121L20.437 3.99107C20.2644 2.90007 19.2401 2.15551 18.1491 2.32807L3.70177 4.61309ZM12.5175 14.1726C12.8583 14.118 13.0904 13.7974 13.0358 13.4566C12.9812 13.1157 12.6606 12.8837 12.3198 12.9383L4.48217 14.1937C3.37941 14.3704 2.62785 15.4065 2.80232 16.5096L3.35244 19.9878C3.52716 21.0925 4.56428 21.8463 5.66893 21.6716L20.0583 19.3958C21.1618 19.2212 21.9155 18.186 21.7426 17.0822L21.6508 16.4961C21.5974 16.1551 21.2776 15.922 20.9366 15.9754C20.5956 16.0288 20.3624 16.3486 20.4158 16.6896L20.5077 17.2757C20.5738 17.6981 20.2854 18.0943 19.8631 18.1611L5.47365 20.437C5.05089 20.5038 4.65396 20.2153 4.5871 19.7925L4.03697 16.3143C3.9702 15.8921 4.25783 15.4956 4.67988 15.428L12.5175 14.1726ZM5.48645 8.13141C5.4213 7.72235 5.70009 7.33793 6.10914 7.27278L12.7667 6.21241C13.1757 6.14726 13.5602 6.42605 13.6253 6.83511C13.6905 7.24417 13.4117 7.62859 13.0026 7.69374L6.34508 8.75411C5.93602 8.81926 5.5516 8.54047 5.48645 8.13141Z" 200 - fill="currentColor" 201 - /> 202 - </svg> 203 - ); 204 - }; 205 - 206 - const MoveBlockUp = () => { 207 - return ( 208 - <svg 209 - width="24" 210 - height="24" 211 - viewBox="0 0 24 24" 212 - fill="none" 213 - xmlns="http://www.w3.org/2000/svg" 214 - > 215 - <path 216 - fillRule="evenodd" 217 - clipRule="evenodd" 218 - d="M4.12086 10.3069C3.69777 10.3744 3.30016 10.0858 3.23323 9.66265L2.68364 6.18782C2.61677 5.76506 2.90529 5.36813 3.32805 5.30127L17.7149 3.0258C18.1378 2.95892 18.5348 3.24759 18.6015 3.67049L18.7835 4.82361C18.8373 5.16457 19.1573 5.39736 19.4983 5.34356C19.8392 5.28975 20.072 4.96974 20.0182 4.62878L19.8363 3.47566C19.6619 2.37067 18.6246 1.61639 17.5197 1.79115L3.13278 4.06661C2.02813 4.24133 1.27427 5.27845 1.44899 6.3831L1.99857 9.85793C2.17346 10.9637 3.21238 11.7177 4.31788 11.5413L11.5185 10.392C11.8594 10.3376 12.0916 10.0171 12.0372 9.67628C11.9828 9.33542 11.6624 9.1032 11.3215 9.15761L4.12086 10.3069ZM19.9004 11.6151L5.45305 13.9001C5.04392 13.9649 4.76471 14.349 4.82942 14.7581L5.38775 18.2882C5.45246 18.6974 5.83658 18.9766 6.24571 18.9119L20.6931 16.6268C21.1022 16.5621 21.3814 16.178 21.3167 15.7689L20.7583 12.2388C20.6936 11.8296 20.3095 11.5504 19.9004 11.6151ZM5.25777 12.6655C4.21806 12.8299 3.49299 13.7679 3.57645 14.8C3.17867 15.1511 2.9637 15.6918 3.05264 16.2541L3.57767 19.5737C3.75023 20.6647 4.77455 21.4093 5.86556 21.2367L19.9927 19.0023C20.7197 18.8873 21.2751 18.3524 21.4519 17.6846C22.2223 17.3097 22.6921 16.4638 22.5513 15.5736L21.993 12.0435C21.8204 10.9525 20.7961 10.2079 19.7051 10.3805L17.9019 10.6657L17.3957 7.46986L19.3483 8.96297C19.6773 9.21457 20.148 9.1518 20.3996 8.82276C20.6512 8.49373 20.5885 8.02302 20.2594 7.77141L16.2213 4.68355L13.1334 8.72172C12.8818 9.05076 12.9445 9.52146 13.2736 9.77307C13.6026 10.0247 14.0733 9.96191 14.3249 9.63287L15.8945 7.58034L16.4203 10.9L5.25777 12.6655ZM7.66514 15.3252C7.25609 15.3903 6.97729 15.7748 7.04245 16.1838C7.1076 16.5929 7.49202 16.8717 7.90108 16.8065L14.5586 15.7461C14.9677 15.681 15.2465 15.2966 15.1813 14.8875C15.1162 14.4785 14.7317 14.1997 14.3227 14.2648L7.66514 15.3252Z" 219 - fill="currentColor" 220 - /> 221 - </svg> 222 - ); 223 - };
+36 -8
components/Toolbar/ImageToolbar.tsx
··· 6 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 8 import { useSubscribe } from "src/replicache/useSubscribe"; 9 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 9 + import { 10 + ImageCoverImage, 11 + ImageCoverImageRemove, 12 + } from "components/Icons/ImageCoverImage"; 13 + import { Separator } from "components/Layout"; 14 + import { TextAlignmentButton } from "./TextAlignmentToolbar"; 15 + 16 + export const ImageToolbar = (props: { 17 + setToolbarState: (state: "image" | "text-alignment") => void; 18 + }) => { 19 + let focusedEntity = useUIState((s) => s.focusedEntity); 20 + let focusedEntityType = useEntity( 21 + focusedEntity?.entityType === "page" 22 + ? focusedEntity.entityID 23 + : focusedEntity?.parent || null, 24 + "page/type", 25 + ); 26 + 27 + return ( 28 + <div className="flex items-center gap-2 justify-between w-full"> 29 + <div className="flex items-center gap-2"> 30 + <TextAlignmentButton setToolbarState={props.setToolbarState} /> 31 + <ImageAltTextButton /> 32 + <ImageFullBleedButton /> 33 + <ImageCoverButton /> 34 + {focusedEntityType?.data.value !== "canvas" && ( 35 + <Separator classname="h-6!" /> 36 + )} 37 + </div> 38 + </div> 39 + ); 40 + }; 10 41 11 42 export const ImageFullBleedButton = (props: {}) => { 12 43 let { rep } = useReplicache(); ··· 36 67 ); 37 68 }; 38 69 39 - export const ImageAltTextButton = (props: { 40 - setToolbarState: (s: "img-alt-text") => void; 41 - }) => { 70 + export const ImageAltTextButton = (props: {}) => { 42 71 let { rep } = useReplicache(); 43 72 let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 44 73 ··· 48 77 let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 49 78 let hasSrc = useEntity(focusedBlock, "block/image")?.data; 50 79 if (!hasSrc) return null; 51 - 52 80 return ( 53 81 <ToolbarButton 54 82 active={altText !== undefined} 55 83 onClick={async (e) => { 56 84 e.preventDefault(); 57 85 if (!focusedBlock) return; 58 - if (!altText) { 86 + if (altText === undefined) { 59 87 await rep?.mutate.assertFact({ 60 88 entity: focusedBlock, 61 89 attribute: "image/alt", ··· 109 137 } 110 138 }} 111 139 tooltipContent={ 112 - <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 140 + <div>{isCoverImage ? "Remove Cover Image" : "Use as Cover Image"}</div> 113 141 } 114 142 > 115 - <ImageCoverImage /> 143 + {isCoverImage ? <ImageCoverImageRemove /> : <ImageCoverImage />} 116 144 </ToolbarButton> 117 145 ); 118 146 };
-103
components/Toolbar/LockBlockButton.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { ToolbarButton } from "."; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - 5 - import { focusBlock } from "src/utils/focusBlock"; 6 - import { Props } from "components/Icons/Props"; 7 - 8 - export function LockBlockButton() { 9 - let focusedBlock = useUIState((s) => s.focusedEntity); 10 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 11 - let type = useEntity(focusedBlock?.entityID || null, "block/type"); 12 - let locked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 13 - let { rep } = useReplicache(); 14 - if (focusedBlock?.entityType !== "block") return; 15 - return ( 16 - <ToolbarButton 17 - disabled={false} 18 - onClick={async () => { 19 - if (!locked?.data.value) { 20 - await rep?.mutate.assertFact({ 21 - entity: focusedBlock.entityID, 22 - attribute: "block/is-locked", 23 - data: { value: true, type: "boolean" }, 24 - }); 25 - if (selectedBlocks.length > 1) { 26 - for (let block of selectedBlocks) { 27 - await rep?.mutate.assertFact({ 28 - attribute: "block/is-locked", 29 - entity: block.value, 30 - data: { value: true, type: "boolean" }, 31 - }); 32 - } 33 - } 34 - } else { 35 - await rep?.mutate.retractFact({ factID: locked.id }); 36 - if (selectedBlocks.length > 1) { 37 - for (let block of selectedBlocks) { 38 - await rep?.mutate.retractAttribute({ 39 - attribute: "block/is-locked", 40 - entity: block.value, 41 - }); 42 - } 43 - } else { 44 - type && 45 - focusBlock( 46 - { 47 - type: type.data.value, 48 - parent: focusedBlock.parent, 49 - value: focusedBlock.entityID, 50 - }, 51 - { type: "end" }, 52 - ); 53 - } 54 - } 55 - }} 56 - tooltipContent={ 57 - <span>{!locked?.data.value ? "Lock Editing" : " Unlock to Edit"}</span> 58 - } 59 - > 60 - {!locked?.data.value ? <LockSmall /> : <UnlockSmall />} 61 - </ToolbarButton> 62 - ); 63 - } 64 - 65 - const LockSmall = (props: Props) => { 66 - return ( 67 - <svg 68 - width="24" 69 - height="24" 70 - viewBox="0 0 24 24" 71 - fill="none" 72 - xmlns="http://www.w3.org/2000/svg" 73 - {...props} 74 - > 75 - <path 76 - fillRule="evenodd" 77 - clipRule="evenodd" 78 - d="M12 3.9657C9.73217 3.9657 7.89374 5.80413 7.89374 8.07196V10.1794H7.78851C6.82201 10.1794 6.03851 10.9629 6.03851 11.9294V17C6.03851 18.6569 7.38166 20 9.03851 20H14.9615C16.6184 20 17.9615 18.6569 17.9615 17V11.9294C17.9615 10.9629 17.178 10.1794 16.2115 10.1794H16.1063V8.07196C16.1063 5.80413 14.2678 3.9657 12 3.9657ZM14.3563 10.1794V8.07196C14.3563 6.77063 13.3013 5.7157 12 5.7157C10.6987 5.7157 9.64374 6.77063 9.64374 8.07196V10.1794H14.3563ZM12.5824 15.3512C12.9924 15.1399 13.2727 14.7123 13.2727 14.2193C13.2727 13.5165 12.7029 12.9467 12 12.9467C11.2972 12.9467 10.7274 13.5165 10.7274 14.2193C10.7274 14.7271 11.0247 15.1654 11.4548 15.3696L11.2418 17.267C11.2252 17.4152 11.3411 17.5449 11.4902 17.5449H12.5147C12.6621 17.5449 12.7774 17.4181 12.7636 17.2714L12.5824 15.3512Z" 79 - fill="currentColor" 80 - /> 81 - </svg> 82 - ); 83 - }; 84 - 85 - const UnlockSmall = (props: Props) => { 86 - return ( 87 - <svg 88 - width="24" 89 - height="24" 90 - viewBox="0 0 24 24" 91 - fill="none" 92 - xmlns="http://www.w3.org/2000/svg" 93 - {...props} 94 - > 95 - <path 96 - fillRule="evenodd" 97 - clipRule="evenodd" 98 - d="M7.89376 6.62482C7.89376 4.35699 9.7322 2.51855 12 2.51855C14.2678 2.51855 16.1063 4.35699 16.1063 6.62482V10.1794H16.2115C17.178 10.1794 17.9615 10.9629 17.9615 11.9294V17C17.9615 18.6569 16.6184 20 14.9615 20H9.03854C7.38168 20 6.03854 18.6569 6.03854 17V11.9294C6.03854 10.9629 6.82204 10.1794 7.78854 10.1794H14.3563V6.62482C14.3563 5.32349 13.3013 4.26855 12 4.26855C10.6987 4.26855 9.64376 5.32349 9.64376 6.62482V7.72078C9.64376 8.20403 9.25201 8.59578 8.76876 8.59578C8.28551 8.59578 7.89376 8.20403 7.89376 7.72078V6.62482ZM13.1496 14.2193C13.1496 14.7123 12.8693 15.1399 12.4593 15.3512L12.6405 17.2714C12.6544 17.4181 12.539 17.5449 12.3916 17.5449H11.3672C11.218 17.5449 11.1021 17.4152 11.1187 17.267L11.3317 15.3696C10.9016 15.1654 10.6043 14.7271 10.6043 14.2193C10.6043 13.5165 11.1741 12.9467 11.8769 12.9467C12.5798 12.9467 13.1496 13.5165 13.1496 14.2193ZM5.62896 5.3862C5.4215 5.20395 5.10558 5.2244 4.92333 5.43186C4.74109 5.63932 4.76153 5.95525 4.969 6.13749L6.06209 7.09771C6.26955 7.27996 6.58548 7.25951 6.76772 7.05205C6.94997 6.84458 6.92952 6.52866 6.72206 6.34642L5.62896 5.3862ZM3.5165 6.64283C3.25418 6.55657 2.97159 6.69929 2.88533 6.96161C2.79906 7.22393 2.94178 7.50652 3.20411 7.59278L5.54822 8.36366C5.81054 8.44992 6.09313 8.3072 6.1794 8.04488C6.26566 7.78256 6.12294 7.49997 5.86062 7.41371L3.5165 6.64283ZM3.54574 9.42431C3.52207 9.14918 3.72592 8.90696 4.00105 8.8833L5.52254 8.75244C5.79766 8.72878 6.03988 8.93263 6.06354 9.20776C6.08721 9.48288 5.88335 9.7251 5.60823 9.74876L4.08674 9.87962C3.81162 9.90329 3.5694 9.69943 3.54574 9.42431Z" 99 - fill="currentColor" 100 - /> 101 - </svg> 102 - ); 103 - };
+33 -12
components/Toolbar/MultiSelectToolbar.tsx
··· 2 2 import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 3 import { ToolbarButton } from "./index"; 4 4 import { copySelection } from "src/utils/copySelection"; 5 - import { useSmoker } from "components/Toast"; 6 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 - import { Replicache } from "replicache"; 8 - import { LockBlockButton } from "./LockBlockButton"; 5 + import { useSmoker, useToaster } from "components/Toast"; 6 + 9 7 import { Props } from "components/Icons/Props"; 10 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 9 import { getSortedSelection } from "components/SelectionManager/selectionState"; 10 + import { deleteBlock } from "src/utils/deleteBlock"; 11 + import { Separator, ShortcutKey } from "components/Layout"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 - setToolbarState: ( 15 - state: "areYouSure" | "multiselect" | "text-alignment", 16 - ) => void; 14 + setToolbarState: (state: "multiselect" | "text-alignment") => void; 17 15 }) => { 18 - const { rep } = useReplicache(); 16 + const { rep, undoManager } = useReplicache(); 19 17 const smoker = useSmoker(); 18 + const toaster = useToaster(); 20 19 21 20 const handleCopy = async (event: React.MouseEvent) => { 22 21 if (!rep) return; 23 - const [sortedSelection] = await getSortedSelection(rep); 22 + let [sortedSelection] = await getSortedSelection(rep); 24 23 await copySelection(rep, sortedSelection); 25 24 smoker({ 26 25 position: { x: event.clientX, y: event.clientY }, ··· 33 32 <div className="flex items-center gap-2"> 34 33 <ToolbarButton 35 34 tooltipContent="Delete Selected Blocks" 36 - onClick={() => { 37 - props.setToolbarState("areYouSure"); 35 + onClick={async (e) => { 36 + e.stopPropagation(); 37 + if (!rep) return; 38 + let [sortedSelection] = await getSortedSelection(rep); 39 + await deleteBlock( 40 + sortedSelection.map((b) => b.value), 41 + rep, 42 + undoManager, 43 + ); 44 + 45 + toaster({ 46 + content: ( 47 + <div className="font-bold items-center flex"> 48 + {sortedSelection.length} block 49 + {sortedSelection.length === 1 ? "" : "s"} deleted!{" "} 50 + <span className="px-2 flex"> 51 + <ShortcutKey>Ctrl</ShortcutKey> 52 + <ShortcutKey>Z</ShortcutKey>{" "} 53 + </span> 54 + to undo. 55 + </div> 56 + ), 57 + type: "success", 58 + }); 38 59 }} 39 60 > 40 61 <TrashSmall /> ··· 47 68 <CopySmall /> 48 69 </ToolbarButton> 49 70 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 50 - <LockBlockButton /> 71 + <Separator classname="h-6!" /> 51 72 </div> 52 73 </div> 53 74 );
+8
components/Toolbar/TextAlignmentToolbar.tsx
··· 7 7 export function TextAlignmentToolbar() { 8 8 let focusedBlock = useUIState((s) => s.focusedEntity); 9 9 let { rep } = useReplicache(); 10 + let alignment = useEntity( 11 + focusedBlock?.entityID || null, 12 + "block/text-alignment", 13 + )?.data.value; 10 14 let setAlignment = useCallback( 11 15 (alignment: Fact<"block/text-alignment">["data"]["value"]) => { 12 16 let blocks = useUIState.getState().selectedBlocks; ··· 26 30 <ToolbarButton 27 31 onClick={() => setAlignment("left")} 28 32 tooltipContent="Align Left" 33 + active={alignment === "left"} 29 34 > 30 35 <AlignLeftSmall /> 31 36 </ToolbarButton> 32 37 <ToolbarButton 33 38 onClick={() => setAlignment("center")} 34 39 tooltipContent="Align Center" 40 + active={alignment === "center"} 35 41 > 36 42 <AlignCenterSmall /> 37 43 </ToolbarButton> 38 44 <ToolbarButton 39 45 onClick={() => setAlignment("right")} 40 46 tooltipContent="Align Right" 47 + active={alignment === "right"} 41 48 > 42 49 <AlignRightSmall /> 43 50 </ToolbarButton> ··· 45 52 <ToolbarButton 46 53 onClick={() => setAlignment("justify")} 47 54 tooltipContent="Align Justified" 55 + active={alignment === "justify"} 48 56 > 49 57 <AlignJustifiedSmall /> 50 58 </ToolbarButton>
-3
components/Toolbar/TextToolbar.tsx
··· 8 8 import { ToolbarTypes } from "."; 9 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { LockBlockButton } from "./LockBlockButton"; 12 11 import { Props } from "components/Icons/Props"; 13 12 import { isMac } from "src/utils/isDevice"; 14 13 ··· 81 80 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 81 <ListButton setToolbarState={props.setToolbarState} /> 83 82 <Separator classname="h-6!" /> 84 - 85 - <LockBlockButton /> 86 83 </> 87 84 ); 88 85 };
+57 -87
components/Toolbar/index.tsx
··· 11 11 import { ListToolbar } from "./ListToolbar"; 12 12 import { HighlightToolbar } from "./HighlightToolbar"; 13 13 import { TextToolbar } from "./TextToolbar"; 14 - import { BlockToolbar } from "./BlockToolbar"; 14 + import { ImageToolbar } from "./ImageToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 - import { deleteBlock } from "src/utils/deleteBlock"; 18 16 import { TooltipButton } from "components/Buttons"; 19 17 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 18 import { useIsMobile } from "src/hooks/isMobile"; 21 19 import { CloseTiny } from "components/Icons/CloseTiny"; 22 20 23 21 export type ToolbarTypes = 24 - | "areYouSure" 25 22 | "default" 26 - | "block" 27 23 | "multiselect" 28 24 | "highlight" 29 25 | "link" ··· 31 27 | "text-alignment" 32 28 | "list" 33 29 | "linkBlock" 34 - | "img-alt-text"; 35 - 36 - export const Toolbar = (props: { pageID: string; blockID: string }) => { 37 - let { rep } = useReplicache(); 30 + | "img-alt-text" 31 + | "image"; 38 32 33 + export const Toolbar = (props: { 34 + pageID: string; 35 + blockID: string; 36 + blockType: string | null | undefined; 37 + }) => { 39 38 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 40 39 41 - let focusedEntity = useUIState((s) => s.focusedEntity); 42 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 43 40 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 44 - 45 - let blockType = useEntity(props.blockID, "block/type")?.data.value; 41 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 46 42 47 43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 48 44 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 64 60 }; 65 61 }, [toolbarState]); 66 62 67 - useEffect(() => { 68 - if (!blockType) return; 69 - if ( 70 - blockType !== "heading" && 71 - blockType !== "text" && 72 - blockType !== "blockquote" 73 - ) { 74 - setToolbarState("block"); 75 - } else { 76 - setToolbarState("default"); 77 - } 78 - }, [blockType]); 63 + let isTextBlock = 64 + props.blockType === "heading" || 65 + props.blockType === "text" || 66 + props.blockType === "blockquote"; 79 67 80 68 useEffect(() => { 81 - if ( 82 - selectedBlocks.length > 1 && 83 - !["areYousure", "text-alignment"].includes(toolbarState) 84 - ) { 69 + if (selectedBlocks.length > 1) { 85 70 setToolbarState("multiselect"); 86 - } else if (toolbarState === "multiselect") { 71 + return; 72 + } 73 + if (isTextBlock) { 87 74 setToolbarState("default"); 88 75 } 89 - }, [selectedBlocks.length, toolbarState]); 90 - let isMobile = useIsMobile(); 76 + if (props.blockType === "image") { 77 + setToolbarState("image"); 78 + } 79 + if (props.blockType === "button" || props.blockType === "datetime") { 80 + setToolbarState("text-alignment"); 81 + } else null; 82 + }, [props.blockType, selectedBlocks]); 91 83 84 + let isMobile = useIsMobile(); 92 85 return ( 93 86 <Tooltip.Provider> 94 87 <div ··· 125 118 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 126 119 ) : toolbarState === "text-alignment" ? ( 127 120 <TextAlignmentToolbar /> 128 - ) : toolbarState === "block" ? ( 129 - <BlockToolbar setToolbarState={setToolbarState} /> 121 + ) : toolbarState === "image" ? ( 122 + <ImageToolbar setToolbarState={setToolbarState} /> 130 123 ) : toolbarState === "multiselect" ? ( 131 124 <MultiselectToolbar setToolbarState={setToolbarState} /> 132 - ) : toolbarState === "areYouSure" ? ( 133 - <AreYouSure 134 - compact 135 - type={blockType} 136 - entityID={selectedBlocks.map((b) => b.value)} 137 - onClick={() => { 138 - rep && 139 - deleteBlock( 140 - selectedBlocks.map((b) => b.value), 141 - rep, 142 - ); 143 - }} 144 - closeAreYouSure={() => { 145 - setToolbarState( 146 - selectedBlocks.length > 1 147 - ? "multiselect" 148 - : blockType !== "heading" && blockType !== "text" 149 - ? "block" 150 - : "default", 151 - ); 152 - }} 153 - /> 154 125 ) : null} 155 126 </div> 156 127 {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */} 157 - {toolbarState !== "areYouSure" && ( 158 - <button 159 - className="toolbarBackToDefault hover:text-accent-contrast" 160 - onMouseDown={(e) => { 161 - e.preventDefault(); 162 - if ( 163 - toolbarState === "multiselect" || 164 - toolbarState === "block" || 165 - toolbarState === "default" 166 - ) { 167 - useUIState.setState(() => ({ 168 - focusedEntity: { 169 - entityType: "page", 170 - entityID: props.pageID, 171 - }, 172 - selectedBlocks: [], 173 - })); 174 - } else { 175 - if (blockType !== "heading" && blockType !== "text") { 176 - setToolbarState("block"); 177 - } else { 178 - setToolbarState("default"); 179 - } 128 + 129 + <button 130 + className="toolbarBackToDefault hover:text-accent-contrast" 131 + onMouseDown={(e) => { 132 + e.preventDefault(); 133 + if ( 134 + toolbarState === "multiselect" || 135 + toolbarState === "image" || 136 + toolbarState === "default" 137 + ) { 138 + // close the toolbar 139 + useUIState.setState(() => ({ 140 + focusedEntity: { 141 + entityType: "page", 142 + entityID: props.pageID, 143 + }, 144 + selectedBlocks: [], 145 + })); 146 + } else { 147 + if (props.blockType === "image") { 148 + setToolbarState("image"); 180 149 } 181 - }} 182 - > 183 - <CloseTiny /> 184 - </button> 185 - )} 150 + if (isTextBlock) { 151 + setToolbarState("default"); 152 + } 153 + } 154 + }} 155 + > 156 + <CloseTiny /> 157 + </button> 186 158 </div> 187 159 </Tooltip.Provider> 188 160 ); ··· 198 170 hiddenOnCanvas?: boolean; 199 171 }) => { 200 172 let focusedEntity = useUIState((s) => s.focusedEntity); 201 - let isLocked = useEntity(focusedEntity?.entityID || null, "block/is-locked"); 202 - let isDisabled = 203 - props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 173 + let isDisabled = props.disabled; 204 174 205 175 let focusedEntityType = useEntity( 206 176 focusedEntity?.entityType === "page"
+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 }));
+78 -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"), 143 + metadata: jsonb("metadata"), 143 144 }, 144 145 (table) => { 145 146 return { ··· 173 174 } 174 175 }); 175 176 177 + export const site_standard_publications = pgTable("site_standard_publications", { 178 + uri: text("uri").primaryKey().notNull(), 179 + data: jsonb("data").notNull(), 180 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 181 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 182 + }); 183 + 176 184 export const custom_domain_routes = pgTable("custom_domain_routes", { 177 185 id: uuid("id").defaultRandom().primaryKey().notNull(), 178 186 domain: text("domain").notNull().references(() => custom_domains.domain), ··· 186 194 edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 187 195 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 188 196 } 197 + }); 198 + 199 + export const site_standard_documents = pgTable("site_standard_documents", { 200 + uri: text("uri").primaryKey().notNull(), 201 + data: jsonb("data").notNull(), 202 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 203 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 189 204 }); 190 205 191 206 export const custom_domains = pgTable("custom_domains", { ··· 260 275 } 261 276 }); 262 277 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(), 278 + export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { 279 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 280 + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), 281 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 267 282 }, 268 283 (table) => { 269 284 return { 270 - permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 285 + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), 271 286 } 272 287 }); 273 288 ··· 295 310 } 296 311 }); 297 312 313 + export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 314 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 315 + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 316 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 317 + archived: boolean("archived"), 318 + }, 319 + (table) => { 320 + return { 321 + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 322 + } 323 + }); 324 + 298 325 export const publication_domains = pgTable("publication_domains", { 299 326 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 300 327 domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), ··· 308 335 } 309 336 }); 310 337 311 - export const leaflets_in_publications = pgTable("leaflets_in_publications", { 338 + export const publication_subscriptions = pgTable("publication_subscriptions", { 312 339 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(), 340 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 341 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 342 + record: jsonb("record").notNull(), 343 + uri: text("uri").notNull(), 317 344 }, 318 345 (table) => { 319 346 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"}), 347 + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 348 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 349 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 323 350 } 324 351 }); 325 352 326 - export const publication_subscriptions = pgTable("publication_subscriptions", { 327 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 353 + export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { 354 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 328 355 identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 329 356 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 330 357 record: jsonb("record").notNull(), ··· 332 359 }, 333 360 (table) => { 334 361 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), 362 + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), 363 + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), 364 + } 365 + }); 366 + 367 + export const leaflets_to_documents = pgTable("leaflets_to_documents", { 368 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 369 + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 370 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 371 + title: text("title").default('').notNull(), 372 + description: text("description").default('').notNull(), 373 + tags: text("tags").default('RRAY[').array(), 374 + cover_image: text("cover_image"), 375 + }, 376 + (table) => { 377 + return { 378 + leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), 338 379 } 339 380 }); 340 381 ··· 352 393 token_idx: index("permission_token_rights_token_idx").on(table.token), 353 394 entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), 354 395 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 396 + } 397 + }); 398 + 399 + export const leaflets_in_publications = pgTable("leaflets_in_publications", { 400 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 401 + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 402 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 403 + description: text("description").default('').notNull(), 404 + title: text("title").default('').notNull(), 405 + archived: boolean("archived"), 406 + tags: text("tags").default('RRAY[').array(), 407 + cover_image: text("cover_image"), 408 + }, 409 + (table) => { 410 + return { 411 + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 412 + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 413 + leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 355 414 } 356 415 });
+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: 'union', 2219 + refs: ['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?: $Typed<PubLeafletPublication.Theme> | { $type: string } 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": "union", 13 + "refs": ["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" },
+317
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 * 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 + // with the theme narrowed to only the valid pub.leaflet.publication#theme type 35 + // (isTheme validates that $type is present, so we use $Typed) 36 + // Note: We explicitly list fields rather than using Omit because the generated Record type 37 + // has an index signature [k: string]: unknown that interferes with property typing 38 + export type NormalizedPublication = { 39 + $type: "site.standard.publication"; 40 + name: string; 41 + url: string; 42 + description?: string; 43 + icon?: SiteStandardPublication.Record["icon"]; 44 + basicTheme?: SiteStandardThemeBasic.Main; 45 + theme?: $Typed<PubLeafletPublication.Theme>; 46 + preferences?: SiteStandardPublication.Preferences; 47 + }; 48 + 49 + /** 50 + * Checks if the record is a pub.leaflet.document 51 + */ 52 + export function isLeafletDocument( 53 + record: unknown 54 + ): record is PubLeafletDocument.Record { 55 + if (!record || typeof record !== "object") return false; 56 + const r = record as Record<string, unknown>; 57 + return ( 58 + r.$type === "pub.leaflet.document" || 59 + // Legacy records without $type but with pages array 60 + (Array.isArray(r.pages) && typeof r.author === "string") 61 + ); 62 + } 63 + 64 + /** 65 + * Checks if the record is a site.standard.document 66 + */ 67 + export function isStandardDocument( 68 + record: unknown 69 + ): record is SiteStandardDocument.Record { 70 + if (!record || typeof record !== "object") return false; 71 + const r = record as Record<string, unknown>; 72 + return r.$type === "site.standard.document"; 73 + } 74 + 75 + /** 76 + * Checks if the record is a pub.leaflet.publication 77 + */ 78 + export function isLeafletPublication( 79 + record: unknown 80 + ): record is PubLeafletPublication.Record { 81 + if (!record || typeof record !== "object") return false; 82 + const r = record as Record<string, unknown>; 83 + return ( 84 + r.$type === "pub.leaflet.publication" || 85 + // Legacy records without $type but with name and no url 86 + (typeof r.name === "string" && !("url" in r)) 87 + ); 88 + } 89 + 90 + /** 91 + * Checks if the record is a site.standard.publication 92 + */ 93 + export function isStandardPublication( 94 + record: unknown 95 + ): record is SiteStandardPublication.Record { 96 + if (!record || typeof record !== "object") return false; 97 + const r = record as Record<string, unknown>; 98 + return r.$type === "site.standard.publication"; 99 + } 100 + 101 + /** 102 + * Extracts RGB values from a color union type 103 + */ 104 + function extractRgb( 105 + color: 106 + | $Typed<PubLeafletThemeColor.Rgba> 107 + | $Typed<PubLeafletThemeColor.Rgb> 108 + | { $type: string } 109 + | undefined 110 + ): { r: number; g: number; b: number } | undefined { 111 + if (!color || typeof color !== "object") return undefined; 112 + const c = color as Record<string, unknown>; 113 + if ( 114 + typeof c.r === "number" && 115 + typeof c.g === "number" && 116 + typeof c.b === "number" 117 + ) { 118 + return { r: c.r, g: c.g, b: c.b }; 119 + } 120 + return undefined; 121 + } 122 + 123 + /** 124 + * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 + */ 126 + export function leafletThemeToBasicTheme( 127 + theme: PubLeafletPublication.Theme | undefined 128 + ): SiteStandardThemeBasic.Main | undefined { 129 + if (!theme) return undefined; 130 + 131 + const background = extractRgb(theme.backgroundColor); 132 + const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 + const accentForeground = extractRgb(theme.accentText); 134 + 135 + // If we don't have the required colors, return undefined 136 + if (!background || !accent) return undefined; 137 + 138 + // Default foreground to dark if not specified 139 + const foreground = { r: 0, g: 0, b: 0 }; 140 + 141 + // Default accent foreground to white if not specified 142 + const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; 143 + 144 + return { 145 + $type: "site.standard.theme.basic", 146 + background: { $type: "site.standard.theme.color#rgb", ...background }, 147 + foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, 148 + accent: { $type: "site.standard.theme.color#rgb", ...accent }, 149 + accentForeground: { 150 + $type: "site.standard.theme.color#rgb", 151 + ...finalAccentForeground, 152 + }, 153 + }; 154 + } 155 + 156 + /** 157 + * Normalizes a document record from either format to the standard format. 158 + * 159 + * @param record - The document record from the database (either pub.leaflet or site.standard) 160 + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 + * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 + */ 163 + export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 164 + if (!record || typeof record !== "object") return null; 165 + 166 + // Pass through site.standard records directly (theme is already in correct format if present) 167 + if (isStandardDocument(record)) { 168 + return { 169 + ...record, 170 + theme: record.theme, 171 + } as NormalizedDocument; 172 + } 173 + 174 + if (isLeafletDocument(record)) { 175 + // Convert from pub.leaflet to site.standard 176 + const publishedAt = record.publishedAt; 177 + 178 + if (!publishedAt) { 179 + return null; 180 + } 181 + 182 + // For standalone documents (no publication), construct a site URL from the author 183 + // This matches the pattern used in publishToPublication.ts for new standalone docs 184 + const site = record.publication || `https://leaflet.pub/p/${record.author}`; 185 + 186 + // Extract path from URI if available 187 + const path = uri ? new AtUri(uri).rkey : undefined; 188 + 189 + // Wrap pages in pub.leaflet.content structure 190 + const content: $Typed<PubLeafletContent.Main> | undefined = record.pages 191 + ? { 192 + $type: "pub.leaflet.content" as const, 193 + pages: record.pages, 194 + } 195 + : undefined; 196 + 197 + return { 198 + $type: "site.standard.document", 199 + title: record.title, 200 + site, 201 + path, 202 + publishedAt, 203 + description: record.description, 204 + tags: record.tags, 205 + coverImage: record.coverImage, 206 + bskyPostRef: record.postRef, 207 + content, 208 + theme: record.theme, 209 + }; 210 + } 211 + 212 + return null; 213 + } 214 + 215 + /** 216 + * Normalizes a publication record from either format to the standard format. 217 + * 218 + * @param record - The publication record from the database (either pub.leaflet or site.standard) 219 + * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 + */ 221 + export function normalizePublication( 222 + record: unknown 223 + ): NormalizedPublication | null { 224 + if (!record || typeof record !== "object") return null; 225 + 226 + // Pass through site.standard records directly, but validate the theme 227 + if (isStandardPublication(record)) { 228 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 229 + const theme = PubLeafletPublication.isTheme(record.theme) 230 + ? (record.theme as $Typed<PubLeafletPublication.Theme>) 231 + : undefined; 232 + return { 233 + ...record, 234 + theme, 235 + }; 236 + } 237 + 238 + if (isLeafletPublication(record)) { 239 + // Convert from pub.leaflet to site.standard 240 + const url = record.base_path ? `https://${record.base_path}` : undefined; 241 + 242 + if (!url) { 243 + return null; 244 + } 245 + 246 + const basicTheme = leafletThemeToBasicTheme(record.theme); 247 + 248 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 249 + // For legacy records without $type, add it during normalization 250 + let theme: $Typed<PubLeafletPublication.Theme> | undefined; 251 + if (record.theme) { 252 + if (PubLeafletPublication.isTheme(record.theme)) { 253 + theme = record.theme as $Typed<PubLeafletPublication.Theme>; 254 + } else { 255 + // Legacy theme without $type - add it 256 + theme = { 257 + ...record.theme, 258 + $type: "pub.leaflet.publication#theme", 259 + }; 260 + } 261 + } 262 + 263 + // Convert preferences to site.standard format (strip/replace $type) 264 + const preferences: SiteStandardPublication.Preferences | undefined = 265 + record.preferences 266 + ? { 267 + showInDiscover: record.preferences.showInDiscover, 268 + showComments: record.preferences.showComments, 269 + showMentions: record.preferences.showMentions, 270 + showPrevNext: record.preferences.showPrevNext, 271 + } 272 + : undefined; 273 + 274 + return { 275 + $type: "site.standard.publication", 276 + name: record.name, 277 + url, 278 + description: record.description, 279 + icon: record.icon, 280 + basicTheme, 281 + theme, 282 + preferences, 283 + }; 284 + } 285 + 286 + return null; 287 + } 288 + 289 + /** 290 + * Type guard to check if a normalized document has leaflet content 291 + */ 292 + export function hasLeafletContent( 293 + doc: NormalizedDocument 294 + ): doc is NormalizedDocument & { 295 + content: $Typed<PubLeafletContent.Main>; 296 + } { 297 + return ( 298 + doc.content !== undefined && 299 + (doc.content as { $type?: string }).$type === "pub.leaflet.content" 300 + ); 301 + } 302 + 303 + /** 304 + * Gets the pages array from a normalized document, handling both formats 305 + */ 306 + export function getDocumentPages( 307 + doc: NormalizedDocument 308 + ): PubLeafletContent.Main["pages"] | undefined { 309 + if (!doc.content) return undefined; 310 + 311 + if (hasLeafletContent(doc)) { 312 + return doc.content.pages; 313 + } 314 + 315 + // Unknown content type 316 + return undefined; 317 + }
+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.
+1 -1
src/hooks/useLongPress.ts
··· 90 90 return useMemo( 91 91 () => ({ 92 92 isLongPress: isLongPress, 93 - handlers: { 93 + longPressHandlers: { 94 94 onPointerDown, 95 95 onPointerUp: end, 96 96 onClickCapture: click,
+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);
+7
src/replicache/clientMutationContext.ts
··· 67 67 textData.value = base64.fromByteArray(updateBytes); 68 68 } 69 69 } 70 + } else if (f.id) { 71 + // For cardinality "many" with an explicit ID, fetch the existing fact 72 + // so undo can restore it instead of deleting 73 + let fact = await tx.get(f.id); 74 + if (fact) { 75 + existingFact = [fact as Fact<any>]; 76 + } 70 77 } 71 78 if (!ignoreUndo) 72 79 undoManager.add({
+35 -12
src/replicache/mutations.ts
··· 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; 7 + import { v7 } from "uuid"; 7 8 8 9 export type MutationContext = { 9 10 permission_token_id: string; ··· 307 308 { blockEntity: string } | { blockEntity: string }[] 308 309 > = async (args, ctx) => { 309 310 for (let block of [args].flat()) { 310 - let [isLocked] = await ctx.scanIndex.eav( 311 - block.blockEntity, 312 - "block/is-locked", 313 - ); 314 - if (isLocked?.data.value) continue; 315 311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 316 312 await ctx.runOnServer(async ({ supabase }) => { 317 313 if (image) { ··· 427 423 }, 428 424 }); 429 425 }; 430 - const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async ( 431 - args, 432 - ctx, 433 - ) => { 426 + const moveBlockDown: Mutation<{ 427 + entityID: string; 428 + parent: string; 429 + permission_set?: string; 430 + }> = async (args, ctx) => { 434 431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 435 432 (a, b) => (a.data.position > b.data.position ? 1 : -1), 436 433 ); 437 434 let index = children.findIndex((f) => f.data.value === args.entityID); 438 435 if (index === -1) return; 439 436 let next = children[index + 1]; 440 - if (!next) return; 437 + if (!next) { 438 + // If this is the last block, create a new empty block above it using the addBlock helper 439 + if (!args.permission_set) return; // Can't create block without permission_set 440 + 441 + let newEntityID = v7(); 442 + let previousBlock = children[index - 1]; 443 + let position = generateKeyBetween( 444 + previousBlock?.data.position || null, 445 + children[index].data.position, 446 + ); 447 + 448 + // Call the addBlock mutation helper directly 449 + await addBlock( 450 + { 451 + parent: args.parent, 452 + permission_set: args.permission_set, 453 + factID: v7(), 454 + type: "text", 455 + newEntityID: newEntityID, 456 + position: position, 457 + }, 458 + ctx, 459 + ); 460 + return; 461 + } 441 462 await ctx.retractFact(children[index].id); 442 463 await ctx.assertFact({ 443 464 id: children[index].id, ··· 637 658 description?: string; 638 659 tags?: string[]; 639 660 cover_image?: string | null; 661 + localPublishedAt?: string | null; 640 662 }> = async (args, ctx) => { 641 663 await ctx.runOnServer(async (serverCtx) => { 642 664 console.log("updating"); ··· 670 692 } 671 693 }); 672 694 await ctx.runOnClient(async ({ tx }) => { 673 - if (args.title !== undefined) 674 - await tx.set("publication_title", args.title); 695 + if (args.title !== undefined) await tx.set("publication_title", args.title); 675 696 if (args.description !== undefined) 676 697 await tx.set("publication_description", args.description); 677 698 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 678 699 if (args.cover_image !== undefined) 679 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); 680 703 }); 681 704 }; 682 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 + }
+10 -2
src/utils/deleteBlock.ts
··· 4 4 import { scanIndex } from "src/replicache/utils"; 5 5 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { UndoManager } from "src/undoManager"; 7 8 8 9 export async function deleteBlock( 9 10 entities: string[], 10 11 rep: Replicache<ReplicacheMutators>, 12 + undoManager?: UndoManager, 11 13 ) { 12 14 // get what pagess we need to close as a result of deleting this block 13 15 let pagesToClose = [] as string[]; ··· 32 34 } 33 35 } 34 36 35 - // the next and previous blocks in the block list 36 - // if the focused thing is a page and not a block, return 37 + // figure out what to focus 37 38 let focusedBlock = useUIState.getState().focusedEntity; 38 39 let parent = 39 40 focusedBlock?.entityType === "page" ··· 44 45 let parentType = await rep?.query((tx) => 45 46 scanIndex(tx).eav(parent, "page/type"), 46 47 ); 48 + // if the page is a canvas, focus the page 47 49 if (parentType[0]?.data.value === "canvas") { 48 50 useUIState 49 51 .getState() 50 52 .setFocusedBlock({ entityType: "page", entityID: parent }); 51 53 useUIState.getState().setSelectedBlocks([]); 52 54 } else { 55 + // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block) 53 56 let siblings = 54 57 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 58 ··· 105 108 } 106 109 } 107 110 111 + // close the pages 108 112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 + undoManager && undoManager.startGroup(); 114 + 115 + // delete the blocks 109 116 await Promise.all( 110 117 entities.map((entity) => 111 118 rep?.mutate.removeBlock({ ··· 113 120 }), 114 121 ), 115 122 ); 123 + undoManager && undoManager.endGroup(); 116 124 }
+3 -1
src/utils/focusBlock.ts
··· 48 48 } 49 49 50 50 if (pos?.offset !== undefined) { 51 - el?.focus(); 51 + // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page. 52 + // idk how to fix so i'm giving up -- celine 53 + // el?.focus(); 52 54 requestAnimationFrame(() => { 53 55 el?.setSelectionRange(pos.offset, pos.offset); 54 56 });
+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)}`;
+81
src/utils/moveBlock.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + export const moveBlockUp = async (rep: Replicache<ReplicacheMutators>) => { 7 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 8 + if (sortedBlocks.length > 1) return; 9 + let block = sortedBlocks[0]; 10 + let previousBlock = 11 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 12 + if (previousBlock.value === block.listData?.parent) { 13 + previousBlock = 14 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 2]; 15 + } 16 + 17 + if ( 18 + previousBlock?.listData && 19 + block.listData && 20 + block.listData.depth > 1 && 21 + !previousBlock.listData.path.find( 22 + (f) => f.entity === block.listData?.parent, 23 + ) 24 + ) { 25 + let depth = block.listData.depth; 26 + let newParent = previousBlock.listData.path.find( 27 + (f) => f.depth === depth - 1, 28 + ); 29 + if (!newParent) return; 30 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 31 + useUIState.getState().toggleFold(newParent.entity); 32 + rep?.mutate.moveBlock({ 33 + block: block.value, 34 + oldParent: block.listData?.parent, 35 + newParent: newParent.entity, 36 + position: { type: "end" }, 37 + }); 38 + } else { 39 + rep?.mutate.moveBlockUp({ 40 + entityID: block.value, 41 + parent: block.listData?.parent || block.parent, 42 + }); 43 + } 44 + }; 45 + 46 + export const moveBlockDown = async ( 47 + rep: Replicache<ReplicacheMutators>, 48 + permission_set: string, 49 + ) => { 50 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 51 + if (sortedBlocks.length > 1) return; 52 + let block = sortedBlocks[0]; 53 + let nextBlock = siblings 54 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 55 + .find( 56 + (f) => 57 + f.listData && 58 + block.listData && 59 + !f.listData.path.find((f) => f.entity === block.value), 60 + ); 61 + if ( 62 + nextBlock?.listData && 63 + block.listData && 64 + nextBlock.listData.depth === block.listData.depth - 1 65 + ) { 66 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 67 + useUIState.getState().toggleFold(nextBlock.value); 68 + rep?.mutate.moveBlock({ 69 + block: block.value, 70 + oldParent: block.listData?.parent, 71 + newParent: nextBlock.value, 72 + position: { type: "first" }, 73 + }); 74 + } else { 75 + rep?.mutate.moveBlockDown({ 76 + entityID: block.value, 77 + parent: block.listData?.parent || block.parent, 78 + permission_set: permission_set, 79 + }); 80 + } 81 + };
+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";
+29 -1
src/utils/timeAgo.ts
··· 1 - export function timeAgo(timestamp: string): string { 1 + export function timeAgo( 2 + timestamp: string, 3 + options?: { compact?: boolean }, 4 + ): string { 5 + const { compact } = options ?? {}; 2 6 const now = new Date(); 3 7 const date = new Date(timestamp); 4 8 const diffMs = now.getTime() - date.getTime(); ··· 6 10 const diffMinutes = Math.floor(diffSeconds / 60); 7 11 const diffHours = Math.floor(diffMinutes / 60); 8 12 const diffDays = Math.floor(diffHours / 24); 13 + const diffWeeks = Math.floor(diffDays / 7); 14 + const diffMonths = Math.floor(diffDays / 30); 9 15 const diffYears = Math.floor(diffDays / 365); 10 16 17 + if (compact) { 18 + if (diffYears > 0) { 19 + return `${diffYears}y`; 20 + } else if (diffMonths > 0) { 21 + return `${diffMonths}mo`; 22 + } else if (diffWeeks > 0) { 23 + return `${diffWeeks}w`; 24 + } else if (diffDays > 0) { 25 + return `${diffDays}d`; 26 + } else if (diffHours > 0) { 27 + return `${diffHours}h`; 28 + } else if (diffMinutes > 0) { 29 + return `${diffMinutes}m`; 30 + } else { 31 + return "now"; 32 + } 33 + } 34 + 11 35 if (diffYears > 0) { 12 36 return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`; 37 + } else if (diffMonths > 0) { 38 + return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`; 39 + } else if (diffWeeks > 0) { 40 + return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`; 13 41 } else if (diffDays > 0) { 14 42 return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 15 43 } 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 + }
+143 -1
supabase/database.types.ts
··· 551 551 home_page: string 552 552 id: string 553 553 interface_state: Json | null 554 + metadata: Json | null 554 555 } 555 556 Insert: { 556 557 atp_did?: string | null ··· 559 560 home_page?: string 560 561 id?: string 561 562 interface_state?: Json | null 563 + metadata?: Json | null 562 564 } 563 565 Update: { 564 566 atp_did?: string | null ··· 567 569 home_page?: string 568 570 id?: string 569 571 interface_state?: Json | null 572 + metadata?: Json | null 570 573 } 571 574 Relationships: [ 572 575 { ··· 586 589 doc: string | null 587 590 leaflet: string 588 591 publication: string 592 + tags: string[] | null 589 593 title: string 590 594 } 591 595 Insert: { ··· 595 599 doc?: string | null 596 600 leaflet: string 597 601 publication: string 602 + tags?: string[] | null 598 603 title?: string 599 604 } 600 605 Update: { ··· 604 609 doc?: string | null 605 610 leaflet?: string 606 611 publication?: string 612 + tags?: string[] | null 607 613 title?: string 608 614 } 609 615 Relationships: [ ··· 632 638 } 633 639 leaflets_to_documents: { 634 640 Row: { 641 + archived: boolean | null 635 642 cover_image: string | null 636 643 created_at: string 637 644 description: string 638 645 document: string 639 646 leaflet: string 647 + tags: string[] | null 640 648 title: string 641 649 } 642 650 Insert: { 651 + archived?: boolean | null 643 652 cover_image?: string | null 644 653 created_at?: string 645 654 description?: string 646 655 document: string 647 656 leaflet: string 657 + tags?: string[] | null 648 658 title?: string 649 659 } 650 660 Update: { 661 + archived?: boolean | null 651 662 cover_image?: string | null 652 663 created_at?: string 653 664 description?: string 654 665 document?: string 655 666 leaflet?: string 667 + tags?: string[] | null 656 668 title?: string 657 669 } 658 670 Relationships: [ ··· 762 774 referencedColumns: ["id"] 763 775 }, 764 776 { 765 - foreignKeyName: "permission_token_creator_token_fkey" 777 + foreignKeyName: "permission_token_on_homepage_token_fkey" 766 778 columns: ["token"] 767 779 isOneToOne: false 768 780 referencedRelation: "permission_tokens" ··· 1079 1091 last_mutation?: number 1080 1092 } 1081 1093 Relationships: [] 1094 + } 1095 + site_standard_documents: { 1096 + Row: { 1097 + data: Json 1098 + identity_did: string 1099 + indexed_at: string 1100 + uri: string 1101 + } 1102 + Insert: { 1103 + data: Json 1104 + identity_did: string 1105 + indexed_at?: string 1106 + uri: string 1107 + } 1108 + Update: { 1109 + data?: Json 1110 + identity_did?: string 1111 + indexed_at?: string 1112 + uri?: string 1113 + } 1114 + Relationships: [ 1115 + { 1116 + foreignKeyName: "site_standard_documents_identity_did_fkey" 1117 + columns: ["identity_did"] 1118 + isOneToOne: false 1119 + referencedRelation: "identities" 1120 + referencedColumns: ["atp_did"] 1121 + }, 1122 + ] 1123 + } 1124 + site_standard_documents_in_publications: { 1125 + Row: { 1126 + document: string 1127 + indexed_at: string 1128 + publication: string 1129 + } 1130 + Insert: { 1131 + document: string 1132 + indexed_at?: string 1133 + publication: string 1134 + } 1135 + Update: { 1136 + document?: string 1137 + indexed_at?: string 1138 + publication?: string 1139 + } 1140 + Relationships: [ 1141 + { 1142 + foreignKeyName: "site_standard_documents_in_publications_document_fkey" 1143 + columns: ["document"] 1144 + isOneToOne: false 1145 + referencedRelation: "site_standard_documents" 1146 + referencedColumns: ["uri"] 1147 + }, 1148 + { 1149 + foreignKeyName: "site_standard_documents_in_publications_publication_fkey" 1150 + columns: ["publication"] 1151 + isOneToOne: false 1152 + referencedRelation: "site_standard_publications" 1153 + referencedColumns: ["uri"] 1154 + }, 1155 + ] 1156 + } 1157 + site_standard_publications: { 1158 + Row: { 1159 + data: Json 1160 + identity_did: string 1161 + indexed_at: string 1162 + uri: string 1163 + } 1164 + Insert: { 1165 + data: Json 1166 + identity_did: string 1167 + indexed_at?: string 1168 + uri: string 1169 + } 1170 + Update: { 1171 + data?: Json 1172 + identity_did?: string 1173 + indexed_at?: string 1174 + uri?: string 1175 + } 1176 + Relationships: [ 1177 + { 1178 + foreignKeyName: "site_standard_publications_identity_did_fkey" 1179 + columns: ["identity_did"] 1180 + isOneToOne: false 1181 + referencedRelation: "identities" 1182 + referencedColumns: ["atp_did"] 1183 + }, 1184 + ] 1185 + } 1186 + site_standard_subscriptions: { 1187 + Row: { 1188 + created_at: string 1189 + identity: string 1190 + publication: string 1191 + record: Json 1192 + uri: string 1193 + } 1194 + Insert: { 1195 + created_at?: string 1196 + identity: string 1197 + publication: string 1198 + record: Json 1199 + uri: string 1200 + } 1201 + Update: { 1202 + created_at?: string 1203 + identity?: string 1204 + publication?: string 1205 + record?: Json 1206 + uri?: string 1207 + } 1208 + Relationships: [ 1209 + { 1210 + foreignKeyName: "site_standard_subscriptions_identity_fkey" 1211 + columns: ["identity"] 1212 + isOneToOne: false 1213 + referencedRelation: "identities" 1214 + referencedColumns: ["atp_did"] 1215 + }, 1216 + { 1217 + foreignKeyName: "site_standard_subscriptions_publication_fkey" 1218 + columns: ["publication"] 1219 + isOneToOne: false 1220 + referencedRelation: "site_standard_publications" 1221 + referencedColumns: ["uri"] 1222 + }, 1223 + ] 1082 1224 } 1083 1225 subscribers_to_publications: { 1084 1226 Row: {
+1
supabase/migrations/20260123000000_add_metadata_to_identities.sql
··· 1 + alter table "public"."identities" add column "metadata" jsonb;