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