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