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