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