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