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 PubLeafletRichtextFacet,
16 PubLeafletBlocksWebsite,
17 PubLeafletBlocksCode,
18 PubLeafletBlocksMath,
19 PubLeafletBlocksHorizontalRule,
20 PubLeafletBlocksBskyPost,
21 PubLeafletBlocksBlockquote,
22 PubLeafletBlocksIframe,
23} from "lexicons/api";
24import { Block } from "components/Blocks/Block";
25import { TID } from "@atproto/common";
26import { supabaseServerClient } from "supabase/serverClient";
27import { scanIndexLocal } from "src/replicache/utils";
28import type { Fact } from "src/replicache";
29import type { Attribute } from "src/replicache/attributes";
30import {
31 Delta,
32 YJSFragmentToString,
33} from "components/Blocks/TextBlock/RenderYJSFragment";
34import { ids } from "lexicons/api/lexicons";
35import { BlobRef } from "@atproto/lexicon";
36import { AtUri } from "@atproto/syntax";
37import { Json } from "supabase/database.types";
38import { $Typed, UnicodeString } from "@atproto/api";
39import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
40import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
41
42export async function publishToPublication({
43 root_entity,
44 publication_uri,
45 leaflet_id,
46 title,
47 description,
48}: {
49 root_entity: string;
50 publication_uri: string;
51 leaflet_id: string;
52 title?: string;
53 description?: string;
54}) {
55 const oauthClient = await createOauthClient();
56 let identity = await getIdentityData();
57 if (!identity || !identity.atp_did) throw new Error("No Identity");
58
59 let credentialSession = await oauthClient.restore(identity.atp_did);
60 let agent = new AtpBaseClient(
61 credentialSession.fetchHandler.bind(credentialSession),
62 );
63 let { data: draft } = await supabaseServerClient
64 .from("leaflets_in_publications")
65 .select("*, publications(*), documents(*)")
66 .eq("publication", publication_uri)
67 .eq("leaflet", leaflet_id)
68 .single();
69 if (!draft || identity.atp_did !== draft?.publications?.identity_did)
70 throw new Error("No draft or not publisher");
71 let { data } = await supabaseServerClient.rpc("get_facts", {
72 root: root_entity,
73 });
74 let facts = (data as unknown as Fact<Attribute>[]) || [];
75 let scan = scanIndexLocal(facts);
76 let firstEntity = scan.eav(root_entity, "root/page")?.[0];
77 if (!firstEntity) throw new Error("No root page");
78 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
79
80 let images = blocks
81 .filter((b) => b.type === "image")
82 .map((b) => scan.eav(b.value, "block/image")[0]);
83 let links = blocks
84 .filter((b) => b.type == "link")
85 .map((b) => scan.eav(b.value, "link/preview")[0]);
86 let imageMap = new Map<string, BlobRef>();
87 for (const b of [...links, ...images]) {
88 if (!b) continue;
89 let data = await fetch(b.data.src);
90 if (data.status !== 200) continue;
91 let binary = await data.blob();
92 try {
93 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
94 headers: { "Content-Type": binary.type },
95 });
96 if (!blob.success) {
97 console.log(blob);
98 console.log("Error uploading image: " + b.data.src);
99 throw new Error("Failed to upload image");
100 }
101 imageMap.set(b.data.src, blob.data.blob);
102 } catch (e) {
103 console.error(e);
104 console.log("Error uploading image: " + b.data.src);
105 throw new Error("Failed to upload image");
106 }
107 }
108
109 let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord(
110 blocks,
111 imageMap,
112 scan,
113 root_entity,
114 );
115
116 let existingRecord =
117 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {};
118 let record: PubLeafletDocument.Record = {
119 $type: "pub.leaflet.document",
120 author: credentialSession.did!,
121 publication: publication_uri,
122 publishedAt: new Date().toISOString(),
123 ...existingRecord,
124 title: title || "Untitled",
125 description: description || "",
126 pages: [
127 {
128 $type: "pub.leaflet.pages.linearDocument",
129 blocks: b,
130 },
131 ],
132 };
133 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr();
134 let { data: result } = await agent.com.atproto.repo.putRecord({
135 rkey,
136 repo: credentialSession.did!,
137 collection: record.$type,
138 record,
139 validate: false, //TODO publish the lexicon so we can validate!
140 });
141
142 await supabaseServerClient.from("documents").upsert({
143 uri: result.uri,
144 data: record as Json,
145 });
146 await Promise.all([
147 //Optimistically put these in!
148 supabaseServerClient.from("documents_in_publications").upsert({
149 publication: record.publication,
150 document: result.uri,
151 }),
152 supabaseServerClient
153 .from("leaflets_in_publications")
154 .update({
155 doc: result.uri,
156 })
157 .eq("leaflet", leaflet_id)
158 .eq("publication", publication_uri),
159 ]);
160
161 return { rkey, record: JSON.parse(JSON.stringify(record)) };
162}
163
164function blocksToRecord(
165 blocks: Block[],
166 imageMap: Map<string, BlobRef>,
167 scan: ReturnType<typeof scanIndexLocal>,
168 root_entity: string,
169): PubLeafletPagesLinearDocument.Block[] {
170 let parsedBlocks = parseBlocksToList(blocks);
171 return parsedBlocks.flatMap((blockOrList) => {
172 if (blockOrList.type === "block") {
173 let alignmentValue =
174 scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data
175 .value || "left";
176 let alignment =
177 alignmentValue === "center"
178 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
179 : alignmentValue === "right"
180 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight"
181 : undefined;
182 let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity);
183 if (!b) return [];
184 let block: PubLeafletPagesLinearDocument.Block = {
185 $type: "pub.leaflet.pages.linearDocument#block",
186 alignment,
187 block: b,
188 };
189 return [block];
190 } else {
191 let block: PubLeafletPagesLinearDocument.Block = {
192 $type: "pub.leaflet.pages.linearDocument#block",
193 block: {
194 $type: "pub.leaflet.blocks.unorderedList",
195 children: childrenToRecord(
196 blockOrList.children,
197 imageMap,
198 scan,
199 root_entity,
200 ),
201 },
202 };
203 return [block];
204 }
205 });
206}
207
208function childrenToRecord(
209 children: List[],
210 imageMap: Map<string, BlobRef>,
211 scan: ReturnType<typeof scanIndexLocal>,
212 root_entity: string,
213) {
214 return children.flatMap((child) => {
215 let content = blockToRecord(child.block, imageMap, scan, root_entity);
216 if (!content) return [];
217 let record: PubLeafletBlocksUnorderedList.ListItem = {
218 $type: "pub.leaflet.blocks.unorderedList#listItem",
219 content,
220 children: childrenToRecord(child.children, imageMap, scan, root_entity),
221 };
222 return record;
223 });
224}
225function blockToRecord(
226 b: Block,
227 imageMap: Map<string, BlobRef>,
228 scan: ReturnType<typeof scanIndexLocal>,
229 root_entity: string,
230) {
231 const getBlockContent = (b: string) => {
232 let [content] = scan.eav(b, "block/text");
233 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
234 let doc = new Y.Doc();
235 const update = base64.toByteArray(content.data.value);
236 Y.applyUpdate(doc, update);
237 let nodes = doc.getXmlElement("prosemirror").toArray();
238 let stringValue = YJSFragmentToString(nodes[0]);
239 let facets = YJSFragmentToFacets(nodes[0]);
240 return [stringValue, facets] as const;
241 };
242
243 if (b.type === "bluesky-post") {
244 let [post] = scan.eav(b.value, "block/bluesky-post");
245 if (!post || !post.data.value.post) return;
246 let block: $Typed<PubLeafletBlocksBskyPost.Main> = {
247 $type: ids.PubLeafletBlocksBskyPost,
248 postRef: {
249 uri: post.data.value.post.uri,
250 cid: post.data.value.post.cid,
251 },
252 };
253 return block;
254 }
255 if (b.type === "horizontal-rule") {
256 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = {
257 $type: ids.PubLeafletBlocksHorizontalRule,
258 };
259 return block;
260 }
261
262 if (b.type === "heading") {
263 let [headingLevel] = scan.eav(b.value, "block/heading-level");
264
265 let [stringValue, facets] = getBlockContent(b.value);
266 let block: $Typed<PubLeafletBlocksHeader.Main> = {
267 $type: "pub.leaflet.blocks.header",
268 level: headingLevel?.data.value || 1,
269 plaintext: stringValue,
270 facets,
271 };
272 return block;
273 }
274
275 if (b.type === "blockquote") {
276 let [stringValue, facets] = getBlockContent(b.value);
277 let block: $Typed<PubLeafletBlocksBlockquote.Main> = {
278 $type: ids.PubLeafletBlocksBlockquote,
279 plaintext: stringValue,
280 facets,
281 };
282 return block;
283 }
284
285 if (b.type == "text") {
286 let [stringValue, facets] = getBlockContent(b.value);
287 let block: $Typed<PubLeafletBlocksText.Main> = {
288 $type: ids.PubLeafletBlocksText,
289 plaintext: stringValue,
290 facets,
291 };
292 return block;
293 }
294 if (b.type === "embed") {
295 let [url] = scan.eav(b.value, "embed/url");
296 let [height] = scan.eav(b.value, "embed/height");
297 if (!url) return;
298 let block: $Typed<PubLeafletBlocksIframe.Main> = {
299 $type: "pub.leaflet.blocks.iframe",
300 url: url.data.value,
301 height: Math.floor(height?.data.value || 600),
302 };
303 return block;
304 }
305 if (b.type == "image") {
306 let [image] = scan.eav(b.value, "block/image");
307 if (!image) return;
308 let [altText] = scan.eav(b.value, "image/alt");
309 let blobref = imageMap.get(image.data.src);
310 if (!blobref) return;
311 let block: $Typed<PubLeafletBlocksImage.Main> = {
312 $type: "pub.leaflet.blocks.image",
313 image: blobref,
314 aspectRatio: {
315 height: image.data.height,
316 width: image.data.width,
317 },
318 alt: altText ? altText.data.value : undefined,
319 };
320 return block;
321 }
322 if (b.type === "link") {
323 let [previewImage] = scan.eav(b.value, "link/preview");
324 let [description] = scan.eav(b.value, "link/description");
325 let [src] = scan.eav(b.value, "link/url");
326 if (!src) return;
327 let blobref = previewImage
328 ? imageMap.get(previewImage?.data.src)
329 : undefined;
330 let [title] = scan.eav(b.value, "link/title");
331 let block: $Typed<PubLeafletBlocksWebsite.Main> = {
332 $type: "pub.leaflet.blocks.website",
333 previewImage: blobref,
334 src: src.data.value,
335 description: description?.data.value,
336 title: title?.data.value,
337 };
338 return block;
339 }
340 if (b.type === "code") {
341 let [language] = scan.eav(b.value, "block/code-language");
342 let [code] = scan.eav(b.value, "block/code");
343 let [theme] = scan.eav(root_entity, "theme/code-theme");
344 let block: $Typed<PubLeafletBlocksCode.Main> = {
345 $type: "pub.leaflet.blocks.code",
346 language: language?.data.value,
347 plaintext: code?.data.value || "",
348 syntaxHighlightingTheme: theme?.data.value,
349 };
350 return block;
351 }
352 if (b.type === "math") {
353 let [math] = scan.eav(b.value, "block/math");
354 let block: $Typed<PubLeafletBlocksMath.Main> = {
355 $type: "pub.leaflet.blocks.math",
356 tex: math?.data.value || "",
357 };
358 return block;
359 }
360 return;
361}
362
363async function sendPostToEmailSubscribers(
364 publication_uri: string,
365 post: { content: string; title: string },
366) {
367 let { data: publication } = await supabaseServerClient
368 .from("publications")
369 .select("*, subscribers_to_publications(*)")
370 .eq("uri", publication_uri)
371 .single();
372
373 let res = await fetch("https://api.postmarkapp.com/email/batch", {
374 method: "POST",
375 headers: {
376 "Content-Type": "application/json",
377 "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!,
378 },
379 body: JSON.stringify(
380 publication?.subscribers_to_publications.map((sub) => ({
381 Headers: [
382 {
383 Name: "List-Unsubscribe-Post",
384 Value: "List-Unsubscribe=One-Click",
385 },
386 {
387 Name: "List-Unsubscribe",
388 Value: `<${"TODO"}/mail/unsubscribe?sub_id=${sub.identity}>`,
389 },
390 ],
391 MessageStream: "broadcast",
392 From: `${publication.name} <mailbox@leaflet.pub>`,
393 Subject: post.title,
394 To: sub.identity,
395 HtmlBody: `
396 <h1>${publication.name}</h1>
397 <hr style="margin-top: 1em; margin-bottom: 1em;">
398 ${post.content}
399 <hr style="margin-top: 1em; margin-bottom: 1em;">
400 This is a super alpha release! Ask Jared if you want to unsubscribe (sorry)
401 `,
402 TextBody: post.content,
403 })),
404 ),
405 });
406}
407
408function YJSFragmentToFacets(
409 node: Y.XmlElement | Y.XmlText | Y.XmlHook,
410): PubLeafletRichtextFacet.Main[] {
411 if (node.constructor === Y.XmlElement) {
412 return node
413 .toArray()
414 .map((f) => YJSFragmentToFacets(f))
415 .flat();
416 }
417 if (node.constructor === Y.XmlText) {
418 let facets: PubLeafletRichtextFacet.Main[] = [];
419 let delta = node.toDelta() as Delta[];
420 let byteStart = 0;
421 for (let d of delta) {
422 let unicodestring = new UnicodeString(d.insert);
423 let facet: PubLeafletRichtextFacet.Main = {
424 index: {
425 byteStart,
426 byteEnd: byteStart + unicodestring.length,
427 },
428 features: [],
429 };
430
431 if (d.attributes?.strikethrough)
432 facet.features.push({
433 $type: "pub.leaflet.richtext.facet#strikethrough",
434 });
435
436 if (d.attributes?.code)
437 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
438 if (d.attributes?.highlight)
439 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" });
440 if (d.attributes?.underline)
441 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" });
442 if (d.attributes?.strong)
443 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" });
444 if (d.attributes?.em)
445 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" });
446 if (d.attributes?.link)
447 facet.features.push({
448 $type: "pub.leaflet.richtext.facet#link",
449 uri: d.attributes.link.href,
450 });
451 if (facet.features.length > 0) facets.push(facet);
452 byteStart += unicodestring.length;
453 }
454 return facets;
455 }
456 return [];
457}