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