a tool for shared writing and social publishing
298
fork

Configure Feed

Select the types of activity you want to include in your feed.

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