a tool for shared writing and social publishing
at main 1261 lines 43 kB view raw
1"use server"; 2 3import * as Y from "yjs"; 4import * as base64 from "base64-js"; 5import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6import { getIdentityData } from "actions/getIdentityData"; 7import { 8 AtpBaseClient, 9 PubLeafletBlocksHeader, 10 PubLeafletBlocksImage, 11 PubLeafletBlocksText, 12 PubLeafletBlocksUnorderedList, 13 PubLeafletBlocksOrderedList, 14 PubLeafletDocument, 15 SiteStandardDocument, 16 PubLeafletContent, 17 PubLeafletPagesLinearDocument, 18 PubLeafletPagesCanvas, 19 PubLeafletRichtextFacet, 20 PubLeafletBlocksWebsite, 21 PubLeafletBlocksCode, 22 PubLeafletBlocksMath, 23 PubLeafletBlocksHorizontalRule, 24 PubLeafletBlocksBskyPost, 25 PubLeafletBlocksBlockquote, 26 PubLeafletBlocksIframe, 27 PubLeafletBlocksPage, 28 PubLeafletBlocksPoll, 29 PubLeafletBlocksButton, 30 PubLeafletPollDefinition, 31} from "lexicons/api"; 32import { Block } from "components/Blocks/Block"; 33import { TID } from "@atproto/common"; 34import { supabaseServerClient } from "supabase/serverClient"; 35import { scanIndexLocal } from "src/replicache/utils"; 36import type { Fact } from "src/replicache"; 37import type { Attribute } from "src/replicache/attributes"; 38import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39import { ids } from "lexicons/api/lexicons"; 40import { BlobRef } from "@atproto/lexicon"; 41import { AtUri } from "@atproto/syntax"; 42import { Json } from "supabase/database.types"; 43import { $Typed, UnicodeString } from "@atproto/api"; 44import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 46import { Lock } from "src/utils/lock"; 47import type { PubLeafletPublication } from "lexicons/api"; 48import { 49 normalizeDocumentRecord, 50 type NormalizedDocument, 51} from "src/utils/normalizeRecords"; 52import { 53 ColorToRGB, 54 ColorToRGBA, 55} from "components/ThemeManager/colorToLexicons"; 56import { parseColor } from "@react-stately/color"; 57import { 58 Notification, 59 pingIdentityToUpdateNotification, 60} from "src/notifications"; 61import { v7 } from "uuid"; 62import { 63 isDocumentCollection, 64 isPublicationCollection, 65 getDocumentType, 66} from "src/utils/collectionHelpers"; 67 68type PublishResult = 69 | { success: true; rkey: string; record: SiteStandardDocument.Record } 70 | { success: false; error: OAuthSessionError }; 71 72export async function publishToPublication({ 73 root_entity, 74 publication_uri, 75 leaflet_id, 76 title, 77 description, 78 tags, 79 cover_image, 80 entitiesToDelete, 81 publishedAt, 82 postPreferences, 83}: { 84 root_entity: string; 85 publication_uri?: string; 86 leaflet_id: string; 87 title?: string; 88 description?: string; 89 tags?: string[]; 90 cover_image?: string | null; 91 entitiesToDelete?: string[]; 92 publishedAt?: string; 93 postPreferences?: { 94 showComments?: boolean; 95 showMentions?: boolean; 96 showRecommends?: boolean; 97 } | null; 98}): Promise<PublishResult> { 99 let identity = await getIdentityData(); 100 if (!identity || !identity.atp_did) { 101 return { 102 success: false, 103 error: { 104 type: "oauth_session_expired", 105 message: "Not authenticated", 106 did: "", 107 }, 108 }; 109 } 110 111 const sessionResult = await restoreOAuthSession(identity.atp_did); 112 if (!sessionResult.ok) { 113 return { success: false, error: sessionResult.error }; 114 } 115 let credentialSession = sessionResult.value; 116 let agent = new AtpBaseClient( 117 credentialSession.fetchHandler.bind(credentialSession), 118 ); 119 120 // Check if we're publishing to a publication or standalone 121 let draft: any = null; 122 let existingDocUri: string | null = null; 123 124 if (publication_uri) { 125 // Publishing to a publication - use leaflets_in_publications 126 let { data, error } = await supabaseServerClient 127 .from("publications") 128 .select("*, leaflets_in_publications(*, documents(*))") 129 .eq("uri", publication_uri) 130 .eq("leaflets_in_publications.leaflet", leaflet_id) 131 .single(); 132 console.log(error); 133 134 if (!data || identity.atp_did !== data?.identity_did) 135 throw new Error("No draft or not publisher"); 136 draft = data.leaflets_in_publications[0]; 137 existingDocUri = draft?.doc; 138 } else { 139 // Publishing standalone - use leaflets_to_documents 140 let { data } = await supabaseServerClient 141 .from("leaflets_to_documents") 142 .select("*, documents(*)") 143 .eq("leaflet", leaflet_id) 144 .single(); 145 draft = data; 146 existingDocUri = draft?.document; 147 148 // If updating an existing document, verify the current user is the owner 149 if (existingDocUri) { 150 let docOwner = new AtUri(existingDocUri).host; 151 if (docOwner !== identity.atp_did) { 152 return { 153 success: false, 154 error: { 155 type: "oauth_session_expired" as const, 156 message: "Not the document owner", 157 did: identity.atp_did, 158 }, 159 }; 160 } 161 } 162 } 163 164 // Heuristic: Remove title entities if this is the first time publishing 165 // (when coming from a standalone leaflet with entitiesToDelete passed in) 166 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 167 await supabaseServerClient 168 .from("entities") 169 .delete() 170 .in("id", entitiesToDelete); 171 } 172 173 let { data } = await supabaseServerClient.rpc("get_facts", { 174 root: root_entity, 175 }); 176 let facts = (data as unknown as Fact<Attribute>[]) || []; 177 178 let { pages } = await processBlocksToPages( 179 facts, 180 agent, 181 root_entity, 182 credentialSession.did!, 183 ); 184 185 let existingRecord: Partial<PubLeafletDocument.Record> = {}; 186 const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); 187 if (normalizedDoc) { 188 // When reading existing data, use normalized format to extract fields 189 // The theme is preserved in NormalizedDocument for backward compatibility 190 existingRecord = { 191 publishedAt: normalizedDoc.publishedAt, 192 title: normalizedDoc.title, 193 description: normalizedDoc.description, 194 tags: normalizedDoc.tags, 195 coverImage: normalizedDoc.coverImage, 196 theme: normalizedDoc.theme, 197 }; 198 } 199 200 // Resolve preferences: explicit param > draft DB value 201 const preferences = postPreferences ?? draft?.preferences; 202 203 // Extract theme for standalone documents (not for publications) 204 let theme: PubLeafletPublication.Theme | undefined; 205 if (!publication_uri) { 206 theme = await extractThemeFromFacts(facts, root_entity, agent); 207 } 208 209 // Upload cover image if provided 210 let coverImageBlob: BlobRef | undefined; 211 if (cover_image) { 212 let scan = scanIndexLocal(facts); 213 let [imageData] = scan.eav(cover_image, "block/image"); 214 if (imageData) { 215 let imageResponse = await fetch(imageData.data.src); 216 if (imageResponse.status === 200) { 217 let binary = await imageResponse.blob(); 218 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 219 headers: { "Content-Type": binary.type }, 220 }); 221 coverImageBlob = blob.data.blob; 222 } 223 } 224 } 225 226 // Determine the collection to use - preserve existing schema if updating 227 const existingCollection = existingDocUri 228 ? new AtUri(existingDocUri).collection 229 : undefined; 230 const documentType = getDocumentType(existingCollection); 231 232 // Build the pages array (used by both formats) 233 const pagesArray = pages.map((p) => { 234 if (p.type === "canvas") { 235 return { 236 $type: "pub.leaflet.pages.canvas" as const, 237 id: p.id, 238 blocks: p.blocks as PubLeafletPagesCanvas.Block[], 239 }; 240 } else { 241 return { 242 $type: "pub.leaflet.pages.linearDocument" as const, 243 id: p.id, 244 blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 245 }; 246 } 247 }); 248 249 // Determine the rkey early since we need it for the path field 250 const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 251 252 // Create record based on the document type 253 let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 254 255 if (documentType === "site.standard.document") { 256 // site.standard.document format 257 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 258 const siteUri = 259 publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 260 261 record = { 262 $type: "site.standard.document", 263 title: title || "", 264 site: siteUri, 265 path: "/" + rkey, 266 publishedAt: 267 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 268 ...(description && { description }), 269 ...(tags !== undefined && { tags }), 270 ...(coverImageBlob && { coverImage: coverImageBlob }), 271 // Include theme for standalone documents (not for publication documents) 272 ...(!publication_uri && theme && { theme }), 273 ...(preferences && { 274 preferences: { 275 $type: "pub.leaflet.publication#preferences" as const, 276 ...preferences, 277 }, 278 }), 279 content: { 280 $type: "pub.leaflet.content" as const, 281 pages: pagesArray, 282 }, 283 } satisfies SiteStandardDocument.Record; 284 } else { 285 // pub.leaflet.document format (legacy) 286 record = { 287 $type: "pub.leaflet.document", 288 author: credentialSession.did!, 289 ...(publication_uri && { publication: publication_uri }), 290 ...(theme && { theme }), 291 ...(preferences && { 292 preferences: { 293 $type: "pub.leaflet.publication#preferences" as const, 294 ...preferences, 295 }, 296 }), 297 title: title || "", 298 description: description || "", 299 ...(tags !== undefined && { tags }), 300 ...(coverImageBlob && { coverImage: coverImageBlob }), 301 pages: pagesArray, 302 publishedAt: 303 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 304 } satisfies PubLeafletDocument.Record; 305 } 306 307 let { data: result } = await agent.com.atproto.repo.putRecord({ 308 rkey, 309 repo: credentialSession.did!, 310 collection: record.$type, 311 record, 312 validate: false, //TODO publish the lexicon so we can validate! 313 }); 314 315 // Optimistically create database entries 316 await supabaseServerClient.from("documents").upsert({ 317 uri: result.uri, 318 data: record as unknown as Json, 319 indexed: true, 320 }); 321 322 if (publication_uri) { 323 // Publishing to a publication - update both tables 324 await Promise.all([ 325 supabaseServerClient.from("documents_in_publications").upsert({ 326 publication: publication_uri, 327 document: result.uri, 328 }), 329 supabaseServerClient.from("leaflets_in_publications").upsert({ 330 doc: result.uri, 331 leaflet: leaflet_id, 332 publication: publication_uri, 333 title: title, 334 description: description, 335 }), 336 ]); 337 } else { 338 // Publishing standalone - update leaflets_to_documents 339 await supabaseServerClient.from("leaflets_to_documents").upsert({ 340 leaflet: leaflet_id, 341 document: result.uri, 342 title: title || "", 343 description: description || "", 344 }); 345 346 // Heuristic: Remove title entities if this is the first time publishing standalone 347 // (when entitiesToDelete is provided and there's no existing document) 348 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 349 await supabaseServerClient 350 .from("entities") 351 .delete() 352 .in("id", entitiesToDelete); 353 } 354 } 355 356 // Create notifications for mentions (only on first publish) 357 if (!existingDocUri) { 358 await createMentionNotifications( 359 result.uri, 360 record, 361 credentialSession.did!, 362 ); 363 } 364 365 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 366} 367 368async function processBlocksToPages( 369 facts: Fact<any>[], 370 agent: AtpBaseClient, 371 root_entity: string, 372 did: string, 373) { 374 let scan = scanIndexLocal(facts); 375 let pages: { 376 id: string; 377 blocks: 378 | PubLeafletPagesLinearDocument.Block[] 379 | PubLeafletPagesCanvas.Block[]; 380 type: "doc" | "canvas"; 381 }[] = []; 382 383 // Create a lock to serialize image uploads 384 const uploadLock = new Lock(); 385 386 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 387 if (!firstEntity) throw new Error("No root page"); 388 389 // Check if the first page is a canvas or linear document 390 let [pageType] = scan.eav(firstEntity.data.value, "page/type"); 391 392 if (pageType?.data.value === "canvas") { 393 // First page is a canvas 394 let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); 395 pages.unshift({ 396 id: firstEntity.data.value, 397 blocks: canvasBlocks, 398 type: "canvas", 399 }); 400 } else { 401 // First page is a linear document 402 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 403 let b = await blocksToRecord(blocks, did); 404 pages.unshift({ 405 id: firstEntity.data.value, 406 blocks: b, 407 type: "doc", 408 }); 409 } 410 411 return { pages }; 412 413 async function uploadImage(src: string) { 414 let data = await fetch(src); 415 if (data.status !== 200) return; 416 let binary = await data.blob(); 417 return uploadLock.withLock(async () => { 418 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 419 headers: { "Content-Type": binary.type }, 420 }); 421 return blob.data.blob; 422 }); 423 } 424 async function blocksToRecord( 425 blocks: Block[], 426 did: string, 427 ): Promise<PubLeafletPagesLinearDocument.Block[]> { 428 let parsedBlocks = parseBlocksToList(blocks); 429 return ( 430 await Promise.all( 431 parsedBlocks.map(async (blockOrList) => { 432 if (blockOrList.type === "block") { 433 let alignmentValue = scan.eav( 434 blockOrList.block.value, 435 "block/text-alignment", 436 )[0]?.data.value; 437 let alignment: ExcludeString< 438 PubLeafletPagesLinearDocument.Block["alignment"] 439 > = 440 alignmentValue === "center" 441 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 442 : alignmentValue === "right" 443 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 444 : alignmentValue === "justify" 445 ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 446 : alignmentValue === "left" 447 ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 448 : undefined; 449 let b = await blockToRecord(blockOrList.block, did); 450 if (!b) return []; 451 let block: PubLeafletPagesLinearDocument.Block = { 452 $type: "pub.leaflet.pages.linearDocument#block", 453 block: b, 454 }; 455 if (alignment) block.alignment = alignment; 456 return [block]; 457 } else { 458 let runs = splitListByStyle(blockOrList.children); 459 let blocks = await Promise.all( 460 runs.map(async (run) => { 461 if (run.style === "ordered") { 462 let block: PubLeafletPagesLinearDocument.Block = { 463 $type: "pub.leaflet.pages.linearDocument#block", 464 block: { 465 $type: "pub.leaflet.blocks.orderedList", 466 startIndex: 467 run.children[0].block.listData?.listStart || 1, 468 children: await orderedChildrenToRecord( 469 run.children, 470 did, 471 ), 472 }, 473 }; 474 return block; 475 } else { 476 let block: PubLeafletPagesLinearDocument.Block = { 477 $type: "pub.leaflet.pages.linearDocument#block", 478 block: { 479 $type: "pub.leaflet.blocks.unorderedList", 480 children: await unorderedChildrenToRecord( 481 run.children, 482 did, 483 ), 484 }, 485 }; 486 return block; 487 } 488 }), 489 ); 490 return blocks; 491 } 492 }), 493 ) 494 ).flat(); 495 } 496 497 function splitListByStyle(children: List[]) { 498 let runs: { style: "ordered" | "unordered"; children: List[] }[] = []; 499 for (let child of children) { 500 let style: "ordered" | "unordered" = 501 child.block.listData?.listStyle === "ordered" 502 ? "ordered" 503 : "unordered"; 504 let last = runs[runs.length - 1]; 505 if (last && last.style === style) { 506 last.children.push(child); 507 } else { 508 runs.push({ style, children: [child] }); 509 } 510 } 511 return runs; 512 } 513 514 async function unorderedChildrenToRecord( 515 children: List[], 516 did: string, 517 ): Promise<PubLeafletBlocksUnorderedList.ListItem[]> { 518 return ( 519 await Promise.all( 520 children.map(async (child) => { 521 let content = await blockToRecord(child.block, did); 522 if (!content) return []; 523 let record: PubLeafletBlocksUnorderedList.ListItem = { 524 $type: "pub.leaflet.blocks.unorderedList#listItem", 525 content, 526 }; 527 let sameStyle = child.children.filter( 528 (c) => c.block.listData?.listStyle !== "ordered", 529 ); 530 let diffStyle = child.children.filter( 531 (c) => c.block.listData?.listStyle === "ordered", 532 ); 533 if (sameStyle.length > 0) { 534 record.children = await unorderedChildrenToRecord(sameStyle, did); 535 } 536 if (diffStyle.length > 0) { 537 record.orderedListChildren = { 538 $type: "pub.leaflet.blocks.orderedList", 539 children: await orderedChildrenToRecord(diffStyle, did), 540 }; 541 } 542 return record; 543 }), 544 ) 545 ).flat(); 546 } 547 548 async function orderedChildrenToRecord( 549 children: List[], 550 did: string, 551 ): Promise<PubLeafletBlocksOrderedList.ListItem[]> { 552 return ( 553 await Promise.all( 554 children.map(async (child) => { 555 let content = await blockToRecord(child.block, did); 556 if (!content) return []; 557 let record: PubLeafletBlocksOrderedList.ListItem = { 558 $type: "pub.leaflet.blocks.orderedList#listItem", 559 content, 560 }; 561 let sameStyle = child.children.filter( 562 (c) => c.block.listData?.listStyle === "ordered", 563 ); 564 let diffStyle = child.children.filter( 565 (c) => c.block.listData?.listStyle !== "ordered", 566 ); 567 if (sameStyle.length > 0) { 568 record.children = await orderedChildrenToRecord(sameStyle, did); 569 } 570 if (diffStyle.length > 0) { 571 record.unorderedListChildren = { 572 $type: "pub.leaflet.blocks.unorderedList", 573 children: await unorderedChildrenToRecord(diffStyle, did), 574 }; 575 } 576 return record; 577 }), 578 ) 579 ).flat(); 580 } 581 async function blockToRecord(b: Block, did: string) { 582 const footnoteContentResolver = (footnoteEntityID: string) => { 583 let [content] = scan.eav(footnoteEntityID, "block/text"); 584 if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] }; 585 let doc = new Y.Doc(); 586 const update = base64.toByteArray(content.data.value); 587 Y.applyUpdate(doc, update); 588 let nodes = doc.getXmlElement("prosemirror").toArray(); 589 let plaintext = YJSFragmentToString(nodes[0]); 590 let { facets } = YJSFragmentToFacets(nodes[0]); 591 return { plaintext, facets }; 592 }; 593 const getBlockContent = (b: string) => { 594 let [content] = scan.eav(b, "block/text"); 595 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 596 let doc = new Y.Doc(); 597 const update = base64.toByteArray(content.data.value); 598 Y.applyUpdate(doc, update); 599 let nodes = doc.getXmlElement("prosemirror").toArray(); 600 let stringValue = YJSFragmentToString(nodes[0]); 601 let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver); 602 return [stringValue, facets] as const; 603 }; 604 if (b.type === "card") { 605 let [page] = scan.eav(b.value, "block/card"); 606 if (!page) return; 607 let [pageType] = scan.eav(page.data.value, "page/type"); 608 609 if (pageType?.data.value === "canvas") { 610 let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 611 pages.push({ 612 id: page.data.value, 613 blocks: canvasBlocks, 614 type: "canvas", 615 }); 616 } else { 617 let blocks = getBlocksWithTypeLocal(facts, page.data.value); 618 pages.push({ 619 id: page.data.value, 620 blocks: await blocksToRecord(blocks, did), 621 type: "doc", 622 }); 623 } 624 625 let block: $Typed<PubLeafletBlocksPage.Main> = { 626 $type: "pub.leaflet.blocks.page", 627 id: page.data.value, 628 }; 629 return block; 630 } 631 632 if (b.type === "bluesky-post") { 633 let [post] = scan.eav(b.value, "block/bluesky-post"); 634 if (!post || !post.data.value.post) return; 635 let [hostFact] = scan.eav(b.value, "bluesky-post/host"); 636 let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 637 $type: ids.PubLeafletBlocksBskyPost, 638 postRef: { 639 uri: post.data.value.post.uri, 640 cid: post.data.value.post.cid, 641 }, 642 clientHost: hostFact?.data.value, 643 }; 644 return block; 645 } 646 if (b.type === "horizontal-rule") { 647 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 648 $type: ids.PubLeafletBlocksHorizontalRule, 649 }; 650 return block; 651 } 652 653 if (b.type === "heading") { 654 let [headingLevel] = scan.eav(b.value, "block/heading-level"); 655 656 let [stringValue, facets] = getBlockContent(b.value); 657 let block: $Typed<PubLeafletBlocksHeader.Main> = { 658 $type: "pub.leaflet.blocks.header", 659 level: Math.floor(headingLevel?.data.value || 1), 660 plaintext: stringValue, 661 ...(facets.length > 0 && { facets }), 662 }; 663 return block; 664 } 665 666 if (b.type === "blockquote") { 667 let [stringValue, facets] = getBlockContent(b.value); 668 let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 669 $type: ids.PubLeafletBlocksBlockquote, 670 plaintext: stringValue, 671 ...(facets.length > 0 && { facets }), 672 }; 673 return block; 674 } 675 676 if (b.type == "text") { 677 let [stringValue, facets] = getBlockContent(b.value); 678 let [textSize] = scan.eav(b.value, "block/text-size"); 679 let block: $Typed<PubLeafletBlocksText.Main> = { 680 $type: ids.PubLeafletBlocksText, 681 plaintext: stringValue, 682 ...(facets.length > 0 && { facets }), 683 ...(textSize && { textSize: textSize.data.value }), 684 }; 685 return block; 686 } 687 if (b.type === "embed") { 688 let [url] = scan.eav(b.value, "embed/url"); 689 let [height] = scan.eav(b.value, "embed/height"); 690 if (!url) return; 691 let block: $Typed<PubLeafletBlocksIframe.Main> = { 692 $type: "pub.leaflet.blocks.iframe", 693 url: url.data.value, 694 height: Math.floor(height?.data.value || 600), 695 }; 696 return block; 697 } 698 if (b.type == "image") { 699 let [image] = scan.eav(b.value, "block/image"); 700 if (!image) return; 701 let [altText] = scan.eav(b.value, "image/alt"); 702 let blobref = await uploadImage(image.data.src); 703 if (!blobref) return; 704 let block: $Typed<PubLeafletBlocksImage.Main> = { 705 $type: "pub.leaflet.blocks.image", 706 image: blobref, 707 aspectRatio: { 708 height: Math.floor(image.data.height), 709 width: Math.floor(image.data.width), 710 }, 711 alt: altText ? altText.data.value : undefined, 712 }; 713 return block; 714 } 715 if (b.type === "link") { 716 let [previewImage] = scan.eav(b.value, "link/preview"); 717 let [description] = scan.eav(b.value, "link/description"); 718 let [src] = scan.eav(b.value, "link/url"); 719 if (!src) return; 720 let blobref = previewImage 721 ? await uploadImage(previewImage?.data.src) 722 : undefined; 723 let [title] = scan.eav(b.value, "link/title"); 724 let block: $Typed<PubLeafletBlocksWebsite.Main> = { 725 $type: "pub.leaflet.blocks.website", 726 previewImage: blobref, 727 src: src.data.value, 728 description: description?.data.value, 729 title: title?.data.value, 730 }; 731 return block; 732 } 733 if (b.type === "code") { 734 let [language] = scan.eav(b.value, "block/code-language"); 735 let [code] = scan.eav(b.value, "block/code"); 736 let [theme] = scan.eav(root_entity, "theme/code-theme"); 737 let block: $Typed<PubLeafletBlocksCode.Main> = { 738 $type: "pub.leaflet.blocks.code", 739 language: language?.data.value, 740 plaintext: code?.data.value || "", 741 syntaxHighlightingTheme: theme?.data.value, 742 }; 743 return block; 744 } 745 if (b.type === "math") { 746 let [math] = scan.eav(b.value, "block/math"); 747 let block: $Typed<PubLeafletBlocksMath.Main> = { 748 $type: "pub.leaflet.blocks.math", 749 tex: math?.data.value || "", 750 }; 751 return block; 752 } 753 if (b.type === "poll") { 754 // Get poll options from the entity 755 let pollOptions = scan.eav(b.value, "poll/options"); 756 let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 757 (opt) => { 758 let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 759 return { 760 $type: "pub.leaflet.poll.definition#option", 761 text: optionName?.data.value || "", 762 }; 763 }, 764 ); 765 766 // Create the poll definition record 767 let pollRecord: PubLeafletPollDefinition.Record = { 768 $type: "pub.leaflet.poll.definition", 769 name: "Poll", // Default name, can be customized 770 options, 771 }; 772 773 // Upload the poll record 774 let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 775 //use the entity id as the rkey so we can associate it in the editor 776 rkey: b.value, 777 repo: did, 778 collection: pollRecord.$type, 779 record: pollRecord, 780 validate: false, 781 }); 782 783 // Optimistically write poll definition to database 784 console.log( 785 await supabaseServerClient.from("atp_poll_records").upsert({ 786 uri: pollResult.uri, 787 cid: pollResult.cid, 788 record: pollRecord as Json, 789 }), 790 ); 791 792 // Return a poll block with reference to the poll record 793 let block: $Typed<PubLeafletBlocksPoll.Main> = { 794 $type: "pub.leaflet.blocks.poll", 795 pollRef: { 796 uri: pollResult.uri, 797 cid: pollResult.cid, 798 }, 799 }; 800 return block; 801 } 802 if (b.type === "button") { 803 let [text] = scan.eav(b.value, "button/text"); 804 let [url] = scan.eav(b.value, "button/url"); 805 if (!text || !url) return; 806 let block: $Typed<PubLeafletBlocksButton.Main> = { 807 $type: "pub.leaflet.blocks.button", 808 text: text.data.value, 809 url: url.data.value, 810 }; 811 return block; 812 } 813 return; 814 } 815 816 async function canvasBlocksToRecord( 817 pageID: string, 818 did: string, 819 ): Promise<PubLeafletPagesCanvas.Block[]> { 820 let canvasBlocks = scan.eav(pageID, "canvas/block"); 821 return ( 822 await Promise.all( 823 canvasBlocks.map(async (canvasBlock) => { 824 let blockEntity = canvasBlock.data.value; 825 let position = canvasBlock.data.position; 826 827 // Get the block content 828 let blockType = scan.eav(blockEntity, "block/type")?.[0]; 829 if (!blockType) return null; 830 831 let block: Block = { 832 type: blockType.data.value, 833 value: blockEntity, 834 parent: pageID, 835 position: "", 836 factID: canvasBlock.id, 837 }; 838 839 let content = await blockToRecord(block, did); 840 if (!content) return null; 841 842 // Get canvas-specific properties 843 let width = 844 scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 845 let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 846 ?.data.value; 847 848 let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 849 $type: "pub.leaflet.pages.canvas#block", 850 block: content, 851 x: Math.floor(position.x), 852 y: Math.floor(position.y), 853 width: Math.floor(width), 854 ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 855 }; 856 857 return canvasBlockRecord; 858 }), 859 ) 860 ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 861 } 862} 863 864function YJSFragmentToFacets( 865 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 866 byteOffset: number = 0, 867 footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] }, 868): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 869 if (node.constructor === Y.XmlElement) { 870 // Handle footnote inline nodes 871 if (node.nodeName === "footnote") { 872 const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; 873 const placeholder = "*"; 874 const unicodestring = new UnicodeString(placeholder); 875 let footnoteContent = footnoteContentResolver?.(footnoteEntityID); 876 const facet: PubLeafletRichtextFacet.Main = { 877 index: { 878 byteStart: byteOffset, 879 byteEnd: byteOffset + unicodestring.length, 880 }, 881 features: [ 882 { 883 $type: "pub.leaflet.richtext.facet#footnote", 884 footnoteId: footnoteEntityID, 885 contentPlaintext: footnoteContent?.plaintext || "", 886 ...(footnoteContent?.facets?.length 887 ? { contentFacets: footnoteContent.facets } 888 : {}), 889 }, 890 ], 891 }; 892 return { facets: [facet], byteLength: unicodestring.length }; 893 } 894 895 // Handle inline mention nodes 896 if (node.nodeName === "didMention") { 897 const text = node.getAttribute("text") || ""; 898 const unicodestring = new UnicodeString(text); 899 const facet: PubLeafletRichtextFacet.Main = { 900 index: { 901 byteStart: byteOffset, 902 byteEnd: byteOffset + unicodestring.length, 903 }, 904 features: [ 905 { 906 $type: "pub.leaflet.richtext.facet#didMention", 907 did: node.getAttribute("did"), 908 }, 909 ], 910 }; 911 return { facets: [facet], byteLength: unicodestring.length }; 912 } 913 914 if (node.nodeName === "atMention") { 915 const text = node.getAttribute("text") || ""; 916 const unicodestring = new UnicodeString(text); 917 const facet: PubLeafletRichtextFacet.Main = { 918 index: { 919 byteStart: byteOffset, 920 byteEnd: byteOffset + unicodestring.length, 921 }, 922 features: [ 923 { 924 $type: "pub.leaflet.richtext.facet#atMention", 925 atURI: node.getAttribute("atURI"), 926 }, 927 ], 928 }; 929 return { facets: [facet], byteLength: unicodestring.length }; 930 } 931 932 if (node.nodeName === "hard_break") { 933 const unicodestring = new UnicodeString("\n"); 934 return { facets: [], byteLength: unicodestring.length }; 935 } 936 937 // For other elements (like paragraph), process children 938 let allFacets: PubLeafletRichtextFacet.Main[] = []; 939 let currentOffset = byteOffset; 940 for (const child of node.toArray()) { 941 const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver); 942 allFacets.push(...result.facets); 943 currentOffset += result.byteLength; 944 } 945 return { facets: allFacets, byteLength: currentOffset - byteOffset }; 946 } 947 948 if (node.constructor === Y.XmlText) { 949 let facets: PubLeafletRichtextFacet.Main[] = []; 950 let delta = node.toDelta() as Delta[]; 951 let byteStart = byteOffset; 952 let totalLength = 0; 953 for (let d of delta) { 954 let unicodestring = new UnicodeString(d.insert); 955 let facet: PubLeafletRichtextFacet.Main = { 956 index: { 957 byteStart, 958 byteEnd: byteStart + unicodestring.length, 959 }, 960 features: [], 961 }; 962 963 if (d.attributes?.strikethrough) 964 facet.features.push({ 965 $type: "pub.leaflet.richtext.facet#strikethrough", 966 }); 967 968 if (d.attributes?.code) 969 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 970 if (d.attributes?.highlight) 971 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 972 if (d.attributes?.underline) 973 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 974 if (d.attributes?.strong) 975 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); 976 if (d.attributes?.em) 977 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); 978 if (d.attributes?.link) 979 facet.features.push({ 980 $type: "pub.leaflet.richtext.facet#link", 981 uri: d.attributes.link.href, 982 }); 983 if (facet.features.length > 0) facets.push(facet); 984 byteStart += unicodestring.length; 985 totalLength += unicodestring.length; 986 } 987 return { facets, byteLength: totalLength }; 988 } 989 return { facets: [], byteLength: 0 }; 990} 991 992type ExcludeString<T> = T extends string 993 ? string extends T 994 ? never 995 : T /* maybe literal, not the whole `string` */ 996 : T; /* not a string */ 997 998async function extractThemeFromFacts( 999 facts: Fact<any>[], 1000 root_entity: string, 1001 agent: AtpBaseClient, 1002): Promise<PubLeafletPublication.Theme | undefined> { 1003 let scan = scanIndexLocal(facts); 1004 let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data 1005 .value; 1006 let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data 1007 .value; 1008 let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; 1009 let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] 1010 ?.data.value; 1011 let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; 1012 let showPageBackground = !scan.eav( 1013 root_entity, 1014 "theme/card-border-hidden", 1015 )?.[0]?.data.value; 1016 let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; 1017 let backgroundImageRepeat = scan.eav( 1018 root_entity, 1019 "theme/background-image-repeat", 1020 )?.[0]; 1021 let pageWidth = scan.eav(root_entity, "theme/page-width")?.[0]; 1022 1023 let theme: PubLeafletPublication.Theme = { 1024 showPageBackground: showPageBackground ?? true, 1025 }; 1026 1027 if (pageWidth) theme.pageWidth = pageWidth.data.value; 1028 if (pageBackground) 1029 theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); 1030 if (cardBackground) 1031 theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); 1032 if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); 1033 if (accentBackground) 1034 theme.accentBackground = ColorToRGB( 1035 parseColor(`hsba(${accentBackground})`), 1036 ); 1037 if (accentText) 1038 theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`)); 1039 1040 // Upload background image if present 1041 if (backgroundImage?.data) { 1042 let imageData = await fetch(backgroundImage.data.src); 1043 if (imageData.status === 200) { 1044 let binary = await imageData.blob(); 1045 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 1046 headers: { "Content-Type": binary.type }, 1047 }); 1048 1049 theme.backgroundImage = { 1050 $type: "pub.leaflet.theme.backgroundImage", 1051 image: blob.data.blob, 1052 repeat: backgroundImageRepeat?.data.value ? true : false, 1053 ...(backgroundImageRepeat?.data.value && { 1054 width: Math.floor(backgroundImageRepeat.data.value), 1055 }), 1056 }; 1057 } 1058 } 1059 1060 // Only return theme if at least one property is set 1061 if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { 1062 return theme; 1063 } 1064 1065 return undefined; 1066} 1067 1068/** 1069 * Extract mentions from a published document and create notifications 1070 */ 1071async function createMentionNotifications( 1072 documentUri: string, 1073 record: PubLeafletDocument.Record | SiteStandardDocument.Record, 1074 authorDid: string, 1075) { 1076 const mentionedDids = new Set<string>(); 1077 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 1078 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 1079 const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 1080 1081 // Extract pages from either format 1082 let pages: PubLeafletContent.Main["pages"] | undefined; 1083 if (record.$type === "site.standard.document") { 1084 const content = record.content; 1085 if (content && PubLeafletContent.isMain(content)) { 1086 pages = content.pages; 1087 } 1088 } else { 1089 pages = record.pages; 1090 } 1091 1092 if (!pages) return; 1093 1094 // Helper to extract blocks from all pages (both linear and canvas) 1095 function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 1096 const blocks: ( 1097 | PubLeafletPagesLinearDocument.Block["block"] 1098 | PubLeafletPagesCanvas.Block["block"] 1099 )[] = []; 1100 for (const page of pages) { 1101 if (page.$type === "pub.leaflet.pages.linearDocument") { 1102 const linearPage = page as PubLeafletPagesLinearDocument.Main; 1103 for (const blockWrapper of linearPage.blocks) { 1104 blocks.push(blockWrapper.block); 1105 } 1106 } else if (page.$type === "pub.leaflet.pages.canvas") { 1107 const canvasPage = page as PubLeafletPagesCanvas.Main; 1108 for (const blockWrapper of canvasPage.blocks) { 1109 blocks.push(blockWrapper.block); 1110 } 1111 } 1112 } 1113 return blocks; 1114 } 1115 1116 const allBlocks = getAllBlocks(pages); 1117 1118 // Extract mentions from all text blocks and embedded Bluesky posts 1119 for (const block of allBlocks) { 1120 // Check for embedded Bluesky posts 1121 if (PubLeafletBlocksBskyPost.isMain(block)) { 1122 const bskyPostUri = block.postRef.uri; 1123 // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 1124 const postAuthorDid = new AtUri(bskyPostUri).host; 1125 if (postAuthorDid !== authorDid) { 1126 embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 1127 } 1128 } 1129 1130 // Check for text blocks with mentions 1131 if (block.$type === "pub.leaflet.blocks.text") { 1132 const textBlock = block as PubLeafletBlocksText.Main; 1133 if (textBlock.facets) { 1134 for (const facet of textBlock.facets) { 1135 for (const feature of facet.features) { 1136 // Check for DID mentions 1137 if (PubLeafletRichtextFacet.isDidMention(feature)) { 1138 if (feature.did !== authorDid) { 1139 mentionedDids.add(feature.did); 1140 } 1141 } 1142 // Check for AT URI mentions (publications and documents) 1143 if (PubLeafletRichtextFacet.isAtMention(feature)) { 1144 const uri = new AtUri(feature.atURI); 1145 1146 if (isPublicationCollection(uri.collection)) { 1147 // Get the publication owner's DID 1148 const { data: publication } = await supabaseServerClient 1149 .from("publications") 1150 .select("identity_did") 1151 .eq("uri", feature.atURI) 1152 .single(); 1153 1154 if (publication && publication.identity_did !== authorDid) { 1155 mentionedPublications.set( 1156 publication.identity_did, 1157 feature.atURI, 1158 ); 1159 } 1160 } else if (isDocumentCollection(uri.collection)) { 1161 // Get the document owner's DID 1162 const { data: document } = await supabaseServerClient 1163 .from("documents") 1164 .select("uri, data") 1165 .eq("uri", feature.atURI) 1166 .single(); 1167 1168 if (document) { 1169 const normalizedMentionedDoc = normalizeDocumentRecord( 1170 document.data, 1171 ); 1172 // Get the author from the document URI (the DID is the host part) 1173 const mentionedUri = new AtUri(feature.atURI); 1174 const docAuthor = mentionedUri.host; 1175 if (normalizedMentionedDoc && docAuthor !== authorDid) { 1176 mentionedDocuments.set(docAuthor, feature.atURI); 1177 } 1178 } 1179 } 1180 } 1181 } 1182 } 1183 } 1184 } 1185 } 1186 1187 // Create notifications for DID mentions 1188 for (const did of mentionedDids) { 1189 const notification: Notification = { 1190 id: v7(), 1191 recipient: did, 1192 data: { 1193 type: "mention", 1194 document_uri: documentUri, 1195 mention_type: "did", 1196 }, 1197 }; 1198 await supabaseServerClient.from("notifications").insert(notification); 1199 await pingIdentityToUpdateNotification(did); 1200 } 1201 1202 // Create notifications for publication mentions 1203 for (const [recipientDid, publicationUri] of mentionedPublications) { 1204 const notification: Notification = { 1205 id: v7(), 1206 recipient: recipientDid, 1207 data: { 1208 type: "mention", 1209 document_uri: documentUri, 1210 mention_type: "publication", 1211 mentioned_uri: publicationUri, 1212 }, 1213 }; 1214 await supabaseServerClient.from("notifications").insert(notification); 1215 await pingIdentityToUpdateNotification(recipientDid); 1216 } 1217 1218 // Create notifications for document mentions 1219 for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 1220 const notification: Notification = { 1221 id: v7(), 1222 recipient: recipientDid, 1223 data: { 1224 type: "mention", 1225 document_uri: documentUri, 1226 mention_type: "document", 1227 mentioned_uri: mentionedDocUri, 1228 }, 1229 }; 1230 await supabaseServerClient.from("notifications").insert(notification); 1231 await pingIdentityToUpdateNotification(recipientDid); 1232 } 1233 1234 // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1235 if (embeddedBskyPosts.size > 0) { 1236 // Check which of the Bluesky post authors have Leaflet accounts 1237 const { data: identities } = await supabaseServerClient 1238 .from("identities") 1239 .select("atp_did") 1240 .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1241 1242 const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1243 1244 for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1245 // Only notify if the post author has a Leaflet account 1246 if (leafletUserDids.has(postAuthorDid)) { 1247 const notification: Notification = { 1248 id: v7(), 1249 recipient: postAuthorDid, 1250 data: { 1251 type: "bsky_post_embed", 1252 document_uri: documentUri, 1253 bsky_post_uri: bskyPostUri, 1254 }, 1255 }; 1256 await supabaseServerClient.from("notifications").insert(notification); 1257 await pingIdentityToUpdateNotification(postAuthorDid); 1258 } 1259 } 1260 } 1261}