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