a tool for shared writing and social publishing

add pubicon to atmention mark

+154 -1
+121
app/api/pub_icon/route.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { NextRequest, NextResponse } from "next/server"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + 8 + const idResolver = new IdResolver(); 9 + 10 + export const runtime = "nodejs"; 11 + 12 + export async function GET(req: NextRequest) { 13 + try { 14 + const searchParams = req.nextUrl.searchParams; 15 + const at_uri = searchParams.get("at_uri"); 16 + 17 + if (!at_uri) { 18 + return new NextResponse(null, { status: 400 }); 19 + } 20 + 21 + // Parse the AT URI 22 + let uri: AtUri; 23 + try { 24 + uri = new AtUri(at_uri); 25 + } catch (e) { 26 + return new NextResponse(null, { status: 400 }); 27 + } 28 + 29 + let publicationRecord: PubLeafletPublication.Record | null = null; 30 + let publicationUri: string; 31 + 32 + // Check if it's a document or publication 33 + if (uri.collection === "pub.leaflet.document") { 34 + // Query the documents_in_publications table to get the publication 35 + const { data: docInPub } = await supabaseServerClient 36 + .from("documents_in_publications") 37 + .select("publication, publications(record)") 38 + .eq("document", at_uri) 39 + .single(); 40 + 41 + if (!docInPub || !docInPub.publications) { 42 + return new NextResponse(null, { status: 404 }); 43 + } 44 + 45 + publicationUri = docInPub.publication; 46 + publicationRecord = docInPub.publications.record as PubLeafletPublication.Record; 47 + } else if (uri.collection === "pub.leaflet.publication") { 48 + // Query the publications table directly 49 + const { data: publication } = await supabaseServerClient 50 + .from("publications") 51 + .select("record, uri") 52 + .eq("uri", at_uri) 53 + .single(); 54 + 55 + if (!publication || !publication.record) { 56 + return new NextResponse(null, { status: 404 }); 57 + } 58 + 59 + publicationUri = publication.uri; 60 + publicationRecord = publication.record as PubLeafletPublication.Record; 61 + } else { 62 + // Not a supported collection 63 + return new NextResponse(null, { status: 404 }); 64 + } 65 + 66 + // Check if the publication has an icon 67 + if (!publicationRecord?.icon) { 68 + return new NextResponse(null, { status: 404 }); 69 + } 70 + 71 + // Parse the publication URI to get the DID 72 + const pubUri = new AtUri(publicationUri); 73 + 74 + // Get the CID from the icon blob 75 + const cid = (publicationRecord.icon.ref as unknown as { $link: string })["$link"]; 76 + 77 + // Fetch the blob from the PDS 78 + const identity = await idResolver.did.resolve(pubUri.host); 79 + const service = identity?.service?.find((f) => f.id === "#atproto_pds"); 80 + if (!service) return new NextResponse(null, { status: 404 }); 81 + 82 + const blobResponse = await fetch( 83 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, 84 + { 85 + headers: { 86 + "Accept-Encoding": "gzip, deflate, br, zstd", 87 + }, 88 + }, 89 + ); 90 + 91 + if (!blobResponse.ok) { 92 + return new NextResponse(null, { status: 404 }); 93 + } 94 + 95 + // Get the image buffer 96 + const imageBuffer = await blobResponse.arrayBuffer(); 97 + 98 + // Resize to 96x96 using Sharp 99 + const resizedImage = await sharp(Buffer.from(imageBuffer)) 100 + .resize(96, 96, { 101 + fit: "cover", 102 + position: "center", 103 + }) 104 + .webp({ quality: 90 }) 105 + .toBuffer(); 106 + 107 + // Return with aggressive caching headers 108 + return new NextResponse(resizedImage, { 109 + headers: { 110 + "Content-Type": "image/webp", 111 + // Cache indefinitely on CDN, icons don't change 112 + "Cache-Control": 113 + "public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=604800", 114 + "CDN-Cache-Control": "s-maxage=31536000, stale-while-revalidate=604800", 115 + }, 116 + }); 117 + } catch (error) { 118 + console.error("Error fetching publication icon:", error); 119 + return new NextResponse(null, { status: 500 }); 120 + } 121 + }
+33 -1
components/Blocks/TextBlock/schema.ts
··· 1 + import { AtUri } from "@atproto/api"; 1 2 import { Schema, Node, MarkSpec } from "prosemirror-model"; 2 3 import { marks } from "prosemirror-schema-basic"; 3 4 import { theme } from "tailwind.config"; ··· 119 120 }, 120 121 ], 121 122 toDOM(node) { 123 + let className = "atMention text-accent-contrast"; 124 + let aturi = new AtUri(node.attrs.atURI); 125 + if (aturi.collection === "pub.leaflet.publication") 126 + className += " font-bold"; 127 + if (aturi.collection === "pub.leaflet.document") className += " italic"; 128 + 129 + // For publications and documents, show icon 130 + if ( 131 + aturi.collection === "pub.leaflet.publication" || 132 + aturi.collection === "pub.leaflet.document" 133 + ) { 134 + return [ 135 + "span", 136 + { 137 + class: className, 138 + "data-at-uri": node.attrs.atURI, 139 + }, 140 + [ 141 + "img", 142 + { 143 + src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 144 + class: 145 + "inline-block w-4 h-4 rounded-full ml-1 align-text-bottom", 146 + alt: "Publication icon", 147 + loading: "lazy", 148 + }, 149 + ], 150 + ["span", 0], 151 + ]; 152 + } 153 + 122 154 return [ 123 155 "span", 124 156 { 125 - class: "atMention text-accent-contrast", 157 + class: className, 126 158 "data-at-uri": node.attrs.atURI, 127 159 }, 128 160 0,