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