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