diff --git a/actions/publications/sendPostPreview.tsx b/actions/publications/sendPostPreview.tsx index e8e419ab..d2857dba 100644 --- a/actions/publications/sendPostPreview.tsx +++ b/actions/publications/sendPostPreview.tsx @@ -1,14 +1,17 @@ "use server"; import { render } from "@react-email/render"; +import { BlobRef } from "@atproto/lexicon"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; import { Ok, Err, type Result } from "src/result"; import { EMAIL_REGEX } from "src/utils/confirmationEmail"; -import { extractEmailBlocksFromFacts } from "src/utils/postToEmailBlocks"; +import { processBlocksToPages } from "src/utils/factsToPagesRecord"; import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; import { PostEmail } from "emails/post"; import { emailPropsFromPublication } from "emails/fromPublication"; @@ -52,9 +55,26 @@ export async function sendPostPreview(args: { const { data: factsData } = await supabaseServerClient.rpc("get_facts", { root: args.root_entity, }); - const facts = (factsData as unknown as Fact[]) || []; + const facts = (factsData as unknown as Fact[]) || []; - const blocks = extractEmailBlocksFromFacts(facts, args.root_entity); + // Preview runs on drafts before the document is published, so we can't + // upload image blobs or poll records to the PDS here. The email template + // detects an http(s) $link and uses it as a direct image URL; polls + // render as "unsupported" in preview. + const { pages } = await processBlocksToPages({ + facts, + root_entity: args.root_entity, + hooks: { + uploadImage: async (src) => + ({ ref: { $link: src }, mimeType: "image/*", size: 0 }) as unknown as BlobRef, + uploadPoll: null, + }, + }); + const firstPage = pages[0]; + const blocks = + firstPage?.type === "doc" + ? (firstPage.blocks as PubLeafletPagesLinearDocument.Block[]) + : []; const pubRecord = normalizePublicationRecord(publication.record); const pubProps = emailPropsFromPublication(pubRecord); @@ -76,6 +96,7 @@ export async function sendPostPreview(args: { year: "numeric", }), blocks, + did: identity.atp_did, assetsBaseUrl, // Omitting unsubscribeUrl triggers the "(preview)" footer branch. }), diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 5b947e3b..1755090b 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -1,50 +1,29 @@ "use server"; -import * as Y from "yjs"; -import * as base64 from "base64-js"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, - PubLeafletBlocksHeader, - PubLeafletBlocksImage, + PubLeafletBlocksBskyPost, PubLeafletBlocksText, - PubLeafletBlocksUnorderedList, - PubLeafletBlocksOrderedList, - PubLeafletDocument, - SiteStandardDocument, PubLeafletContent, - PubLeafletPagesLinearDocument, + PubLeafletDocument, PubLeafletPagesCanvas, + PubLeafletPagesLinearDocument, PubLeafletRichtextFacet, - PubLeafletBlocksWebsite, - PubLeafletBlocksCode, - PubLeafletBlocksMath, - PubLeafletBlocksHorizontalRule, - PubLeafletBlocksBskyPost, - PubLeafletBlocksBlockquote, - PubLeafletBlocksIframe, - PubLeafletBlocksPage, - PubLeafletBlocksPoll, - PubLeafletBlocksButton, - PubLeafletPollDefinition, + SiteStandardDocument, } from "lexicons/api"; -import { Block } from "components/Blocks/Block"; import { TID } from "@atproto/common"; import { supabaseServerClient } from "supabase/serverClient"; import { scanIndexLocal } from "src/replicache/utils"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; -import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; -import { ids } from "lexicons/api/lexicons"; import { BlobRef } from "@atproto/lexicon"; import { AtUri } from "@atproto/syntax"; import { Json } from "supabase/database.types"; -import { $Typed, UnicodeString } from "@atproto/api"; -import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; -import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; import { Lock } from "src/utils/lock"; import type { PubLeafletPublication } from "lexicons/api"; +import { processBlocksToPages } from "src/utils/factsToPagesRecord"; import { normalizeDocumentRecord, type NormalizedDocument, @@ -176,12 +155,43 @@ export async function publishToPublication({ }); let facts = (data as unknown as Fact[]) || []; - let { pages } = await processBlocksToPages( + const uploadLock = new Lock(); + let { pages } = await processBlocksToPages({ facts, - agent, root_entity, - credentialSession.did!, - ); + hooks: { + uploadImage: async (src: string) => { + const data = await fetch(src); + if (data.status !== 200) return; + const binary = await data.blob(); + return uploadLock.withLock(async () => { + const blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + return blob.data.blob; + }); + }, + uploadPoll: async (entityId, record) => { + // Use the entity id as the rkey so the editor can associate the poll + // definition with the in-document poll block. + const { data: pollResult } = await agent.com.atproto.repo.putRecord({ + rkey: entityId, + repo: credentialSession.did!, + collection: record.$type, + record, + validate: false, + }); + console.log( + await supabaseServerClient.from("atp_poll_records").upsert({ + uri: pollResult.uri, + cid: pollResult.cid, + record: record as Json, + }), + ); + return { uri: pollResult.uri, cid: pollResult.cid }; + }, + }, + }); let existingRecord: Partial = {}; const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); @@ -419,653 +429,6 @@ export async function publishToPublication({ return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; } -async function processBlocksToPages( - facts: Fact[], - agent: AtpBaseClient, - root_entity: string, - did: string, -) { - let scan = scanIndexLocal(facts); - let pages: { - id: string; - blocks: - | PubLeafletPagesLinearDocument.Block[] - | PubLeafletPagesCanvas.Block[]; - type: "doc" | "canvas"; - }[] = []; - - // Create a lock to serialize image uploads - const uploadLock = new Lock(); - - let firstEntity = scan.eav(root_entity, "root/page")?.[0]; - if (!firstEntity) throw new Error("No root page"); - - // Check if the first page is a canvas or linear document - let [pageType] = scan.eav(firstEntity.data.value, "page/type"); - - if (pageType?.data.value === "canvas") { - // First page is a canvas - let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); - pages.unshift({ - id: firstEntity.data.value, - blocks: canvasBlocks, - type: "canvas", - }); - } else { - // First page is a linear document - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); - let b = await blocksToRecord(blocks, did); - pages.unshift({ - id: firstEntity.data.value, - blocks: b, - type: "doc", - }); - } - - return { pages }; - - async function uploadImage(src: string) { - let data = await fetch(src); - if (data.status !== 200) return; - let binary = await data.blob(); - return uploadLock.withLock(async () => { - let blob = await agent.com.atproto.repo.uploadBlob(binary, { - headers: { "Content-Type": binary.type }, - }); - return blob.data.blob; - }); - } - async function blocksToRecord( - blocks: Block[], - did: string, - ): Promise { - let parsedBlocks = parseBlocksToList(blocks); - return ( - await Promise.all( - parsedBlocks.map(async (blockOrList) => { - if (blockOrList.type === "block") { - let alignmentValue = scan.eav( - blockOrList.block.value, - "block/text-alignment", - )[0]?.data.value; - let alignment: ExcludeString< - PubLeafletPagesLinearDocument.Block["alignment"] - > = - alignmentValue === "center" - ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" - : alignmentValue === "right" - ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" - : alignmentValue === "justify" - ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" - : alignmentValue === "left" - ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" - : undefined; - let b = await blockToRecord(blockOrList.block, did); - if (!b) return []; - let block: PubLeafletPagesLinearDocument.Block = { - $type: "pub.leaflet.pages.linearDocument#block", - block: b, - }; - if (alignment) block.alignment = alignment; - return [block]; - } else { - let runs = splitListByStyle(blockOrList.children); - let blocks = await Promise.all( - runs.map(async (run) => { - if (run.style === "ordered") { - let block: PubLeafletPagesLinearDocument.Block = { - $type: "pub.leaflet.pages.linearDocument#block", - block: { - $type: "pub.leaflet.blocks.orderedList", - startIndex: - run.children[0].block.listData?.listStart || 1, - children: await orderedChildrenToRecord( - run.children, - did, - ), - }, - }; - return block; - } else { - let block: PubLeafletPagesLinearDocument.Block = { - $type: "pub.leaflet.pages.linearDocument#block", - block: { - $type: "pub.leaflet.blocks.unorderedList", - children: await unorderedChildrenToRecord( - run.children, - did, - ), - }, - }; - return block; - } - }), - ); - return blocks; - } - }), - ) - ).flat(); - } - - function splitListByStyle(children: List[]) { - let runs: { style: "ordered" | "unordered"; children: List[] }[] = []; - for (let child of children) { - let style: "ordered" | "unordered" = - child.block.listData?.listStyle === "ordered" - ? "ordered" - : "unordered"; - let last = runs[runs.length - 1]; - if (last && last.style === style) { - last.children.push(child); - } else { - runs.push({ style, children: [child] }); - } - } - return runs; - } - - async function unorderedChildrenToRecord( - children: List[], - did: string, - ): Promise { - return ( - await Promise.all( - children.map(async (child) => { - let content = await blockToRecord(child.block, did); - if (!content) return []; - let record: PubLeafletBlocksUnorderedList.ListItem = { - $type: "pub.leaflet.blocks.unorderedList#listItem", - content, - ...(child.block.listData?.checklist && { - checked: child.block.listData.checked ?? false, - }), - }; - let sameStyle = child.children.filter( - (c) => c.block.listData?.listStyle !== "ordered", - ); - let diffStyle = child.children.filter( - (c) => c.block.listData?.listStyle === "ordered", - ); - if (sameStyle.length > 0) { - record.children = await unorderedChildrenToRecord(sameStyle, did); - } - if (diffStyle.length > 0) { - record.orderedListChildren = { - $type: "pub.leaflet.blocks.orderedList", - children: await orderedChildrenToRecord(diffStyle, did), - }; - } - return record; - }), - ) - ).flat(); - } - - async function orderedChildrenToRecord( - children: List[], - did: string, - ): Promise { - return ( - await Promise.all( - children.map(async (child) => { - let content = await blockToRecord(child.block, did); - if (!content) return []; - let record: PubLeafletBlocksOrderedList.ListItem = { - $type: "pub.leaflet.blocks.orderedList#listItem", - content, - ...(child.block.listData?.checklist && { - checked: child.block.listData.checked ?? false, - }), - }; - let sameStyle = child.children.filter( - (c) => c.block.listData?.listStyle === "ordered", - ); - let diffStyle = child.children.filter( - (c) => c.block.listData?.listStyle !== "ordered", - ); - if (sameStyle.length > 0) { - record.children = await orderedChildrenToRecord(sameStyle, did); - } - if (diffStyle.length > 0) { - record.unorderedListChildren = { - $type: "pub.leaflet.blocks.unorderedList", - children: await unorderedChildrenToRecord(diffStyle, did), - }; - } - return record; - }), - ) - ).flat(); - } - async function blockToRecord(b: Block, did: string) { - const footnoteContentResolver = (footnoteEntityID: string) => { - let [content] = scan.eav(footnoteEntityID, "block/text"); - if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] }; - let doc = new Y.Doc(); - const update = base64.toByteArray(content.data.value); - Y.applyUpdate(doc, update); - let nodes = doc.getXmlElement("prosemirror").toArray(); - let plaintext = YJSFragmentToString(nodes[0]); - let { facets } = YJSFragmentToFacets(nodes[0]); - return { plaintext, facets }; - }; - const getBlockContent = (b: string) => { - let [content] = scan.eav(b, "block/text"); - if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; - let doc = new Y.Doc(); - const update = base64.toByteArray(content.data.value); - Y.applyUpdate(doc, update); - let nodes = doc.getXmlElement("prosemirror").toArray(); - let stringValue = YJSFragmentToString(nodes[0]); - let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver); - return [stringValue, facets] as const; - }; - if (b.type === "card") { - let [page] = scan.eav(b.value, "block/card"); - if (!page) return; - let [pageType] = scan.eav(page.data.value, "page/type"); - - if (pageType?.data.value === "canvas") { - let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); - pages.push({ - id: page.data.value, - blocks: canvasBlocks, - type: "canvas", - }); - } else { - let blocks = getBlocksWithTypeLocal(facts, page.data.value); - pages.push({ - id: page.data.value, - blocks: await blocksToRecord(blocks, did), - type: "doc", - }); - } - - let block: $Typed = { - $type: "pub.leaflet.blocks.page", - id: page.data.value, - }; - return block; - } - - if (b.type === "bluesky-post") { - let [post] = scan.eav(b.value, "block/bluesky-post"); - if (!post || !post.data.value.post) return; - let [hostFact] = scan.eav(b.value, "bluesky-post/host"); - let block: $Typed = { - $type: ids.PubLeafletBlocksBskyPost, - postRef: { - uri: post.data.value.post.uri, - cid: post.data.value.post.cid, - }, - clientHost: hostFact?.data.value, - }; - return block; - } - if (b.type === "horizontal-rule") { - let block: $Typed = { - $type: ids.PubLeafletBlocksHorizontalRule, - }; - return block; - } - - if (b.type === "heading") { - let [headingLevel] = scan.eav(b.value, "block/heading-level"); - - let [stringValue, facets] = getBlockContent(b.value); - let block: $Typed = { - $type: "pub.leaflet.blocks.header", - level: Math.floor(headingLevel?.data.value || 1), - plaintext: stringValue, - ...(facets.length > 0 && { facets }), - }; - return block; - } - - if (b.type === "blockquote") { - let [stringValue, facets] = getBlockContent(b.value); - let block: $Typed = { - $type: ids.PubLeafletBlocksBlockquote, - plaintext: stringValue, - ...(facets.length > 0 && { facets }), - }; - return block; - } - - if (b.type == "text") { - let [stringValue, facets] = getBlockContent(b.value); - let [textSize] = scan.eav(b.value, "block/text-size"); - let block: $Typed = { - $type: ids.PubLeafletBlocksText, - plaintext: stringValue, - ...(facets.length > 0 && { facets }), - ...(textSize && { textSize: textSize.data.value }), - }; - return block; - } - if (b.type === "embed") { - let [url] = scan.eav(b.value, "embed/url"); - let [height] = scan.eav(b.value, "embed/height"); - let [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio"); - if (!url) return; - let block: $Typed = { - $type: "pub.leaflet.blocks.iframe", - url: url.data.value, - height: Math.floor(height?.data.value || 600), - }; - if (aspectRatio) { - let [w, h] = aspectRatio.data.value.split("/").map(Number); - if (w && h) { - block.aspectRatio = { width: w, height: h }; - } - } - return block; - } - if (b.type == "image") { - let [image] = scan.eav(b.value, "block/image"); - if (!image) return; - let [altText] = scan.eav(b.value, "image/alt"); - let [fullBleed] = scan.eav(b.value, "image/full-bleed"); - let blobref = await uploadImage(image.data.src); - if (!blobref) return; - let block: $Typed = { - $type: "pub.leaflet.blocks.image", - image: blobref, - aspectRatio: { - height: Math.floor(image.data.height), - width: Math.floor(image.data.width), - }, - alt: altText ? altText.data.value : undefined, - fullBleed: fullBleed?.data.value || undefined, - }; - return block; - } - if (b.type === "link") { - let [previewImage] = scan.eav(b.value, "link/preview"); - let [description] = scan.eav(b.value, "link/description"); - let [src] = scan.eav(b.value, "link/url"); - if (!src) return; - let blobref = previewImage - ? await uploadImage(previewImage?.data.src) - : undefined; - let [title] = scan.eav(b.value, "link/title"); - let block: $Typed = { - $type: "pub.leaflet.blocks.website", - previewImage: blobref, - src: src.data.value, - description: description?.data.value, - title: title?.data.value, - }; - return block; - } - if (b.type === "code") { - let [language] = scan.eav(b.value, "block/code-language"); - let [code] = scan.eav(b.value, "block/code"); - let [theme] = scan.eav(root_entity, "theme/code-theme"); - let block: $Typed = { - $type: "pub.leaflet.blocks.code", - language: language?.data.value, - plaintext: code?.data.value || "", - syntaxHighlightingTheme: theme?.data.value, - }; - return block; - } - if (b.type === "math") { - let [math] = scan.eav(b.value, "block/math"); - let block: $Typed = { - $type: "pub.leaflet.blocks.math", - tex: math?.data.value || "", - }; - return block; - } - if (b.type === "poll") { - // Get poll options from the entity - let pollOptions = scan.eav(b.value, "poll/options"); - let options: PubLeafletPollDefinition.Option[] = pollOptions.map( - (opt) => { - let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; - return { - $type: "pub.leaflet.poll.definition#option", - text: optionName?.data.value || "", - }; - }, - ); - - // Create the poll definition record - let pollRecord: PubLeafletPollDefinition.Record = { - $type: "pub.leaflet.poll.definition", - name: "Poll", // Default name, can be customized - options, - }; - - // Upload the poll record - let { data: pollResult } = await agent.com.atproto.repo.putRecord({ - //use the entity id as the rkey so we can associate it in the editor - rkey: b.value, - repo: did, - collection: pollRecord.$type, - record: pollRecord, - validate: false, - }); - - // Optimistically write poll definition to database - console.log( - await supabaseServerClient.from("atp_poll_records").upsert({ - uri: pollResult.uri, - cid: pollResult.cid, - record: pollRecord as Json, - }), - ); - - // Return a poll block with reference to the poll record - let block: $Typed = { - $type: "pub.leaflet.blocks.poll", - pollRef: { - uri: pollResult.uri, - cid: pollResult.cid, - }, - }; - return block; - } - if (b.type === "button") { - let [text] = scan.eav(b.value, "button/text"); - let [url] = scan.eav(b.value, "button/url"); - if (!text || !url) return; - let block: $Typed = { - $type: "pub.leaflet.blocks.button", - text: text.data.value, - url: url.data.value, - }; - return block; - } - return; - } - - async function canvasBlocksToRecord( - pageID: string, - did: string, - ): Promise { - let canvasBlocks = scan.eav(pageID, "canvas/block"); - return ( - await Promise.all( - canvasBlocks.map(async (canvasBlock) => { - let blockEntity = canvasBlock.data.value; - let position = canvasBlock.data.position; - - // Get the block content - let blockType = scan.eav(blockEntity, "block/type")?.[0]; - if (!blockType) return null; - - let block: Block = { - type: blockType.data.value, - value: blockEntity, - parent: pageID, - position: "", - factID: canvasBlock.id, - }; - - let content = await blockToRecord(block, did); - if (!content) return null; - - // Get canvas-specific properties - let width = - scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; - let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] - ?.data.value; - - let canvasBlockRecord: PubLeafletPagesCanvas.Block = { - $type: "pub.leaflet.pages.canvas#block", - block: content, - x: Math.floor(position.x), - y: Math.floor(position.y), - width: Math.floor(width), - ...(rotation !== undefined && { rotation: Math.floor(rotation) }), - }; - - return canvasBlockRecord; - }), - ) - ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); - } -} - -function YJSFragmentToFacets( - node: Y.XmlElement | Y.XmlText | Y.XmlHook, - byteOffset: number = 0, - footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] }, -): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { - if (node.constructor === Y.XmlElement) { - // Handle footnote inline nodes - if (node.nodeName === "footnote") { - const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; - const placeholder = "*"; - const unicodestring = new UnicodeString(placeholder); - let footnoteContent = footnoteContentResolver?.(footnoteEntityID); - const facet: PubLeafletRichtextFacet.Main = { - index: { - byteStart: byteOffset, - byteEnd: byteOffset + unicodestring.length, - }, - features: [ - { - $type: "pub.leaflet.richtext.facet#footnote", - footnoteId: footnoteEntityID, - contentPlaintext: footnoteContent?.plaintext || "", - ...(footnoteContent?.facets?.length - ? { contentFacets: footnoteContent.facets } - : {}), - }, - ], - }; - return { facets: [facet], byteLength: unicodestring.length }; - } - - // Handle inline mention nodes - if (node.nodeName === "didMention") { - const text = node.getAttribute("text") || ""; - const unicodestring = new UnicodeString(text); - const facet: PubLeafletRichtextFacet.Main = { - index: { - byteStart: byteOffset, - byteEnd: byteOffset + unicodestring.length, - }, - features: [ - { - $type: "pub.leaflet.richtext.facet#didMention", - did: node.getAttribute("did"), - }, - ], - }; - return { facets: [facet], byteLength: unicodestring.length }; - } - - if (node.nodeName === "atMention") { - const text = node.getAttribute("text") || ""; - const unicodestring = new UnicodeString(text); - const facet: PubLeafletRichtextFacet.Main = { - index: { - byteStart: byteOffset, - byteEnd: byteOffset + unicodestring.length, - }, - features: [ - { - $type: "pub.leaflet.richtext.facet#atMention", - atURI: node.getAttribute("atURI"), - ...(node.getAttribute("href") - ? { href: node.getAttribute("href") } - : {}), - }, - ], - }; - return { facets: [facet], byteLength: unicodestring.length }; - } - - if (node.nodeName === "hard_break") { - const unicodestring = new UnicodeString("\n"); - return { facets: [], byteLength: unicodestring.length }; - } - - // For other elements (like paragraph), process children - let allFacets: PubLeafletRichtextFacet.Main[] = []; - let currentOffset = byteOffset; - for (const child of node.toArray()) { - const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver); - allFacets.push(...result.facets); - currentOffset += result.byteLength; - } - return { facets: allFacets, byteLength: currentOffset - byteOffset }; - } - - if (node.constructor === Y.XmlText) { - let facets: PubLeafletRichtextFacet.Main[] = []; - let delta = node.toDelta() as Delta[]; - let byteStart = byteOffset; - let totalLength = 0; - for (let d of delta) { - let unicodestring = new UnicodeString(d.insert); - let facet: PubLeafletRichtextFacet.Main = { - index: { - byteStart, - byteEnd: byteStart + unicodestring.length, - }, - features: [], - }; - - if (d.attributes?.strikethrough) - facet.features.push({ - $type: "pub.leaflet.richtext.facet#strikethrough", - }); - - if (d.attributes?.code) - facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); - if (d.attributes?.highlight) - facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); - if (d.attributes?.underline) - facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); - if (d.attributes?.strong) - facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); - if (d.attributes?.em) - facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); - if (d.attributes?.link) - facet.features.push({ - $type: "pub.leaflet.richtext.facet#link", - uri: d.attributes.link.href, - }); - if (facet.features.length > 0) facets.push(facet); - byteStart += unicodestring.length; - totalLength += unicodestring.length; - } - return { facets, byteLength: totalLength }; - } - return { facets: [], byteLength: 0 }; -} - -type ExcludeString = T extends string - ? string extends T - ? never - : T /* maybe literal, not the whole `string` */ - : T; /* not a string */ async function extractThemeFromFacts( facts: Fact[], diff --git a/app/api/inngest/functions/send_post_broadcast.ts b/app/api/inngest/functions/send_post_broadcast.ts index a660f623..31286d0b 100644 --- a/app/api/inngest/functions/send_post_broadcast.ts +++ b/app/api/inngest/functions/send_post_broadcast.ts @@ -1,14 +1,15 @@ import { render } from "@react-email/render"; +import { AtUri } from "@atproto/syntax"; import { inngest } from "../client"; import { supabaseServerClient } from "supabase/serverClient"; import { PostEmail } from "emails/post"; import { emailPropsFromPublication } from "emails/fromPublication"; -import { extractEmailBlocksFromFacts } from "src/utils/postToEmailBlocks"; import { + getDocumentPages, normalizeDocumentRecord, normalizePublicationRecord, } from "src/utils/normalizeRecords"; -import type { Fact } from "src/replicache"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; import type { Json } from "supabase/database.types"; const BATCH_SIZE = 500; @@ -37,7 +38,7 @@ export const send_post_broadcast = inngest.createFunction( }, { event: "newsletter/post.send.requested" }, async ({ event, step }) => { - const { publication_uri, document_uri, root_entity } = event.data; + const { publication_uri, document_uri } = event.data; const loaded = await step.run("load-pub-and-doc", async () => { const [pubRes, docRes] = await Promise.all([ @@ -83,6 +84,16 @@ export const send_post_broadcast = inngest.createFunction( ? `${pubRecord.url.replace(/\/$/, "")}${docRecord.path}` : pubProps.publicationUrl; const replyToEmail = settings.reply_to_email; + const did = new AtUri(document_uri).host; + + // The first page is the document body. Canvas pages don't map to a linear + // email body — the email renders an empty postContent section and falls + // back to the "See Full Post" link. + const firstPage = docRecord ? getDocumentPages(docRecord)?.[0] : undefined; + const blocks: PubLeafletPagesLinearDocument.Block[] = + firstPage?.$type === "pub.leaflet.pages.linearDocument" + ? (firstPage as PubLeafletPagesLinearDocument.Main).blocks ?? [] + : []; const subscribers = await step.run("snapshot-subscribers", async () => { const { data } = await supabaseServerClient @@ -113,14 +124,6 @@ export const send_post_broadcast = inngest.createFunction( return { sent: 0 }; } - const blocks = await step.run("extract-blocks", async () => { - const { data: factsData } = await supabaseServerClient.rpc("get_facts", { - root: root_entity, - }); - const facts = (factsData as unknown as Fact[]) || []; - return extractEmailBlocksFromFacts(facts, root_entity); - }); - const assetsBaseUrl = ( process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub" ).replace(/\/$/, ""); @@ -134,6 +137,7 @@ export const send_post_broadcast = inngest.createFunction( postDescription, postUrl, blocks, + did, assetsBaseUrl: `${assetsBaseUrl}/`, unsubscribeUrl: UNSUB_PLACEHOLDER, }), diff --git a/emails/post.tsx b/emails/post.tsx index 75aa24e9..068661db 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -18,7 +18,19 @@ import { import type { PrismLanguage } from "@react-email/code-block"; import { Tailwind, pixelBasedPreset } from "@react-email/components"; import React from "react"; -import type { EmailBlock, EmailListItem } from "src/utils/postToEmailBlocks"; +import { + PubLeafletBlocksBlockquote, + PubLeafletBlocksCode, + PubLeafletBlocksHeader, + PubLeafletBlocksHorizontalRule, + PubLeafletBlocksImage, + PubLeafletBlocksOrderedList, + PubLeafletBlocksText, + PubLeafletBlocksUnorderedList, + PubLeafletBlocksWebsite, + PubLeafletPagesLinearDocument, +} from "lexicons/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; export type EmailTheme = { primary: string; @@ -48,7 +60,13 @@ export type PostEmailProps = { postUrl: string; authorName?: string; publishedAtLabel?: string; - blocks: EmailBlock[]; + blocks: PubLeafletPagesLinearDocument.Block[]; + /** + * Used to build absolute URLs for `pub.leaflet.blocks.image` blob refs via + * the /api/atproto_images proxy. Preview renders from drafts may encode a + * direct URL in the blob ref's $link instead — we pass those through. + */ + did: string; /** * If omitted the template renders a "(preview)" footer in place of the * real unsubscribe link — used by the Phase 4 preview-send path. @@ -67,6 +85,19 @@ const drawerUrl = (base: string, drawer: "quotes" | "comments") => { return `${base}${sep}interactionDrawer=${drawer}`; }; +const textBlock = (plaintext: string): PubLeafletPagesLinearDocument.Block => ({ + $type: "pub.leaflet.pages.linearDocument#block", + block: { $type: "pub.leaflet.blocks.text", plaintext }, +}); + +const headingBlock = ( + plaintext: string, + level: number, +): PubLeafletPagesLinearDocument.Block => ({ + $type: "pub.leaflet.pages.linearDocument#block", + block: { $type: "pub.leaflet.blocks.header", plaintext, level }, +}); + const defaultProps: PostEmailProps = { publicationName: "Publication", publicationUrl: "https://leaflet.pub", @@ -77,41 +108,79 @@ const defaultProps: PostEmailProps = { authorName: "author", publishedAtLabel: "Jan 1, 2026", assetsBaseUrl: "https://leaflet.pub/", + did: "did:plc:example", blocks: [ + textBlock( + "This would be the post. I'll give it a little lorem ipsum to make it look longer so i don't forget which thing is what.", + ), + headingBlock("This is a Title", 1), + textBlock( + "We'll keep it nice and separate so we can see what it looks like.", + ), + headingBlock("And a Header", 2), + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: [ + { + content: { $type: "pub.leaflet.blocks.text", plaintext: "fruits" }, + children: [ + { + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "apple", + }, + }, + { + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "banana", + }, + }, + ], + }, + { + content: { + $type: "pub.leaflet.blocks.text", + plaintext: "veggies", + }, + }, + ], + }, + }, { - type: "text", - plaintext: - "This would be the post. I'll give it a little lorem ipsum to make it look longer so i don't forget which thing is what.", + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.blockquote", + plaintext: "A quote of some kind.", + }, }, - { type: "heading", level: 1, plaintext: "This is a Title" }, { - type: "text", - plaintext: "We'll keep it nice and separate so we can see what it looks like.", + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.code", + plaintext: "const x = 1;", + language: "javascript", + }, }, - { type: "heading", level: 2, plaintext: "And a Header" }, { - type: "list", - style: "unordered", - items: [ - { plaintext: "fruits", children: [ - { type: "list", style: "unordered", items: [ - { plaintext: "apple" }, - { plaintext: "banana" }, - ] }, - ] }, - { plaintext: "veggies" }, - ], + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.website", + src: "https://example.com", + title: "Link Title Here", + description: "Description on the link", + }, }, - { type: "blockquote", plaintext: "A quote of some kind." }, - { type: "code", code: "const x = 1;", language: "javascript" }, { - type: "link", - url: "https://example.com", - title: "Link Title Here", - description: "Description on the link", + $type: "pub.leaflet.pages.linearDocument#block", + block: { $type: "pub.leaflet.blocks.horizontalRule" }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { $type: "pub.leaflet.blocks.math" }, }, - { type: "horizontal-rule" }, - { type: "unsupported", kind: "math" }, ], }; @@ -245,8 +314,13 @@ export const PostEmail = (props: Partial = {}) => { ) : null}
- {p.blocks.map((block, i) => ( - + {p.blocks.map((b, i) => ( + ))}
@@ -289,66 +363,83 @@ export default PostEmail; const BlockRenderer = ({ block, + did, assetsBaseUrl, }: { - block: EmailBlock; + block: PubLeafletPagesLinearDocument.Block["block"]; + did: string; assetsBaseUrl: string; }) => { - switch (block.type) { - case "text": - return {block.plaintext || " "}; - case "heading": - return ( - - {block.plaintext} - - ); - case "blockquote": - return ( - - - - - {block.plaintext} - - - ); - case "code": - return ; - case "image": - // Deliberately no numeric `width`/`height` HTML attributes: Outlook - // honors those over CSS `max-width`, so a 1200px natural-size photo - // would blow out our 28rem container. `max-width: px` keeps - // smaller images from being upscaled. - return ( - {block.alt - ); - case "link": - return ( - - ); - case "horizontal-rule": - return
; - case "list": - return ; - case "unsupported": - return ; + if (PubLeafletBlocksText.isMain(block)) { + return {block.plaintext || " "}; } + if (PubLeafletBlocksHeader.isMain(block)) { + const raw = Math.floor(block.level ?? 1); + const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; + return {block.plaintext}; + } + if (PubLeafletBlocksBlockquote.isMain(block)) { + return ( + + + + + {block.plaintext} + + + ); + } + if (PubLeafletBlocksCode.isMain(block)) { + return ; + } + if (PubLeafletBlocksImage.isMain(block)) { + const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); + // Deliberately no numeric `width`/`height` HTML attributes: Outlook + // honors those over CSS `max-width`, so a 1200px natural-size photo + // would blow out our 28rem container. `max-width: px` keeps + // smaller images from being upscaled. + const naturalWidth = block.aspectRatio?.width; + return ( + {block.alt + ); + } + if (PubLeafletBlocksWebsite.isMain(block)) { + const previewSrc = block.previewImage + ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl) + : undefined; + return ( + + ); + } + if (PubLeafletBlocksHorizontalRule.isMain(block)) { + return
; + } + if (PubLeafletBlocksUnorderedList.isMain(block)) { + return ( + + ); + } + if (PubLeafletBlocksOrderedList.isMain(block)) { + return ( + + ); + } + return ; }; export const LeafletWatermark = ({ @@ -412,12 +503,27 @@ export const Heading = (props: { ); }; +type ListItem = + | PubLeafletBlocksUnorderedList.ListItem + | PubLeafletBlocksOrderedList.ListItem; + +const listItemPlaintext = (item: ListItem): string => { + const content = item.content; + if (PubLeafletBlocksText.isMain(content)) return content.plaintext; + if (PubLeafletBlocksHeader.isMain(content)) return content.plaintext; + return ""; +}; + export const List = ({ items, style, + did, + assetsBaseUrl, }: { - items: EmailListItem[]; + items: ListItem[]; style: "ordered" | "unordered"; + did: string; + assetsBaseUrl: string; }) => { const listClass = `my-0 !pl-6`; const listItemClass = `${headingPadding} !ml-2`; @@ -425,18 +531,44 @@ export const List = ({ return (
- {items.map((item, i) => ( - -
  • - {typeof item.checked === "boolean" - ? `${item.checked ? "☑ " : "☐ "}${item.plaintext}` - : item.plaintext} -
  • - {item.children?.map((child, j) => ( - - ))} -
    - ))} + {items.map((item, i) => { + const plaintext = listItemPlaintext(item); + const nestedUnordered = + item.children && style === "unordered" + ? (item.children as PubLeafletBlocksUnorderedList.ListItem[]) + : (item as PubLeafletBlocksOrderedList.ListItem) + .unorderedListChildren?.children; + const nestedOrdered = + item.children && style === "ordered" + ? (item.children as PubLeafletBlocksOrderedList.ListItem[]) + : (item as PubLeafletBlocksUnorderedList.ListItem) + .orderedListChildren?.children; + return ( + +
  • + {typeof item.checked === "boolean" + ? `${item.checked ? "☑ " : "☐ "}${plaintext}` + : plaintext} +
  • + {nestedUnordered && nestedUnordered.length > 0 ? ( + + ) : null} + {nestedOrdered && nestedOrdered.length > 0 ? ( + + ) : null} +
    + ); + })}
    ); diff --git a/src/utils/blobRefToSrc.ts b/src/utils/blobRefToSrc.ts index 2f09f2c4..22b1ca61 100644 --- a/src/utils/blobRefToSrc.ts +++ b/src/utils/blobRefToSrc.ts @@ -1,4 +1,18 @@ import { BlobRef } from "@atproto/lexicon"; -export const blobRefToSrc = (b: BlobRef["ref"], did: string) => - `/api/atproto_images?did=${did}&cid=${(b as unknown as { $link: string })["$link"]}`; +// `baseUrl` produces an absolute URL (needed for outbound email where the proxy +// path can't resolve against the document origin). +// +// If `$link` is already an http(s) URL we return it untouched — the email-preview +// path stuffs a direct draft-image URL into `$link` since drafts aren't uploaded +// to a PDS yet. +export const blobRefToSrc = ( + b: BlobRef["ref"], + did: string, + baseUrl?: string, +) => { + const link = (b as unknown as { $link: string })["$link"]; + if (link.startsWith("http://") || link.startsWith("https://")) return link; + const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : ""; + return `${prefix}/api/atproto_images?did=${did}&cid=${link}`; +}; diff --git a/src/utils/factsToPagesRecord.ts b/src/utils/factsToPagesRecord.ts new file mode 100644 index 00000000..89a3d328 --- /dev/null +++ b/src/utils/factsToPagesRecord.ts @@ -0,0 +1,678 @@ +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { $Typed, UnicodeString } from "@atproto/api"; +import { BlobRef } from "@atproto/lexicon"; + +import { + PubLeafletBlocksBlockquote, + PubLeafletBlocksBskyPost, + PubLeafletBlocksButton, + PubLeafletBlocksCode, + PubLeafletBlocksHeader, + PubLeafletBlocksHorizontalRule, + PubLeafletBlocksIframe, + PubLeafletBlocksImage, + PubLeafletBlocksMath, + PubLeafletBlocksOrderedList, + PubLeafletBlocksPage, + PubLeafletBlocksPoll, + PubLeafletBlocksText, + PubLeafletBlocksUnorderedList, + PubLeafletBlocksWebsite, + PubLeafletPagesCanvas, + PubLeafletPagesLinearDocument, + PubLeafletPollDefinition, + PubLeafletRichtextFacet, +} from "lexicons/api"; +import { ids } from "lexicons/api/lexicons"; + +import { Block } from "components/Blocks/Block"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { scanIndexLocal } from "src/replicache/utils"; +import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; +import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; +import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; + +type ExcludeString = T extends string + ? string extends T + ? never + : T + : T; + +export type ProcessBlocksToPagesHooks = { + /** + * Resolve an image URL to a BlobRef. For publish, this uploads the blob to + * the user's PDS. For preview, this synthesizes a BlobRef that carries the + * URL through to the email template (no PDS side-effects). + * Returning undefined causes the image block to be dropped. + */ + uploadImage: (src: string) => Promise; + + /** + * Persist a poll definition record and return its at-uri / CID. For + * publish, this writes to the PDS and (optionally) Supabase. For preview, + * pass null to skip poll blocks entirely — they'll be rendered as + * "unsupported" in the email. + */ + uploadPoll: + | (( + entityId: string, + record: PubLeafletPollDefinition.Record, + ) => Promise<{ uri: string; cid: string } | undefined>) + | null; +}; + +export type ProcessBlocksToPagesResult = { + pages: { + id: string; + blocks: + | PubLeafletPagesLinearDocument.Block[] + | PubLeafletPagesCanvas.Block[]; + type: "doc" | "canvas"; + }[]; +}; + +export async function processBlocksToPages(opts: { + facts: Fact[]; + root_entity: string; + hooks: ProcessBlocksToPagesHooks; +}): Promise { + const { facts, root_entity, hooks } = opts; + const scan = scanIndexLocal(facts); + const pages: ProcessBlocksToPagesResult["pages"] = []; + + const firstEntity = scan.eav(root_entity, "root/page")?.[0]; + if (!firstEntity) throw new Error("No root page"); + + const [pageType] = scan.eav(firstEntity.data.value, "page/type"); + + if (pageType?.data.value === "canvas") { + const canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value); + pages.unshift({ + id: firstEntity.data.value, + blocks: canvasBlocks, + type: "canvas", + }); + } else { + const blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); + const b = await blocksToRecord(blocks); + pages.unshift({ + id: firstEntity.data.value, + blocks: b, + type: "doc", + }); + } + + return { pages }; + + async function blocksToRecord( + blocks: Block[], + ): Promise { + const parsedBlocks = parseBlocksToList(blocks); + return ( + await Promise.all( + parsedBlocks.map(async (blockOrList) => { + if (blockOrList.type === "block") { + const alignmentValue = scan.eav( + blockOrList.block.value, + "block/text-alignment", + )[0]?.data.value; + const alignment: ExcludeString< + PubLeafletPagesLinearDocument.Block["alignment"] + > = + alignmentValue === "center" + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" + : alignmentValue === "right" + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" + : alignmentValue === "justify" + ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" + : alignmentValue === "left" + ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" + : undefined; + const b = await blockToRecord(blockOrList.block); + if (!b) return []; + const block: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: b, + }; + if (alignment) block.alignment = alignment; + return [block]; + } else { + const runs = splitListByStyle(blockOrList.children); + const out = await Promise.all( + runs.map(async (run) => { + if (run.style === "ordered") { + const block: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.orderedList", + startIndex: + run.children[0].block.listData?.listStart || 1, + children: await orderedChildrenToRecord(run.children), + }, + }; + return block; + } else { + const block: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: await unorderedChildrenToRecord(run.children), + }, + }; + return block; + } + }), + ); + return out; + } + }), + ) + ).flat(); + } + + function splitListByStyle(children: List[]) { + const runs: { style: "ordered" | "unordered"; children: List[] }[] = []; + for (const child of children) { + const style: "ordered" | "unordered" = + child.block.listData?.listStyle === "ordered" + ? "ordered" + : "unordered"; + const last = runs[runs.length - 1]; + if (last && last.style === style) { + last.children.push(child); + } else { + runs.push({ style, children: [child] }); + } + } + return runs; + } + + async function unorderedChildrenToRecord( + children: List[], + ): Promise { + return ( + await Promise.all( + children.map(async (child) => { + const content = await blockToRecord(child.block); + if (!content) return []; + const record: PubLeafletBlocksUnorderedList.ListItem = { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content, + ...(child.block.listData?.checklist && { + checked: child.block.listData.checked ?? false, + }), + }; + const sameStyle = child.children.filter( + (c) => c.block.listData?.listStyle !== "ordered", + ); + const diffStyle = child.children.filter( + (c) => c.block.listData?.listStyle === "ordered", + ); + if (sameStyle.length > 0) { + record.children = await unorderedChildrenToRecord(sameStyle); + } + if (diffStyle.length > 0) { + record.orderedListChildren = { + $type: "pub.leaflet.blocks.orderedList", + children: await orderedChildrenToRecord(diffStyle), + }; + } + return record; + }), + ) + ).flat(); + } + + async function orderedChildrenToRecord( + children: List[], + ): Promise { + return ( + await Promise.all( + children.map(async (child) => { + const content = await blockToRecord(child.block); + if (!content) return []; + const record: PubLeafletBlocksOrderedList.ListItem = { + $type: "pub.leaflet.blocks.orderedList#listItem", + content, + ...(child.block.listData?.checklist && { + checked: child.block.listData.checked ?? false, + }), + }; + const sameStyle = child.children.filter( + (c) => c.block.listData?.listStyle === "ordered", + ); + const diffStyle = child.children.filter( + (c) => c.block.listData?.listStyle !== "ordered", + ); + if (sameStyle.length > 0) { + record.children = await orderedChildrenToRecord(sameStyle); + } + if (diffStyle.length > 0) { + record.unorderedListChildren = { + $type: "pub.leaflet.blocks.unorderedList", + children: await unorderedChildrenToRecord(diffStyle), + }; + } + return record; + }), + ) + ).flat(); + } + + async function blockToRecord(b: Block) { + const footnoteContentResolver = (footnoteEntityID: string) => { + const [content] = scan.eav(footnoteEntityID, "block/text"); + if (!content) + return { + plaintext: "", + facets: [] as PubLeafletRichtextFacet.Main[], + }; + const doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + const nodes = doc.getXmlElement("prosemirror").toArray(); + const plaintext = YJSFragmentToString(nodes[0]); + const { facets } = YJSFragmentToFacets(nodes[0]); + return { plaintext, facets }; + }; + const getBlockContent = (b: string) => { + const [content] = scan.eav(b, "block/text"); + if (!content) + return ["", [] as PubLeafletRichtextFacet.Main[]] as const; + const doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + const nodes = doc.getXmlElement("prosemirror").toArray(); + const stringValue = YJSFragmentToString(nodes[0]); + const { facets } = YJSFragmentToFacets( + nodes[0], + 0, + footnoteContentResolver, + ); + return [stringValue, facets] as const; + }; + if (b.type === "card") { + const [page] = scan.eav(b.value, "block/card"); + if (!page) return; + const [pageType] = scan.eav(page.data.value, "page/type"); + + if (pageType?.data.value === "canvas") { + const canvasBlocks = await canvasBlocksToRecord(page.data.value); + pages.push({ + id: page.data.value, + blocks: canvasBlocks, + type: "canvas", + }); + } else { + const blocks = getBlocksWithTypeLocal(facts, page.data.value); + pages.push({ + id: page.data.value, + blocks: await blocksToRecord(blocks), + type: "doc", + }); + } + + const block: $Typed = { + $type: "pub.leaflet.blocks.page", + id: page.data.value, + }; + return block; + } + + if (b.type === "bluesky-post") { + const [post] = scan.eav(b.value, "block/bluesky-post"); + if (!post || !post.data.value.post) return; + const [hostFact] = scan.eav(b.value, "bluesky-post/host"); + const block: $Typed = { + $type: ids.PubLeafletBlocksBskyPost, + postRef: { + uri: post.data.value.post.uri, + cid: post.data.value.post.cid, + }, + clientHost: hostFact?.data.value, + }; + return block; + } + if (b.type === "horizontal-rule") { + const block: $Typed = { + $type: ids.PubLeafletBlocksHorizontalRule, + }; + return block; + } + + if (b.type === "heading") { + const [headingLevel] = scan.eav(b.value, "block/heading-level"); + + const [stringValue, facets] = getBlockContent(b.value); + const block: $Typed = { + $type: "pub.leaflet.blocks.header", + level: Math.floor(headingLevel?.data.value || 1), + plaintext: stringValue, + ...(facets.length > 0 && { facets }), + }; + return block; + } + + if (b.type === "blockquote") { + const [stringValue, facets] = getBlockContent(b.value); + const block: $Typed = { + $type: ids.PubLeafletBlocksBlockquote, + plaintext: stringValue, + ...(facets.length > 0 && { facets }), + }; + return block; + } + + if (b.type == "text") { + const [stringValue, facets] = getBlockContent(b.value); + const [textSize] = scan.eav(b.value, "block/text-size"); + const block: $Typed = { + $type: ids.PubLeafletBlocksText, + plaintext: stringValue, + ...(facets.length > 0 && { facets }), + ...(textSize && { textSize: textSize.data.value }), + }; + return block; + } + if (b.type === "embed") { + const [url] = scan.eav(b.value, "embed/url"); + const [height] = scan.eav(b.value, "embed/height"); + const [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio"); + if (!url) return; + const block: $Typed = { + $type: "pub.leaflet.blocks.iframe", + url: url.data.value, + height: Math.floor(height?.data.value || 600), + }; + if (aspectRatio) { + const [w, h] = aspectRatio.data.value.split("/").map(Number); + if (w && h) { + block.aspectRatio = { width: w, height: h }; + } + } + return block; + } + if (b.type == "image") { + const [image] = scan.eav(b.value, "block/image"); + if (!image) return; + const [altText] = scan.eav(b.value, "image/alt"); + const [fullBleed] = scan.eav(b.value, "image/full-bleed"); + const blobref = await hooks.uploadImage(image.data.src); + if (!blobref) return; + const block: $Typed = { + $type: "pub.leaflet.blocks.image", + image: blobref, + aspectRatio: { + height: Math.floor(image.data.height), + width: Math.floor(image.data.width), + }, + alt: altText ? altText.data.value : undefined, + fullBleed: fullBleed?.data.value || undefined, + }; + return block; + } + if (b.type === "link") { + const [previewImage] = scan.eav(b.value, "link/preview"); + const [description] = scan.eav(b.value, "link/description"); + const [src] = scan.eav(b.value, "link/url"); + if (!src) return; + const blobref = previewImage + ? await hooks.uploadImage(previewImage.data.src) + : undefined; + const [title] = scan.eav(b.value, "link/title"); + const block: $Typed = { + $type: "pub.leaflet.blocks.website", + previewImage: blobref, + src: src.data.value, + description: description?.data.value, + title: title?.data.value, + }; + return block; + } + if (b.type === "code") { + const [language] = scan.eav(b.value, "block/code-language"); + const [code] = scan.eav(b.value, "block/code"); + const [theme] = scan.eav(root_entity, "theme/code-theme"); + const block: $Typed = { + $type: "pub.leaflet.blocks.code", + language: language?.data.value, + plaintext: code?.data.value || "", + syntaxHighlightingTheme: theme?.data.value, + }; + return block; + } + if (b.type === "math") { + const [math] = scan.eav(b.value, "block/math"); + const block: $Typed = { + $type: "pub.leaflet.blocks.math", + tex: math?.data.value || "", + }; + return block; + } + if (b.type === "poll") { + if (!hooks.uploadPoll) return; + + const pollOptions = scan.eav(b.value, "poll/options"); + const options: PubLeafletPollDefinition.Option[] = pollOptions.map( + (opt) => { + const optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; + return { + $type: "pub.leaflet.poll.definition#option", + text: optionName?.data.value || "", + }; + }, + ); + + const pollRecord: PubLeafletPollDefinition.Record = { + $type: "pub.leaflet.poll.definition", + name: "Poll", + options, + }; + + const result = await hooks.uploadPoll(b.value, pollRecord); + if (!result) return; + + const block: $Typed = { + $type: "pub.leaflet.blocks.poll", + pollRef: { + uri: result.uri, + cid: result.cid, + }, + }; + return block; + } + if (b.type === "button") { + const [text] = scan.eav(b.value, "button/text"); + const [url] = scan.eav(b.value, "button/url"); + if (!text || !url) return; + const block: $Typed = { + $type: "pub.leaflet.blocks.button", + text: text.data.value, + url: url.data.value, + }; + return block; + } + return; + } + + async function canvasBlocksToRecord( + pageID: string, + ): Promise { + const canvasBlocks = scan.eav(pageID, "canvas/block"); + return ( + await Promise.all( + canvasBlocks.map(async (canvasBlock) => { + const blockEntity = canvasBlock.data.value; + const position = canvasBlock.data.position; + + const blockType = scan.eav(blockEntity, "block/type")?.[0]; + if (!blockType) return null; + + const block: Block = { + type: blockType.data.value, + value: blockEntity, + parent: pageID, + position: "", + factID: canvasBlock.id, + }; + + const content = await blockToRecord(block); + if (!content) return null; + + const width = + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; + const rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] + ?.data.value; + + const canvasBlockRecord: PubLeafletPagesCanvas.Block = { + $type: "pub.leaflet.pages.canvas#block", + block: content, + x: Math.floor(position.x), + y: Math.floor(position.y), + width: Math.floor(width), + ...(rotation !== undefined && { rotation: Math.floor(rotation) }), + }; + + return canvasBlockRecord; + }), + ) + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); + } +} + +export function YJSFragmentToFacets( + node: Y.XmlElement | Y.XmlText | Y.XmlHook, + byteOffset: number = 0, + footnoteContentResolver?: (footnoteEntityID: string) => { + plaintext: string; + facets: PubLeafletRichtextFacet.Main[]; + }, +): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { + if (node.constructor === Y.XmlElement) { + if (node.nodeName === "footnote") { + const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; + const placeholder = "*"; + const unicodestring = new UnicodeString(placeholder); + const footnoteContent = footnoteContentResolver?.(footnoteEntityID); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#footnote", + footnoteId: footnoteEntityID, + contentPlaintext: footnoteContent?.plaintext || "", + ...(footnoteContent?.facets?.length + ? { contentFacets: footnoteContent.facets } + : {}), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "didMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#didMention", + did: node.getAttribute("did"), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "atMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#atMention", + atURI: node.getAttribute("atURI"), + ...(node.getAttribute("href") + ? { href: node.getAttribute("href") } + : {}), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "hard_break") { + const unicodestring = new UnicodeString("\n"); + return { facets: [], byteLength: unicodestring.length }; + } + + const allFacets: PubLeafletRichtextFacet.Main[] = []; + let currentOffset = byteOffset; + for (const child of node.toArray()) { + const result = YJSFragmentToFacets( + child, + currentOffset, + footnoteContentResolver, + ); + allFacets.push(...result.facets); + currentOffset += result.byteLength; + } + return { facets: allFacets, byteLength: currentOffset - byteOffset }; + } + + if (node.constructor === Y.XmlText) { + const facets: PubLeafletRichtextFacet.Main[] = []; + const delta = node.toDelta() as Delta[]; + let byteStart = byteOffset; + let totalLength = 0; + for (const d of delta) { + const unicodestring = new UnicodeString(d.insert); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart, + byteEnd: byteStart + unicodestring.length, + }, + features: [], + }; + + if (d.attributes?.strikethrough) + facet.features.push({ + $type: "pub.leaflet.richtext.facet#strikethrough", + }); + + if (d.attributes?.code) + facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); + if (d.attributes?.highlight) + facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); + if (d.attributes?.underline) + facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); + if (d.attributes?.strong) + facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); + if (d.attributes?.em) + facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); + if (d.attributes?.link) + facet.features.push({ + $type: "pub.leaflet.richtext.facet#link", + uri: d.attributes.link.href, + }); + if (facet.features.length > 0) facets.push(facet); + byteStart += unicodestring.length; + totalLength += unicodestring.length; + } + return { facets, byteLength: totalLength }; + } + return { facets: [], byteLength: 0 }; +} diff --git a/src/utils/postToEmailBlocks.ts b/src/utils/postToEmailBlocks.ts deleted file mode 100644 index 08a13042..00000000 --- a/src/utils/postToEmailBlocks.ts +++ /dev/null @@ -1,249 +0,0 @@ -import * as Y from "yjs"; -import * as base64 from "base64-js"; -import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; -import type { Fact } from "src/replicache"; -import { scanIndexLocal } from "src/replicache/utils"; -import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; -import type { Block } from "components/Blocks/Block"; - -export type EmailBlock = - | { - type: "text"; - plaintext: string; - textSize?: "small" | "default" | "large"; - } - | { type: "heading"; level: 1 | 2 | 3; plaintext: string } - | { type: "blockquote"; plaintext: string } - | { type: "code"; code: string; language?: string } - | { type: "image"; src: string; alt?: string; width?: number; height?: number } - | { - type: "link"; - url: string; - title?: string; - description?: string; - previewSrc?: string; - } - | { type: "horizontal-rule" } - | { type: "list"; style: "ordered" | "unordered"; items: EmailListItem[] } - | { type: "unsupported"; kind: string }; - -export type EmailListItem = { - plaintext: string; - checked?: boolean; - children?: EmailBlock[]; -}; - -function decodeYjsText(base64Value: string): string { - const doc = new Y.Doc(); - Y.applyUpdate(doc, base64.toByteArray(base64Value)); - const [node] = doc.getXmlElement("prosemirror").toArray(); - if (!node) return ""; - return YJSFragmentToString(node); -} - -function getPlaintext( - scan: ReturnType, - entity: string, -): string { - const [content] = scan.eav(entity, "block/text"); - if (!content) return ""; - return decodeYjsText(content.data.value); -} - -function renderBlock( - scan: ReturnType, - block: Block, -): EmailBlock | null { - switch (block.type) { - case "text": { - const [textSize] = scan.eav(block.value, "block/text-size"); - const out: EmailBlock = { - type: "text", - plaintext: getPlaintext(scan, block.value), - }; - if (textSize) out.textSize = textSize.data.value; - return out; - } - case "heading": { - const [level] = scan.eav(block.value, "block/heading-level"); - const rawLevel = Math.floor(level?.data.value || 1); - const clamped = (rawLevel < 1 ? 1 : rawLevel > 3 ? 3 : rawLevel) as - | 1 - | 2 - | 3; - return { - type: "heading", - level: clamped, - plaintext: getPlaintext(scan, block.value), - }; - } - case "blockquote": - return { - type: "blockquote", - plaintext: getPlaintext(scan, block.value), - }; - case "code": { - const [code] = scan.eav(block.value, "block/code"); - const [language] = scan.eav(block.value, "block/code-language"); - return { - type: "code", - code: code?.data.value || "", - language: language?.data.value, - }; - } - case "image": { - const [image] = scan.eav(block.value, "block/image"); - if (!image) return null; - const [alt] = scan.eav(block.value, "image/alt"); - return { - type: "image", - src: image.data.src, - alt: alt?.data.value, - width: image.data.width, - height: image.data.height, - }; - } - case "link": { - const [url] = scan.eav(block.value, "link/url"); - if (!url) return null; - const [title] = scan.eav(block.value, "link/title"); - const [description] = scan.eav(block.value, "link/description"); - const [preview] = scan.eav(block.value, "link/preview"); - return { - type: "link", - url: url.data.value, - title: title?.data.value, - description: description?.data.value, - previewSrc: preview?.data.src, - }; - } - case "horizontal-rule": - return { type: "horizontal-rule" }; - default: - return { type: "unsupported", kind: block.type }; - } -} - -type ListNode = { - block: Block; - children: ListNode[]; -}; - -function buildListTree(blocks: Block[]): ListNode[] { - // Build a nested tree from the flattened list output of getBlocksWithTypeLocal, - // using listData.depth to re-nest. - const roots: ListNode[] = []; - const stack: ListNode[] = []; - for (const b of blocks) { - const depth = b.listData?.depth ?? 1; - const node: ListNode = { block: b, children: [] }; - while (stack.length && (stack[stack.length - 1].block.listData?.depth ?? 1) >= depth) { - stack.pop(); - } - if (stack.length === 0) roots.push(node); - else stack[stack.length - 1].children.push(node); - stack.push(node); - } - return roots; -} - -function nodeToListItem( - scan: ReturnType, - node: ListNode, -): EmailListItem { - const item: EmailListItem = { - plaintext: getPlaintext(scan, node.block.value), - }; - if (node.block.listData?.checklist) { - item.checked = node.block.listData.checked ?? false; - } - if (node.children.length > 0) { - // Split children by list style into runs, each a nested list block - const childBlocks: EmailBlock[] = []; - let run: ListNode[] = []; - let runStyle: "ordered" | "unordered" | null = null; - const flushRun = () => { - if (run.length === 0 || !runStyle) return; - childBlocks.push({ - type: "list", - style: runStyle, - items: run.map((n) => nodeToListItem(scan, n)), - }); - run = []; - runStyle = null; - }; - for (const child of node.children) { - const style = - child.block.listData?.listStyle === "ordered" ? "ordered" : "unordered"; - if (runStyle && runStyle !== style) flushRun(); - runStyle = style; - run.push(child); - } - flushRun(); - item.children = childBlocks; - } - return item; -} - -export function extractEmailBlocksFromFacts( - facts: Fact[], - rootEntity: string, -): EmailBlock[] { - const scan = scanIndexLocal(facts); - const [firstPage] = scan.eav(rootEntity, "root/page"); - if (!firstPage) return []; - - const [pageType] = scan.eav(firstPage.data.value, "page/type"); - if (pageType?.data.value === "canvas") { - // Canvas pages aren't sensibly mappable to a linear email; fall back. - return [{ type: "unsupported", kind: "canvas-page" }]; - } - - const rawBlocks = getBlocksWithTypeLocal(facts, firstPage.data.value); - - // Walk the flattened blocks. Group consecutive list blocks into list runs. - const out: EmailBlock[] = []; - let listBuf: Block[] = []; - - const flushList = () => { - if (listBuf.length === 0) return; - const tree = buildListTree(listBuf); - // Split roots by list style into separate list blocks - let run: ListNode[] = []; - let runStyle: "ordered" | "unordered" | null = null; - const flushRun = () => { - if (run.length === 0 || !runStyle) return; - out.push({ - type: "list", - style: runStyle, - items: run.map((n) => nodeToListItem(scan, n)), - }); - run = []; - runStyle = null; - }; - for (const rootNode of tree) { - const style = - rootNode.block.listData?.listStyle === "ordered" - ? "ordered" - : "unordered"; - if (runStyle && runStyle !== style) flushRun(); - runStyle = style; - run.push(rootNode); - } - flushRun(); - listBuf = []; - }; - - for (const block of rawBlocks) { - if (block.listData) { - listBuf.push(block); - continue; - } - flushList(); - const rendered = renderBlock(scan, block); - if (rendered) out.push(rendered); - } - flushList(); - - return out; -}