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 SiteStandardDocument,
15 PubLeafletContent,
16 PubLeafletPagesLinearDocument,
17 PubLeafletPagesCanvas,
18 PubLeafletRichtextFacet,
19 PubLeafletBlocksWebsite,
20 PubLeafletBlocksCode,
21 PubLeafletBlocksMath,
22 PubLeafletBlocksHorizontalRule,
23 PubLeafletBlocksBskyPost,
24 PubLeafletBlocksBlockquote,
25 PubLeafletBlocksIframe,
26 PubLeafletBlocksPage,
27 PubLeafletBlocksPoll,
28 PubLeafletBlocksButton,
29 PubLeafletPollDefinition,
30} from "lexicons/api";
31import { Block } from "components/Blocks/Block";
32import { TID } from "@atproto/common";
33import { supabaseServerClient } from "supabase/serverClient";
34import { scanIndexLocal } from "src/replicache/utils";
35import type { Fact } from "src/replicache";
36import type { Attribute } from "src/replicache/attributes";
37import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
38import { ids } from "lexicons/api/lexicons";
39import { BlobRef } from "@atproto/lexicon";
40import { AtUri } from "@atproto/syntax";
41import { Json } from "supabase/database.types";
42import { $Typed, UnicodeString } from "@atproto/api";
43import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
44import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
45import { Lock } from "src/utils/lock";
46import type { PubLeafletPublication } from "lexicons/api";
47import {
48 normalizeDocumentRecord,
49 type NormalizedDocument,
50} from "src/utils/normalizeRecords";
51import {
52 ColorToRGB,
53 ColorToRGBA,
54} from "components/ThemeManager/colorToLexicons";
55import { parseColor } from "@react-stately/color";
56import {
57 Notification,
58 pingIdentityToUpdateNotification,
59} from "src/notifications";
60import { v7 } from "uuid";
61import {
62 isDocumentCollection,
63 isPublicationCollection,
64 getDocumentType,
65} from "src/utils/collectionHelpers";
66
67type PublishResult =
68 | { success: true; rkey: string; record: SiteStandardDocument.Record }
69 | { success: false; error: OAuthSessionError };
70
71export async function publishToPublication({
72 root_entity,
73 publication_uri,
74 leaflet_id,
75 title,
76 description,
77 tags,
78 cover_image,
79 entitiesToDelete,
80 publishedAt,
81 postPreferences,
82}: {
83 root_entity: string;
84 publication_uri?: string;
85 leaflet_id: string;
86 title?: string;
87 description?: string;
88 tags?: string[];
89 cover_image?: string | null;
90 entitiesToDelete?: string[];
91 publishedAt?: string;
92 postPreferences?: {
93 showComments?: boolean;
94 showMentions?: boolean;
95 showRecommends?: boolean;
96 } | null;
97}): Promise<PublishResult> {
98 let identity = await getIdentityData();
99 if (!identity || !identity.atp_did) {
100 return {
101 success: false,
102 error: {
103 type: "oauth_session_expired",
104 message: "Not authenticated",
105 did: "",
106 },
107 };
108 }
109
110 const sessionResult = await restoreOAuthSession(identity.atp_did);
111 if (!sessionResult.ok) {
112 return { success: false, error: sessionResult.error };
113 }
114 let credentialSession = sessionResult.value;
115 let agent = new AtpBaseClient(
116 credentialSession.fetchHandler.bind(credentialSession),
117 );
118
119 // Check if we're publishing to a publication or standalone
120 let draft: any = null;
121 let existingDocUri: string | null = null;
122
123 if (publication_uri) {
124 // Publishing to a publication - use leaflets_in_publications
125 let { data, error } = await supabaseServerClient
126 .from("publications")
127 .select("*, leaflets_in_publications(*, documents(*))")
128 .eq("uri", publication_uri)
129 .eq("leaflets_in_publications.leaflet", leaflet_id)
130 .single();
131 console.log(error);
132
133 if (!data || identity.atp_did !== data?.identity_did)
134 throw new Error("No draft or not publisher");
135 draft = data.leaflets_in_publications[0];
136 existingDocUri = draft?.doc;
137 } else {
138 // Publishing standalone - use leaflets_to_documents
139 let { data } = await supabaseServerClient
140 .from("leaflets_to_documents")
141 .select("*, documents(*)")
142 .eq("leaflet", leaflet_id)
143 .single();
144 draft = data;
145 existingDocUri = draft?.document;
146
147 // If updating an existing document, verify the current user is the owner
148 if (existingDocUri) {
149 let docOwner = new AtUri(existingDocUri).host;
150 if (docOwner !== identity.atp_did) {
151 return {
152 success: false,
153 error: {
154 type: "oauth_session_expired" as const,
155 message: "Not the document owner",
156 did: identity.atp_did,
157 },
158 };
159 }
160 }
161 }
162
163 // Heuristic: Remove title entities if this is the first time publishing
164 // (when coming from a standalone leaflet with entitiesToDelete passed in)
165 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
166 await supabaseServerClient
167 .from("entities")
168 .delete()
169 .in("id", entitiesToDelete);
170 }
171
172 let { data } = await supabaseServerClient.rpc("get_facts", {
173 root: root_entity,
174 });
175 let facts = (data as unknown as Fact<Attribute>[]) || [];
176
177 let { pages } = await processBlocksToPages(
178 facts,
179 agent,
180 root_entity,
181 credentialSession.did!,
182 );
183
184 let existingRecord: Partial<PubLeafletDocument.Record> = {};
185 const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data);
186 if (normalizedDoc) {
187 // When reading existing data, use normalized format to extract fields
188 // The theme is preserved in NormalizedDocument for backward compatibility
189 existingRecord = {
190 publishedAt: normalizedDoc.publishedAt,
191 title: normalizedDoc.title,
192 description: normalizedDoc.description,
193 tags: normalizedDoc.tags,
194 coverImage: normalizedDoc.coverImage,
195 theme: normalizedDoc.theme,
196 };
197 }
198
199 // Resolve preferences: explicit param > draft DB value
200 const preferences = postPreferences ?? draft?.preferences;
201
202 // Extract theme for standalone documents (not for publications)
203 let theme: PubLeafletPublication.Theme | undefined;
204 if (!publication_uri) {
205 theme = await extractThemeFromFacts(facts, root_entity, agent);
206 }
207
208 // Upload cover image if provided
209 let coverImageBlob: BlobRef | undefined;
210 if (cover_image) {
211 let scan = scanIndexLocal(facts);
212 let [imageData] = scan.eav(cover_image, "block/image");
213 if (imageData) {
214 let imageResponse = await fetch(imageData.data.src);
215 if (imageResponse.status === 200) {
216 let binary = await imageResponse.blob();
217 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
218 headers: { "Content-Type": binary.type },
219 });
220 coverImageBlob = blob.data.blob;
221 }
222 }
223 }
224
225 // Determine the collection to use - preserve existing schema if updating
226 const existingCollection = existingDocUri
227 ? new AtUri(existingDocUri).collection
228 : undefined;
229 const documentType = getDocumentType(existingCollection);
230
231 // Build the pages array (used by both formats)
232 const pagesArray = pages.map((p) => {
233 if (p.type === "canvas") {
234 return {
235 $type: "pub.leaflet.pages.canvas" as const,
236 id: p.id,
237 blocks: p.blocks as PubLeafletPagesCanvas.Block[],
238 };
239 } else {
240 return {
241 $type: "pub.leaflet.pages.linearDocument" as const,
242 id: p.id,
243 blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
244 };
245 }
246 });
247
248 // Determine the rkey early since we need it for the path field
249 const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
250
251 // Create record based on the document type
252 let record: PubLeafletDocument.Record | SiteStandardDocument.Record;
253
254 if (documentType === "site.standard.document") {
255 // site.standard.document format
256 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
257 const siteUri =
258 publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
259
260 record = {
261 $type: "site.standard.document",
262 title: title || "",
263 site: siteUri,
264 path: "/" + rkey,
265 publishedAt:
266 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
267 ...(description && { description }),
268 ...(tags !== undefined && { tags }),
269 ...(coverImageBlob && { coverImage: coverImageBlob }),
270 // Include theme for standalone documents (not for publication documents)
271 ...(!publication_uri && theme && { theme }),
272 ...(preferences && {
273 preferences: {
274 $type: "pub.leaflet.publication#preferences" as const,
275 ...preferences,
276 },
277 }),
278 content: {
279 $type: "pub.leaflet.content" as const,
280 pages: pagesArray,
281 },
282 } satisfies SiteStandardDocument.Record;
283 } else {
284 // pub.leaflet.document format (legacy)
285 record = {
286 $type: "pub.leaflet.document",
287 author: credentialSession.did!,
288 ...(publication_uri && { publication: publication_uri }),
289 ...(theme && { theme }),
290 ...(preferences && {
291 preferences: {
292 $type: "pub.leaflet.publication#preferences" as const,
293 ...preferences,
294 },
295 }),
296 title: title || "",
297 description: description || "",
298 ...(tags !== undefined && { tags }),
299 ...(coverImageBlob && { coverImage: coverImageBlob }),
300 pages: pagesArray,
301 publishedAt:
302 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
303 } satisfies PubLeafletDocument.Record;
304 }
305
306 let { data: result } = await agent.com.atproto.repo.putRecord({
307 rkey,
308 repo: credentialSession.did!,
309 collection: record.$type,
310 record,
311 validate: false, //TODO publish the lexicon so we can validate!
312 });
313
314 // Optimistically create database entries
315 await supabaseServerClient.from("documents").upsert({
316 uri: result.uri,
317 data: record as unknown as Json,
318 indexed: true,
319 });
320
321 if (publication_uri) {
322 // Publishing to a publication - update both tables
323 await Promise.all([
324 supabaseServerClient.from("documents_in_publications").upsert({
325 publication: publication_uri,
326 document: result.uri,
327 }),
328 supabaseServerClient.from("leaflets_in_publications").upsert({
329 doc: result.uri,
330 leaflet: leaflet_id,
331 publication: publication_uri,
332 title: title,
333 description: description,
334 }),
335 ]);
336 } else {
337 // Publishing standalone - update leaflets_to_documents
338 await supabaseServerClient.from("leaflets_to_documents").upsert({
339 leaflet: leaflet_id,
340 document: result.uri,
341 title: title || "",
342 description: description || "",
343 });
344
345 // Heuristic: Remove title entities if this is the first time publishing standalone
346 // (when entitiesToDelete is provided and there's no existing document)
347 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
348 await supabaseServerClient
349 .from("entities")
350 .delete()
351 .in("id", entitiesToDelete);
352 }
353 }
354
355 // Create notifications for mentions (only on first publish)
356 if (!existingDocUri) {
357 await createMentionNotifications(
358 result.uri,
359 record,
360 credentialSession.did!,
361 );
362 }
363
364 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
365}
366
367async function processBlocksToPages(
368 facts: Fact<any>[],
369 agent: AtpBaseClient,
370 root_entity: string,
371 did: string,
372) {
373 let scan = scanIndexLocal(facts);
374 let pages: {
375 id: string;
376 blocks:
377 | PubLeafletPagesLinearDocument.Block[]
378 | PubLeafletPagesCanvas.Block[];
379 type: "doc" | "canvas";
380 }[] = [];
381
382 // Create a lock to serialize image uploads
383 const uploadLock = new Lock();
384
385 let firstEntity = scan.eav(root_entity, "root/page")?.[0];
386 if (!firstEntity) throw new Error("No root page");
387
388 // Check if the first page is a canvas or linear document
389 let [pageType] = scan.eav(firstEntity.data.value, "page/type");
390
391 if (pageType?.data.value === "canvas") {
392 // First page is a canvas
393 let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did);
394 pages.unshift({
395 id: firstEntity.data.value,
396 blocks: canvasBlocks,
397 type: "canvas",
398 });
399 } else {
400 // First page is a linear document
401 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
402 let b = await blocksToRecord(blocks, did);
403 pages.unshift({
404 id: firstEntity.data.value,
405 blocks: b,
406 type: "doc",
407 });
408 }
409
410 return { pages };
411
412 async function uploadImage(src: string) {
413 let data = await fetch(src);
414 if (data.status !== 200) return;
415 let binary = await data.blob();
416 return uploadLock.withLock(async () => {
417 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
418 headers: { "Content-Type": binary.type },
419 });
420 return blob.data.blob;
421 });
422 }
423 async function blocksToRecord(
424 blocks: Block[],
425 did: string,
426 ): Promise<PubLeafletPagesLinearDocument.Block[]> {
427 let parsedBlocks = parseBlocksToList(blocks);
428 return (
429 await Promise.all(
430 parsedBlocks.map(async (blockOrList) => {
431 if (blockOrList.type === "block") {
432 let alignmentValue = scan.eav(
433 blockOrList.block.value,
434 "block/text-alignment",
435 )[0]?.data.value;
436 let alignment: ExcludeString<
437 PubLeafletPagesLinearDocument.Block["alignment"]
438 > =
439 alignmentValue === "center"
440 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
441 : alignmentValue === "right"
442 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight"
443 : alignmentValue === "justify"
444 ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify"
445 : alignmentValue === "left"
446 ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft"
447 : undefined;
448 let b = await blockToRecord(blockOrList.block, did);
449 if (!b) return [];
450 let block: PubLeafletPagesLinearDocument.Block = {
451 $type: "pub.leaflet.pages.linearDocument#block",
452 block: b,
453 };
454 if (alignment) block.alignment = alignment;
455 return [block];
456 } else {
457 let block: PubLeafletPagesLinearDocument.Block = {
458 $type: "pub.leaflet.pages.linearDocument#block",
459 block: {
460 $type: "pub.leaflet.blocks.unorderedList",
461 children: await childrenToRecord(blockOrList.children, did),
462 },
463 };
464 return [block];
465 }
466 }),
467 )
468 ).flat();
469 }
470
471 async function childrenToRecord(children: List[], did: string) {
472 return (
473 await Promise.all(
474 children.map(async (child) => {
475 let content = await blockToRecord(child.block, did);
476 if (!content) return [];
477 let record: PubLeafletBlocksUnorderedList.ListItem = {
478 $type: "pub.leaflet.blocks.unorderedList#listItem",
479 content,
480 children: await childrenToRecord(child.children, did),
481 };
482 return record;
483 }),
484 )
485 ).flat();
486 }
487 async function blockToRecord(b: Block, did: string) {
488 const footnoteContentResolver = (footnoteEntityID: string) => {
489 let [content] = scan.eav(footnoteEntityID, "block/text");
490 if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] };
491 let doc = new Y.Doc();
492 const update = base64.toByteArray(content.data.value);
493 Y.applyUpdate(doc, update);
494 let nodes = doc.getXmlElement("prosemirror").toArray();
495 let plaintext = YJSFragmentToString(nodes[0]);
496 let { facets } = YJSFragmentToFacets(nodes[0]);
497 return { plaintext, facets };
498 };
499 const getBlockContent = (b: string) => {
500 let [content] = scan.eav(b, "block/text");
501 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
502 let doc = new Y.Doc();
503 const update = base64.toByteArray(content.data.value);
504 Y.applyUpdate(doc, update);
505 let nodes = doc.getXmlElement("prosemirror").toArray();
506 let stringValue = YJSFragmentToString(nodes[0]);
507 let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver);
508 return [stringValue, facets] as const;
509 };
510 if (b.type === "card") {
511 let [page] = scan.eav(b.value, "block/card");
512 if (!page) return;
513 let [pageType] = scan.eav(page.data.value, "page/type");
514
515 if (pageType?.data.value === "canvas") {
516 let canvasBlocks = await canvasBlocksToRecord(page.data.value, did);
517 pages.push({
518 id: page.data.value,
519 blocks: canvasBlocks,
520 type: "canvas",
521 });
522 } else {
523 let blocks = getBlocksWithTypeLocal(facts, page.data.value);
524 pages.push({
525 id: page.data.value,
526 blocks: await blocksToRecord(blocks, did),
527 type: "doc",
528 });
529 }
530
531 let block: $Typed<PubLeafletBlocksPage.Main> = {
532 $type: "pub.leaflet.blocks.page",
533 id: page.data.value,
534 };
535 return block;
536 }
537
538 if (b.type === "bluesky-post") {
539 let [post] = scan.eav(b.value, "block/bluesky-post");
540 if (!post || !post.data.value.post) return;
541 let [hostFact] = scan.eav(b.value, "bluesky-post/host");
542 let block: $Typed<PubLeafletBlocksBskyPost.Main> = {
543 $type: ids.PubLeafletBlocksBskyPost,
544 postRef: {
545 uri: post.data.value.post.uri,
546 cid: post.data.value.post.cid,
547 },
548 clientHost: hostFact?.data.value,
549 };
550 return block;
551 }
552 if (b.type === "horizontal-rule") {
553 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = {
554 $type: ids.PubLeafletBlocksHorizontalRule,
555 };
556 return block;
557 }
558
559 if (b.type === "heading") {
560 let [headingLevel] = scan.eav(b.value, "block/heading-level");
561
562 let [stringValue, facets] = getBlockContent(b.value);
563 let block: $Typed<PubLeafletBlocksHeader.Main> = {
564 $type: "pub.leaflet.blocks.header",
565 level: Math.floor(headingLevel?.data.value || 1),
566 plaintext: stringValue,
567 facets,
568 };
569 return block;
570 }
571
572 if (b.type === "blockquote") {
573 let [stringValue, facets] = getBlockContent(b.value);
574 let block: $Typed<PubLeafletBlocksBlockquote.Main> = {
575 $type: ids.PubLeafletBlocksBlockquote,
576 plaintext: stringValue,
577 facets,
578 };
579 return block;
580 }
581
582 if (b.type == "text") {
583 let [stringValue, facets] = getBlockContent(b.value);
584 let [textSize] = scan.eav(b.value, "block/text-size");
585 let block: $Typed<PubLeafletBlocksText.Main> = {
586 $type: ids.PubLeafletBlocksText,
587 plaintext: stringValue,
588 facets,
589 ...(textSize && { textSize: textSize.data.value }),
590 };
591 return block;
592 }
593 if (b.type === "embed") {
594 let [url] = scan.eav(b.value, "embed/url");
595 let [height] = scan.eav(b.value, "embed/height");
596 if (!url) return;
597 let block: $Typed<PubLeafletBlocksIframe.Main> = {
598 $type: "pub.leaflet.blocks.iframe",
599 url: url.data.value,
600 height: Math.floor(height?.data.value || 600),
601 };
602 return block;
603 }
604 if (b.type == "image") {
605 let [image] = scan.eav(b.value, "block/image");
606 if (!image) return;
607 let [altText] = scan.eav(b.value, "image/alt");
608 let blobref = await uploadImage(image.data.src);
609 if (!blobref) return;
610 let block: $Typed<PubLeafletBlocksImage.Main> = {
611 $type: "pub.leaflet.blocks.image",
612 image: blobref,
613 aspectRatio: {
614 height: Math.floor(image.data.height),
615 width: Math.floor(image.data.width),
616 },
617 alt: altText ? altText.data.value : undefined,
618 };
619 return block;
620 }
621 if (b.type === "link") {
622 let [previewImage] = scan.eav(b.value, "link/preview");
623 let [description] = scan.eav(b.value, "link/description");
624 let [src] = scan.eav(b.value, "link/url");
625 if (!src) return;
626 let blobref = previewImage
627 ? await uploadImage(previewImage?.data.src)
628 : undefined;
629 let [title] = scan.eav(b.value, "link/title");
630 let block: $Typed<PubLeafletBlocksWebsite.Main> = {
631 $type: "pub.leaflet.blocks.website",
632 previewImage: blobref,
633 src: src.data.value,
634 description: description?.data.value,
635 title: title?.data.value,
636 };
637 return block;
638 }
639 if (b.type === "code") {
640 let [language] = scan.eav(b.value, "block/code-language");
641 let [code] = scan.eav(b.value, "block/code");
642 let [theme] = scan.eav(root_entity, "theme/code-theme");
643 let block: $Typed<PubLeafletBlocksCode.Main> = {
644 $type: "pub.leaflet.blocks.code",
645 language: language?.data.value,
646 plaintext: code?.data.value || "",
647 syntaxHighlightingTheme: theme?.data.value,
648 };
649 return block;
650 }
651 if (b.type === "math") {
652 let [math] = scan.eav(b.value, "block/math");
653 let block: $Typed<PubLeafletBlocksMath.Main> = {
654 $type: "pub.leaflet.blocks.math",
655 tex: math?.data.value || "",
656 };
657 return block;
658 }
659 if (b.type === "poll") {
660 // Get poll options from the entity
661 let pollOptions = scan.eav(b.value, "poll/options");
662 let options: PubLeafletPollDefinition.Option[] = pollOptions.map(
663 (opt) => {
664 let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0];
665 return {
666 $type: "pub.leaflet.poll.definition#option",
667 text: optionName?.data.value || "",
668 };
669 },
670 );
671
672 // Create the poll definition record
673 let pollRecord: PubLeafletPollDefinition.Record = {
674 $type: "pub.leaflet.poll.definition",
675 name: "Poll", // Default name, can be customized
676 options,
677 };
678
679 // Upload the poll record
680 let { data: pollResult } = await agent.com.atproto.repo.putRecord({
681 //use the entity id as the rkey so we can associate it in the editor
682 rkey: b.value,
683 repo: did,
684 collection: pollRecord.$type,
685 record: pollRecord,
686 validate: false,
687 });
688
689 // Optimistically write poll definition to database
690 console.log(
691 await supabaseServerClient.from("atp_poll_records").upsert({
692 uri: pollResult.uri,
693 cid: pollResult.cid,
694 record: pollRecord as Json,
695 }),
696 );
697
698 // Return a poll block with reference to the poll record
699 let block: $Typed<PubLeafletBlocksPoll.Main> = {
700 $type: "pub.leaflet.blocks.poll",
701 pollRef: {
702 uri: pollResult.uri,
703 cid: pollResult.cid,
704 },
705 };
706 return block;
707 }
708 if (b.type === "button") {
709 let [text] = scan.eav(b.value, "button/text");
710 let [url] = scan.eav(b.value, "button/url");
711 if (!text || !url) return;
712 let block: $Typed<PubLeafletBlocksButton.Main> = {
713 $type: "pub.leaflet.blocks.button",
714 text: text.data.value,
715 url: url.data.value,
716 };
717 return block;
718 }
719 return;
720 }
721
722 async function canvasBlocksToRecord(
723 pageID: string,
724 did: string,
725 ): Promise<PubLeafletPagesCanvas.Block[]> {
726 let canvasBlocks = scan.eav(pageID, "canvas/block");
727 return (
728 await Promise.all(
729 canvasBlocks.map(async (canvasBlock) => {
730 let blockEntity = canvasBlock.data.value;
731 let position = canvasBlock.data.position;
732
733 // Get the block content
734 let blockType = scan.eav(blockEntity, "block/type")?.[0];
735 if (!blockType) return null;
736
737 let block: Block = {
738 type: blockType.data.value,
739 value: blockEntity,
740 parent: pageID,
741 position: "",
742 factID: canvasBlock.id,
743 };
744
745 let content = await blockToRecord(block, did);
746 if (!content) return null;
747
748 // Get canvas-specific properties
749 let width =
750 scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360;
751 let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0]
752 ?.data.value;
753
754 let canvasBlockRecord: PubLeafletPagesCanvas.Block = {
755 $type: "pub.leaflet.pages.canvas#block",
756 block: content,
757 x: Math.floor(position.x),
758 y: Math.floor(position.y),
759 width: Math.floor(width),
760 ...(rotation !== undefined && { rotation: Math.floor(rotation) }),
761 };
762
763 return canvasBlockRecord;
764 }),
765 )
766 ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null);
767 }
768}
769
770function YJSFragmentToFacets(
771 node: Y.XmlElement | Y.XmlText | Y.XmlHook,
772 byteOffset: number = 0,
773 footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] },
774): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
775 if (node.constructor === Y.XmlElement) {
776 // Handle footnote inline nodes
777 if (node.nodeName === "footnote") {
778 const footnoteEntityID = node.getAttribute("footnoteEntityID") || "";
779 const placeholder = "*";
780 const unicodestring = new UnicodeString(placeholder);
781 let footnoteContent = footnoteContentResolver?.(footnoteEntityID);
782 const facet: PubLeafletRichtextFacet.Main = {
783 index: {
784 byteStart: byteOffset,
785 byteEnd: byteOffset + unicodestring.length,
786 },
787 features: [
788 {
789 $type: "pub.leaflet.richtext.facet#footnote",
790 footnoteId: footnoteEntityID,
791 contentPlaintext: footnoteContent?.plaintext || "",
792 ...(footnoteContent?.facets?.length
793 ? { contentFacets: footnoteContent.facets }
794 : {}),
795 },
796 ],
797 };
798 return { facets: [facet], byteLength: unicodestring.length };
799 }
800
801 // Handle inline mention nodes
802 if (node.nodeName === "didMention") {
803 const text = node.getAttribute("text") || "";
804 const unicodestring = new UnicodeString(text);
805 const facet: PubLeafletRichtextFacet.Main = {
806 index: {
807 byteStart: byteOffset,
808 byteEnd: byteOffset + unicodestring.length,
809 },
810 features: [
811 {
812 $type: "pub.leaflet.richtext.facet#didMention",
813 did: node.getAttribute("did"),
814 },
815 ],
816 };
817 return { facets: [facet], byteLength: unicodestring.length };
818 }
819
820 if (node.nodeName === "atMention") {
821 const text = node.getAttribute("text") || "";
822 const unicodestring = new UnicodeString(text);
823 const facet: PubLeafletRichtextFacet.Main = {
824 index: {
825 byteStart: byteOffset,
826 byteEnd: byteOffset + unicodestring.length,
827 },
828 features: [
829 {
830 $type: "pub.leaflet.richtext.facet#atMention",
831 atURI: node.getAttribute("atURI"),
832 },
833 ],
834 };
835 return { facets: [facet], byteLength: unicodestring.length };
836 }
837
838 if (node.nodeName === "hard_break") {
839 const unicodestring = new UnicodeString("\n");
840 return { facets: [], byteLength: unicodestring.length };
841 }
842
843 // For other elements (like paragraph), process children
844 let allFacets: PubLeafletRichtextFacet.Main[] = [];
845 let currentOffset = byteOffset;
846 for (const child of node.toArray()) {
847 const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver);
848 allFacets.push(...result.facets);
849 currentOffset += result.byteLength;
850 }
851 return { facets: allFacets, byteLength: currentOffset - byteOffset };
852 }
853
854 if (node.constructor === Y.XmlText) {
855 let facets: PubLeafletRichtextFacet.Main[] = [];
856 let delta = node.toDelta() as Delta[];
857 let byteStart = byteOffset;
858 let totalLength = 0;
859 for (let d of delta) {
860 let unicodestring = new UnicodeString(d.insert);
861 let facet: PubLeafletRichtextFacet.Main = {
862 index: {
863 byteStart,
864 byteEnd: byteStart + unicodestring.length,
865 },
866 features: [],
867 };
868
869 if (d.attributes?.strikethrough)
870 facet.features.push({
871 $type: "pub.leaflet.richtext.facet#strikethrough",
872 });
873
874 if (d.attributes?.code)
875 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
876 if (d.attributes?.highlight)
877 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" });
878 if (d.attributes?.underline)
879 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" });
880 if (d.attributes?.strong)
881 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" });
882 if (d.attributes?.em)
883 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" });
884 if (d.attributes?.link)
885 facet.features.push({
886 $type: "pub.leaflet.richtext.facet#link",
887 uri: d.attributes.link.href,
888 });
889 if (facet.features.length > 0) facets.push(facet);
890 byteStart += unicodestring.length;
891 totalLength += unicodestring.length;
892 }
893 return { facets, byteLength: totalLength };
894 }
895 return { facets: [], byteLength: 0 };
896}
897
898type ExcludeString<T> = T extends string
899 ? string extends T
900 ? never
901 : T /* maybe literal, not the whole `string` */
902 : T; /* not a string */
903
904async function extractThemeFromFacts(
905 facts: Fact<any>[],
906 root_entity: string,
907 agent: AtpBaseClient,
908): Promise<PubLeafletPublication.Theme | undefined> {
909 let scan = scanIndexLocal(facts);
910 let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data
911 .value;
912 let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data
913 .value;
914 let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value;
915 let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0]
916 ?.data.value;
917 let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value;
918 let showPageBackground = !scan.eav(
919 root_entity,
920 "theme/card-border-hidden",
921 )?.[0]?.data.value;
922 let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0];
923 let backgroundImageRepeat = scan.eav(
924 root_entity,
925 "theme/background-image-repeat",
926 )?.[0];
927 let pageWidth = scan.eav(root_entity, "theme/page-width")?.[0];
928
929 let theme: PubLeafletPublication.Theme = {
930 showPageBackground: showPageBackground ?? true,
931 };
932
933 if (pageWidth) theme.pageWidth = pageWidth.data.value;
934 if (pageBackground)
935 theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
936 if (cardBackground)
937 theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`));
938 if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`));
939 if (accentBackground)
940 theme.accentBackground = ColorToRGB(
941 parseColor(`hsba(${accentBackground})`),
942 );
943 if (accentText)
944 theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`));
945
946 // Upload background image if present
947 if (backgroundImage?.data) {
948 let imageData = await fetch(backgroundImage.data.src);
949 if (imageData.status === 200) {
950 let binary = await imageData.blob();
951 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
952 headers: { "Content-Type": binary.type },
953 });
954
955 theme.backgroundImage = {
956 $type: "pub.leaflet.theme.backgroundImage",
957 image: blob.data.blob,
958 repeat: backgroundImageRepeat?.data.value ? true : false,
959 ...(backgroundImageRepeat?.data.value && {
960 width: Math.floor(backgroundImageRepeat.data.value),
961 }),
962 };
963 }
964 }
965
966 // Only return theme if at least one property is set
967 if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) {
968 return theme;
969 }
970
971 return undefined;
972}
973
974/**
975 * Extract mentions from a published document and create notifications
976 */
977async function createMentionNotifications(
978 documentUri: string,
979 record: PubLeafletDocument.Record | SiteStandardDocument.Record,
980 authorDid: string,
981) {
982 const mentionedDids = new Set<string>();
983 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
984 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
985 const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
986
987 // Extract pages from either format
988 let pages: PubLeafletContent.Main["pages"] | undefined;
989 if (record.$type === "site.standard.document") {
990 const content = record.content;
991 if (content && PubLeafletContent.isMain(content)) {
992 pages = content.pages;
993 }
994 } else {
995 pages = record.pages;
996 }
997
998 if (!pages) return;
999
1000 // Helper to extract blocks from all pages (both linear and canvas)
1001 function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
1002 const blocks: (
1003 | PubLeafletPagesLinearDocument.Block["block"]
1004 | PubLeafletPagesCanvas.Block["block"]
1005 )[] = [];
1006 for (const page of pages) {
1007 if (page.$type === "pub.leaflet.pages.linearDocument") {
1008 const linearPage = page as PubLeafletPagesLinearDocument.Main;
1009 for (const blockWrapper of linearPage.blocks) {
1010 blocks.push(blockWrapper.block);
1011 }
1012 } else if (page.$type === "pub.leaflet.pages.canvas") {
1013 const canvasPage = page as PubLeafletPagesCanvas.Main;
1014 for (const blockWrapper of canvasPage.blocks) {
1015 blocks.push(blockWrapper.block);
1016 }
1017 }
1018 }
1019 return blocks;
1020 }
1021
1022 const allBlocks = getAllBlocks(pages);
1023
1024 // Extract mentions from all text blocks and embedded Bluesky posts
1025 for (const block of allBlocks) {
1026 // Check for embedded Bluesky posts
1027 if (PubLeafletBlocksBskyPost.isMain(block)) {
1028 const bskyPostUri = block.postRef.uri;
1029 // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
1030 const postAuthorDid = new AtUri(bskyPostUri).host;
1031 if (postAuthorDid !== authorDid) {
1032 embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
1033 }
1034 }
1035
1036 // Check for text blocks with mentions
1037 if (block.$type === "pub.leaflet.blocks.text") {
1038 const textBlock = block as PubLeafletBlocksText.Main;
1039 if (textBlock.facets) {
1040 for (const facet of textBlock.facets) {
1041 for (const feature of facet.features) {
1042 // Check for DID mentions
1043 if (PubLeafletRichtextFacet.isDidMention(feature)) {
1044 if (feature.did !== authorDid) {
1045 mentionedDids.add(feature.did);
1046 }
1047 }
1048 // Check for AT URI mentions (publications and documents)
1049 if (PubLeafletRichtextFacet.isAtMention(feature)) {
1050 const uri = new AtUri(feature.atURI);
1051
1052 if (isPublicationCollection(uri.collection)) {
1053 // Get the publication owner's DID
1054 const { data: publication } = await supabaseServerClient
1055 .from("publications")
1056 .select("identity_did")
1057 .eq("uri", feature.atURI)
1058 .single();
1059
1060 if (publication && publication.identity_did !== authorDid) {
1061 mentionedPublications.set(
1062 publication.identity_did,
1063 feature.atURI,
1064 );
1065 }
1066 } else if (isDocumentCollection(uri.collection)) {
1067 // Get the document owner's DID
1068 const { data: document } = await supabaseServerClient
1069 .from("documents")
1070 .select("uri, data")
1071 .eq("uri", feature.atURI)
1072 .single();
1073
1074 if (document) {
1075 const normalizedMentionedDoc = normalizeDocumentRecord(
1076 document.data,
1077 );
1078 // Get the author from the document URI (the DID is the host part)
1079 const mentionedUri = new AtUri(feature.atURI);
1080 const docAuthor = mentionedUri.host;
1081 if (normalizedMentionedDoc && docAuthor !== authorDid) {
1082 mentionedDocuments.set(docAuthor, feature.atURI);
1083 }
1084 }
1085 }
1086 }
1087 }
1088 }
1089 }
1090 }
1091 }
1092
1093 // Create notifications for DID mentions
1094 for (const did of mentionedDids) {
1095 const notification: Notification = {
1096 id: v7(),
1097 recipient: did,
1098 data: {
1099 type: "mention",
1100 document_uri: documentUri,
1101 mention_type: "did",
1102 },
1103 };
1104 await supabaseServerClient.from("notifications").insert(notification);
1105 await pingIdentityToUpdateNotification(did);
1106 }
1107
1108 // Create notifications for publication mentions
1109 for (const [recipientDid, publicationUri] of mentionedPublications) {
1110 const notification: Notification = {
1111 id: v7(),
1112 recipient: recipientDid,
1113 data: {
1114 type: "mention",
1115 document_uri: documentUri,
1116 mention_type: "publication",
1117 mentioned_uri: publicationUri,
1118 },
1119 };
1120 await supabaseServerClient.from("notifications").insert(notification);
1121 await pingIdentityToUpdateNotification(recipientDid);
1122 }
1123
1124 // Create notifications for document mentions
1125 for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
1126 const notification: Notification = {
1127 id: v7(),
1128 recipient: recipientDid,
1129 data: {
1130 type: "mention",
1131 document_uri: documentUri,
1132 mention_type: "document",
1133 mentioned_uri: mentionedDocUri,
1134 },
1135 };
1136 await supabaseServerClient.from("notifications").insert(notification);
1137 await pingIdentityToUpdateNotification(recipientDid);
1138 }
1139
1140 // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
1141 if (embeddedBskyPosts.size > 0) {
1142 // Check which of the Bluesky post authors have Leaflet accounts
1143 const { data: identities } = await supabaseServerClient
1144 .from("identities")
1145 .select("atp_did")
1146 .in("atp_did", Array.from(embeddedBskyPosts.keys()));
1147
1148 const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
1149
1150 for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
1151 // Only notify if the post author has a Leaflet account
1152 if (leafletUserDids.has(postAuthorDid)) {
1153 const notification: Notification = {
1154 id: v7(),
1155 recipient: postAuthorDid,
1156 data: {
1157 type: "bsky_post_embed",
1158 document_uri: documentUri,
1159 bsky_post_uri: bskyPostUri,
1160 },
1161 };
1162 await supabaseServerClient.from("notifications").insert(notification);
1163 await pingIdentityToUpdateNotification(postAuthorDid);
1164 }
1165 }
1166 }
1167}