grain.social is a photo sharing platform built on atproto.
grain.social
atproto
photography
appview
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}