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: show bsky cross-post link in story viewer and DRY up lookup

- Extract BskyIcon atom, use in gallery detail and story viewer
- Extract shared lookupCrossPosts helper in _hydrate.ts
- Hydrate crossPost in getStories endpoint
- Use path-only search for cross-post matching across environments

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

+65 -32
+5
app/lib/components/atoms/BskyIcon.svelte
··· 1 + <script lang="ts"> 2 + let { size = 18 }: { size?: number } = $props() 3 + </script> 4 + 5 + <svg width={size} height={size} viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.889-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.135-1.612 123.121 33.664Z"/></svg>
+24
app/lib/components/organisms/StoryViewer.svelte
··· 7 7 import { viewer as viewerStore } from '$lib/stores' 8 8 import { resolveLabels, labelDefsQuery } from '$lib/labels' 9 9 import ReportButton from '$lib/components/molecules/ReportButton.svelte' 10 + import BskyIcon from '$lib/components/atoms/BskyIcon.svelte' 10 11 11 12 let { 12 13 initialDid, ··· 56 57 const currentStory = $derived(stories.data?.[currentStoryIndex]) 57 58 const totalStories = $derived(stories.data?.length ?? 0) 58 59 const isOwn = $derived(currentDid === $viewerStore?.did) 60 + const bskyUrl = $derived((currentStory as any)?.crossPost?.url ?? null) 61 + 59 62 let deleting = $state(false) 60 63 61 64 // Label moderation ··· 279 282 </div> 280 283 {/if} 281 284 285 + <!-- Bluesky cross-post link --> 286 + {#if bskyUrl} 287 + <a class="bsky-link" href={bskyUrl} target="_blank" rel="noopener noreferrer" title="View on Bluesky" onclick={(e) => e.stopPropagation()}> 288 + <BskyIcon size={16} /> 289 + </a> 290 + {/if} 291 + 282 292 <!-- Location overlay --> 283 293 {#if currentStory.location} 284 294 <div class="story-location"> ··· 459 469 border-radius: 8px; 460 470 font-size: 13px; 461 471 cursor: pointer; 472 + backdrop-filter: blur(4px); 473 + } 474 + 475 + /* Bluesky link */ 476 + .bsky-link { 477 + position: absolute; 478 + bottom: 24px; 479 + right: 12px; 480 + display: flex; 481 + align-items: center; 482 + color: white; 483 + background: rgba(0, 0, 0, 0.4); 484 + padding: 6px; 485 + border-radius: 50%; 462 486 backdrop-filter: blur(4px); 463 487 } 464 488
+3 -1
app/routes/profile/[did]/gallery/[rkey]/+page.svelte
··· 9 9 import CommentSheet from '$lib/components/organisms/CommentSheet.svelte' 10 10 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 11 11 import { Trash2 } from 'lucide-svelte' 12 + import BskyIcon from '$lib/components/atoms/BskyIcon.svelte' 12 13 import type { GalleryView, PhotoView } from '$hatk/client' 13 14 14 15 let { data } = $props() ··· 66 67 {#snippet actions()} 67 68 {#if bskyUrl} 68 69 <a class="bsky-link" href={bskyUrl} target="_blank" rel="noopener noreferrer" title="View on Bluesky"> 69 - <svg width="18" height="18" viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.889-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.135-1.612 123.121 33.664Z"/></svg> 70 + <BskyIcon /> 70 71 </a> 71 72 {/if} 72 73 {#if isOwner} ··· 110 111 display: flex; 111 112 align-items: center; 112 113 padding: 4px; 114 + margin-right: 8px; 113 115 transition: color 0.15s; 114 116 } 115 117 .bsky-link:hover {
+24 -20
server/feeds/_hydrate.ts
··· 58 58 }); 59 59 } 60 60 61 + /** Look up Bluesky cross-posts for a set of grain URIs by searching bsky post text. */ 62 + export async function lookupCrossPosts( 63 + db: BaseContext["db"], 64 + items: Array<{ uri: string; did: string }>, 65 + collection: "gallery" | "story", 66 + ): Promise<Map<string, string>> { 67 + const map = new Map<string, string>(); 68 + for (const item of items) { 69 + const rkey = item.uri.split("/").pop(); 70 + const url = `/profile/${item.did}/${collection}/${rkey}`; 71 + const rows = (await db.query( 72 + `SELECT uri FROM "app.bsky.feed.post" WHERE did = $1 AND "text" LIKE '%' || $2 || '%' LIMIT 1`, 73 + [item.did, url], 74 + )) as Array<{ uri: string }>; 75 + if (rows.length) { 76 + const postRkey = rows[0].uri.split("/").pop(); 77 + map.set(item.uri, `https://bsky.app/profile/${item.did}/post/${postRkey}`); 78 + } 79 + } 80 + return map; 81 + } 82 + 61 83 /** Shared hydration for gallery feeds — resolves photos, profiles, fav/comment counts. */ 62 84 export async function hydrateGalleries( 63 85 ctx: BaseContext, ··· 111 133 }> 112 134 >) 113 135 : Promise.resolve([]), 114 - // Cross-post lookup: find Bluesky posts that link back to these galleries 115 - galleryUris.length > 0 116 - ? (async () => { 117 - const map = new Map<string, string>(); 118 - for (const item of items) { 119 - const rkey = item.uri.split("/").pop(); 120 - const url = `grain.social/profile/${item.did}/gallery/${rkey}`; 121 - const rows = (await ctx.db.query( 122 - `SELECT uri FROM "app.bsky.feed.post" WHERE did = $1 AND "text" LIKE '%' || $2 || '%' LIMIT 1`, 123 - [item.did, url], 124 - )) as Array<{ uri: string }>; 125 - if (rows.length) { 126 - const postRkey = rows[0].uri.split("/").pop(); 127 - map.set(item.uri, `https://bsky.app/profile/${item.did}/post/${postRkey}`); 128 - } 129 - } 130 - return map; 131 - })() 132 - : Promise.resolve(new Map<string, string>()), 136 + lookupCrossPosts(ctx.db, items, "gallery"), 133 137 ]); 134 138 135 139 // Group gallery items by gallery URI ··· 217 221 : {}), 218 222 ...(labelsByUri.has(item.uri) ? { labels: labelsByUri.get(item.uri) } : {}), 219 223 ...(viewerFavs.has(item.uri) ? { viewer: { fav: viewerFavs.get(item.uri) } } : {}), 220 - ...(crossPosts.has(item.uri) ? { crossPost: { url: crossPosts.get(item.uri) } } : {}), 224 + ...(crossPosts.has(item.uri) ? { crossPost: { url: crossPosts.get(item.uri)! } } : {}), 221 225 }); 222 226 }); 223 227 }
+5
server/xrpc/getStories.ts
··· 2 2 import { views } from "$hatk"; 3 3 import type { GrainActorProfile, Story, Label } from "$hatk"; 4 4 import { HIDE_LABELS } from "../labels/_hidden.ts"; 5 + import { lookupCrossPosts } from "../feeds/_hydrate.ts"; 5 6 6 7 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; 7 8 ··· 65 66 return !labels.some((l) => HIDE_LABELS.has(l.val) && !l.neg); 66 67 }); 67 68 69 + // Cross-post lookup 70 + const crossPosts = await lookupCrossPosts(db, visibleRows, "story"); 71 + 68 72 const stories = visibleRows.map((row) => { 69 73 // Parse the JSON blob reference for URL generation 70 74 let blobRef: any; ··· 121 125 : {}), 122 126 createdAt: row.created_at, 123 127 ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 128 + ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), 124 129 }); 125 130 }); 126 131
+4 -11
server/xrpc/getStory.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 2 import { views } from "$hatk"; 3 3 import type { GrainActorProfile, Story } from "$hatk"; 4 + import { lookupCrossPosts } from "../feeds/_hydrate.ts"; 4 5 5 6 export default defineQuery("social.grain.unspecced.getStory", async (ctx) => { 6 7 const { db, ok } = ctx; ··· 79 80 } 80 81 81 82 // Cross-post lookup 82 - let crossPost: { url: string } | undefined; 83 - const rkey = row.uri.split("/").pop(); 84 - const searchUrl = `grain.social/profile/${row.did}/story/${rkey}`; 85 - const postRows = (await db.query( 86 - `SELECT uri FROM "app.bsky.feed.post" WHERE did = $1 AND "text" LIKE '%' || $2 || '%' LIMIT 1`, 87 - [row.did, searchUrl], 88 - )) as Array<{ uri: string }>; 89 - if (postRows.length) { 90 - const postRkey = postRows[0].uri.split("/").pop(); 91 - crossPost = { url: `https://bsky.app/profile/${row.did}/post/${postRkey}` }; 92 - } 83 + const crossPostMap = await lookupCrossPosts(db, [row], "story"); 84 + const crossPostUrl = crossPostMap.get(row.uri); 85 + const crossPost = crossPostUrl ? { url: crossPostUrl } : undefined; 93 86 94 87 const story = views.storyView({ 95 88 uri: row.uri,