[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-garden. atmo.garden
0

Configure Feed

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

add submitted by, make numbers more readable (2100 -> 2.1k)

+158 -25
+50
src/lib/atproto/server/feed.remote.ts
··· 6 6 import * as TID from '@atcute/tid'; 7 7 8 8 /** 9 + * Resolve a batch of DIDs to their minimal profile view (handle + display 10 + * name + avatar). Uses the public appview so it works for signed-out 11 + * viewers too. Returns a map keyed by DID; missing or failed lookups are 12 + * simply absent from the result. 13 + */ 14 + export const resolveProfiles = command( 15 + v.object({ 16 + dids: v.array(v.string()) 17 + }), 18 + async (input) => { 19 + const out: Record< 20 + string, 21 + { handle: string; displayName: string | null; avatar: string | null } 22 + > = {}; 23 + if (input.dids.length === 0) return { profiles: out }; 24 + 25 + // Dedupe before hitting the network — a feed with N posts often has 26 + // far fewer distinct submitters than N. Uses the public appview, 27 + // unauthenticated, so there are no scope concerns. 28 + const unique = Array.from(new Set(input.dids)); 29 + const publicClient = new Client({ 30 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 31 + }); 32 + 33 + for (let i = 0; i < unique.length; i += 25) { 34 + const batch = unique.slice(i, i + 25); 35 + try { 36 + const res = await publicClient.get('app.bsky.actor.getProfiles', { 37 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 + params: { actors: batch as any } 39 + }); 40 + if (res.ok) { 41 + for (const p of res.data.profiles) { 42 + out[p.did] = { 43 + handle: p.handle, 44 + displayName: p.displayName ?? null, 45 + avatar: p.avatar ?? null 46 + }; 47 + } 48 + } 49 + } catch (e) { 50 + console.error('[resolveProfiles] batch failed', e); 51 + } 52 + } 53 + 54 + return { profiles: out }; 55 + } 56 + ); 57 + 58 + /** 9 59 * Fetch viewer-specific state for a batch of post URIs. Returns a map 10 60 * `{ [uri]: { likeUri: string | null } }` so the caller can render 11 61 * per-post "liked by me" UI. Requires an authenticated viewer — returns
+25 -9
src/lib/components/user-profile/UserProfile.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte'; 3 - import { Avatar, Button, cn, sanitize, Prose } from '@foxui/core'; 3 + import { Avatar, cn, sanitize, Prose } from '@foxui/core'; 4 4 5 - let { profile, class: className, children }: { profile: { 6 - banner?: string; 7 - avatar?: string; 8 - displayName?: string; 9 - handle?: string; 10 - description?: string; 11 - }, class: string; children?: Snippet } = $props(); 5 + let { 6 + profile, 7 + class: className, 8 + children, 9 + headerActions 10 + }: { 11 + profile: { 12 + banner?: string; 13 + avatar?: string; 14 + displayName?: string; 15 + handle?: string; 16 + description?: string; 17 + }; 18 + class: string; 19 + children?: Snippet; 20 + /** Rendered to the right of the name row (where a Follow button 21 + * would normally live). Caller controls the content entirely. */ 22 + headerActions?: Snippet; 23 + } = $props(); 12 24 13 25 function linkify(text: string): string { 14 26 // Escape HTML first ··· 72 84 </div> 73 85 </div> 74 86 75 - <!-- <Button>Follow</Button> --> 87 + {#if headerActions} 88 + <div class="flex shrink-0 items-center gap-2 sm:pb-1"> 89 + {@render headerActions()} 90 + </div> 91 + {/if} 76 92 </div> 77 93 78 94 {#if children}
+20 -6
src/lib/reddit/RedditPostCard.svelte
··· 4 4 import { Avatar } from '@foxui/core'; 5 5 import { Heart, MessageCircle, Repeat2 } from '@lucide/svelte'; 6 6 import { wireEmbedClicks } from '$lib/components/embed'; 7 - import { blueskyPostToPostData } from '$lib/components'; 7 + import { blueskyPostToPostData, numberToHumanReadable } from '$lib/components'; 8 8 import { Post } from '$lib/components'; 9 9 import { likePost, unlikePost } from '$lib/atproto/server/feed.remote'; 10 10 import { user } from '$lib/atproto/auth.svelte'; ··· 38 38 /** Tailwind color label, e.g. "pink". Only needed when `row` is a bare 39 39 * PostRow (no community_accent_color). On a PostWithCommunity the row 40 40 * already carries the cached accent color. */ 41 - accentColor: accentColorProp 41 + accentColor: accentColorProp, 42 + /** Resolved profile of the user who submitted this post via DM. 43 + * Caller is responsible for fetching the handle by `row.author_did`. */ 44 + submitter 42 45 }: { 43 46 row: CardRow; 44 47 quoted?: PostView | null; 45 48 showCommunity?: boolean; 46 49 accentColor?: string | null; 50 + submitter?: { handle: string; displayName: string | null } | null; 47 51 } = $props(); 48 52 49 53 const communityHandle = $derived( ··· 176 180 {:else} 177 181 <span>{fmtRelative(row.indexed_at)}</span> 178 182 {/if} 183 + {#if submitter} 184 + <span>·</span> 185 + <span>submitted by</span> 186 + <a 187 + href={`/profile/${submitter.handle}`} 188 + class="hover:underline" 189 + > 190 + @{submitter.handle} 191 + </a> 192 + {/if} 179 193 </div> 180 194 181 195 {#if row.title} ··· 203 217 aria-label={isLiked ? 'Unlike' : 'Like'} 204 218 > 205 219 <Heart size={14} fill={isLiked ? 'currentColor' : 'none'} /> 206 - {displayLikeCount} 220 + {numberToHumanReadable(displayLikeCount)} 207 221 </button> 208 222 {#if bskyUrl} 209 223 <a ··· 214 228 class="hover:text-accent-500 flex items-center gap-1 transition-colors" 215 229 aria-label="Reply on Bluesky" 216 230 > 217 - <MessageCircle size={14} /> {replyCount} 231 + <MessageCircle size={14} /> {numberToHumanReadable(replyCount)} 218 232 </a> 219 233 {:else} 220 234 <span class="flex items-center gap-1"> 221 - <MessageCircle size={14} /> {replyCount} 235 + <MessageCircle size={14} /> {numberToHumanReadable(replyCount)} 222 236 </span> 223 237 {/if} 224 238 <span class="flex items-center gap-1"> 225 - <Repeat2 size={14} /> {repostCount} 239 + <Repeat2 size={14} /> {numberToHumanReadable(repostCount)} 226 240 </span> 227 241 {#if bskyUrl} 228 242 <Bluesky
+30 -4
src/routes/+page.svelte
··· 3 3 import { Loader2 } from '@lucide/svelte'; 4 4 import { getHomeFeed } from '$lib/reddit/server/communities.remote'; 5 5 import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; 6 + import { resolveProfiles } from '$lib/atproto/server/feed.remote'; 6 7 import RedditPostCard from '$lib/reddit/RedditPostCard.svelte'; 7 8 import SortTabs from '$lib/reddit/SortTabs.svelte'; 8 9 import type { PostSort, PostWithCommunity } from '$lib/reddit/db'; 10 + 11 + type SubmitterProfile = { handle: string; displayName: string | null; avatar: string | null }; 9 12 10 13 const PAGE_SIZE = 50; 11 14 ··· 14 17 let hasMore = $state(true); 15 18 let feed = $state<PostWithCommunity[]>([]); 16 19 let quoted = $state<Record<string, unknown>>({}); 20 + let submitters = $state<Record<string, SubmitterProfile>>({}); 17 21 let sort = $state<PostSort>('hot'); 18 22 23 + function uniqueAuthorDids(rows: PostWithCommunity[]): string[] { 24 + const seen: Record<string, true> = {}; 25 + const out: string[] = []; 26 + for (const r of rows) { 27 + if (r.author_did && !seen[r.author_did]) { 28 + seen[r.author_did] = true; 29 + out.push(r.author_did); 30 + } 31 + } 32 + return out; 33 + } 34 + 19 35 async function loadFeed(nextSort: PostSort) { 20 36 loading = true; 21 37 hasMore = true; 22 38 feed = []; 23 39 quoted = {}; 40 + submitters = {}; 24 41 try { 25 42 const rows = await getHomeFeed({ limit: PAGE_SIZE, sort: nextSort }); 26 43 feed = rows; 27 44 if (rows.length > 0) { 28 - const res = await getQuotedPosts({ uris: rows.map((p) => p.quoted_post_uri) }); 29 - quoted = res.posts; 45 + const [quotedRes, profileRes] = await Promise.all([ 46 + getQuotedPosts({ uris: rows.map((p) => p.quoted_post_uri) }), 47 + resolveProfiles({ dids: uniqueAuthorDids(rows) }) 48 + ]); 49 + quoted = quotedRes.posts; 50 + submitters = profileRes.profiles; 30 51 } 31 52 hasMore = rows.length >= PAGE_SIZE; 32 53 } catch (e) { ··· 47 68 }); 48 69 feed = [...feed, ...rows]; 49 70 if (rows.length > 0) { 50 - const res = await getQuotedPosts({ uris: rows.map((p) => p.quoted_post_uri) }); 51 - quoted = { ...quoted, ...res.posts }; 71 + const [quotedRes, profileRes] = await Promise.all([ 72 + getQuotedPosts({ uris: rows.map((p) => p.quoted_post_uri) }), 73 + resolveProfiles({ dids: uniqueAuthorDids(rows) }) 74 + ]); 75 + quoted = { ...quoted, ...quotedRes.posts }; 76 + submitters = { ...submitters, ...profileRes.profiles }; 52 77 } 53 78 if (rows.length < PAGE_SIZE) { 54 79 hasMore = false; ··· 99 124 <RedditPostCard 100 125 row={p} 101 126 quoted={(quoted[p.quoted_post_uri] ?? undefined) as never} 127 + submitter={p.author_did ? (submitters[p.author_did] ?? null) : null} 102 128 showCommunity 103 129 /> 104 130 {/each}
+29 -5
src/routes/c/[handle]/+page.svelte
··· 5 5 import { Loader2, Check, UserPlus, Plus } from '@lucide/svelte'; 6 6 import { getCommunity, getCommunityPosts } from '$lib/reddit/server/communities.remote'; 7 7 import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; 8 - import { followUser, unfollowUser, getProfile } from '$lib/atproto/server/feed.remote'; 8 + import { followUser, unfollowUser, getProfile, resolveProfiles } from '$lib/atproto/server/feed.remote'; 9 9 import { user } from '$lib/atproto/auth.svelte'; 10 10 import { loginModalState } from '$lib/LoginModal.svelte'; 11 11 import RedditPostCard from '$lib/reddit/RedditPostCard.svelte'; ··· 14 14 import type { PostSort, PostWithCommunity } from '$lib/reddit/db'; 15 15 16 16 type CommunityInfo = Awaited<ReturnType<typeof getCommunity>>; 17 + type SubmitterProfile = { handle: string; displayName: string | null; avatar: string | null }; 17 18 18 19 let loading = $state(true); 19 20 let postsLoading = $state(false); ··· 23 24 let community = $state<CommunityInfo>(null); 24 25 let posts = $state<PostWithCommunity[]>([]); 25 26 let quoted = $state<Record<string, unknown>>({}); 27 + let submitters = $state<Record<string, SubmitterProfile>>({}); 26 28 let sort = $state<PostSort>('hot'); 27 29 30 + function uniqueAuthorDids(rows: PostWithCommunity[]): string[] { 31 + const seen: Record<string, true> = {}; 32 + const out: string[] = []; 33 + for (const r of rows) { 34 + if (r.author_did && !seen[r.author_did]) { 35 + seen[r.author_did] = true; 36 + out.push(r.author_did); 37 + } 38 + } 39 + return out; 40 + } 41 + 28 42 // Follow state — populated after the community loads, if the user is 29 43 // signed in. `followUri` is the AT-URI of the user's follow record 30 44 // (needed to unfollow). ··· 49 63 const rows = await getCommunityPosts({ handle, limit: 50, sort: nextSort }); 50 64 posts = rows; 51 65 quoted = {}; 66 + submitters = {}; 52 67 if (rows.length > 0) { 53 - const res = await getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }); 54 - quoted = res.posts; 68 + const [quotedRes, profileRes] = await Promise.all([ 69 + getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }), 70 + resolveProfiles({ dids: uniqueAuthorDids(rows) }) 71 + ]); 72 + quoted = quotedRes.posts; 73 + submitters = profileRes.profiles; 55 74 } 56 75 hasMore = rows.length >= 50; 57 76 } catch (e) { ··· 73 92 }); 74 93 posts = [...posts, ...rows]; 75 94 if (rows.length > 0) { 76 - const res = await getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }); 77 - quoted = { ...quoted, ...res.posts }; 95 + const [quotedRes, profileRes] = await Promise.all([ 96 + getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }), 97 + resolveProfiles({ dids: uniqueAuthorDids(rows) }) 98 + ]); 99 + quoted = { ...quoted, ...quotedRes.posts }; 100 + submitters = { ...submitters, ...profileRes.profiles }; 78 101 } 79 102 if (rows.length < 50) { 80 103 hasMore = false; ··· 261 284 row={p} 262 285 quoted={(quoted[p.quoted_post_uri] ?? undefined) as never} 263 286 accentColor={community.accentColor} 287 + submitter={p.author_did ? (submitters[p.author_did] ?? null) : null} 264 288 showCommunity 265 289 /> 266 290 {/each}
+4 -1
src/routes/profile/[handle]/+page.svelte
··· 132 132 <p class="text-sm text-red-500">{error}</p> 133 133 </div> 134 134 {:else if profile} 135 + {#snippet profileHeaderActions()} 136 + <Bluesky href={`https://bsky.app/profile/${profile.handle}`} svgClasses="size-5" /> 137 + {/snippet} 135 138 <UserProfile 136 139 profile={{ 137 140 banner: profile.banner, ··· 141 144 description: profile.description 142 145 }} 143 146 class="" 147 + headerActions={profileHeaderActions} 144 148 > 145 149 <div class="flex items-center justify-between"> 146 150 <div class="flex items-center gap-4 text-sm"> ··· 161 165 {/if} 162 166 </div> 163 167 <div class="flex items-center gap-2"> 164 - <Bluesky href={`https://bsky.app/profile/${profile.handle}`} svgClasses="size-5" /> 165 168 {#if isOwnProfile} 166 169 <Button variant="ghost" onclick={logout} class="gap-2" size="sm"> 167 170 <LogOut size={14} />