a tool for shared writing and social publishing
1"use server";
2
3import * as Y from "yjs";
4import * as base64 from "base64-js";
5import { createOauthClient } from "src/atproto-oauth";
6import { getIdentityData } from "actions/getIdentityData";
7import {
8 AtpBaseClient,
9 PubLeafletBlocksHeader,
10 PubLeafletBlocksImage,
11 PubLeafletBlocksText,
12 PubLeafletBlocksUnorderedList,
13 PubLeafletDocument,
14 PubLeafletPagesLinearDocument,
15 PubLeafletPagesCanvas,
16 PubLeafletRichtextFacet,
17 PubLeafletBlocksWebsite,
18 PubLeafletBlocksCode,
19 PubLeafletBlocksMath,
20 PubLeafletBlocksHorizontalRule,
21 PubLeafletBlocksBskyPost,
22 PubLeafletBlocksBlockquote,
23 PubLeafletBlocksIframe,
24 PubLeafletBlocksPage,
25 PubLeafletBlocksPoll,
26 PubLeafletBlocksButton,
27 PubLeafletPollDefinition,
28} from "lexicons/api";
29import { Block } from "components/Blocks/Block";
30import { TID } from "@atproto/common";
31import { supabaseServerClient } from "supabase/serverClient";
32import { scanIndexLocal } from "src/replicache/utils";
33import type { Fact } from "src/replicache";
34import type { Attribute } from "src/replicache/attributes";
35import {
36 Delta,
37 YJSFragmentToString,
38} from "components/Blocks/TextBlock/RenderYJSFragment";
39import { ids } from "lexicons/api/lexicons";
40import { BlobRef } from "@atproto/lexicon";
41import { AtUri } from "@atproto/syntax";
42import { Json } from "supabase/database.types";
43import { $Typed, UnicodeString } from "@atproto/api";
44import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
45import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
46import { Lock } from "src/utils/lock";
47
48export async function publishToPublication({
49 root_entity,
50 publication_uri,
51 leaflet_id,
52 title,
53 description,
54}: {
55 root_entity: string;
56 publication_uri: string;
57 leaflet_id: string;
58 title?: string;
59 description?: string;
60}) {
61 const oauthClient = await createOauthClient();
62 let identity = await getIdentityData();
63 if (!identity || !identity.atp_did) throw new Error("No Identity");
64
65 let credentialSession = await oauthClient.restore(identity.atp_did);
66 let agent = new AtpBaseClient(
67 credentialSession.fetchHandler.bind(credentialSession),
68 );
69 let { data: draft } = await supabaseServerClient
70 .from("leaflets_in_publications")
71 .select("*, publications(*), documents(*)")
72 .eq("publication", publication_uri)
73 .eq("leaflet", leaflet_id)
74 .single();
75 if (!draft || identity.atp_did !== draft?.publications?.identity_did)
76 throw new Error("No draft or not publisher");
77 let { data } = await supabaseServerClient.rpc("get_facts", {
78 root: root_entity,
79 });
80 let facts = (data as unknown as Fact<Attribute>[]) || [];
81
82 let { firstPageBlocks, pages } = await processBlocksToPages(
83 facts,
84 agent,
85 root_entity,
86 credentialSession.did!,
87 );
88
89 let existingRecord =
90 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {};
91 let record: PubLeafletDocument.Record = {
92 $type: "pub.leaflet.document",
93 author: credentialSession.did!,
94 publication: publication_uri,
95 publishedAt: new Date().toISOString(),
96 ...existingRecord,
97 title: title || "Untitled",
98 description: description || "",
99 pages: [
100 {
101 $type: "pub.leaflet.pages.linearDocument",
102 blocks: firstPageBlocks,
103 },
104 ...pages.map((p) => {
105 if (p.type === "canvas") {
106 return {
107 $type: "pub.leaflet.pages.canvas" as const,
108 id: p.id,
109 blocks: p.blocks as PubLeafletPagesCanvas.Block[],
110 };
111 } else {
112 return {
113 $type: "pub.leaflet.pages.linearDocument" as const,
114 id: p.id,
115 blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
116 };
117 }
118 }),
119 ],
120 };
121 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr();
122 let { data: result } = await agent.com.atproto.repo.putRecord({
123 rkey,
124 repo: credentialSession.did!,
125 collection: record.$type,
126 record,
127 validate: false, //TODO publish the lexicon so we can validate!
128 });
129
130 await supabaseServerClient.from("documents").upsert({
131 uri: result.uri,
132 data: record as Json,
133 });
134 await Promise.all([
135 //Optimistically put these in!
136 supabaseServerClient.from("documents_in_publications").upsert({
137 publication: record.publication,
138 document: result.uri,
139 }),
140 supabaseServerClient
141 .from("leaflets_in_publications")
142 .update({
143 doc: result.uri,
144 })
145 .eq("leaflet", leaflet_id)
146 .eq("publication", publication_uri),
147 ]);
148
149 return { rkey, record: JSON.parse(JSON.stringify(record)) };
150}
151
152async function processBlocksToPages(
153 facts: Fact<any>[],
154 agent: AtpBaseClient,
155 root_entity: string,
156 did: string,
157) {
158 let scan = scanIndexLocal(facts);
159 let pages: {
160 id: string;
161 blocks:
162 | PubLeafletPagesLinearDocument.Block[]
163 | PubLeafletPagesCanvas.Block[];
164 type: "doc" | "canvas";
165 }[] = [];
166
167 // Create a lock to serialize image uploads
168 const uploadLock = new Lock();
169
170 let firstEntity = scan.eav(root_entity, "root/page")?.[0];
171 if (!firstEntity) throw new Error("No root page");
172 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
173 let b = await blocksToRecord(blocks, did);
174 return { firstPageBlocks: b, pages };
175
176 async function uploadImage(src: string) {
177 let data = await fetch(src);
178 if (data.status !== 200) return;
179 let binary = await data.blob();
180 return uploadLock.withLock(async () => {
181 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
182 headers: { "Content-Type": binary.type },
183 });
184 return blob.data.blob;
185 });
186 }
187 async function blocksToRecord(
188 blocks: Block[],
189 did: string,
190 ): Promise<PubLeafletPagesLinearDocument.Block[]> {
191 let parsedBlocks = parseBlocksToList(blocks);
192 return (
193 await Promise.all(
194 parsedBlocks.map(async (blockOrList) => {
195 if (blockOrList.type === "block") {
196 let alignmentValue = scan.eav(
197 blockOrList.block.value,
198 "block/text-alignment",
199 )[0]?.data.value;
200 let alignment: ExcludeString<
201 PubLeafletPagesLinearDocument.Block["alignment"]
202 > =
203 alignmentValue === "center"
204 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
205 : alignmentValue === "right"
206 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight"
207 : alignmentValue === "justify"
208 ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify"
209 : alignmentValue === "left"
210 ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft"
211 : undefined;
212 let b = await blockToRecord(blockOrList.block, did);
213 if (!b) return [];
214 let block: PubLeafletPagesLinearDocument.Block = {
215 $type: "pub.leaflet.pages.linearDocument#block",
216 alignment,
217 block: b,
218 };
219 return [block];
220 } else {
221 let block: PubLeafletPagesLinearDocument.Block = {
222 $type: "pub.leaflet.pages.linearDocument#block",
223 block: {
224 $type: "pub.leaflet.blocks.unorderedList",
225 children: await childrenToRecord(blockOrList.children, did),
226 },
227 };
228 return [block];
229 }
230 }),
231 )
232 ).flat();
233 }
234
235 async function childrenToRecord(children: List[], did: string) {
236 return (
237 await Promise.all(
238 children.map(async (child) => {
239 let content = await blockToRecord(child.block, did);
240 if (!content) return [];
241 let record: PubLeafletBlocksUnorderedList.ListItem = {
242 $type: "pub.leaflet.blocks.unorderedList#listItem",
243 content,
244 children: await childrenToRecord(child.children, did),
245 };
246 return record;
247 }),
248 )
249 ).flat();
250 }
251 async function blockToRecord(b: Block, did: string) {
252 const getBlockContent = (b: string) => {
253 let [content] = scan.eav(b, "block/text");
254 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
255 let doc = new Y.Doc();
256 const update = base64.toByteArray(content.data.value);
257 Y.applyUpdate(doc, update);
258 let nodes = doc.getXmlElement("prosemirror").toArray();
259 let stringValue = YJSFragmentToString(nodes[0]);
260 let facets = YJSFragmentToFacets(nodes[0]);
261 return [stringValue, facets] as const;
262 };
263 if (b.type === "card") {
264 let [page] = scan.eav(b.value, "block/card");
265 if (!page) return;
266 let [pageType] = scan.eav(page.data.value, "page/type");
267
268 if (pageType?.data.value === "canvas") {
269 let canvasBlocks = await canvasBlocksToRecord(page.data.value, did);
270 pages.push({
271 id: page.data.value,
272 blocks: canvasBlocks,
273 type: "canvas",
274 });
275 } else {
276 let blocks = getBlocksWithTypeLocal(facts, page.data.value);
277 pages.push({
278 id: page.data.value,
279 blocks: await blocksToRecord(blocks, did),
280 type: "doc",
281 });
282 }
283
284 let block: $Typed<PubLeafletBlocksPage.Main> = {
285 $type: "pub.leaflet.blocks.page",
286 id: page.data.value,
287 };
288 return block;
289 }
290
291 if (b.type === "bluesky-post") {
292 let [post] = scan.eav(b.value, "block/bluesky-post");
293 if (!post || !post.data.value.post) return;
294 let block: $Typed<PubLeafletBlocksBskyPost.Main> = {
295 $type: ids.PubLeafletBlocksBskyPost,
296 postRef: {
297 uri: post.data.value.post.uri,
298 cid: post.data.value.post.cid,
299 },
300 };
301 return block;
302 }
303 if (b.type === "horizontal-rule") {
304 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = {
305 $type: ids.PubLeafletBlocksHorizontalRule,
306 };
307 return block;
308 }
309
310 if (b.type === "heading") {
311 let [headingLevel] = scan.eav(b.value, "block/heading-level");
312
313 let [stringValue, facets] = getBlockContent(b.value);
314 let block: $Typed<PubLeafletBlocksHeader.Main> = {
315 $type: "pub.leaflet.blocks.header",
316 level: headingLevel?.data.value || 1,
317 plaintext: stringValue,
318 facets,
319 };
320 return block;
321 }
322
323 if (b.type === "blockquote") {
324 let [stringValue, facets] = getBlockContent(b.value);
325 let block: $Typed<PubLeafletBlocksBlockquote.Main> = {
326 $type: ids.PubLeafletBlocksBlockquote,
327 plaintext: stringValue,
328 facets,
329 };
330 return block;
331 }
332
333 if (b.type == "text") {
334 let [stringValue, facets] = getBlockContent(b.value);
335 let block: $Typed<PubLeafletBlocksText.Main> = {
336 $type: ids.PubLeafletBlocksText,
337 plaintext: stringValue,
338 facets,
339 };
340 return block;
341 }
342 if (b.type === "embed") {
343 let [url] = scan.eav(b.value, "embed/url");
344 let [height] = scan.eav(b.value, "embed/height");
345 if (!url) return;
346 let block: $Typed<PubLeafletBlocksIframe.Main> = {
347 $type: "pub.leaflet.blocks.iframe",
348 url: url.data.value,
349 height: height?.data.value || 600,
350 };
351 return block;
352 }
353 if (b.type == "image") {
354 let [image] = scan.eav(b.value, "block/image");
355 if (!image) return;
356 let [altText] = scan.eav(b.value, "image/alt");
357 let blobref = await uploadImage(image.data.src);
358 if (!blobref) return;
359 let block: $Typed<PubLeafletBlocksImage.Main> = {
360 $type: "pub.leaflet.blocks.image",
361 image: blobref,
362 aspectRatio: {
363 height: image.data.height,
364 width: image.data.width,
365 },
366 alt: altText ? altText.data.value : undefined,
367 };
368 return block;
369 }
370 if (b.type === "link") {
371 let [previewImage] = scan.eav(b.value, "link/preview");
372 let [description] = scan.eav(b.value, "link/description");
373 let [src] = scan.eav(b.value, "link/url");
374 if (!src) return;
375 let blobref = previewImage
376 ? await uploadImage(previewImage?.data.src)
377 : undefined;
378 let [title] = scan.eav(b.value, "link/title");
379 let block: $Typed<PubLeafletBlocksWebsite.Main> = {
380 $type: "pub.leaflet.blocks.website",
381 previewImage: blobref,
382 src: src.data.value,
383 description: description?.data.value,
384 title: title?.data.value,
385 };
386 return block;
387 }
388 if (b.type === "code") {
389 let [language] = scan.eav(b.value, "block/code-language");
390 let [code] = scan.eav(b.value, "block/code");
391 let [theme] = scan.eav(root_entity, "theme/code-theme");
392 let block: $Typed<PubLeafletBlocksCode.Main> = {
393 $type: "pub.leaflet.blocks.code",
394 language: language?.data.value,
395 plaintext: code?.data.value || "",
396 syntaxHighlightingTheme: theme?.data.value,
397 };
398 return block;
399 }
400 if (b.type === "math") {
401 let [math] = scan.eav(b.value, "block/math");
402 let block: $Typed<PubLeafletBlocksMath.Main> = {
403 $type: "pub.leaflet.blocks.math",
404 tex: math?.data.value || "",
405 };
406 return block;
407 }
408 if (b.type === "poll") {
409 // Get poll options from the entity
410 let pollOptions = scan.eav(b.value, "poll/options");
411 let options: PubLeafletPollDefinition.Option[] = pollOptions.map(
412 (opt) => {
413 let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0];
414 return {
415 $type: "pub.leaflet.poll.definition#option",
416 text: optionName?.data.value || "",
417 };
418 },
419 );
420
421 // Create the poll definition record
422 let pollRecord: PubLeafletPollDefinition.Record = {
423 $type: "pub.leaflet.poll.definition",
424 name: "Poll", // Default name, can be customized
425 options,
426 };
427
428 // Upload the poll record
429 let { data: pollResult } = await agent.com.atproto.repo.putRecord({
430 //use the entity id as the rkey so we can associate it in the editor
431 rkey: b.value,
432 repo: did,
433 collection: pollRecord.$type,
434 record: pollRecord,
435 validate: false,
436 });
437
438 // Optimistically write poll definition to database
439 console.log(
440 await supabaseServerClient.from("atp_poll_records").upsert({
441 uri: pollResult.uri,
442 cid: pollResult.cid,
443 record: pollRecord as Json,
444 }),
445 );
446
447 // Return a poll block with reference to the poll record
448 let block: $Typed<PubLeafletBlocksPoll.Main> = {
449 $type: "pub.leaflet.blocks.poll",
450 pollRef: {
451 uri: pollResult.uri,
452 cid: pollResult.cid,
453 },
454 };
455 return block;
456 }
457 if (b.type === "button") {
458 let [text] = scan.eav(b.value, "button/text");
459 let [url] = scan.eav(b.value, "button/url");
460 if (!text || !url) return;
461 let block: $Typed<PubLeafletBlocksButton.Main> = {
462 $type: "pub.leaflet.blocks.button",
463 text: text.data.value,
464 url: url.data.value,
465 };
466 return block;
467 }
468 return;
469 }
470
471 async function canvasBlocksToRecord(
472 pageID: string,
473 did: string,
474 ): Promise<PubLeafletPagesCanvas.Block[]> {
475 let canvasBlocks = scan.eav(pageID, "canvas/block");
476 return (
477 await Promise.all(
478 canvasBlocks.map(async (canvasBlock) => {
479 let blockEntity = canvasBlock.data.value;
480 let position = canvasBlock.data.position;
481
482 // Get the block content
483 let blockType = scan.eav(blockEntity, "block/type")?.[0];
484 if (!blockType) return null;
485
486 let block: Block = {
487 type: blockType.data.value,
488 value: blockEntity,
489 parent: pageID,
490 position: "",
491 factID: canvasBlock.id,
492 };
493
494 let content = await blockToRecord(block, did);
495 if (!content) return null;
496
497 // Get canvas-specific properties
498 let width =
499 scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360;
500 let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0]
501 ?.data.value;
502
503 let canvasBlockRecord: PubLeafletPagesCanvas.Block = {
504 $type: "pub.leaflet.pages.canvas#block",
505 block: content,
506 x: Math.floor(position.x),
507 y: Math.floor(position.y),
508 width: Math.floor(width),
509 ...(rotation !== undefined && { rotation: Math.floor(rotation) }),
510 };
511
512 return canvasBlockRecord;
513 }),
514 )
515 ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null);
516 }
517}
518
519function YJSFragmentToFacets(
520 node: Y.XmlElement | Y.XmlText | Y.XmlHook,
521): PubLeafletRichtextFacet.Main[] {
522 if (node.constructor === Y.XmlElement) {
523 return node
524 .toArray()
525 .map((f) => YJSFragmentToFacets(f))
526 .flat();
527 }
528 if (node.constructor === Y.XmlText) {
529 let facets: PubLeafletRichtextFacet.Main[] = [];
530 let delta = node.toDelta() as Delta[];
531 let byteStart = 0;
532 for (let d of delta) {
533 let unicodestring = new UnicodeString(d.insert);
534 let facet: PubLeafletRichtextFacet.Main = {
535 index: {
536 byteStart,
537 byteEnd: byteStart + unicodestring.length,
538 },
539 features: [],
540 };
541
542 if (d.attributes?.strikethrough)
543 facet.features.push({
544 $type: "pub.leaflet.richtext.facet#strikethrough",
545 });
546
547 if (d.attributes?.code)
548 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
549 if (d.attributes?.highlight)
550 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" });
551 if (d.attributes?.underline)
552 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" });
553 if (d.attributes?.strong)
554 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" });
555 if (d.attributes?.em)
556 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" });
557 if (d.attributes?.link)
558 facet.features.push({
559 $type: "pub.leaflet.richtext.facet#link",
560 uri: d.attributes.link.href,
561 });
562 if (facet.features.length > 0) facets.push(facet);
563 byteStart += unicodestring.length;
564 }
565 return facets;
566 }
567 return [];
568}
569
570type ExcludeString<T> = T extends string
571 ? string extends T
572 ? never
573 : T /* maybe literal, not the whole `string` */
574 : T; /* not a string */