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