a tool for shared writing and social publishing
at feature/reader 457 lines 14 kB view raw
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}