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