grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
49
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 111 lines 3.2 kB view raw
1import { callXrpc } from "$hatk/client"; 2import { formatStoredLocation } from "$lib/utils/formatLocation"; 3import { parseTextToFacets } from "$lib/utils/rich-text"; 4 5interface BskyPostOptions { 6 url: string; 7 title?: string; 8 location?: { 9 name: string; 10 address?: { 11 locality?: string; 12 region?: string; 13 country?: string; 14 }; 15 } | null; 16 description?: string; 17 images: Array<{ 18 dataUrl: string; 19 alt?: string; 20 width: number; 21 height: number; 22 }>; 23} 24 25export async function createBskyPost(options: BskyPostOptions): Promise<void> { 26 const { url, title, location, description, images } = options; 27 28 const graphemeLength = (s: string) => [...new Intl.Segmenter().segment(s)].length; 29 30 const locationLine = location ? `📍 ${formatStoredLocation(location, location.address)}` : null; 31 32 // Build suffix (location + hashtag + link) 33 const suffixLines: string[] = []; 34 if (locationLine) { 35 suffixLines.push(""); 36 suffixLines.push(locationLine); 37 } 38 suffixLines.push(""); 39 suffixLines.push(`#GrainSocial ${url}`); 40 const suffix = suffixLines.join("\n"); 41 42 const maxContent = 300 - graphemeLength(suffix); 43 44 // Build title + description content 45 let content = ""; 46 const t = title?.trim() ?? ""; 47 const d = description?.trim() ?? ""; 48 if (t && d) content = `${t}, ${d}`; 49 else if (t) content = t; 50 else if (d) content = d; 51 52 if (content && graphemeLength(content) > maxContent) { 53 const segments = [...new Intl.Segmenter().segment(content)]; 54 content = 55 segments 56 .slice(0, Math.max(0, maxContent - 1)) 57 .map((s) => s.segment) 58 .join("") + "…"; 59 } 60 61 const lines: string[] = []; 62 if (content) lines.push(content); 63 lines.push(...suffixLines); 64 65 const postText = lines.join("\n"); 66 67 const resolveHandle = async (handle: string): Promise<string | null> => { 68 try { 69 const res = await fetch( 70 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 71 ); 72 if (!res.ok) return null; 73 const data = await res.json(); 74 return data.did ?? null; 75 } catch { 76 return null; 77 } 78 }; 79 const postFacets = (await parseTextToFacets(postText, resolveHandle)).facets; 80 81 const imageRefs: Array<{ 82 image: any; 83 alt: string; 84 aspectRatio?: { width: number; height: number }; 85 }> = []; 86 for (const img of images.slice(0, 4)) { 87 const base64 = img.dataUrl.split(",")[1]; 88 const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); 89 const blob = new Blob([binary], { type: "image/jpeg" }); 90 const uploadResult = await callXrpc("dev.hatk.uploadBlob", blob as any); 91 imageRefs.push({ 92 image: (uploadResult as any).blob, 93 alt: img.alt || "", 94 aspectRatio: { width: img.width, height: img.height }, 95 }); 96 } 97 98 await callXrpc("dev.hatk.createRecord", { 99 collection: "app.bsky.feed.post", 100 record: { 101 text: postText, 102 facets: postFacets.length > 0 ? postFacets : undefined, 103 embed: 104 imageRefs.length > 0 105 ? { $type: "app.bsky.embed.images" as const, images: imageRefs } 106 : undefined, 107 tags: ["grainsocial"], 108 createdAt: new Date().toISOString(), 109 }, 110 }); 111}