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