0
2
import { callXrpc } from "$hatk/client";
import { formatStoredLocation } from "$lib/utils/formatLocation";
import { parseTextToFacets } from "$lib/utils/rich-text";
interface BskyPostOptions {
url: string;
title?: string;
location?: {
// Optional to match community.lexicon.location.hthree, where only `value`
// (the H3 code) is required. formatStoredLocation already handles a
// missing name.
name?: string;
address?: {
locality?: string;
region?: string;
country?: string;
};
} | null;
description?: string;
images: Array<{
dataUrl: string;
alt?: string;
width: number;
height: number;
}>;
}
export async function createBskyPost(options: BskyPostOptions): Promise<void> {
const { url, title, location, description, images } = options;
const graphemeLength = (s: string) => [...new Intl.Segmenter().segment(s)].length;
const locationLine = location ? `📍 ${formatStoredLocation(location, location.address)}` : null;
// Build suffix (location + hashtag + link)
const suffixLines: string[] = [];
if (locationLine) {
suffixLines.push("");
suffixLines.push(locationLine);
}
suffixLines.push("");
suffixLines.push(`#GrainSocial ${url}`);
const suffix = suffixLines.join("\n");
const maxContent = 300 - graphemeLength(suffix);
// Build title + description content
let content = "";
const t = title?.trim() ?? "";
const d = description?.trim() ?? "";
if (t && d) content = `${t}, ${d}`;
else if (t) content = t;
else if (d) content = d;
if (content && graphemeLength(content) > maxContent) {
const segments = [...new Intl.Segmenter().segment(content)];
content =
segments
.slice(0, Math.max(0, maxContent - 1))
.map((s) => s.segment)
.join("") + "…";
}
const lines: string[] = [];
if (content) lines.push(content);
lines.push(...suffixLines);
const postText = lines.join("\n");
const resolveHandle = async (handle: string): Promise<string | null> => {
try {
const res = await fetch(
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
);
if (!res.ok) return null;
const data = await res.json();
return data.did ?? null;
} catch {
return null;
}
};
const postFacets = (await parseTextToFacets(postText, resolveHandle)).facets;
const imageRefs: Array<{
image: any;
alt: string;
aspectRatio?: { width: number; height: number };
}> = [];
for (const img of images.slice(0, 4)) {
const base64 = img.dataUrl.split(",")[1];
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const blob = new Blob([binary], { type: "image/jpeg" });
const uploadResult = await callXrpc("dev.hatk.uploadBlob", blob as any);
imageRefs.push({
image: (uploadResult as any).blob,
alt: img.alt || "",
aspectRatio: { width: img.width, height: img.height },
});
}
await callXrpc("dev.hatk.createRecord", {
collection: "app.bsky.feed.post",
record: {
text: postText,
facets: postFacets.length > 0 ? postFacets : undefined,
embed:
imageRefs.length > 0
? { $type: "app.bsky.embed.images" as const, images: imageRefs }
: undefined,
tags: ["grainsocial"],
createdAt: new Date().toISOString(),
},
});
}