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