a tool for shared writing and social publishing
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}