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

Configure Feed

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

feat: update Bluesky cross-post format with shortened location

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+41 -32
+7
app/lib/components/molecules/StoryCreate.svelte
··· 196 196 <div class="divider"></div> 197 197 <div class="bsky-field"> 198 198 <Checkbox bind:checked={postToBluesky} label="Post to Bluesky" /> 199 + <span class="bsky-hint">Includes location and photo.</span> 199 200 </div> 200 201 {/if} 201 202 </div> ··· 302 303 } 303 304 .bsky-field { 304 305 padding: 8px 16px 12px; 306 + } 307 + .bsky-hint { 308 + display: block; 309 + font-size: 12px; 310 + color: var(--text-muted); 311 + margin-top: 4px; 305 312 } 306 313 </style>
+34 -32
app/lib/utils/bsky-post.ts
··· 3 3 4 4 interface BskyPostOptions { 5 5 url: string; 6 + title?: string; 6 7 location?: { 7 8 name: string; 8 9 address?: { ··· 21 22 } 22 23 23 24 export async function createBskyPost(options: BskyPostOptions): Promise<void> { 24 - const { url, location, description, images } = options; 25 + const { url, title, location, description, images } = options; 25 26 26 27 const graphemeLength = (s: string) => [...new Intl.Segmenter().segment(s)].length; 27 28 28 - const lines: string[] = []; 29 + // Build location line (shortened: name, region, country) 30 + let locationLine: string | null = null; 29 31 if (location) { 30 - lines.push(`📍 ${location.name}`); 31 - if (location.address) { 32 - const parts: string[] = []; 33 - if (location.address.locality) parts.push(location.address.locality); 34 - if (location.address.region) parts.push(location.address.region); 35 - if (location.address.country) parts.push(location.address.country); 36 - if (parts.length > 0) lines.push(parts.join(", ")); 37 - } 32 + const parts = [location.name]; 33 + if (location.address?.region) parts.push(location.address.region); 34 + if (location.address?.country) parts.push(location.address.country); 35 + locationLine = `📍 ${parts.join(", ")}`; 38 36 } 39 37 40 - const suffix = `\n\n${url}\n\n#grainsocial`; 41 - const prefixText = lines.length > 0 ? lines.join("\n") + "\n" : ""; 42 - const overhead = graphemeLength(prefixText + suffix); 43 - const maxDesc = 300 - overhead; 38 + // Build suffix (location + hashtag + link) 39 + const suffixLines: string[] = []; 40 + if (locationLine) { 41 + suffixLines.push(""); 42 + suffixLines.push(locationLine); 43 + } 44 + suffixLines.push(""); 45 + suffixLines.push(`#GrainSocial ${url}`); 46 + const suffix = suffixLines.join("\n"); 44 47 45 - if (description?.trim()) { 46 - let desc = description.trim(); 47 - if (graphemeLength(desc) > maxDesc) { 48 - const segments = [...new Intl.Segmenter().segment(desc)]; 49 - desc = 50 - segments 51 - .slice(0, Math.max(0, maxDesc - 1)) 52 - .map((s) => s.segment) 53 - .join("") + "…"; 54 - } 55 - if (desc) { 56 - lines.push(""); 57 - lines.push(desc); 58 - } 48 + const maxContent = 300 - graphemeLength(suffix); 49 + 50 + // Build title + description content 51 + let content = ""; 52 + const t = title?.trim() ?? ""; 53 + const d = description?.trim() ?? ""; 54 + if (t && d) content = `${t}, ${d}`; 55 + else if (t) content = t; 56 + else if (d) content = d; 57 + 58 + if (content && graphemeLength(content) > maxContent) { 59 + const segments = [...new Intl.Segmenter().segment(content)]; 60 + content = segments.slice(0, Math.max(0, maxContent - 1)).map((s) => s.segment).join("") + "…"; 59 61 } 60 - lines.push(""); 61 - lines.push(url); 62 - lines.push(""); 63 - lines.push("#grainsocial"); 62 + 63 + const lines: string[] = []; 64 + if (content) lines.push(content); 65 + lines.push(...suffixLines); 64 66 65 67 const postText = lines.join("\n"); 66 68