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