Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TypeScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114"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, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletDocument, SiteStandardDocument, PubLeafletContent, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletRichtextFacet, PubLeafletBlocksWebsite, PubLeafletBlocksCode, PubLeafletBlocksMath, PubLeafletBlocksHorizontalRule, PubLeafletBlocksBskyPost, PubLeafletBlocksBlockquote, PubLeafletBlocksIframe, PubLeafletBlocksPage, PubLeafletBlocksPoll, PubLeafletBlocksButton, PubLeafletPollDefinition,} 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/hooks/queries/useBlocks";import { Lock } from "src/utils/lock";import type { PubLeafletPublication } from "lexicons/api";import { normalizeDocumentRecord, type NormalizedDocument,} from "src/utils/normalizeRecords";import { ColorToRGB, ColorToRGBA,} from "components/ThemeManager/colorToLexicons";import { parseColor } from "@react-stately/color";import { Notification, pingIdentityToUpdateNotification,} from "src/notifications";import { v7 } from "uuid";import { isDocumentCollection, isPublicationCollection, getDocumentType,} from "src/utils/collectionHelpers";
type PublishResult = | { success: true; rkey: string; record: SiteStandardDocument.Record } | { success: false; error: OAuthSessionError };
export async function publishToPublication({ root_entity, publication_uri, leaflet_id, title, description, tags, cover_image, entitiesToDelete, publishedAt, postPreferences,}: { root_entity: string; publication_uri?: string; leaflet_id: string; title?: string; description?: string; tags?: string[]; cover_image?: string | null; entitiesToDelete?: string[]; publishedAt?: string; postPreferences?: { showComments?: boolean; showMentions?: boolean; showRecommends?: boolean; } | null;}): Promise<PublishResult> { let identity = await getIdentityData(); if (!identity || !identity.atp_did) { return { success: false, error: { type: "oauth_session_expired", message: "Not authenticated", did: "", }, }; }
const sessionResult = await restoreOAuthSession(identity.atp_did); if (!sessionResult.ok) { return { success: false, error: sessionResult.error }; } let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), );
// Check if we're publishing to a publication or standalone let draft: any = null; let existingDocUri: string | null = null;
if (publication_uri) { // Publishing to a publication - use leaflets_in_publications let { data, error } = await supabaseServerClient .from("publications") .select("*, leaflets_in_publications(*, documents(*))") .eq("uri", publication_uri) .eq("leaflets_in_publications.leaflet", leaflet_id) .single(); console.log(error);
if (!data || identity.atp_did !== data?.identity_did) throw new Error("No draft or not publisher"); draft = data.leaflets_in_publications[0]; existingDocUri = draft?.doc; } else { // Publishing standalone - use leaflets_to_documents let { data } = await supabaseServerClient .from("leaflets_to_documents") .select("*, documents(*)") .eq("leaflet", leaflet_id) .single(); draft = data; existingDocUri = draft?.document; }
// Heuristic: Remove title entities if this is the first time publishing // (when coming from a standalone leaflet with entitiesToDelete passed in) if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { await supabaseServerClient .from("entities") .delete() .in("id", entitiesToDelete); }
let { data } = await supabaseServerClient.rpc("get_facts", { root: root_entity, }); let facts = (data as unknown as Fact<Attribute>[]) || [];
let { pages } = await processBlocksToPages( facts, agent, root_entity, credentialSession.did!, );
let existingRecord: Partial<PubLeafletDocument.Record> = {}; const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); if (normalizedDoc) { // When reading existing data, use normalized format to extract fields // The theme is preserved in NormalizedDocument for backward compatibility existingRecord = { publishedAt: normalizedDoc.publishedAt, title: normalizedDoc.title, description: normalizedDoc.description, tags: normalizedDoc.tags, coverImage: normalizedDoc.coverImage, theme: normalizedDoc.theme, }; }
// Resolve preferences: explicit param > draft DB value const preferences = postPreferences ?? draft?.preferences;
// Extract theme for standalone documents (not for publications) let theme: PubLeafletPublication.Theme | undefined; if (!publication_uri) { theme = await extractThemeFromFacts(facts, root_entity, agent); }
// Upload cover image if provided let coverImageBlob: BlobRef | undefined; if (cover_image) { let scan = scanIndexLocal(facts); let [imageData] = scan.eav(cover_image, "block/image"); if (imageData) { let imageResponse = await fetch(imageData.data.src); if (imageResponse.status === 200) { let binary = await imageResponse.blob(); let blob = await agent.com.atproto.repo.uploadBlob(binary, { headers: { "Content-Type": binary.type }, }); coverImageBlob = blob.data.blob; } } }
// Determine the collection to use - preserve existing schema if updating const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; const documentType = getDocumentType(existingCollection);
// Build the pages array (used by both formats) const pagesArray = pages.map((p) => { if (p.type === "canvas") { return { $type: "pub.leaflet.pages.canvas" as const, id: p.id, blocks: p.blocks as PubLeafletPagesCanvas.Block[], }; } else { return { $type: "pub.leaflet.pages.linearDocument" as const, id: p.id, blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], }; } });
// Determine the rkey early since we need it for the path field const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
// Create record based on the document type let record: PubLeafletDocument.Record | SiteStandardDocument.Record;
if (documentType === "site.standard.document") { // site.standard.document format // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
record = { $type: "site.standard.document", title: title || "Untitled", site: siteUri, path: "/" + rkey, publishedAt: publishedAt || existingRecord.publishedAt || new Date().toISOString(), ...(description && { description }), ...(tags !== undefined && { tags }), ...(coverImageBlob && { coverImage: coverImageBlob }), // Include theme for standalone documents (not for publication documents) ...(!publication_uri && theme && { theme }), ...(preferences && { preferences: { $type: "pub.leaflet.publication#preferences" as const, ...preferences, }, }), content: { $type: "pub.leaflet.content" as const, pages: pagesArray, }, } satisfies SiteStandardDocument.Record; } else { // pub.leaflet.document format (legacy) record = { $type: "pub.leaflet.document", author: credentialSession.did!, ...(publication_uri && { publication: publication_uri }), ...(theme && { theme }), ...(preferences && { preferences: { $type: "pub.leaflet.publication#preferences" as const, ...preferences, }, }), title: title || "Untitled", description: description || "", ...(tags !== undefined && { tags }), ...(coverImageBlob && { coverImage: coverImageBlob }), pages: pagesArray, publishedAt: publishedAt || existingRecord.publishedAt || new Date().toISOString(), } satisfies PubLeafletDocument.Record; }
let { data: result } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, collection: record.$type, record, validate: false, //TODO publish the lexicon so we can validate! });
// Optimistically create database entries await supabaseServerClient.from("documents").upsert({ uri: result.uri, data: record as unknown as Json, indexed: true, });
if (publication_uri) { // Publishing to a publication - update both tables await Promise.all([ supabaseServerClient.from("documents_in_publications").upsert({ publication: publication_uri, document: result.uri, }), supabaseServerClient.from("leaflets_in_publications").upsert({ doc: result.uri, leaflet: leaflet_id, publication: publication_uri, title: title, description: description, }), ]); } else { // Publishing standalone - update leaflets_to_documents await supabaseServerClient.from("leaflets_to_documents").upsert({ leaflet: leaflet_id, document: result.uri, title: title || "Untitled", description: description || "", });
// Heuristic: Remove title entities if this is the first time publishing standalone // (when entitiesToDelete is provided and there's no existing document) if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { await supabaseServerClient .from("entities") .delete() .in("id", entitiesToDelete); } }
// Create notifications for mentions (only on first publish) if (!existingDocUri) { await createMentionNotifications( result.uri, record, credentialSession.did!, ); }
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };}
async function processBlocksToPages( facts: Fact<any>[], 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<PubLeafletPagesLinearDocument.Block[]> { 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 block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", children: await childrenToRecord(blockOrList.children, did), }, }; return [block]; } }), ) ).flat(); }
async function childrenToRecord(children: List[], did: string) { 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, children: await childrenToRecord(child.children, did), }; return record; }), ) ).flat(); } async function blockToRecord(b: Block, did: string) { 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]); 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<PubLeafletBlocksPage.Main> = { $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 block: $Typed<PubLeafletBlocksBskyPost.Main> = { $type: ids.PubLeafletBlocksBskyPost, postRef: { uri: post.data.value.post.uri, cid: post.data.value.post.cid, }, }; return block; } if (b.type === "horizontal-rule") { let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { $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<PubLeafletBlocksHeader.Main> = { $type: "pub.leaflet.blocks.header", level: Math.floor(headingLevel?.data.value || 1), plaintext: stringValue, facets, }; return block; }
if (b.type === "blockquote") { let [stringValue, facets] = getBlockContent(b.value); let block: $Typed<PubLeafletBlocksBlockquote.Main> = { $type: ids.PubLeafletBlocksBlockquote, plaintext: stringValue, 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<PubLeafletBlocksText.Main> = { $type: ids.PubLeafletBlocksText, plaintext: stringValue, 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"); if (!url) return; let block: $Typed<PubLeafletBlocksIframe.Main> = { $type: "pub.leaflet.blocks.iframe", url: url.data.value, height: Math.floor(height?.data.value || 600), }; 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 blobref = await uploadImage(image.data.src); if (!blobref) return; let block: $Typed<PubLeafletBlocksImage.Main> = { $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, }; 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<PubLeafletBlocksWebsite.Main> = { $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<PubLeafletBlocksCode.Main> = { $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<PubLeafletBlocksMath.Main> = { $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<PubLeafletBlocksPoll.Main> = { $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<PubLeafletBlocksButton.Main> = { $type: "pub.leaflet.blocks.button", text: text.data.value, url: url.data.value, }; return block; } return; }
async function canvasBlocksToRecord( pageID: string, did: string, ): Promise<PubLeafletPagesCanvas.Block[]> { 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,): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { // 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"), }, ], }; 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); 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> = T extends string ? string extends T ? never : T /* maybe literal, not the whole `string` */ : T; /* not a string */
async function extractThemeFromFacts( facts: Fact<any>[], root_entity: string, agent: AtpBaseClient,): Promise<PubLeafletPublication.Theme | undefined> { let scan = scanIndexLocal(facts); let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data .value; let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data .value; let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] ?.data.value; let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; let showPageBackground = !scan.eav( root_entity, "theme/card-border-hidden", )?.[0]?.data.value; let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; let backgroundImageRepeat = scan.eav( root_entity, "theme/background-image-repeat", )?.[0]; let pageWidth = scan.eav(root_entity, "theme/page-width")?.[0];
let theme: PubLeafletPublication.Theme = { showPageBackground: showPageBackground ?? true, };
if (pageWidth) theme.pageWidth = pageWidth.data.value; if (pageBackground) theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); if (cardBackground) theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); if (accentBackground) theme.accentBackground = ColorToRGB( parseColor(`hsba(${accentBackground})`), ); if (accentText) theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`));
// Upload background image if present if (backgroundImage?.data) { let imageData = await fetch(backgroundImage.data.src); if (imageData.status === 200) { let binary = await imageData.blob(); let blob = await agent.com.atproto.repo.uploadBlob(binary, { headers: { "Content-Type": binary.type }, });
theme.backgroundImage = { $type: "pub.leaflet.theme.backgroundImage", image: blob.data.blob, repeat: backgroundImageRepeat?.data.value ? true : false, ...(backgroundImageRepeat?.data.value && { width: Math.floor(backgroundImageRepeat.data.value), }), }; } }
// Only return theme if at least one property is set if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { return theme; }
return undefined;}
/** * Extract mentions from a published document and create notifications */async function createMentionNotifications( documentUri: string, record: PubLeafletDocument.Record | SiteStandardDocument.Record, authorDid: string,) { const mentionedDids = new Set<string>(); const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
// Extract pages from either format let pages: PubLeafletContent.Main["pages"] | undefined; if (record.$type === "site.standard.document") { const content = record.content; if (content && PubLeafletContent.isMain(content)) { pages = content.pages; } } else { pages = record.pages; }
if (!pages) return;
// Helper to extract blocks from all pages (both linear and canvas) function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { const blocks: ( | PubLeafletPagesLinearDocument.Block["block"] | PubLeafletPagesCanvas.Block["block"] )[] = []; for (const page of pages) { if (page.$type === "pub.leaflet.pages.linearDocument") { const linearPage = page as PubLeafletPagesLinearDocument.Main; for (const blockWrapper of linearPage.blocks) { blocks.push(blockWrapper.block); } } else if (page.$type === "pub.leaflet.pages.canvas") { const canvasPage = page as PubLeafletPagesCanvas.Main; for (const blockWrapper of canvasPage.blocks) { blocks.push(blockWrapper.block); } } } return blocks; }
const allBlocks = getAllBlocks(pages);
// Extract mentions from all text blocks and embedded Bluesky posts for (const block of allBlocks) { // Check for embedded Bluesky posts if (PubLeafletBlocksBskyPost.isMain(block)) { const bskyPostUri = block.postRef.uri; // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) const postAuthorDid = new AtUri(bskyPostUri).host; if (postAuthorDid !== authorDid) { embeddedBskyPosts.set(postAuthorDid, bskyPostUri); } }
// Check for text blocks with mentions if (block.$type === "pub.leaflet.blocks.text") { const textBlock = block as PubLeafletBlocksText.Main; if (textBlock.facets) { for (const facet of textBlock.facets) { for (const feature of facet.features) { // Check for DID mentions if (PubLeafletRichtextFacet.isDidMention(feature)) { if (feature.did !== authorDid) { mentionedDids.add(feature.did); } } // Check for AT URI mentions (publications and documents) if (PubLeafletRichtextFacet.isAtMention(feature)) { const uri = new AtUri(feature.atURI);
if (isPublicationCollection(uri.collection)) { // Get the publication owner's DID const { data: publication } = await supabaseServerClient .from("publications") .select("identity_did") .eq("uri", feature.atURI) .single();
if (publication && publication.identity_did !== authorDid) { mentionedPublications.set( publication.identity_did, feature.atURI, ); } } else if (isDocumentCollection(uri.collection)) { // Get the document owner's DID const { data: document } = await supabaseServerClient .from("documents") .select("uri, data") .eq("uri", feature.atURI) .single();
if (document) { const normalizedMentionedDoc = normalizeDocumentRecord( document.data, ); // Get the author from the document URI (the DID is the host part) const mentionedUri = new AtUri(feature.atURI); const docAuthor = mentionedUri.host; if (normalizedMentionedDoc && docAuthor !== authorDid) { mentionedDocuments.set(docAuthor, feature.atURI); } } } } } } } } }
// Create notifications for DID mentions for (const did of mentionedDids) { const notification: Notification = { id: v7(), recipient: did, data: { type: "mention", document_uri: documentUri, mention_type: "did", }, }; await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(did); }
// Create notifications for publication mentions for (const [recipientDid, publicationUri] of mentionedPublications) { const notification: Notification = { id: v7(), recipient: recipientDid, data: { type: "mention", document_uri: documentUri, mention_type: "publication", mentioned_uri: publicationUri, }, }; await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(recipientDid); }
// Create notifications for document mentions for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { const notification: Notification = { id: v7(), recipient: recipientDid, data: { type: "mention", document_uri: documentUri, mention_type: "document", mentioned_uri: mentionedDocUri, }, }; await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(recipientDid); }
// Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) if (embeddedBskyPosts.size > 0) { // Check which of the Bluesky post authors have Leaflet accounts const { data: identities } = await supabaseServerClient .from("identities") .select("atp_did") .in("atp_did", Array.from(embeddedBskyPosts.keys()));
const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { // Only notify if the post author has a Leaflet account if (leafletUserDids.has(postAuthorDid)) { const notification: Notification = { id: v7(), recipient: postAuthorDid, data: { type: "bsky_post_embed", document_uri: documentUri, bsky_post_uri: bskyPostUri, }, }; await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(postAuthorDid); } } }}