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